执行引擎
JVM的主要任务之一是负责装载字节码到其内部(运行时数据区),但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只 是一些能够被 JVM 所识别的字节码指令、符号表,以及其他辅助信息。
那么,如果想要让一个 Java 程序运行起来,执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。
# 1.1 解释器
- 当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令然后执行。
- JVM 设计者们的初衷仅仅只是单纯地为了满足 Java 程序实现跨平台特性,因此避免采用静态编译的方式由高级语言直接生成本地机器指令,从而诞生了实现解释器在运行时采用逐行解释字节码执行程序的想法。
- 解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行,执行效率低。
- 在Java的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低效。而模板解释器将每一条字节码和一个模板函数性关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。
- 在Hotspot VM中,解释器主要由Interpreter模块和Code模块构成。
- Interpreter模块:实现了解释器的核心功能。
- Code模块:用于管理Hotspot VM在与运行时生成的本地机器指令。
- 由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如:Python、Perl、Ruby等。但就是因为多了中间这一“翻译”过程,导致代码执行效率低下。
- 为了解决这个问题,JVM平台支持一种叫做即时编译的的技术。即时编译的目的是为了避免函数被解释执行,而是将整个函数编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
# 1.2 即时(JIT)编译器
就是虚拟机将Java字节码一次性整体编译成和本地机器平台相关的机器语言,但并不是马上执行。JIT 编译器将字节码翻译成本地机器指令后,就可以做一个缓存操作,存储在方法区 的 JIT 代码缓存中。JVM真正执行程序时将直接从缓存中获取本地指令去执行,省去了解释器的工作,提高了执行效率高。
HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与及时编辑器并行的结构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。
JIT 编译器执行效率高为什么还需要解释器?
- 当程序启动后,解释器可以马上发挥作用,响应速度快,省去编译的时间,立即执行。
- 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后,执行效率高。就需要采用解释器与即时编译器并存的架构来换取 一个平衡点。
是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用的执行频率而定。关于那些需要被编译成本地代码的字节码,也被称为热点代码,JIT编译器在运行时会对那些频繁被调用的热点代码做出深度优化,将其直接编译成对应平台的本地机器指令,以此提升Java程序的执行性能。
一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体,都可以被称为热点代码。因此都可以通过JIT编译器编译成本地机器指令。由于这种编译方式发生在方法执行的过程中,因此也被称为栈上替换,或者简称为OSR编译。
一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准,必然需要一个明确的阈值。JIT编译器才会将这些热点代码编译成本地机器码执行。
# 1.3 垃圾收集器
# 1、JVM中的垃圾
简单的说垃圾就是内存中不再使用的对象,所谓使用中的对象(已引用对象),指的是程序中有指针指向的对象;而不再使用的对象(未引用对象),则没有被任何指针指向。如果这些不再使用的对象不被清除掉,我们内存里面的对象会越来越多,而可使用的内存空间会越来越少,最后导致无空间可用。
垃圾回收的基本步骤分两步:
- 查找内存中不再使用的对象(GC判断策略)
- 释放这些对象占用的内存(GC收集算法)
# 2、对象存活判断
即内存中不再使用的对象,判断对象存活一般有两种方式:引用计数算法 和 可达性分析法
(1)引用计数算法
给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器+1,当引用失效时,计数器-1,任何时候当计数器为0的时候,该对象不再被引用。
- 优点:引用计数器这个方法实现简单,判定效率也高,回收没有延迟性。 缺点:无法检测出循环引用。 如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0,Java的垃圾收集器没有使用这类算法。
(2)可达性分析算法
可达性分析算法是目前主流的虚拟机都采用的算法,程序把所有的引用关系看作一张图,从所有的GC Roots节点开始,寻找该节点所引用的节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点,无用的节点将会被判定为是可回收的对象。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 虚拟机栈中引用的对象(局部变量);
- 方法区中类静态属性引用的对象;
- 方法区中常量引用的对象;
- 本地方法栈中JNI(Native方法)引用的对象
- 所有被同步锁持有的对象;
- 虚拟机的内部引用如类加载器、异常管理对象;
- 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
- …
小技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root。
# 3、对象的finalization机制
- Java语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
- 垃圾(内存中不再使用的对象)回收对象之前,会调用该对象的 finalize()方法(主要在对象被回收时进行资源释放,通常是一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等)
# 4、垃圾回收算法
# (1)标记-清除算法
标记-清除算法的基本思想就跟它的名字一样,分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象
- 标记阶段
标记的过程其实就是前面介绍的可达性分析算法的过程,遍历所有的 GC Roots 对象,对从 GCRoots 对象可达的对象都打上一个标识,一般是在对象的 header 中,将其记录为可达对
象;
- 清除阶段
清除的过程是对堆内存进行遍历,如果发现某个对象没有被标记为可达对象(通过读取对象header 信息),则将其回收。
标记-清除算法缺点
效率问题 标记和清除两个阶段的效率都不高,因为这两个阶段都需要遍历内存中的对象,很多时候内存中的对象实例数量是非常庞大的,这无疑很耗费时间,而且 GC 时需要停止应用程序,这会导致非常差的用户体验。
空间问题 标记清除之后会产生大量不连续的内存碎片(从上图可以看出),内存空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
# (2)复制算法
复制算法是将可用内存按容量划分为大小相等的两块,每次使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另一块内存上,然后把这一块内存所有的对象一次性清理掉。
复制算法每次都是对整个半区进行内存回收,这样就减少了标记对象遍历的时间,在清除使用区域对象时,不用进行遍历,直接清空整个区域内存,而且在将存活对象复制到保留区域时也是按地址顺序存储的,这样就解决了内存碎片的问题,在分配对象内存时不用考虑内存碎片等复杂问题,只需要按顺序分配内存即可。
复制算法优点
- 复制算法简单高效,优化了标记清除算法的效率低、内存碎片多问题
复制算法缺点
- 将内存缩小为原来的一半,浪费了一半的内存空间,代价太高; 如果对象的存活率很高,极端一点的情况假设对象存活率为 100%,那么我们需要将所有存活的对象复制一遍,耗费的时间代价也是不可忽视的。
# (3)标记-整理算法
标记-整理算法算法与标记-清除算法很像,事实上,标记-整理算法的标记过程任然与标记-清除算法一样,但后续步骤不是直接对可回收对象进行回收,而是让所有存活的对象都向一端移动,然后直接清理掉端边线以外的内存。
可以看到,回收后可回收对象被清理掉了,存活的对象按规则排列存放在内存中。这样一来,当我们给新对象分配内存时,JVM只需要持有内存的起始地址即可。标记/整理算法弥补了标记/清除算法存在内存碎片的问题消除了复制算法内存减半的高额代价,可谓一举两得。
标记-整理缺点
效率不高:不仅要标记存活对象,还要整理所有存活对象的引用地址,在效率上不如复制算法。
# (4)分代收集算法
- 前文介绍JVM堆内存时已经说过了分代概念和对象在分代中的转移,垃圾回收伴随了对象的转移,其中新生代的回收算法以复制算法为主,老年代的回收算法以标记-清除以及标记-整理为主。
# 5、方法区的垃圾回收
方法区主要回收的内容有:废弃常量和无用的类。Full GC(Major GC)的时候会触发方法区的垃圾回收。
废弃常量 通过可达性分析算法确定的可回收常量
无用类
对于无用的类的判断则需要同时满足下面3个条件:
(1)该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例;
(2)加载该类的ClassLoader已经被回收;
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。