自 1996 年 JDK 1.0
发布以来,Sun 公司在大版本上发行了 JDK 1.1
、JDK 1.2
、JDK 1.3
、JDK 1.4
、JDK 5
,JDK 6
,这些版本的 JDK 都可以统称为 SunJDK 。之后在 2006 年的 JavaOne 大会上,Sun 公司宣布将 Java 开源,在随后的一年多里,它陆续将 JDK 的各个部分在 GPL v2(GNU General Public License,version 2)协议下开源,并建立了 OpenJDK 组织来对这些代码进行独立的管理,这就是 OpenJDK 的来源,此时的 OpenJDK 拥有当时 sunJDK 7 的几乎全部代码。
在 JDK 7 的开发期间,由于各种原因的影响 Sun 公司市值一路下跌,已无力推进 JDK 7 的开发,JDK 7 的发布一直被推迟。之后在 2009 年 Sun 公司被 Oracle 公司所收购,为解决 JDK 7 长期跳票的问题,Oracle 将 JDK 7 中大部分未能完成的项目推迟到 JDK 8 ,并于 2011 年发布了JDK 7,在这之后由 Oracle 公司正常发行的 JDK 版本就由 SunJDK 改称为 Oracle JDK。
在 2017 年 JDK 9 发布后,Oracle 公司宣布从此以后 JDK 将会在每年的 3 月和 9 月各发布一个大版本,即半年发行一个大版本,目的是为了避免众多功能被捆绑到一个 JDK 版本上而引发的无法交付的风险。
在 JDK 11 发布后,Oracle 同步调整了 JDK 的商业授权,宣布从 JDK 11 起将以前的商业特性全部开源给 OpenJDK ,这样 OpenJDK 11 和 OracleJDK 11 的代码和功能,在本质上就完全相同了。同时还宣布以后都会发行两个版本的 JDK :
两者共享大部分源码,在功能上几乎一致。唯一的区别是 Oracle OpenJDK 可以在开发、测试或者生产环境中使用,但只有半年的更新支持;而 OracleJDK 对个人免费,但在生产环境中商用收费,可以有三年时间的更新支持。
它是 Sun/Oracle JDK 和 OpenJDK 中默认的虚拟机,也是目前使用最为广泛的虚拟机。最初由 Longview Technologies 公司设计发明,该公司在 1997 年被 Sun 公司收购,随后 Sun 公司在 2006 年开源 SunJDK 时也将 HotSpot 虚拟机一并进行了开源。之后 Oracle 收购 Sun 以后,建立了 HotRockit 项目,并将其收购的另外一家公司(BEA)的 JRockit 虚拟机中的优秀特性集成到 HotSpot 中。HotSpot 在这个过程里面移除掉永久代,并吸收了 JRockit 的 Java Mission Control 监控工具等功能。到 JDK 8 发行时,采用的就是集两者之长的 HotSpot VM 。
我们可以在自己的电脑上使用 java -version
来获得 JDK 的信息:
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虚拟机,默认为服务端模式
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器通过改变程序计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要该计数器来完成。每条线程都拥有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。
Java 虚拟机栈(Java Virtual Machine Stack)也为线程私有,它描述的是 Java 方法执行的线程内存模型:每个方法被执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息。方法从调用到结束就对应着一个栈帧从入栈到出栈的过程。在《Java 虚拟机规范》中,对该内存区域规定了两类异常:
StackOverflowError
异常;OutOfMemoryError
异常。本地方法栈(Native Method Stacks)与虚拟机栈类似,其区别在于:Java 虚拟机栈是为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
Java 堆(Java Heap)是虚拟机所管理的最大一块的内存空间,它被所有线程所共享,用于存放对象实例。Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为是连续的。Java 堆可以被实现成固定大小的,也可以是可扩展的,当前大多数主流的虚拟机都是按照可扩展来实现的,即可以通过最大值参数 -Xmx
和最小值参数 -Xms
进行设定。如果 Java 堆中没有足够的内存来完成实例分配,并且堆也无法再扩展时,Java 虚拟机将会抛出 OutOfMemoryError
异常。
方法区(Method Area)也是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区也被称为 “非堆”,目的是与 Java 堆进行区分。《Java 虚拟机规范》规定,如果方法区无法满足新的内存分配需求时,将会抛出 OutOfMemoryError
异常。
运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放常量池表(Constant Pool Table),常量池表中存放了编译期生成的各种符号字面量和符号引用。
当我们在代码中使用 new
关键字创建一个对象时,其在虚拟机中需要经过以下步骤:
1. 类加载过程
当虚拟机遇到一条字节码 new
指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就必须先执行相应的类加载过程。
2. 分配内存
在类加载检查通过后,虚拟机需要新生对象分配内存空间。根据 Java 堆是否规整,可以有以下两种分配方案:
注:Java 堆是否规整取决于其采用的垃圾收集器是否带有空间压缩整理能力,后文将会介绍。
除了分配方式外,由于对象创建在虚拟机中是一个非常频繁的行为,此时需要保证在并发环境下的线程安全:如果一个线程给对象 A 分配了内存空间,但指针还没来得及修改,此时就可能出现另外一个线程使用原来的指针来给对象 B 分配内存空间的情况。想要解决这个问题有两个方案:
3. 对象头设置
将对象有关的元数据信息、对象的哈希码、分代年龄等信息存储到对象头中。
4. 对象初始化
调用对象的构造函数,即 Class 文件中的 <init>()
来初始化对象,为相关字段赋值。
在 HotSpot 虚拟机中,对象在堆内存中的存储布局可以划分为以下三个部分:
1. 对象头 (Header)
对象头包括两部分信息:
2. 实例数据 (Instance Data)
即我们在程序代码中定义的各种类型的字段的内容,无论是从父类继承而来,还是子类中定义的都需要记录。
3. 对齐填充 (Padding)
主要起占位符的作用。HotSpot 虚拟机要求对象起始地址必须是 8 字节的整倍数,即间接要求了任何对象的大小都必须是 8 字节的整倍数。对象头部分在设计上就是 8 字节的整倍数,如果对象的实例数据不是 8 字节的整倍数,则由对齐填充进行补全。
对象创建后,Java 程序就可以通过栈上的 reference
来操作堆上的具体对象。《Java 虚拟机规范》规定 reference
是一个指向对象的引用,但并未规定其具体实现方式。主流的方式方式有以下两种:
reference
中存储的是对象的句柄地址,而句柄则包含了对象实例数据和类型数据的地址信息。reference
中存储的直接就是对象地址,而对象的类型数据则由上文介绍的对象头中的类型指针来指定。通过句柄访问对象:
通过直接指针访问对象:
句柄访问的优点在于对象移动时(垃圾收集时移动对象是非常普遍的行为)只需要改变句柄中实例数据的指针,而 reference
本生并不需要修改;指针访问则反之,由于其 reference
中存储的直接就是对象地址,所以当对象移动时, reference
需要被修改。但针对只需要访问对象本身的场景,指针访问则可以减少一次定位开销。由于对象访问是一项非常频繁的操作,所以这类减少的效果会非常显著,基于这个原因,HotSpot 主要使用的是指针访问的方式。
在 Java 虚拟机内存模型中,程序计数器、虚拟机栈、本地方法栈这 3 个区域都是线程私有的,会随着线程的结束而销毁,因此在这 3 个区域当中,无需过多考虑垃圾回收问题。垃圾回收问题主要发生在 Java 堆和方法区上。
在 Java 堆上,垃圾回收的主要内容是死亡对象(不可能再被任何途径使用的对象)。而判断对象是否死亡有以下两种方法:
在对象中添加一个引用计数器,对象每次被引用时,该计数器加一;当引用失效时,计数器的值减一;只要计数器的值为零,则代表对应的对象不可能再被使用。该方法的缺点在于无法避免相互循环引用的问题:
objA.instance = objB
objB.instance = objA
objA = null;
objB = null;
System.gc();
如上所示,此时两个对象已经不能再被访问,但其互相持有对对方的引用,如果采用引用计数法,则两个对象都无法被回收。
上面的代码在大多数虚拟机中都能被正确的回收,因为大多数主流的虚拟机都是采用的可达性分析方法来判断对象是否死亡。可达性分析是通过一系列被称为 GC Roots
的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径被称为引用链(Reference Chain),如果某个对象到 GC Roots
间没有任何引用链相连,这代表 GC Roots
到该对象不可达, 此时证明此该对象不可能再被使用。
在 Java 语言中,固定可作为 GC Roots
的对象包括以下几种:
除了这些固定的 GC Roots
集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域的不同,还可能会有其他对象 “临时性” 地加入,共同构成完整的 GC Roots
集合。
可达性分析是基于引用链进行判断的,在 JDK 1.2 之后,Java 将引用关系分为以下四类:
Object obj = new Object()
。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。要真正宣告一个对象死亡,需要经过至少两次标记过程:
GC Roots
不可达,将会进行第一次标记;finalized()
方法。如果对象没有覆盖 finalized()
方法,或者 finalized()
已经被虚拟机调用过,这两种情况都会视为没有必要执行。如果判定结果是有必要执行,此时对象会被放入名为 F-Queue
的队列,等待 Finalizer 线程执行其 finalized()
方法。在这个过程中,收集器会进行第二次小规模的标记,如果对象在 finalized()
方法中重新将自己与引用链上的任何一个对象进行了关联,如将自己(this 关键字)赋值给某个类变量或者对象的成员变量,此时它就实现了自我拯救,则第二次标记会将其移除 “即将回收” 的集合,否则该对象就将被真正回收,走向死亡。在 Java 堆上进行对象回收的性价比通常比较高,因为大多数对象都是朝生夕灭的。而方法区由于回收条件比较苛刻,对应的回收性价比通常比较低,主要回收两部分内容:废弃的常量和不再使用的类型。
当前大多数虚拟机都遵循 “分代收集” 的理论进行设计,它建立在强弱两个分代假说下:
强弱分代假说奠定了垃圾收集器的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄就是对象经历垃圾收集的次数)分配到不同的区域中进行存储。之后如果一个区域中的对象都是朝生夕灭的,那么收集器只需要关注少量对象的存活而不是去标记那些大量将要被回收的对象,此时就能以较小的代价获取较大的空间。最后再将难以消亡的对象集中到一块,根据强分代假说,它们是很难消亡的,因此虚拟机可以使用较低的频率进行回收,这就兼顾了时间和内存空间的开销。
根据分代收集理论,收集范围可以分为以下几种类型:
它是最基础的垃圾收集算法,收集过程分为两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象;也可以反过来,标记存活对象,统一回收所有未被标记的对象。
它主要有以下两个缺点:
标记-复制算法基于 ”半区复制“ 算法:它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块的内存使用完了,就将还存活着的对象复制到另外一块上面,然后再把已经使用过的那块内存空间一次性清理掉。其优点在于避免了内存空间碎片化的问题,其缺点如下:
基于新生代 “朝生夕灭” 的特点,大多数虚拟机都不会按照 1:1 的比例来进行内存划分,例如 HotSpot 虚拟机会将内存空间划分为一块较大的 Eden
和 两块较小的 Survivor
空间,它们之间的比例是 8:1:1 。 每次分配时只会使用 Eden
和其中的一块 Survivor
,发生垃圾回收时,只需要将存活的对象一次性复制到另外一块 Survivor
上,这样只有 10% 的内存空间会被浪费掉。当 Survivor
空间不足以容纳一次 Minor GC
时,此时由其他内存区域(通常是老年代)来进行分配担保。
标记-整理算法是在标记完成后,让所有存活对象都向内存的一端移动,然后直接清理掉边界以外的内存。其优点在于可以避免内存空间碎片化的问题,也可以充分利用内存空间;其缺点在于根据所使用的收集器的不同,在移动存活对象时可能要全程暂停用户程序:
并行与并发是并发编程中的专有名词,在谈论垃圾收集器的上下文语境中,它们的含义如下:
并行 (Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,此时通常默认用户线程是处于等待状态。
并发 (Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。但由于垃圾收集器线程会占用一部分系统资源,所以程序的吞吐量依然会受到一定影响。
HotSpot 虚拟机中一共存在七款经典的垃圾收集器:
注:收集器之间存在连线,则代表它们可以搭配使用。
Serial 收集器是最基础、历史最悠久的收集器,它是一个单线程收集器,在进行垃圾回收时,必须暂停其他所有的工作线程,直到收集结束,这是其主要缺点。它的优点在于单线程避免了多线程复杂的上下文切换,因此在单线程环境下收集效率非常高,由于这个优点,迄今为止,其仍然是 HotSpot 虚拟机在客户端模式下默认的新生代收集器:
他是 Serial 收集器的多线程版本,可以使用多条线程进行垃圾回收:
Parallel Scavenge 也是新生代收集器,基于 标记-复制 算法进行实现,它的目标是达到一个可控的吞吐量。这里的吞吐量指的是处理器运行用户代码的时间与处理器总消耗时间的比值:
吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)
Parallel Scavenge 收集器提供两个参数用于精确控制吞吐量:
从名字也可以看出来,它是 Serial 收集器的老年代版本,同样是一个单线程收集器,采用 标记-整理 算法,主要用于给客户端模式下的 HotSpot 虚拟机使用:
Paralled Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,采用 标记-整理 算法实现:
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于 标记-清除 算法实现,整个收集过程分为以下四个阶段:
GC Roots
能直接关联到的对象,耗时短但需要暂停用户线程;GC Roots
能直接关联到的对象开始遍历整个对象图,耗时长但不需要暂停用户线程;其优点在于耗时长的 并发标记 和 并发清除 阶段都不需要暂停用户线程,因此其停顿时间较短,其主要缺点如下:
Garbage First(简称 G1)是一款面向服务端的垃圾收集器,也是 JDK 9 服务端模式下默认的垃圾收集器,它的诞生具有里程碑式的意义。G1 虽然也遵循分代收集理论,但不再以固定大小和固定数量来划分分代区域,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region)。每一个 Region 都可以根据不同的需求来扮演新生代的 Eden
空间、Survivor
空间或者老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略。
上面还有一些 Region 使用 H 进行标注,它代表 Humongous,表示这些 Region 用于存储大对象(humongous object,H-obj),即大小大于等于 region 一半的对象。G1 收集器的运行大致可以分为以下四个步骤:
GC Roots
能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象。G1 为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围;GC Roots
能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象。SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高;大多数情况下,对象在新生代的 Eden
区中进行分配,当 Eden
区没有足够空间时,虚拟机将进行一次 Minor GC。
大对象就是指需要大量连续内存空间的 Java 对象,最典型的就是超长的字符串或者元素数量很多的数组,它们将直接进入老年代。主要是因为如果在新生代分配,因为其需要大量连续的内存空间,可能会导致提前触发垃圾回收;并且由于新生代的垃圾回收本身就很频繁,此时复制大对象也需要额外的性能开销。
虚拟机会给每个对象在其对象头中定义一个年龄计数器。对象通常在 Eden
区中诞生,如果经历第一次 Minor GC 后仍然存活,并且能够被 Survivor 容纳的话,该对象就会被移动到 Survivor 中,并将其年龄加 1。对象在 Survivor 中每经过一次 Minor GC,年龄就加 1,当年龄达到一定程度后(由 -XX:MaxTenuringThreshold
设置,默认值为 15)就会进入老年代中。
如果在 Survivor 空间中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,而无需等待年龄到达 -XX:MaxTenuringThreshold
设置的值。
在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,那么这一次的 Minor GC 可以确认是安全的。如果不成立,虚拟机会查看 -XX:HandlePromotionFailure
的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于或者 -XX:HandlePromotionFailure
的值设置不允许冒险,那么就要改为进行一次 Full GC 。
Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称为虚拟机的类加载机制。
一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验证、准备、卸载、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接:
《Java 虚拟机规范》严格规定了有且只有六种情况必须立即对类进行初始化:
new
、 getstatic
、 putstatic
、 invokestatic
这四条字节码指令,如果类型进行过初始化,则需要先触发其进行初始化,能够生成这四条指令码的典型 Java 代码场景有:
new
关键字实例化对象时;java.lang.reflect
包的方法对类型进行反射调用时,如果类型没有进行过初始化、则需要触发其初始化;java.lang.invoke.MethodHandle
实例最后解析的结果为 REF_getStatic
, REF_putStatic
, REF_invokeStatic
, REF_newInvokeSpecial
四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化;在加载阶段,虚拟机需要完成以下三件事:
java.lang.Class
对象,作为方法区这个类的各种数据的访问入口。《Java 虚拟机规范》并没有限制从何处获取二进制流,因此可以从 JAR 包、WAR 包获取,也可以从 JSP 生成的 Class 文件等处获取。
这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,从而保证这些信息被当做代码运行后不会危害虚拟机自身的安全。验证阶段大致会完成下面四项验证:
java.lang.Object
外,所有的类都应该有父类);java.lang.IncompatibleClassChangeError
的子类异常,如 java.lang.NoSuchFieldError
、 java.lang.NoSuchMethodError
等。准备阶段是正式为类中定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段。
解析是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程:
整个解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行解析。
初始化阶段就是执行类构造器的 <clinit>()
方法的过程,该方法具有以下特点:
<clinit>()
方法由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生,编译器收集顺序由语句在源文件中出现的顺序决定。<clinit>()
方法与类的构造器函数(即在虚拟机视角中的实例构造器 <init>()
方法)不同,它不需要显示的调用父类的构造器,Java 虚拟机会保证在子类的 <clinit>()
方法执行前,父类的 <clinit>()
方法已经执行完毕。<clinit>()
方法先执行,也就意味着父类中定义的静态语句块要优先于子类变量的赋值操作。<clinit>()
方法对于类或者接口不是必须的,如果一个类中没有静态语句块,也没有对变量进行赋值操作,那么编译器可以不为这个类生成 <clinit>()
方法。<clinit>()
方法。<clinit>()
方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 <clinit>()
方法,其他线程都需要阻塞等待。能够通过一个类的全限定名来获取描述该类的二进制字节流的工具称为类加载器。每一个类加载器都拥有一个独立的类名空间,因此对于任意一个类,都必须由加载它的类加载器和这个类本身来共同确立其在 Java 虚拟机中的唯一性。这意味着要想比较两个类是否相等,必须在同一类加载器加载的前提下;如果两个类的类加载器不同,则它们一定不相等。
从 Java 虚拟机角度而言,类加载器可以分为以下两类:
java.lang.ClassLoader
。从开发人员角度而言,类加载器可以分为以下三类:
<JAVA_HOME>\lib
目录中,或被 -Xbootclasspath
参数所指定的路径中存放的能被 Java 虚拟机识别的类库加载到虚拟机的内存中;<JAVA_HOME>\lib\ext
目录中,或被 java.ext.dirs
系统变量所指定的路径中的所有类库。JDK 9 之前的 Java 应用都是由这三种类加载器相互配合来完成加载:
上图所示的各种类加载器之间的层次关系被称为类加载器的 “双亲委派模型”,“双亲委派模型” 要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器,需要注意的是这里的加载器之间的父子关系一般不是以继承关系来实现的,而是使用组合关系来复用父类加载器的代码。
双亲委派模型的工作过程如下:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。基于双亲委派模型可以保证程序中的类在各种类加载器环境中都是同一个类,否则就有可能出现一个程序中存在两个不同的 java.lang.Object
的情况。
JDK 9 之后为了适应模块化的发展,类加载器做了如下变化:
java.internal.loader.BuiltinClassLoader
,BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问性的处理。*.java
文件转变成 .class
文件的过程;如 JDK 的 Javac,Eclipse JDT 中的增量式编译器。在 HotSpot 虚拟机中,Java 程序最初都是通过解释器(Interpreter)进行解释执行的,其优点在于可以省去编译时间,让程序快速启动。当程序启动后,如果虚拟机发现某个方法或代码块的运行特别频繁,就会使用编译器将其编译为本地机器码,并使用各种手段进行优化,从而提高执行效率,这就是即时编译器。HotSpot 内置了两个(或三个)即时编译器:
在分层编译的工作模式出现前,采用客户端编译器还是服务端编译器完全取决于虚拟机是运行在客户端模式还是服务端模式下,可以在启动时通过 -client
或 -server
参数进行指定,也可以让虚拟机根据自身版本和宿主机性能来自主选择。
要编译出优化程度越高的代码通常都需要越长的编译时间,为了在程序启动速度与运行效率之间达到最佳平衡,HotSpot 虚拟机在编译子系统中加入了分层编译(Tiered Compilation):
以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互转换关系如下图所示:
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,可以用客户端编译器获取更高的编译速度、用服务端编译器来获取更好的编译质量。
即时编译器编译的目标是 “热点代码”,它主要分为以下两类:
判断某段代码是否是热点代码的行为称为 “热点探测” (Hot Spot Code Detection),主流的热点探测方法有以下两种:
即时编译器除了将字节码编译为本地机器码外,还会对代码进行一定程度的优化,它包含多达几十种优化技术,这里选取其中代表性的四种进行介绍:
最重要的优化手段,它会将目标方法中的代码原封不动地 “复制” 到发起调用的方法之中,避免发生真实的方法调用,并采用名为类型继承关系分析(Class Hierarchy Analysis,CHA)的技术来解决虚方法(Java 语言中默认的实例方法都是虚方法)的内联问题。
逃逸行为主要分为以下两类:
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(); // 没有发生方法逃逸
}
如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可以为这个对象实例采取不同程序的优化:
如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生过变化,那么 E 这次的出现就称为公共子表达式。对于这种表达式,无需再重新进行计算,只需要直接使用前面的计算结果即可。
对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的上下文检查以避免访问越界。如果数组的访问发生在循环之中,并且使用循环变量来访问数据,即循环变量的取值永远在 [0,list.length) 之间,那么此时就可以消除整个循环的数据边界检查,从而避免多次无用的判断。
更多文章,欢迎访问 [全栈工程师手册] ,GitHub 地址:https://github.com/heibaiying/Full-Stack-Notes
原文:https://www.cnblogs.com/heibaiying/p/13392470.html