《深入理解Java虚拟机》总结

本文深入剖析Java虚拟机(JVM)的关键概念和技术细节,涵盖内存管理、类加载机制、执行子系统、编译与优化、高效开发等方面。揭示JVM如何高效运行Java程序,包括自动内存管理、垃圾回收算法、类文件结构、执行引擎、代码优化策略以及线程安全与锁优化技术。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

目录

第一部分  走进Java

第二部分    自动内存管理机制

第2章  Java内存区域与内存溢出异常

1.运行时数据区域

2.HotSpot虚拟机对象

3.异常检测

第3章    垃圾收集器与内存分配策略

1.判断对象是否死亡

2.引用

3.方法区回收

4.垃圾收集算法

5.HotSpot的算法实现

6.垃圾收集器

7.内存分配与回收策略

8.空间分配担保

第4章    虚拟机性能监控与故障处理工具

1.常用监控命令

2.JDK可视化工具

第三部分    虚拟机执行子系统

第6章    类文件结构

1.类文件结构

2.字节码指令简介

3.公有设计和私有实现

4.Class文件结构的发展

第7章    虚拟机类加载机制

1.概述

2.类加载的时机

3.类加载的过程

4.验证

5.准备

6.解析

7.初始化

8.类加载器

第8章    虚拟机字节码执行引擎

1.运行时栈帧结构

2.方法调用

3.基于栈的字节码解释执行引擎

第四部分    程序编译与代码优化

第10章    早期(编译期)优化

1.Javac编译器

第11章    晚期(运行期)优化

1.编译器与解释器

2.编译对象与触发条件

3.编译过程

4.编译优化技术

5.Java与C/C++的编译器比较

第五部分    高效开发

第12章    Java内存模型与线程

1.Java内存模型

2.内存间的交互操作

3.对于volatile型变量的特殊规则

4.原子性、可见性与有序性

5.先行发生原则

6.线程的实现

7.Java线程调度                                             

第13章    线程安全与锁优化

1.线程安全

2.线程安全的实现方法

3.锁优化


第一部分  走进Java

1.Sun官方所定义的Java技术体系包括以下几个组成部分:

   1)Java程序设计语言

   2)各种硬件平台上的Java虚拟机

   3)Class文件格式

   4)Java API类库

   来自商业机构和开源社区的第三方Java类库

其中Java程序设计语言、Java虚拟机和JavaAPI类库这三部分统称为JDK,JDK是用于支持Java程序开发的最小环境,亦可代替整个Java技术体系。

而Java API类库中的Java SE API子集和Java虚拟机这两部分统称为JRE,JRE是支持Java程序运行的标准环境。

 

2.HotSpot VM

    HotSpot VM是Sun JDK和OpenJDK中所带的虚拟机,也是目前使用范围最广的虚拟机。最大的优势就是热点代码探测技术。

所谓热点代码探测能力,就是通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。

如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译OSR(栈上替换)编译动作。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,有助于引入更多的代码优化技术,输出质量更高的本地代码。

 

第二部分    自动内存管理机制

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外的人想进去,墙里面的人想出来。

第2章  Java内存区域与内存溢出异常

1.运行时数据区域

1.1程序计数器:较小的一块内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

注1:如果线程正在执行的是一个Java方法,这个计数器记录的则是正在执行的虚拟机字节码指令的地址,如果正在执行的是Native方法,则这个计数器值则为空。这是唯一一个在Java虚拟机规范中没有规定内存溢出情况的区域。

注2:Java多线程就是通过线程轮流切换并分配处理器执行时间的方式来实现的,一个处理器只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每一条线程都需要一个独立的程序计数器,各计数器之间互不影响、相互独立,即为线程私有

 

1.2Java虚拟机栈:线程私有,生命周期与线程相同,描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,都对应着一个栈帧从入栈到出栈的过程。
注1:局部变量表存放了编译期可知的各种基本数据类型(byte、short、int、long、double、float、boolean、char)、对象引用(reference类型,对象的起始地址引用指针或句柄)、returnAddress类型(指向一条字节码指令的地址)。
注2:局部变量表所需的内存空间在编译期间就完成分配,当进入一个方法时,方法需要的栈帧大小就是完全确定的,运行期间也不会改变。
注3:Java虚拟机规范中,对此区域规定了两种异常状况:
1)StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度(一个栈的栈帧过多:调用方法次数过多且均没有结束,例递归调用)
2)OutOfMemoryError异常:虚拟机栈可以动态扩展时,如果扩展时无法申请到足够的内存(所有栈的所有占用内存过大:调用的方法所需的栈内存过大)

 

1.3本地方法栈:与虚拟机栈所发挥的作用非常相似,区别只是虚拟机栈为虚拟机执行Java方法(字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。同样会抛出StackOverflowError异常和OutOfMemoryError异常。

 

1.4Java堆:Java虚拟机所管理的内存中最大的一块,Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建,此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在此处分配。
Java堆也是垃圾收集器管理的主要区域,因此很多时候也被称为”GC堆“,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果对中没有内存完成实例分配,并且堆也无法再扩展时,则会抛出OutOfMemoryError异常。

 

1.5方法区:Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。垃圾收集行为较少,但需要执行,主要是常量池的回收和类型的卸载。当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

注1:运行时常量池:方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。除了存放Class文件中描述的符号引用,还会把翻译出来的直接引用也存储到运行时常量池中。
注2:具备动态性,并不要求常量一定只有编译期才能产生,运行期也可以将新的常量放入池中。
注3:当常量池无法再申请到内存时会抛出OutOfMemoryError异常

 

1.6直接内存:并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。在JDK1.4种新加入的NIO类,引入了一种基于通道与缓冲区的I/O方式,可以使用Native函数库直接分配堆外内存,通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。避免了在Java堆和Native堆中来回复制数据。

 

2.HotSpot虚拟机对象

2.1对象的创建:

虚拟机遇到一条new指令时:
1.检查这个指令的参数能否在运行时常量池中定位到一个类的符号引用,并检查这个类是否加载
2.若已加载,则对象所需内存便可完全确定,为对象分配内存空间(指针碰撞、空闲列表、TLAB本地线程分配缓冲)
3.将分配到的内存空间都初始化为零值,初始化内存
4.虚拟机对对象进行必要的设置(对象头信息:哪个类的实例,元数据信息、哈希码、GC分代年龄)
5.执行<init>方法,初始化对象

2.2对象的内存布局:
对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
对象头包括两部分信息:
第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳。这部分数据长度在32位和64位虚拟机中分别位32bit和64bit,称为”Mark Word“会根据对象的状态复用自己的存储空间。
第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
注:如果对象是一个Java数组,那对象头中还必须有一块用于记录数组长度的数据,虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。
实例数据:
对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。
对其补充:
并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用

2.3对象的访问定位
为了使用对象,Java程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有使用句柄和直接指针两种。
句柄访问:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
直接指针:Java堆中对象的布局中必须考虑如何放置访问类型数据的相关信息,而reference中存储的直接就是对象地址。

使用句柄访问的优势就是修改快,reference中存储的是稳定的句柄地址,对象被移动只会改变句柄中的实例数据指针,而reference本身不需要修改。
使用直接指针访问的优势就是访问速度更快,节省了依次指针定位的时间开销,HotSpot使用的是直接指针访问。

 

3.异常检测

-verbose:gc输出gc信息

-XX:SurvivorRatio=8

 

设置堆中survivor:eden=2:8
-Xms20m设置最小堆内存容量
-Xmx20m设置最大堆内存容量
-XX:+HeapDumpOnOutOfMemoryError打印出堆溢出错误信息
-Xss128k定义栈容量
-XX:PermSize=10M指定方法区内存容量
-XX:MaxPermSize=10M指定方法区最大内存容量
-XX:MaxDirectMemorySize=10M

指定本机直接内存容量

默认为Java堆最大值一样

转存储快照:

1.安装Memory Analysis插件

2.运行测试数据

3.点开memory analysis窗口,打开生成在项目根目录下的.hprof文件

4.观察快照情况,进行分析

 

第3章    垃圾收集器与内存分配策略

1.判断对象是否死亡

1)引用计数法:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1,当引用失效时,计数器值就减1,任何时刻计数器为0的对象就是不可能再被使用的。

缺点:如果有多个对象相互多次引用,则就会让计数器无法通知GC收集器回收这些对象

2)可达性分析算法:通过一系列的“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到达GC Roots没有任何引用链相连时,则证明此对象时不可用的。

可作为GC Roots的对象包括以下几种:

1.虚拟机栈(栈帧中的本地变量表)中引用的对象

2.方法区中类静态属性引用的对象

3.方法区中常量引用的对象

4.本地方法栈中JNI(Native方法)引用的对象。

 

2.引用

如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

强引用:程序代码之间普遍存在的引用,只要强引用还在,垃圾收集器永远不会回收掉被引用的对象

软引用:还有用但并非必需的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

弱引用:非必需的对象,强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前,当垃圾收集器工作时,无论当前的内存是否足够,都会回收掉只被弱引用关联的对象

虚引用:最弱的一种引用,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,无法通过虚引用取得一个对象的实例,唯一目的就是这个对象被垃圾收集器回收时收到一个系统通知。

 

3.方法区回收

主要回收的的内容:废弃常量与无用的类,回收废弃常量与回收java堆中的对象非常类似。

回收无用的类:

1)该类所有的实例都已被回收,也就是java堆中不存在该类的任何实例

2)加载该类的ClassLoader已经被回收

3)该类对象的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射机制访问该类的方法。

 

4.垃圾收集算法

1)标记-清除算法:首先标记需要回收的对象,在标记完成后统一回收所有被标记的对象

缺点:效率低,标记和清除两个过程的效率都不高

          空间利用率低,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后的程序运行过程中需要分配较大的对象时找不到足够的空间。

2)复制算法:将可用的内存按容量划分为大小相等的两块,每次只使用其中的一块,每次只对整个半区进行回收。

缺点:复制操作较多,效率变低,浪费50%的空间,或者需要有额外的空间进行分配担保

3)标记-整理算法:首先标记需要回收的对象,再让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

4)分代收集算法:将java堆分为新生代和老年代,根据各个年代的特点采用最适当的收集算法。新生代使用复制算法,老年代使用标记清除或者标记整理算法。

 

5.HotSpot的算法实现

1)枚举根节点:使用一组称为OopMap的数据结构来达到直接得知什么地方存放着对象引用的目的,在类加载完成时,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。

2)安全点:在特定的位置记录OopMap信息,这些位置被称为“安全点”,只有达到安全点才会暂停下来执行GC操作。

3)中断方式:

     抢先式中断:在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点,就恢复线程,让他进行到安全点上。

     主动式中断:不直接对线程操作,仅仅简单设置一个标记,各个线程执行时主动轮询这个标志,发现中断标志为真时就自己中断挂起。

4)安全区域:指在一段代码中,引用关系不会发生变化。在这个区域任何地方执行GC都是安全的。如果线程到达安全区域,则将自己标识,这段时间内,JVM需要进行GC时,不需要管标识了为安全状态的线程。当该线程需要离开安全区域时,需要检查JVM是否完成了枚举根节点动作(或者整个GC活动),如果完成了,则继续进行,否则,需要等到收到可以安全离开的信号才能了离开。

 

6.垃圾收集器

    收集算法时内存回收的方法论,则垃圾收集器就是内存回收的具体实现,Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定。

1)Serial收集器:单线程收集、暂停其他所有工作线程、使用复制算法,直到收集结束,Client模式下默认的新生代收集器,简单高效,但是有停顿。

2)ParNew收集器:Serial收集器的多线程版本,并发收集器,第一次实现了垃圾回收线程与用户线程同时工作,Server模式下的首选新生代收集器,可以与CMS收集器配合工作。

3)Parallel Scavenge收集器:复制算法,并行多线程收集器,与ParNew收集器类似,但是它的关注点是达到一个可控制的吞吐量(运行用户代码时间 /(运行用户代码时间+垃圾收集时间)) ,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的计算任务,主要适合在后台运算而不需要太多的交互任务。

4)Serial Old收集器:单线程收集、暂停其他所有工作线程,使用标记-整理算法,直到收集结束,Client模式下的老年代收集器,主要用于jdk1.5之前的版本中与Parallel Scavenge收集器搭配使用,以及作为CMS收集器的后备预案。

5)Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用多线程和标记-整理算法,主要用于注重吞吐量和CPU资源敏感的场合。

6)CMS收集器(Concurrent Mark Sweep):并发、标记、清除,一种以获取最短回收停顿时间为目标的收集器,尤其重视服务器的响应速度,给用户带来较好的体验。总共有 四个步骤:

(1)初始标记:标记GC Roots直接关联的对象    暂停

(2)并发标记:依次向下追溯引用链上的对象     并发

(3)重新标记:修改不需要被回收的对象目录     暂停

(4)并发清除:清除回收不可达的无用对象         并发

缺点:

(1)CMS收集器对CPU资源非常敏感

(2)无法处理浮动垃圾

(3)形成大量的空间碎片

7)G1收集器:面向服务端应用的垃圾收集器,将整个java堆划分为多个大小相等的独立区域,有计划的避免在整个java堆中进行全区域的垃圾收集,G1跟踪各个独立区域里面的垃圾堆积价值大小,在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的独立区域。

(1)初始标记:标记GC Roots直接关联的对象    暂停

(2)并发标记:依次向下追溯引用链上的对象     并发

(3)最终标记:重新更新标记,合并Remembered Set    暂停

(4)筛选回收:对GC的独立区域进行排序,回收价值最大的区域    并发

 

7.内存分配与回收策略

1)对象优先分配在Eden区域,当Eden区域没有足够的空间进行分配时,则会发起依次Minor GC.

2)需要大量连续内存空间的Java对象,如很长的字符串以及数组,直接在老年代进行分配

3)虚拟机对每一个对象定义了一个对象年龄计数器,如果对象在Eden出生并经过一次Minor GC后仍然存活,并且能够被Survivor容纳,则移动到Survivor区域,并且年龄加一,当年龄达到一定数值(默认为15)则会被晋升到老年代中。

4)如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象直接进入老年代。

 

8.空间分配担保

1)在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于,则说明Minor GC可以确保是安全的,如果不成立,则虚拟机会查看是否允许担保失败,如果允许,则检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,否则进行Full GC。

2)如果担保了还是失败了,则在失败后重新发起一次Full GC,JDK1.6之后取消了担保策略

 

第4章    虚拟机性能监控与故障处理工具

 

1.常用监控命令

jps:虚拟机进程状况工具   例:jps -l

jstat:虚拟机统计信息监视工具,显示本地或远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。

例 jstat -gc 2763 250 20     每250毫秒查询依次进程2763垃圾收集情况,一共查询20次

jinfo:Java配置信息工具,实时地查看和调整虚拟机各项参数。

jmap:Java内存映像工具,用于生成堆转存储快照,查询finalize执行队列,Java堆和永久代的详细信息

jhat:虚拟机堆转存储快照分析工具

jstack:Java堆栈跟踪工具,用于生成虚拟机当前时刻的线程快照

 

2.JDK可视化工具

JConsole:一种基于JMX的可视化监视、管理工具,通过JDK/bin目录下的jconsole.exe启动

VisualVM:目前为止随JDK发布的功能最强大的运行监视和故障处理程序

 

 

第三部分    虚拟机执行子系统

代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言发展的一大步。

第6章    类文件结构

    Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他的辅助信息。为了安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束。

1.类文件结构

1)基础结构

    Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。当需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。

    Class文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。

    无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,可用来描述数字、索引应用、数量值或者按照UTF-8编码构成的字符串值。

    表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾,用于描述有层次关系的复合结构的数据。整个Class文件本质上就是一张表。

    当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。

 

2)魔数:每个Class文件的头四个字节称为魔数,唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件,紧接着魔数的四个字节存储的是Class文件的版本号,5,6字节为次版本号,7,8字节为主版本号。高版本的JDK能向下兼容,但不能向上兼容。

 

3)常量池:紧接着版本号之后的是常量池入口,常量池即为Class文件中的资源仓库,是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,还是第一个出现的表类型数据项目。

常量池入口需要放置一项u2类型的数据,代表常量池的容量计数值,容量计数从1开始,0表示不引用任何一个常量池项目。

常量池中主要存放两大类常量:字面量与符号引用。

字面量:Java语言层面的常量概念,如文本字符串、声明为final的常量值等。

符号引用:属于编译原理方面的概念,主要有三类:

(1)类和接口的权限定名

(2)字段的名称和描述符

(3)方法的名称和描述符

常量池中每一项常量都是一个表。

 

4)访问标志:常量池之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息。(修饰信息)

5)类索引、父索引与接口索引集合:类索引和父类索引都是一个u2类型的数据,接口索引集合是一组u2类型的数据的集合。类索引用于确定这个类的权限定名,父类索引用于确定这个类的父类的全限定名。接口索引集合用来描述这个类实现了哪些接口。

6)字段表集合:用于描述接口或者类中声明的变量。字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。

7)方法表集合:和字段表结构一样,依次包括了访问标志、名称索引、描述符索引、属性表集合等。方法里的代码,经过编译器编译成字节码指令之后,存放在方法属性表集合中一个名为“Code”的属性里面。属性表作为Class文件格式中最具扩展性的一种数据项目。

重载:重载方法要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合。而返回值不在特征签名中。

8)属性表集合:在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。对于表中的每一个属性,它的名称都需要从常量池中引用一个相应的类型的常量来表示,属性值的结构则完全是自定义的,只需要通过一个u4长度属性去说明属性的值锁占用的位数即可。

(1)Code属性:Java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在code属性内。Code属性是Class文件中最重要的一个属性,Java程序中的信息可分为代码和元数据,Code属性则用于描述代码,所有的其他数据项目都用来描述元数据。

(2)Exciteptions属性:方法表中与code属性平级的一项属性,作用是列举出方法中可能抛出的受查异常,即方法描述时在throws关键字后面列举的异常。

(3)LineNumberTable属性:用于描述java源代码行号与字节码行号之间的对应关系。

(4)LocalVariableTable属性:描述栈帧中局部变量表中的变量与java源代码中定义的变量之间的关系。

(5)SourceFile属性:用于记录生成这个Class文件的源码文件名称。

(6)ConstantValue属性:通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量才可以用这项属性。

(7)InnerClass属性:记录内部类与宿主类之间的关联

(8)Deprecated以及Synthetic属性:前者表示某个类、字段或者方法,不被推荐使用,后者代表此字段或者方法并不是由Java源代码直接产生的,而是由编译器自行添加的。

(9)StackMapTable属性:类型推导验证器,提升字节码验证性能

(10)Signature属性:记录泛型签名信息,弥补伪泛型缺陷。

(11)BootstrapMethod属性:保存invokedynamic指令引用的引导方法限定符

2.字节码指令简介

    Java虚拟机的指令一个字节长度的、代表着某种特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需参数(操作数)而构成。

    字节码指令集是一种具有鲜明特点、优劣势都很突出的指令集架构,由于限制了Java虚拟机操作码的长度为一个字节(0-255),因此指令集的操作码总数不可能超过256。由于Class文件格式放弃了编译后代码的操作数长度对齐,导致虚拟机处理那些超过一个字节数据时会损失一些性能。放弃了操作数长度对齐,意味着可以省略很多填充和间隔符号,用一个字节来代表操作码,尽可能获得短小精干的编译代码,追求小数据量、高传输效率。

 

3.公有设计和私有实现

Java虚拟机规范描绘了Java虚拟机应有的共同程序存储格式:Class文件格式以及字节码指令集,这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的。是程序在各种Java平台实现之间互相安全地交互的手段。

虚拟机实现的方式主要有以下两种:

将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集。

将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(JIT代码生产技术)

 

4.Class文件结构的发展

Class文件结构一直处于比较稳定的状态,所有针对Class文件格式的改进,都集中在向访问标志、属性表这些在设计上就可扩展的数据结构中添加内容。

Class文件格式所具备的平台中立(不依赖特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。

 

第7章    虚拟机类加载机制

1.概述

    虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

    在Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特性实现的。

 

2.类加载的时机

    类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析3个部分统称为连接。

    加载、验证、准备、初始化和卸载这5个阶段的顺序都是确定的,类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定,在某些情况下可以在初始化阶段之后开始。同时这些阶段通常都是互相交叉混合式进行的,通常会在一个阶段执行的过程中调用,激活另外一个阶段。

   有且仅有五种情况必须立即对类进行“初始化”

1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。即对应着实例化对象、读取或设置一个类的静态字段以及调用一个类的静态方法的时候。

2)使用java.lang.reflect包的方法对类进行反射调用的时候

3)当初始化一个类的时候,如果发现父类还没有进行过初始化,先触发父类的初始化

4)虚拟机启动时,用户指定一个要执行的主类

5)使用jdk1.7的动态语言支持时,使用方法句柄时,方法句柄对应的类没有进行过初始化。

类初始化时,要求其父类必须初始化,接口初始化时,不要求其父接口必须初始化,需要用到时才初始化。

 

3.类加载的过程

1)通过一个类的全限定名获取定义此类的二进制字节流

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口

注:二进制字节流不一定要从Class文件中获取,有多种获取方式,如从ZIP包中读取、从网络中获取、运行时计算生成、由其他文件生成、从数据库中读取等

 

4.验证

验证是连接的第一步,目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证是虚拟机对自身保护的一项重要工作。

验证阶段会完成4个检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

1)文件格式验证:验证字节流是否符合Class文件格式的规范,能够被当前版本的虚拟机处理。

(1)是否以魔数0xCAFEBABE开头

(2)主、次版本号是否在当前虚拟机的处理范围内

(3)常量池的敞亮是否有不被支持的常量类型

等等...

主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求。

2)元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

(1)这个类是否有父类

(2)这个类的父类是否继承了不允许被继承的类

(3)如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法

等等...

主要目的是对类的元数据信息进行语义校验,保证不存在不符合Java语言规范的元数据信息

3)字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

(1)保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作

(2)保证跳转指令不会跳转到方法体以外的字节码指令上

(3)保证方法体中的类型转换是有效的

等等...

4)符号引用验证:对类自身以外的信息进行匹配性校验

(1)符号引用中通过字符串描述的全限定名是否能够找到对应的类

(2)在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段

(3)符号引用中的类、字段、方法的访问性是否可以被当前类访问

等等...

主要目的是确保解析动作能正常执行。

 

5.准备

正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。其中常量直接进行初始化并赋值,变量则进行默认值初始化。

 

6.解析

虚拟机将常量池内的符号引用替换为直接引用的过程。

符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。与内存布局无关,引用的目标不一定已经加载到内存中。

直接引用:直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。与内存布局有关,引用的目标必定已经在内存中存在。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。

 

7.初始化

类初始化时类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的Java程序代码(字节码)。执行类构造器<clinit>()方法的过程。

1)<clinit>()方法时由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。

2)<clinit>()方法与类的构造函数不同,不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的就已经执行完毕了。

 

8.类加载器

应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器”。

对于一个任意的类都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

双亲委派模型:

从Java虚拟机的角度讲,只存在两种不同的类加载器,第一种是启动类加载器,由C++语言实现,虚拟机自身的一部分。另一种就是所有其他的类加载器,由Java语言实现,独立于虚拟机之外。

另一种分法:

1)启动类加载器:负责加载<JAVA_HOME>\lib目录中的,被虚拟机识别的类库加载到虚拟机内存中,无法被Java程序直接引用,编写自定义类加载器时,需要把加载请求委派给引导类加载器,直接使用null代替即可。

2)扩展类加载器:负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

3)应用程序类加载器:又称为系统类加载器,负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器。

工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,每一个层次的类加载器都是如此,所有的类加载请求都会传送到顶层的启动类加载中,只有当父加载器反馈自己无法完成加载请求时,子加载器才会尝试自己去加载。

好处:这样就具备了一种带有优先级的层次关系,应用程序不会一片混乱。

破坏双亲委派模型:

1)第一次“被破坏”发生在双亲委派模型出现之前。

2)第二次“被破坏”是由这个模型自身的缺陷所导致的。

3)第三次“被破坏”是由于用户对程序动态性的追求而导致的。

 

 

第8章    虚拟机字节码执行引擎

    执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”和“物理机”相对应,后者的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的。而虚拟机的执行引擎则是由自己实现的,因此可以自行指定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

     执行引擎:字节码文件——>字节码解析——>执行结果

 

1.运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

在编译程序代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入到方法表中的Code属性中。因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的就是当前方法。

1).局部变量表

一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Slot)为最小单位。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围时从0开始至局部变量表最大的Slot数量。在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程。

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的。

2).操作数栈

也称操作栈,是一个后入先出的栈,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

3).动态连接

每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

4).方法返回地址

当一个方法开始执行后,只有两种分方式可以退出这个方法:

(1)执行引擎遇到任意一个方法返回的字节码指令,这时可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。

(2)方法执行过程中遇到了异常,这个异常没有在方法体内得到处理,就会导致方法退出,称为异常完成出口。

无论何种方法退出,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(有的话)压入调用者栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令。

5).附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,这部分信息完全取决于具体的虚拟机实现,一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。

 

2.方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还不涉及方法内部的具体运行过程。

1)解析:

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。即“编译器可知,运行期不可变”

主要包括静态方法和私有方法两大类,前者与类型直接关联,后者外部不可访问,因此他们都适合在类加载阶段解析。与之对应,在Java虚拟机里面提供了5条方法调用字节码指令,分别为:

(1)invokestatic:调用静态方法

(2)invokespecial:调用实例构造器<init>方法、私有方法和父类方法

(3)invokevirtual:调用所有的虚方法

(4)invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

(5)invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

其中静态方法、私有方法、实例构造器、父类方法以及被“final”修饰的五类方法,在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法称为非虚方法。

解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

2)分派:

分派调用则可能是静态的也可能是动态的,根据分派一句的宗量数可分为单分派和多分派。两种分派方式的两两组合就构成了动态单分派、静态单分派、动态多分派、静态多分派四类。

静态分派:所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机执行的。

Human man = new Man();

其中Human即为静态类型,Man则是实际类型。

动态分派:在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,动态分派的典型应用是方法重写。

单分派与多分派:方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派与多分派,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

3)虚拟机动态分派的实现:

为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。虚方法表中存放着各个方法的实际入口地址。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

 

3.基于栈的字节码解释执行引擎

对于一门具体语言的实现来说,词法分析、语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现。,Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。即为半独立的实现。

Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构指令集的主要缺点是由于指令数量和内存访问的原因,执行速度相对来说会稍慢一些。

 

 

第四部分    程序编译与代码优化

从计算机程序出现的第一天起,对效率的追求就是程序天生的坚定信仰,这个过程犹如一场没有终点,永不停歇的F1方程式竞赛,程序员是车手,技术平台则是赛道上飞驰的赛车。

第10章    早期(编译期)优化

Java语言的编译期:

前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)      *.java  ——>  *.class

后端运行期编译器:HotSpot VM的C1、C2编译器                                 *.class  ——>  机器码

静态提前编译器:GNU Compiler for the Java(GCJ)、Excelsior JET     *.java   ——>  机器码

Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序代码来说关系更加密切。

 

1.Javac编译器

Javac编译器本身就是一个由Java语言编写的程序,其编译过程大致可以分为3个过程。分别为:

1)解析与填充符号表

2)插入式注解处理器的注解处理

3)分析与字节码生成

 

1.解析与填充符号表:

(1)词法、语法分析:词法分析是将源代码的字节流变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素。关键字、变量名、字面量、运算符都可以称为标记。

语法分析是根据Token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表这程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释都可以是一个语法结构。

(2)填充符号表:符号表是由一组符号地址和符号信息构成的表格,符号表中所登记的信息在编译的不同阶段都要用到,在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表时地址分配的依据。

2.注解处理器:

JDK1.5之后,Java语言提供了对注解的支持,这些注解与普通的java代码一样,是在运行期间发挥作用的。JDK1.6中提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,在这些插件中,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,知道所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round。

3.语义分析与字节码生成

对结构上正确的源程序进行上下文有关性质的审查。语义分析过程分为标注检查以及数据及控制流分析两个步骤。

(1)标注检查:

标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等,同时还要进行常量折叠动作。

(2)数据及控制流分析

对程序上下文逻辑更进一步的验证,检查诸如程序局部变量在使用前是否有赋值,方法的每一条路径是否都有返回值等问题

(3)解语法糖

语法糖也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是方便程序员使用,使用语法糖能够增加程序的可读性,从而减少程序代码的出错机会,Java中最常用的语法糖即为泛型、变长参数、自动装箱/拆箱等。虚拟机运行时不支持这些语法,他们在编译阶段还原回简单的基础语法结构,这个过程叫作解语法糖。

(4)字节码生成

Javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。

 

总结:在前端编译器中,“优化”手段主要用于提升程序的编码效率,之所以把Javac这类将Java代码转变为字节码的编译器称作“前端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,在此之后,还有一组内置于虚拟机内部的“后端编译器”完成了从字节码生成本地机器码的过程,即前面多次提到的即时编译器或JIT编译器,这个编译器的编译速度及编译结果的优劣,是衡量虚拟机性能一个很重要的指标。

 

 

第11章    晚期(运行期)优化

Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机会将这些代码编译成与本地平台相关的机器码。并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Java In Time Complier,JIT)。

即时编译器并不是虚拟机必需的部分,更没有限定或指导即时编译器应该如何去实现,但是,即时编译器编译性能的好坏,代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,也是虚拟机中最核心且最能体现技术水平的部分。

 

1.编译器与解释器

并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但主流商用虚拟机都同时包含编译器与解释器。解释器可以首先发挥作用,省去编译时间,立即执行,在程序运行之后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。

当程序运行环境中内存资源限制很大时,可以使用解释执行节约内存,反之可以使用编译执行来提升效率,同时,解释器还可以作为编译器激进优化时的一个“逃生门”。在整个虚拟机执行架构中,解释器与编译器经常配合工作。

HotSpot虚拟机中内置了两个即时编译器,分别为Client Compiler和Server Compiler或者简称为C1编译器和C2编译器。目前主流的HotSpot虚拟机默认采用解释器与其中一个编译器直接配合工作,程序使用哪个编译器,取决于虚拟机运行的模式。虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式。

解释器可能还要替编译器收集性能监控信息,为了在程序启动响应速度与运行效率之间达到最佳平衡,虚拟机会逐渐启用分层编译的策略:

第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译

第1层:称为C1编译,将字节码编译为本地代码,进行简单,可靠的优化,如有必要将加入性能监控的逻辑

第2层:称为C2编译,将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

 

2.编译对象与触发条件

运行过程中会被即时编译器编译的“热点代码”有两类,即:

1)被多次调用的方法

2)被多次执行的循环体

第一种情况下,由于是方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式。

第二种情况下,尽管编译动作是由循环体所触发的,但是编译器依然会以整个方法作为编译对象,这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换。

 

判断一段代码是否为热点代码的行为称为“热点探测”,主要有两种方式:

1)基于采样的热点探测:周期性检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个方法就是“热点方法”。实现简单,搞笑,很容易获取方法的调用关系,缺点是很难精确地确认一个方法的热度,容易受线程阻塞或其他因素的干扰。

2)基于计数器的热点探测:为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为是“热点方法”。实现麻烦,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是结果更加精确和严谨。

HotSpot虚拟机采用的是第二种方式,并且设置了两种计数器:方法调用计数器和回边计数器。

方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数,当超过一定的时间限度,如果方法的调用次数仍然不能让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半。即半衰周期。

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,统计的目的就是为了触发OSR编译。回边计数器没有半衰周期,当计数器溢出的时候,还会把方法计数器的值也调整到溢出状态(同步溢出),这样下次再进入该方法时就会执行标准编译过程。

 

3.编译过程

在默认设置下,无论方法调用产生的即时编译请求还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然会按照解释方式继续执行,而编译动作则在后台的编译线程中进行。

C1编译器和C2编译器的编译过程是不一样的,对于C1编译器来说,是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,放弃了许多耗时较长的全局优化手段。

第一阶段:一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR),在此之前编译器会在字节码上完成一部分基础优化,如方法内联,常量传播等优化。

第二阶段:一个平台相关的后端从HIR中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化,如空值检查消除,范围检查消除等。

第三阶段:平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,产生机器代码。

而C2编译器则是专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,会执行所有经典的优化动作。

 

4.编译优化技术

最有代表性的优化技术:

1)语言无关的经典优化技术之一:公共子表达式消除

2)语言相关的经典优化技术之一:数组范围检查消除

3)最重要的优化技术之一:方法内联

4)最前沿的优化技术之一:逃逸分析

公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,只需要直接用前面的计算过的表达式结果代替E就可以了。

数组范围检查消除:如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间之中,那在整个循环中就可以把数组的上下界检查消除,可以节省很多次条件判断操作。

方法内联:非虚方法直接进行内联,如果是虚方法,会使用CHA(类型继承关系分析)查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,则可以进行内联,不过需要预留一个“逃生门”,称为守护内联,如果由于加载导致继承关系发生变化,那么则需要抛弃已经编译的代码,退回到解释状态执行,或者重新编译。

如果CHA查询得到的是多个目标版本可供选择,则编译器还会进行最后一次努力,使用内联缓存来完成方法内联。在未发生方法调用前,内联缓存为空,当第一次调用发生后,缓存记录方法接受者的版本信息,并且每次进行方法调用时比较接受者的版本,如果不一致,则会取消内联,查找虚方法表进行方法分派。

逃逸分析:并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。分析方法是否会被其他方法调用或者是否会被外部线程访问。如果能证明一个对象不会逃逸到方法或者线程之外,则可以为这个对象进行一些高效的优化:

1)栈上分配:将对象分配到栈上,对象会随着方法的结束而自动销毁,垃圾收集系统的压力会减少很多。

2)同步消除:如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,则可以将对这个变量实施的同步措施可以消除掉。

3)标量替换:将一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复到原始类型来访问。

 

5.Java与C/C++的编译器比较

Java所代表的是最经典的即时编译器,C/C++所代表的是静态编译器。

第一.因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,能提供的优化手段也严重受制于编译成本。

第二.Java语言是动态的类型安全语言,意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。

第三.Java语言中虽然没有virtual关键字,但是使用虚方法的频率远远大于C/C++语言,意味着优化时难度要远大于C/C++

第四.Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,使得很多全局优化都难以进行

第五.Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配,而C/C++的对象则有多种内存分配方式。效率不如C/C++。

而C/C++编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行。

 

第五部分    高效开发

并发处理的广泛应用是使得Amdahl定律代替摩尔定律称为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。

第12章    Java内存模型与线程

1.Java内存模型

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

主内存与工作内存

Java内存模型规定了所有的变量都存储在主内存中。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低的层次上讲,主内存就直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储与寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。

 

2.内存间的交互操作

关于主内存与工作内存之间具体的交互协议,一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之类的实现细节,Java内存模型中定义了以下八种操作来完成,每一种操作都是原子的,不可再分的。

1)lock(锁定):作用于主内存的变量,将一个变量标识为一条线程独占的状态。

2)unlock(解锁):作用于主内存的变量,将其从锁定状态解放出来。

3)read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。

4)load(载入):作用于工作内存的变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中。

5)use(使用):作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。

6)assign(赋值):作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量复制的字节码指令时执行此操作。

7)store(存储):作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用

8)write(写入):作用于主内存的变量,把strore操作从工作内存中得到的变量的值放入主内存的变量中。

交互规则:

1)不允许read和load、store和write操作之一单独出现。

2)不允许一个线程丢弃它最近的assign操作。

3)不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中

4)一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量

5)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次。

6)如果对一个变量执行lock操作,那么将会清空工作内存中此变量的值。

7)如果一个变量事先没有被lock操作锁定,那就不允许对其执行unlock操作

8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中。

 

3.对于volatile型变量的特殊规则

关键字volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量定义为volatile之后,将具备两种特性:

第一是保证此变量对所有线程的可见性,指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。volatile变量在各个线程的工作内存中不存在一致性问题,但是Java里面的运算并非原子操作,导致volatile变量的运算在并发下一样是不安全的。只能保证可见性,不能保证原子性。

第二是禁止指令重排序优化,让执行顺序等于程序顺序。

volatile的同步机制的性能要优于锁,volatile变量读操作的性能消耗与普通变量几乎没有什么差别,写操作可能会慢一些,因为需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行,不过总开销还是比锁低。

 

4.原子性、可见性与有序性

原子性:基本数据类型的访问读写是具备原子性的。由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write。在synchronized块之间的操作也具备原子性。

可见性:指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java中有三个关键字能实现可见性:volatile、synchroinzed和final

有序性:如果在本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的。Java中提供了synchroinzed和volatile关键字保证有序性。

 

5.先行发生原则

判断数据是否存在竞争、线程是否安全的主要依据,根据此原则,可以解决并发环境下两个操作之间是否可能存在冲突的所有问题。

1)程序次序规则:一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。

2)管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。

3)volatile变量规则:对于一个volatile变量的写操作先行发生于后面对这个变量的读操作。

4)线程启动规则:Thread对象的start()方法发生于此线程的每一个动作

5)线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。可以通过Thread.interrupted()方法检测到是否有中断发生。

6)对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始。

7)传递性:A先发生与B,B先发生与C,则A先发生与C。

时间先后顺序与先行发生原则之间基本没有太大的关系。

 

6.线程的实现

实现线程主要有三种方式:使用内核线程实现、使用用户线程实现和使用用户线程加轻量级进程混合实现。

1)使用内核线程实现

内核线程就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程,轻量级进程就是通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能由轻量级进程,且为一对一的关系。

由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使由一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作。

但是有其局限性:由于是基于内核线程实现的,所以各种线程操作都需要进行系统调用,而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量是有限的。

2)使用用户线程实现

广义上讲,不是内核线程就是用户线程,狭义上讲,用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知线程存在的实现。使用用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要用户程序自己处理。

3)使用用户线程加轻量级进程混合实现

将内核线程与用户线程一起使用,用户线程完全建立在用户空间中,因此用户线程的创建、切换、构析等操作依然廉价,并且可以支持大规模的用户线程并发,而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险,多对多的线程模型。

4)Java的线程实现

在JDK1.2中,线程模型替换为基于操作系统原生线程模型来实现,对于Sun JDK来说,使用的是一对一的线程模型实现的,一条Java线程就映射到一条轻量级进程之中。

 

7.Java线程调度                                             

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种:协同式线程调度和抢占式线程调度。

协同式调度是指线程的执行时间由线程本身来控制,线程把自己的工作执行完之后,要主动通知系统切换到另外一个线程上。好处是实现简单,坏处是线程执行时间不可控。

抢占式调度是每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定,这种方式下,线程的执行时间是系统可控的,不会有一个线程导致整个进程阻塞的问题。

 

 

第13章    线程安全与锁优化

面向过程:将数据和过程分别作为独立的部分来考虑,数据代表问题空间的客体,程序代码则用于处理这些数据,这种思维方式直接站在计算机的角度去抽象问题和解决问题,称为面向过程的编程思想。

面向对象:站在现实世界的角度去抽象和解决问题,把数据和行为都看作对象的一部分,以符合现实世界的思维方式来编写和组织程序。

 

1.线程安全

当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。

Java语言中各种操作共享数据分为5类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

1.不可变

不可变的对象一定是线程安全的,无论是对象的方法实现还是方法的调用者,都不需要再采用任何线程安全保障措施。最简单和最纯粹。

保证对象行为不影响自己状态的途径有很多种,最简单的就是把对象中带有状态的变量都声明为final,这样在构造函数结束之后,它就是不可变的。除此之外还有String类型与枚举类。

2.绝对线程安全

绝对的线程安全需要达到上述的要求,但通常需要付出很大的,甚至有时候不切实际的代价,Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全。

3.相对线程安全

相对的线程安全就是通常意义上的线程安全,需要保证对这个对象单独操作是线程安全的,在调用时不需要做额外的保障措施,但是对于一些特定顺序的连续调用,可能需要在调用端使用额外的同步手段来保证调用的正确性。

4.线程兼容

指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全使用。

5.线程对立

指无论调用端是否采取同步措施,都无法在多线程环境中并发使用的代码。

 

2.线程安全的实现方法

1.互斥同步

常见的一种并发正确性保障手段。同步是指在多个线程并发访问共享数据时,保证共享数据在同一时刻只被一个线程使用。而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方法。互斥是因,同步是果,互斥是方法,同步是目的。

Java中,最基础的互斥同步手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解除的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference,如果没有明确指定,那就根据synchronized修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。

在执行monitorenter指令时,首先要尝试获取对象的锁,如果这个对象没有被锁定,或者当前线程已经有了那个对象的锁,把锁的计数器加1,相应的,在执行monitorexit指令时会将锁计数器减1,当计数器为0时,锁就被释放。如果获取对象锁失败,当前线程就要阻塞等待,直到对象锁被另外一个线程释放为止。

synchronized同步块对同一条线程来说是可重入的,不会出现自己把自己锁死的问题,同步块在已进入的线程执行完之前,会阻塞后面其他线程进入。但Java的线程是映射到操作系统的原生线程上,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙,从用户态转换到核心态中,因此状态转换需要耗费很多处理器时间。所以synchronized是Java语言中一个重量级的操作。而虚拟机本身会进行一些优化,避免频繁的切入到核心态中。

而ReentrantLock时表现为API层面的互斥锁,synchronized是表现为原生语法层面的互斥锁。优先考虑使用synchronized来进行同步。

2.非阻塞同步

互斥同步最主要的问题就是进行线程阻塞和唤醒所带来的性能问题,因此这种同步也成为阻塞同步,互斥同步属于一种悲观的并发策略。

而另一种则是基于冲突检测的乐观并发策略,即先进行操作,如果没有其他线程争用共享数据,那操作就成功了,如果共享数据有争用,产生了冲突,那就再采取其他的补偿措施(最常见的补偿措施就是不断重试,知道成功为止),这种乐观的并发策略的许多实现都不需要把线程挂起,因此这种同步操作称为非阻塞同步。

但非阻塞同步需要操作和冲突检测这两个步骤具备原子性,为保证原子性,只能靠硬件来完成,硬件保证一个从语义上看起来需要多此操作的行为只通过一条处理器指令就能完成。这类指令常用的有:

1)测试并设置

2)获取并增加

3)交换

4)比较并交换(CAS)

5)加载链接/条件存储(LL/SC)

CAS指令需要有三个操作数,分别是内存位置(V)、旧的预期值(A)和新值(B),CAS指令执行时,当且仅当V符合旧预期值A时,处理器用新值B更新V的值,否则它就不执行更新,但是无论是否更新了V的值,都会返回V的旧值,上述的处理过程是一个原子操作。

CAS指令有一个漏洞,如果V初次读取的是A,经过一段时间A先变成B,再又变成A,则CAS判断时会认为没有改变过,即为“ABA”漏洞。

3.无同步方案

要保证线程安全,并不是一定要进行同步,两者没有因果关系。同步只是保证共享数据争用时的正确性的手段,如果一个方法本来就不涉及共享数据,则不需任何同步措施去保证正确性,因此会有一些代码天生就是线程安全的。

可重入代码:也叫纯代码,可以在代码执行的任何时刻中断它,转而去执行另外一段代码,而控制权返回后,原来的程序不会出现任何错误。如果一个方法,它的返回结果是可以预测的,只要输入了相同的数据,就能返回相同的结果,那就满足可重入性的要求,也就是线程安全的。

线程本地存储:如果一段代码中所需要的数据必须与其他代码共享,那就看这些共享数据的代码是否能保证在同一个线程中执行。如果能保证,我们就可以把共享数据的可见范围限制在同一个线程之内,这样无须同步也能保证线程之间不出现数据争用的问题。

 

3.锁优化

1.自旋锁与自适应自旋

互斥同步对性能影响最大的就是阻塞的实现,挂起线程和恢复线程都要涉及到用户态和内核态之间的转换,给系统的并发性能带来很大的压力,而且有时候共享数据的锁定状态只会持续很短的一段时间。为了这段时间去挂起和恢复很不值得。如果让两个或两个以上的线程同时并发执行,让后面本应该进入阻塞状态的进程先等一下,自己忙循环(自旋),看看持有锁的线程是否很快就会释放锁。这就是自旋锁。

但自旋等待不能代替阻塞,首先对处理器数量有要求,而且自旋等待虽然避免了线程切换的开销,但是需要占用处理器的时间,如果锁被占用的时间过长,则自旋锁会白白消耗处理器资源,而不会做任何有用的工作,反而会来带性能上的浪费。因此自旋次数默认值是10次。

JDK1.6之后,引入自适应自旋锁,自旋的时间由前一次在同一个锁上的自旋时间及锁的拥有者的状态决定。

2.锁消除

指虚拟机即时编译器在运行时,对一些代码上要求同步,但是检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断在一段代码中,堆上的所有数据都不会逃逸出去从而被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁也就无须进行。

3.锁粗化

如果虚拟机探测到有一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,这样就只需要加锁依次就行了。

4.轻量级锁

轻量级锁的本意是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。在代码进入到同步块中,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝。

然后,虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针。如果更新成功,那么线程就有该对象的锁,并且此对象处于轻量级锁定状态。

如果更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了,如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,后面等待的线程也要进入阻塞状态。

如果没有竞争,轻量级锁使用CAS操作避免了使用互斥量的开销,如果存在锁竞争,除了互斥量的开销,还额外发生了CAS操作,因此在有竞争的情况下,轻量级锁会比传统的重量级锁更慢。

5.偏向锁

目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。

偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他线程获取,则持有偏向锁的线程将永不需要再进行同步。

当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”,即偏向模式,同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中,如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作。

当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,根据锁对象目前是否处于被锁定状态,撤销偏向后恢复到未锁定(“01”)或轻量级锁(“00”)的状态,后续的同步操作如轻量级锁那样执行。

偏向锁可以提高带有同步但无竞争的程序性能,但同样是一个带有效益权衡性质的优化,并不一定总是对程序运行有利,如果程序中大多数锁总是被多个不同的线程访问,那偏向模式就是多余的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值