JVM解析<三>执行引擎及垃圾回收算法

98 阅读13分钟

六、执行引擎

6.1执行引擎的作用

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被VM所识别的字节码指令、符号表,以及其他辅助信息。 那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

6.2 执行引擎的工作

  • 1)执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。
  • 2)每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。
  • 3)当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型。

6.3 编译器

  1. Java 语言的“编译期”其实是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把.java文件转变成.class文件的过程; Sun 的javac  Eclipse的JDT
  2. 也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In TimeCompiler)把字节码转变成机器码的过程。HotSpot VM的C1 C2编译器
  3. 还可能是指使用静态提前编译器(AOT 编译器,Ahead Of TimeCompiler)直接把.java文件编译成本地机器代码的过程。缺点:破坏一次编译到处运行,降低了java链接过程的动态性

6.3.1JIT编译器

在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler,但大多数情况下我们简称为C1编译器和C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器

  • 1.client:指定Java虚拟机运行在Client模式下,并使用c1编译器;
    • C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
    • client启动快,占用内存小,执行效率没有server快,默认情况下不进行动态编译,适用于桌面应用程序。
  • 2.server:指定Java虚拟机运行在Server模式下,并使用C2编译器。
    • C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高.
    • server启动慢,占用内存多,执行效率高,适用于服务器端应用;

6.3.2C1和C2编译器不同的优化策略

  • 在不同的编译器上有不同的优化策略,c1编译器上主要有方法内联,去虚拟化、冗余消除
    • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
    • 去虚拟化:对唯一的实现类进行内联
    • 冗余消除:在运行期间把一些不会执行的代码折叠掉
  • C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在c2上有如下几种优化:
    • 标量替换:用标量值代替聚合对象的属性值
    • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
    • 同步消除:清除同步操作,通常指synchronized

总结:

  • 一般来讲,JIT编译出来的机器码性能比解释器高。
  • C2编译器启动时长比c1编译器慢,系统稳定执行以后,C2编译器执行速度远远快于C1编译器

java是半编译半解释型,根据什么选择JIT编译器 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR(On StackReplacement)编译。

一个方法究竞要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。

目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。

采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为 方法调用计数器(Invocation Counter)用于统计方法的调用次数 回边计数器(Back EdgeCounter)用于统计循环体执行的循环次数

这个计数器就用于统计方法被调用的次数,它的默认阈值在client模式下是 1500次,在 server模式下是 10000次。超过这个阈便,就会触发JIT编译。

当一个方法被调用时,会先检查该方法是否存在被JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阙值,那么将会向即时编译器提交一个该方法的代码编译请求。

热度衰减 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(counter Decay),而这段时间就称为此方法统计的半衰周期(counter Half Life Time) 。

6.4解释器

当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字书码文件中的内容“翻译”为对应平台的本地机器指令执行。

七、垃圾回收算法

7.1垃圾的定义

运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾

7.2垃圾回收算法

  • 引用计数算法 引用计数算法(Reference counting)比较简单,对每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。 缺点:无法解决循环引用问题(手动解除, 使用弱引用)
  • 可达性分析算法
    • 可达性分析算法是以根对象集合(GC Roots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。
    • 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为引用链(Reference Chain)
    • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为垃圾对象。
    • 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

7.3可达性分析算法

可达性分析算法中会出现内存泄漏吗

如果一个对象是垃圾但是没有被当做垃圾回收就是内存泄漏 在可达性算法中一个对象是垃圾但是被跟对象引用,导致不能被回收,就叫做内存泄漏

7.3.1GC Roots有哪些

  • 本地方法栈内JNI(通常说的本地方法)引用的对象类
  • 静态属性引用的对象
    • 比如: Java类的引用类型静态变量
  • 方法区中常量引用的对象
    • 比如:字符串常量池(string Table)里的引用
  • 所有被同步锁synchronized持有的对象
  • Java虚拟机内部的引用。
    • 基本数据类型对应的class对象,一些常驻的异常对象(如:NullpointerException、outofMemoryError),系统类加载器。
  • 反映java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

技巧:由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root 。

注意点: 使用可达性算法,只能某一个快照下去对对象去做判定,这点也是导致Gc进行时必须“stop The world”的一个重要原因 成员变量,也叫实例变量,不同于类变量(静态变量),前面讲到类变量是存储在方法区中,而成员变量是存储在堆内存的对象中的,和对象共存亡,所以是不能作为GC Roots的

7.4垃圾清除算法

7.4.1标记清除算法

缺点

  • 效率比较低:递归与全堆对象遍历两次
  • 在进行GC的时候,需要停止整个应用程序,导致用户体验差
  • 这种方式清理出来的空闲内存是不连续的,产生内存碎片。

7.4.2复制算法

核心思想: 将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。

优点

  • 没有标记和清除过程,实现简单,运行高效
  • 复制过去以后保证空间的连续性,不会出现“碎片”问题。

缺点

  • 需要两倍的内存空间。
  • 对于G1这种分拆成为大量region的Gc,复制而不是移动,风意味着cc需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
  • 如果系统中的存活对象很多,复制算法不会很理想。因为复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。

7.4.3标记整理算法

收集过程: 第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象 第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。 优点

  • 消除了标记/清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
  • 消除了复制算法当中,内存减半的高额代价。

缺点

  • 从效率上来说,标记-压缩算法要低于复制算法。
  • 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  • 移动过程中,需要全程暂停用户应用程序。即:STW

7.5内存溢出和内存泄漏

7.5.1内存溢出

  • Java虚拟机的堆内存设置不够
  • 代码中创建了大量大对象,并长时间不能被垃圾回收期回收

7.5.2 内存泄漏

如果一个对象是垃圾但是没有被当做垃圾回收就是内存泄漏

内存泄漏的8种情况

  • 静态集合类
  • 单例模式
  • 内部类持有外部类
  • 各种连接,如数据库连接,网络连接
  • 变量不合理的左右域
    • 在方法内给成员变量赋值然后不置为null,导致成员变量长期不会被回收
  • 改变哈希值
    • 往hash集合内放入值,然后改变hash值
	HashSet<GCHash> objects = new HashSet();
    GCHash a = new GCHash("张三", "18");
    GCHash b = new GCHash("李四", "20");

    objects.add(a);
    objects.add(b);
    a.setName("王五");
    objects.remove(a);
    log.info("集合的对象有{}",objects);
  • 缓存泄漏
  • 监听器和回调

7.6安全点和安全区域

安全点: 程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始Gc,这些位置称为“安全点(Safepoint) ”

Safe Point的选择很重要,如果太少可能导致Gc等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point,如方法调用、循环跳转和异常跳转等。

如何在cc发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  • 抢先式中断:(目前没有虚拟机采用了) 首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断: 设置一个中断标志,各个线程运行到safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

安全区域(Safepoint) Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint 。但是,程序“不执行”的时候呢?例如线程处于Sleep状态或 Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

  • 当线程运行到Safe Region的代码时,首先标识已经进入了Safe Region,如果这段时间内发生GC,JVM会忽略标识为Safe Region状态的线程;
  • 当线程即将离开Safe Region时,会检查JVM是否已经完成Gc,如果完成了,则继续运行,否则线程必须等待直到收到可以安全离开Safe Region的信号为止;