2020-heibaiying-Java 虚拟机
Java 虚拟机
一、基本概念
1.1 OpenJDK
自JDK 1.0
发布以来,JDK 1.1
、JDK 1.2
、JDK 1.3
、JDK 1.4
、JDK 5
,JDK 6
,这些版本的
1.2 OracleJDK
在
在
在
- 一个是在
GPLv2 + CE 协议下由Oracle 开源的OpenJDK ; - 一个是在
OTN 协议下正常发行的OracleJDK 。
两者共享大部分源码,在功能上几乎一致。唯一的区别是
1.3 HotSpot VM
它是
我们可以在自己的电脑上使用 java -version
来获得
C:\Users> java -version
java version "1.8.0_171" # 如果是openJDK, 则这里会显示:openjdk version
Java(TM) SE Runtime Environment (build 1.8.0_171-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.171-b11, mixed mode) # 使用的是HotSpot虚拟机,默认为服务端模式
二、Java 内存区域

2.1 程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。每条线程都拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。
2.2 Java 虚拟机栈
- 如果线程请求的栈深度大于虚拟机所允许的栈深度,将抛出
StackOverflowError
异常; - 如果
Java 虚拟机栈的容量允许动态扩展,当栈扩展时如果无法申请到足够的内存会抛出OutOfMemoryError
异常。
2.3 本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:
2.4 Java 堆
-Xmx
和最小值参数 -Xms
进行设定。如果OutOfMemoryError
异常。
2.5 方法区
方法区(Method Area)也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区也被称为 “非堆”,目的是与OutOfMemoryError
异常。
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放常量池表(Constant Pool Table
三、对象
3.1 对象的创建
当我们在代码中使用 new
关键字创建一个对象时,其在虚拟机中需要经过以下步骤:
当虚拟机遇到一条字节码 new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
在类加载检查通过后,虚拟机需要新生对象分配内存空间。根据
- 指针碰撞:假设
Java 堆中内存是绝对规整的,所有使用的内存放在一边,所有未被使用的内存放在另外一边,中间以指针作为分界点指示器。此时内存分配只是将指针向空闲方向偏移出对象大小的空间即可,这种方式被称为指针碰撞。

- 空闲列表:如果
Java 堆不是规整的,此时虚拟机需要维护一个列表,记录哪些内存块是可用的,哪些是不可用的。在进行内存分配时,只需要从该列表中选取出一块足够的内存空间划分给对象实例即可。
注:
Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,后文将会介绍。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象
- 方式一:采用同步锁定,或采用
CAS 配上失败重试的方式来保证更新操作的原子性。 - 方式二:为每个线程在
Java 堆中预先分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB) 。线程在进行内存分配时优先使用本地缓冲,当本地缓冲使用完成后,再向Java 堆申请分配,此时Java 堆采用同步锁定的方式来保证分配行为的线程安全。
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
调用对象的构造函数,即<init>()
来初始化对象,为相关字段赋值。
3.2 对象的内存布局
在
对象头包括两部分信息:
- Mark Word:对象自身的运行时数据,如哈希码、
GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID 、偏向时间戳等,官方统称为Mark Word 。 - 类型指针:对象指向它类型元数据的指针,
Java 虚拟机通过这个指针来确定该对象是哪个类的示例。需要说明的是并非所有的虚拟机都必须要在对象数据上保留类型指针,这取决于对象的访问定位方式(详见下文) 。
即我们在程序代码中定义的各种类型的字段的内容,无论是从父类继承而来,还是子类中定义的都需要记录。
主要起占位符的作用。
3.3 对象的访问定位
对象创建后,reference
来操作堆上的具体对象reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
- 句柄访问:
Java 堆将划分出一块内存来作为句柄池,reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。 - 指针访问:
reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。
通过句柄访问对象:

通过直接指针访问对象:

句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本生并不需要修改;指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,
四、垃圾收集算法
在
4.1 Java 堆回收
在
1. 引用计数法
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互循环引用的问题:
objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
2. 可达性分析
上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference ChainGC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明此该对象不可能再被使用。

在GC Roots
的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区中类静态属性引用的对象,譬如
Java 类中引用类型的静态变量; - 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用;
- 在本地方法栈中的
JNI (即Native 方法)引用的对象; Java 虚拟机内部的引用,如基本数据类型对应的Class 对象,一些常驻的异常对象(如NullPointException ,OutOfMemoryError 等)及系统类加载器;- 所有被同步锁(
synchronized 关键字)持有的对象; - 反应
Java 虚拟机内部情况的JMXBean ,JVMTI 中注册的回调,本地代码缓存等。
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
3. 对象引用
可达性分析是基于引用链进行判断的,在
- 强引用
(Strongly Reference) :最传统的引用,如Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。 - 软引用
(Soft Reference) :用于描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常之前,会被列入回收范围内进行第二次回收,如果这次回收后还没有足够的内存,才会抛出内存溢出异常。 - 弱引用
(Weak Reference) :用于描述那些非必须的对象,强度比软引用弱。被弱引用关联对象只能生存到下一次垃圾收集发生时,无论当前内存是否足够,弱引用对象都会被回收。 - 虚引用
(Phantom Reference) :最弱的引用关系。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被回收时收到一个系统通知。
4. 对象真正死亡
要真正宣告一个对象死亡,需要经过至少两次标记过程:
- 如果对象在进行可达性分析后发现
GC Roots
不可达,将会进行第一次标记; - 随后进行一次筛选,筛选的条件是此对象是否有必要执行
finalized()
方法。如果对象没有覆盖finalized()
方法,或者finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。如果判定结果是有必要执行,此时对象会被放入名为F-Queue
的队列,等待Finalizer 线程执行其finalized()
方法。在这个过程中,收集器会进行第二次小规模的标记,如果对象在finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。
4.2 方法区回收
在
4.3 垃圾收集算法
1. 分代收集理论
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
- 弱分代假说
(Weak Generational Hypothesis) :绝大多数对象都是朝生夕灭的。 - 强分代假说
(Strong Generational Hypothesis) :熬过越多次垃圾收集过程的对象就越难以消亡。 - 跨带引用假说
(Intergenerational Reference Hypothesis) :基于上面两条假说还可以得出的一条隐含推论:存在相互引用关系的两个对象,应该倾向于同时生存或者同时消亡。
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将
2. 回收类型
根据分代收集理论,收集范围可以分为以下几种类型:
- 部分收集
(Partial GC) :具体分为:- 新生代收集(Minor GC / Young GC
) :只对新生代进行垃圾收集; - 老年代收集(Major GC / Old GC
) :只对老年代进行垃圾收集。需要注意的是Major GC 在有的语境中也用于指代整堆收集; - 混合收集(Mixed GC
) :对整个新生代和部分老年代进行垃圾收集。
- 新生代收集(Minor GC / Young GC
- 整堆收集
(Full GC) :收集整个Java 堆和方法区。
3. 标记- 清除算法
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。

它主要有以下两个缺点:
- 执行效率不稳定:如果
Java 堆上包含大量需要回收的对象,则需要进行大量标记和清除动作; - 内存空间碎片化:标记清除后会产生大量不连续的空间,从而可能导致无法为大对象分配足够的连续内存。
4. 标记- 复制算法
标记
- 如果内存中多数对象都是存活的,这种算法将产生大量的复制开销;
- 浪费内存空间,内存空间变为了原有的一半。

基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照Eden
和 两块较小的 Survivor
空间,它们之间的比例是Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
5. 标记- 整理算法
标记

五、经典垃圾收集器
并行与并发是并发编程中的专有名词,在谈论垃圾收集器的上下文语境中,它们的含义如下:
-
并行
(Parallel) :并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,此时通常默认用户线程是处于等待状态。 -
并发
(Concurrent) :并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。但由于垃圾收集器线程会占用一部分系统资源,所以程序的吞吐量依然会受到一定影响。

注:收集器之间存在连线,则代表它们可以搭配使用。
5.1 Serial 收集器

5.2 ParNew 收集器
他是

5.3 Parallel Scavenge 收集器
吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)
- -XX:MaxGCPauseMillis:控制最大垃圾收集时间,假设需要回收的垃圾总量不变,那么降低垃圾收集的时间就会导致收集频率变高,所以需要将其设置为合适的值,不能一味减小。
- -XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于
0 小于100 的整数。假设把它设置为19 ,表示此时允许的最大垃圾收集时间占总时间的5% (即1/(1+19) ) ;默认值为99 ,即允许最大1% ( 1/(1+99) )的垃圾收集时间。
5.4 Serial Old 收集器
从名字也可以看出来,它是

5.5 Paralled Old 收集器

5.6 CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于 标记
- 初始标记
(inital mark) :标记GC Roots
能直接关联到的对象,耗时短但需要暂停用户线程; - 并发标记
(concurrent mark) :从GC Roots
能直接关联到的对象开始遍历整个对象图,耗时长但不需要暂停用户线程; - 重新标记
(remark) :采用增量更新算法,对并发标记阶段因为用户线程运行而产生变动的那部分对象进行重新标记,耗时比初始标记稍长且需要暂停用户线程; - 并发清除
(inital sweep) :并发清除掉已经死亡的对象,耗时长但不需要暂停用户线程。

其优点在于耗时长的 并发标记 和 并发清除 阶段都不需要暂停用户线程,因此其停顿时间较短,其主要缺点如下:
- 由于涉及并发操作,因此对处理器资源比较敏感。
- 由于是基于 标记
- 清除 算法实现的,因此会产生大量空间碎片。 - 无法处理浮动垃圾(Floating Garbage
) :由于并发清除时用户线程还是在继续,所以此时仍然会产生垃圾,这些垃圾就被称为浮动垃圾,只能等到下一次垃圾收集时再进行清理。
5.7 Garbage First 收集器
Garbage First(简称Eden
空间、Survivor
空间或者老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略。

上面还有一些
- 初始标记
(Inital Marking) :标记GC Roots
能直接关联到的对象,并且修改TAMS (Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在Reigin 中分配新对象。G1 为每一个Reigin 都设计了两个名为TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围; - 并发标记
(Concurrent Marking) :从GC Roots
能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理SATB 记录中变动的对象。SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比CMS 重新标记阶段所使用的增量更新算法效率更高; - 最终标记
(Final Marking) :对用户线程做一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的少量的STAB 记录。虽然并发标记阶段会处理SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理; - 筛选回收
(Live Data Counting and Evacuation) :负责更新Regin 统计数据,按照各个Regin 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个Regin 构成回收集。然后将回收集中Regin 的存活对象复制到空的Regin 中,再清理掉整个旧的Regin 。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行。

5.8 内存分配原则
1. 对象优先在Eden 分配
大多数情况下,对象在新生代的 Eden
区中进行分配,当 Eden
区没有足够空间时,虚拟机将进行一次
2. 大对象直接进入老年代
大对象就是指需要大量连续内存空间的
3. 长期存活的对象将进入老年代
虚拟机会给每个对象在其对象头中定义一个年龄计数器。对象通常在 Eden
区中诞生,如果经历第一次-XX:MaxTenuringThreshold
设置,默认值为
4. 动态年龄判断
如果在-XX:MaxTenuringThreshold
设置的值。
5. 空间担保分配
在发生-XX:HandlePromotionFailure
的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次-XX:HandlePromotionFailure
的值设置不允许冒险,那么就要改为进行一次
六、虚拟机类加载机制
6.1 类加载时机
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、卸载、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接:

《
- 遇到
new
、getstatic
、putstatic
、invokestatic
这四条字节码指令,如果类型进行过初始化,则需要先触发其进行初始化,能够生成这四条指令码的典型Java 代码场景有:- 使用
new
关键字实例化对象时; - 读取或设置一个类型的静态字段时(被
final 修饰,已在编译期把结果放入常量池的静态字段除外) ; - 调用一个类型的静态方法时。
- 使用
- 使用
java.lang.reflect
包的方法对类型进行反射调用时,如果类型没有进行过初始化、则需要触发其初始化; - 当初始化类时,如发现其父类还没有进行过初始化、则需要触发其父类进行初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含
main() 方法的那个类) ,虚拟机会先初始化这个主类; - 当使用
JDK 7 新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle
实例最后解析的结果为REF_getStatic
,REF_putStatic
,REF_invokeStatic
,REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化; - 当一个接口中定义了
JDK 8 新加入的默认方法(被default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那么该接口要在其之前被初始化。
6.2 类加载过程
1. 加载
在加载阶段,虚拟机需要完成以下三件事:
- 通过一个类的全限定名来获取定义此类的二进制字节流 ;
- 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构;
- 在内存中生成一个代表这个类的
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。
《
2. 验证
这一阶段的目的是确保
- 文件格式验证:验证字节流是否符合
Class 文件格式的规范; - 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合《
Java 语言规范》的要求(如除了java.lang.Object
外,所有的类都应该有父类) ; - 字节码验证:通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的(如允许把子类对象赋值给父类数据类型,但不能把父类对象赋值给子类数据类型
) ; - 符号引用验证:验证类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。如果无法验证通过,则会抛出一个
java.lang.IncompatibleClassChangeError
的子类异常,如java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
3. 准备
准备阶段是正式为类中定义的变量(即静态变量,被
4. 解析
解析是
- 符号引用:符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
- 直接引用:直接引用是指可以直接指向目标的指针、相对偏移量或者一个能间接定位到目标的句柄。
整个解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这
5. 初始化
初始化阶段就是执行类构造器的 <clinit>()
方法的过程,该方法具有以下特点:
<clinit>()
方法由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,编译器收集顺序由语句在源文件中出现的顺序决定。<clinit>()
方法与类的构造器函数(即在虚拟机视角中的实例构造器<init>()
方法)不同,它不需要显示的调用父类的构造器,Java 虚拟机会保证在子类的<clinit>()
方法执行前,父类的<clinit>()
方法已经执行完毕。- 由于父类的
<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类变量的赋值操作。 <clinit>()
方法对于类或者接口不是必须的,如果一个类中没有静态语句块,也没有对变量进行赋值操作,那么编译器可以不为这个类生成<clinit>()
方法。- 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成
<clinit>()
方法。 Java 虚拟机必须保证一个类的<clinit>()
方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()
方法,其他线程都需要阻塞等待。
6.3 类加载器
能够通过一个类的全限定名来获取描述该类的二进制字节流的工具称为类加载器。每一个类加载器都拥有一个独立的类名空间,因此对于任意一个类,都必须由加载它的类加载器和这个类本身来共同确立其在
6.4 双亲委派模型
从
- 启动类加载器:启动类加载器(Bootstrap ClassLoader)由
C++ 语言实现(以HotSpot 为例) ,它是虚拟机自身的一部分; - 其他所有类的类加载器:由
Java 语言实现,独立存在于虚拟机外部,并且全部继承自java.lang.ClassLoader
。
从开发人员角度而言,类加载器可以分为以下三类:
- 启动类加载器
(Boostrap Class Loader) :负责把存放在<JAVA_HOME>\lib
目录中,或被-Xbootclasspath
参数所指定的路径中存放的能被Java 虚拟机识别的类库加载到虚拟机的内存中; - 扩展类加载器
(Extension Class Loader) :负责加载<JAVA_HOME>\lib\ext
目录中,或被java.ext.dirs
系统变量所指定的路径中的所有类库。 - 应用程序类加载器
(Application Class Loader) :负责加载用户类路径(ClassPath)上的所有的类库。

上图所示的各种类加载器之间的层次关系被称为类加载器的 “双亲委派模型”
双亲委派模型的工作过程如下:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。基于双亲委派模型可以保证程序中的类在各种类加载器环境中都是同一个类,否则就有可能出现一个程序中存在两个不同的 java.lang.Object
的情况。
6.5 模块化下的类加载器
- 仍维持三层类加载器和双亲委派的架构,但扩展类加载器被平台类加载器所取代;
- 当平台及应用程序类加载器收到类加载请求时,要首先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载;
- 启动类加载器、平台类加载器、应用程序类加载器全部继承自
java.internal.loader.BuiltinClassLoader
,BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。

七、程序编译
7.1 编译器分类
- 前端编译器:把
*.java
文件转变成.class
文件的过程;如JDK 的Javac ,Eclipse JDT 中的增量式编译器。 - 即时编译器:常称为
JIT 编译器(Just In Time Complier) ,在运行期把字节码转变成本地机器码的过程;如HotSpot 虚拟机中的C1 、C2 编译器,Graal 编译器。 - 提前编译器:直接把程序编译成目标机器指令集相关的二进制代码的过程。如
JDK 的jaotc ,GUN Compiler for the Java(GCJ) ,Excelsior JET 。
7.2 解释器与编译器
在
- 客户端编译器
(Client Complier) :简称C1 ; - 服务端编译器
(Servier Complier) :简称C2 ,在有的资料和JDK 源码中也称为Opto 编译器; Graal 编译器:在JDK 10 时才出现,长期目标是替代C2 。
在分层编译的工作模式出现前,采用客户端编译器还是服务端编译器完全取决于虚拟机是运行在客户端模式还是服务端模式下,可以在启动时通过 -client
或 -server
参数进行指定,也可以让虚拟机根据自身版本和宿主机性能来自主选择。
7.3 分层编译
要编译出优化程度越高的代码通常都需要越长的编译时间,为了在程序启动速度与运行效率之间达到最佳平衡,
- 第
0 层:程序纯解释执行,并且解释器不开启性能监控功能; - 第
1 层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能; - 第
2 层:仍然使用客户端编译执行,仅开启方法及回边次数统计等有限的性能监控; - 第
3 层:仍然使用客户端编译执行,开启全部性能监控; - 第
4 层:使用服务端编译器将字节码编译为本地代码,其耗时更长,并且会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互转换关系如下图所示:

实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,可以用客户端编译器获取更高的编译速度、用服务端编译器来获取更好的编译质量。
7.4 热点探测
即时编译器编译的目标是 “热点代码”,它主要分为以下两类:
- 被多次调用的方法。
- 被多次执行循环体。这里指的是一个方法只被少量调用过,但方法体内部存在循环次数较多的循环体,此时也认为是热点代码。但编译器编译的仍然是循环体所在的方法,而不会单独编译循环体。
判断某段代码是否是热点代码的行为称为 “热点探测” (Hot Spot Code Detection
- 基于采样的热点探测
(Sample Based Hot Spot Code Detection) :采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么就认为它是 “热点方法”。 - 基于计数的热点探测
(Counter Based Hot Spot Code Detection) :采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。
八、代码优化
即时编译器除了将字节码编译为本地机器码外,还会对代码进行一定程度的优化,它包含多达几十种优化技术,这里选取其中代表性的四种进行介绍:
8.1 方法内联
最重要的优化手段,它会将目标方法中的代码原封不动地 “复制” 到发起调用的方法之中,避免发生真实的方法调用,并采用名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术来解决虚方法(
8.2 逃逸分析
逃逸行为主要分为以下两类:
- 方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,此时称为方法逃逸;
- 线程逃逸:当一个对象在方法里面被定义后,它可能被外部线程所访问,例如赋值给可以在其他线程中访问的实例变量,此时称为线程,其逃逸程度高于方法逃逸。
public static StringBuilder concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb; // 发生了方法逃逸
}
public static String concat(String... strings) {
StringBuilder sb = new StringBuilder();
for (String string : strings) {
sb.append(string);
}
return sb.toString(); // 没有发生方法逃逸
}
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程
- 栈上分配
(Stack Allocations) :如果一个对象不会逃逸到线程外,那么将会在栈上分配内存来创建这个对象,而不是Java 堆上,此时对象所占用的内存空间就会随着栈帧的出栈而销毁,从而可以减轻垃圾回收的压力。 - 标量替换
(Scalar Replacement) :如果一个数据已经无法再分解成为更小的数据类型,那么这些数据就称为标量(如int 、long 等数值类型及reference 类型等) ;反之,如果一个数据可以继续分解,那它就被称为聚合量(如对象) 。如果一个对象不会逃逸外方法外,那么就可以将其改为直接创建若干个被这个方法使用的成员变量来替代,从而减少内存占用。 - 同步消除
(Synchronization Elimination) :如果一个变量不会逃逸出线程,那么对这个变量实施的同步措施就可以消除掉。
8.3 公共子表达式消除
如果一个表达式
8.4 数组边界检查消除
对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的上下文检查以避免访问越界。如果数组的访问发生在循环之中,并且使用循环变量来访问数据,即循环变量的取值永远在
参考资料
- 主要参考自:周志明
. 深入理解Java 虚拟机(第3 版). 机械工业出版社, 2019-12 ,想要深入了解虚拟机的话,推荐阅读原书。 - 美团技术团队 ——
Java Hotspot G1 GC 的一些关键技术