阅读 2127

《面试补习》- JVM知识点大梳理

概述

1、什么是虚拟机?

Java 虚拟机,是一个可以执行 Java 字节码的虚拟机进程。Java 源文件被编译成能被 Java 虚拟机执行的字节码文件( .class )。

跨平台的是 Java 程序(包括字节码文件),,而不是 JVM。JVM 是用 C/C++ 开发的,是编译后的机器码,不能跨平台,不同平台下需要安装不同版本的 JVM 。

2、JVM 组成部分

  • 类加载器,在 JVM 启动时或者类运行时将需要的 class 加载到 JVM 中。

  • 内存区,将内存划分成若干个区以模拟实际机器上的存储、记录和调度功能模块,如实际机器上的各种功能的寄存器或者 PC 指针的记录器等。

  • 执行引擎,执行引擎的任务是负责执行 class 文件中包含的字节码指令,相当于实际机器上的 CPU 。

  • 本地方法调用,调用 C 或 C++ 实现的本地方法的代码返回结果。

1、类加载器

从类被加载到虚拟机内存中开始,到卸御出内存为止,它的整个生命周期分为7个阶段,加载(Loading)验证(Verification)准备(Preparation)解析(Resolution)初始化(Initialization)使用(Using)卸御(Unloading)。其中验证、准备、解析三个部分统称为连接。 7个阶段发生的顺序如下:

加载(Loading)、验证(Verification)、准备(Preparation)、初始化(Initialization)、卸载(Unloading) 这五个阶段的过程是固定的,在类加载过程中必须按照这种顺序按部就班地进行,而解析阶段则不一定,他在某种情况下可以在初始化之后进行,这个是为了支持Java语言的运行时绑定(也称为动态绑定或者晚期绑定)。

1.1、加载

加载阶段,虚拟机需要完成3件事:

  • 通过一个类的全限定名获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。
加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。 
复制代码

1.2、验证

验证阶段主要,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合虚拟机的要求,并且不会危害虚拟机自身的安全。

验证阶段主要完成下面4个阶段的校验动作:

文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型。

元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了java.lang.Object之外。

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

符号引用验证:确保解析动作能正确执行。
复制代码

1.3、准备

准备阶段是正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都将在方法区分配。

进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。

初始值通常情况下是数据类型默认的零值(如0、0L、null、false等)
复制代码

1.4、解析

解析阶段是将虚拟机常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号进行.

符号引用:简单的理解就是字符串,比如引用一个类,java.util.ArrayList 这就是一个符号引用,字符串引用的对象不一定被加载。

直接引用:指针或者地址偏移量。引用对象一定在内存(已经加载)。
复制代码

1.5、初始化

类初始化是类加载的最后一步,除了加载阶段,用户可以通过自定义的类加载器参与,其他阶段都完全由虚拟机主导和控制。到了初始化阶段才真正执行Java代码。

类的初始化的主要工作是为静态变量赋程序设定的初值

如static int a = 100;在准备阶段,a被赋默认值0,在初始化阶段就会被赋值为100。
复制代码

Java虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:

  • 1、使用new字节码指令创建类的实例,或者使用getstatic、putstatic读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。

  • 2、通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。

  • 3、当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。

  • 4、当虚拟机启动时,用户需要指定一个主类(包含main()方法的类),虚拟机会首先初始化这个类。

  • 5、使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、RE_invokeStatic的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。

2、对象的创建过程

Java 中对象的创建就是在堆上分配内存空间的过程,此处说的对象创建仅限于 new 关键字创建的普通 Java 对象,不包括数组对象的创建。

当虚拟机遇到一条含有new的指令时,会进行一系列对象创建的操作:

2.1、检查类是否被加载

1、检查常量池中是否有即将要创建的这个对象所属的类的符号引用; 若常量池中没有这个类的符号引用,说明这个类还没有被定义!抛出ClassNotFoundException;

2、进而检查这个符号引用所代表的类是否已经被JVM加载; 若该类还没有被加载,就找该类的class文件,并加载进方法区; 若该类已经被JVM加载,则准备为对象分配内存;

2.2、为对象分配内存

3、根据方法区中该类的信息确定该类所需的内存大小; 一个对象所需的内存大小是在这个对象所属类被定义完就能确定的!且一个类所生产的所有对象的内存大小是一样的!JVM在一个类被加载进方法区的时候就知道该类生产的每一个对象所需要的内存大小。 4、从堆中划分一块对应大小的内存空间给新的对象; 分配堆中内存有两种方式: 指针碰撞 如果JVM的垃圾收集器采用复制算法或标记-整理算法,那么堆中空闲内存是完整的区域,并且空闲内存和已使用内存之间由一个指针标记。那么当为一个对象分配内存时,只需移动指针即可。因此,这种在完整空闲区域上通过移动指针来分配内存的方式就叫做“指针碰撞”。 空闲列表 如果JVM的垃圾收集器采用标记-清除算法,那么堆中空闲区域和已使用区域交错,因此需要用一张“空闲列表”来记录堆中哪些区域是空闲区域,从而在创建对象的时候根据这张“空闲列表”找到空闲区域,并分配内存。 综上所述:JVM究竟采用哪种内存分配方法,取决于它使用了何种垃圾收集器。

多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。
解决这种问题有两种方案:
第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。
另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB 参数决定。
复制代码

2.3、为分配的内存空间初始化零值

5、为对象中的成员变量赋上初始值(默认初始化);

对象的内存分配完成后,还需要将对象的内存空间都初始化为零值,这样能保证对象即使没有赋初值,也可以直接使用

2.4、为对象进行其他设置

6、设置对象头中的信息;

所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息

2.5、执行 init 方法

7、调用对象的构造函数进行初始化

执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要执行 init 方法才算真正的创建完成,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码分配初始值,调用了 init 方法之后,这个对象才真正能使用。

初始化顺序:

在new B一个实例时首先要进行类的装载。(类只有在使用New调用创建的时候才会被java类装载器装入)

在装载类时,先装载父类A,再装载子类B

装载父类A后,完成静态动作(包括静态代码和变量,它们的级别是相同的,按照代码中出现的顺序初始化)

装载子类B后,完成静态动作

类装载完成,开始进行实例化

在实例化子类B时,先要实例化父类A2,实例化父类A时,先成员实例化(非静态代码)
父类A的构造方法
子类B的成员实例化(非静态代码)
子类B的构造方法

先初始化父类的静态代码--->初始化子类的静态代码-->初始化父类的非静态代码--->初始化父类构造函数--->初始化子类非静态代码--->初始化子类构造函数

复制代码

3、对象的内存布局

3.1、对象头(markword)

  • 第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳、对象分代年龄,这部分信息称为“Mark Word”;Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据自己的状态复用自己的存储空间。
  • 第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例;
Klass Word  这里其实是虚拟机设计的一个oop-klass model模型,这里的OOP是指Ordinary Object Pointer(普通对象指针),看起来像个指针实际上是藏在指针里的对象。而 klass 则包含 元数据和方法信息,用来描述 Java 类。它在64位虚拟机开启压缩指针的环境下占用 32bits 空间。
复制代码
  • 如果对象是一个 Java 数组,那在对象头中还必须有一块用于记录数组长度的数据。因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是从数组的元数据中无法确定数组的大小。

在32位系统下,对象头8字节,64位则是16个字节【未开启压缩指针,开启后12字节】。

假设当前为32bit,在对象未被锁定情况下。25bit为存储对象的哈希码、4bit用于存储分代年龄,2bit用于存储锁标志位,1bit固定为0。

不同状态下存放数据:

这其中锁标识位需要特别关注下。锁标志位与是否为偏向锁对应到唯一的锁状态。

锁的状态分为四种无锁状态、偏向锁、轻量级锁和重量级锁

不同状态时对象头的区间含义,如图所示。

3.2、实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在 Java 源码中定义顺序的影响。

分配策略:相同宽度的字段总是放在一起,比如double和long

3.3、对其填充(Padding)

对齐填充不是必然存在的,没有特别的含义,它仅起到占位符的作用。

由于HotSpot规定对象的大小必须是8的整数倍,对象头刚好是整数倍,如果实例数据不是的话,就需要占位符对齐填充。
复制代码

3.4、预估对象大小

32 位系统下,当使用 new Object() 时,JVM 将会分配 8(Mark Word+类型指针) 字节的空间,128 个 Object 对象将占用 1KB 的空间。 如果是 new Integer(),那么对象里还有一个 int 值,其占用 4 字节,这个对象也就是 8+4=12 字节,对齐后,该对象就是 16 字节。

以上只是一些简单的对象,那么对象的内部属性是怎么排布的?

Class A {
    int i;
    byte b;
    String str;
}
复制代码

其中对象头部占用 ‘Mark Word’4 + ‘类型指针’4 = 8 字节;byte 8 位长,占用 1 字节;int 32 位长,占用 4 字节;String 只有引用,占用 4 字节; 那么对象 A 一共占用了 8+1+4+4=17 字节,按照 8 字节对齐原则,对象大小也就是 24 字节。

这个计算看起来是没有问题的,对象的大小也确实是 24 字节,但是对齐(padding)的位置并不对:

在 HotSpot VM 中,对象排布时,间隙是在 4 字节基础上的(在 32 位和 64 位压缩模式下),上述例子中,int 后面的 byte,空隙只剩下 3 字节,接下来的 String 对象引用需要 4 字节来存放,因此 byte 和对象引用之间就会有 3 字节对齐,对象引用排布后,最后会有 4 字节对齐,因此结果上依然是 7 字节对齐。此时对象的结构示意图,如下图所示:

4、对象访问

对象的访问方式由虚拟机决定,java虚拟机提供两种主流的方式
1.句柄访问对象
2.直接指针访问对象。(Sun HotSpot使用这种方式)
复制代码

4.1、句柄访问

简单来说就是java堆划出一块内存作为句柄池,引用中存储对象的句柄地址,句柄中包含对象实例数据、类型数据的地址信息。

优点:引用中存储的是稳定的句柄地址,在对象被移动【垃圾收集时移动对象是常态】只需改变句柄中实例数据的指针,不需要改动引用【ref】本身。

4.2、直接指针

与句柄访问不同的是,ref中直接存储的就是对象的实例数据,但是类型数据跟句柄访问方式一样。

优点:优势很明显,就是速度快,相比于句柄访问少了一次指针定位的开销时间。【可能是出于Java中对象的访问时十分频繁的,平时我们常用的JVM HotSpot采用此种方式】

5、JVM 内存区域

5.1、虚拟机栈

描述的是方法执行时的内存模型,是线程私有的,生命周期与线程相同,每个方法被执行的同时会创建栈桢,主要保存执行方法时的局部变量表、操作数栈、动态连接和方法返回地址等信息,方法执行时入栈,方法执行完出栈,出栈就相当于清空了数据,入栈出栈的时机很明确,所以这块区域不需要进行 GC。

Java虚拟机栈可能出现两种类型的异常:

  • 线程请求的栈深度大于虚拟机允许的栈深度,将抛出StackOverflowError

  • 虚拟机栈空间可以动态扩展,当动态扩展是无法申请到足够的空间时,抛出OutOfMemory异常

  • 拓展link: 栈帧

5.2、本地方法栈

与虚拟机栈功能非常类似,主要区别在于虚拟机栈为虚拟机执行 Java 方法时服务,而本地方法栈为虚拟机执行本地方法时服务的。这块区域也不需要进行 GC。

5.3、程序计数器

  • 程序计数器是一块很小的内存空间,它是线程私有的,可以认作为当前线程的行号指示器。
  • 程序计数器的主要作用是记录线程运行时的状态,方便线程被唤醒时能从上一次被挂起时的状态继续执行
  • 程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域,所以这块区域也不需要进行 GC

5.4、本地内存

  • 线程共享区域,Java 8 中,本地内存,也是我们通常说的堆外内存,包括元空间和方法区
  • 主要存储类的信息,常量,静态变量,即时编译器编译后代码等,这部分由于是在堆中实现的,受 GC 的管理,不过由于永久代有 -XX:MaxPermSize 的上限
  • 所以如果动态生成类(将类信息放入永久代)或大量地执行 String.intern (将字段串放入永久代中的常量区),很容易造成 OOM,有人说可以把永久代设置得足够大,但很难确定一个合适的大小,受类数量,常量数量的多少影响很大。
  • 所以在 Java 8 中就把方法区的实现移到了本地内存中的元空间中,这样方法区就不受 JVM 的控制了,也就不会进行 GC,也因此提升了性能(发生 GC 会发生 Stop The Word,造成性能受到一定影响,后文会提到),也就不存在由于永久代限制大小而导致的 OOM 异常了(假设总内存2G,JVM 被分配内存 100M, 理论上元空间可以分配 2G-100M = 1.9G,空间大小足够),也方便在元空间中统一管理。
  • 综上所述,在 Java 8 以后这一区域也不需要进行 GC
  • 拓展link: 堆外内存回收

5.5、堆

  • 对象实例和数组都是在堆上分配的,GC 也主要对这两类数据进行回收。
  • java虚拟机规范对这块的描述是:所有对象实例及数组都要在堆上分配内存,但随着JIT编译器的发展和逃逸分析技术的成熟,这个说法也不是那么绝对,但是大多数情况都是这样的。
  • 堆细分: 新生代(Eden,survior)和老年代

6、对象存活判断

  • 引用计数
  • 可达性分析

6.1、引用计数

每个对象有一个引用计数属性,新增一个引用时计数加 1 ,引用释放时计数减 1 ,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题。目前在用的有 Python、ActionScript3 等语言。

6.2、可达性分析

从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。目前在用的有 Java、C# 等语言。

GC Roots 对象:
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中的类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI(即一般说的 Native 方法)中引用的对象。
复制代码

如何判断无用的类:

该类所有实例都被回收(Java 堆中没有该类的对象)。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方利用反射访问该类。
复制代码

6.3、finalize

finallize()方法,是在释放该对象内存前由 GC (垃圾回收器)调用。

通常建议在这个方法中释放该对象持有的资源,例如持有的堆外内存、和远程服务的长连接。 一般情况下,不建议重写该方法。 对于一个对象,该方法有且仅会被调用一次。

6.4、对象引用类型

  • 强引用
  • 软引用(SoftReference)
  • 弱引用(WeakReference)
  • 虚引用(PhantomReference)

6.4.1、强引用

如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题

6.4.2、软引用

如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。

6.4.3、弱引用

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

6.4.4、虚引用

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

拓展

利用软引用和弱引用解决 OOM 问题。用一个 HashMap 来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM 会自动回收这些缓存图片对象所占用的空间,从而有效地避免了 OOM 的问题. 通过软引用实现 Java 对象的高速缓存。比如我们创建了一 Person 的类,如果每次需要查询一个人的信息,哪怕是几秒中之前刚刚查询过的,都要重新构建一个实例,这将引起大量 Person 对象的消耗,并且由于这些对象的生命周期相对较短,会引起多次 GC 影响性能。此时,通过软引用和 HashMap 的结合可以构建高速缓存,提供性能。

7、垃圾回收算法

  • 标记-清除算法
  • 标记-整理算法
  • 复制算法
  • 分代收集算法

7.1、标记-清除

在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象(好多资料说标记出要回收的对象,其实明白大概意思就可以了)。然后,在清除阶段,清除所有未被标记的对象。

缺点:

  • 1、效率问题,标记和清除两个过程的效率都不高。
  • 2、空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

7.2、标记-整理

标记整理算法,类似与标记清除算法,不过它标记完对象后,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

优点:

  • 1、相对标记清除算法,解决了内存碎片问题。
  • 2、没有内存碎片后,对象创建内存分配也更快速了(可以使用TLAB进行分配)。

缺点:

  • 1、效率问题,(同标记清除算法)标记和整理两个过程的效率都不高。

7.3、复制算法

复制算法,可以解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可(还可使用TLAB进行高效分配内存)

优点:

  • 1、效率高,没有内存碎片。

缺点:

  • 1、浪费一半的内存空间。
  • 2、复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。

7.4、分代算法

当前商业虚拟机都是采用分代收集算法,它根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。

在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用复制算法。 而老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就必须使用“标记清理”或者“标记整理”算法来进行回收。

图的左半部分是未回收前的内存区域,右半部分是回收后的内存区域。

对象分配策略: 对象优先在 Eden 区域分配,如果对象过大直接分配到 Old 区域。 长时间存活的对象进入到 Old 区域。

改进自复制算法

现在的商业虚拟机都采用这种收集算法来回收新生代,IBM 公司的专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 2 块 Survivor 的大小比例是 8:1:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被“浪费”。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。

8、安全点

8.1、安全点

SafePoint 安全点,顾名思义是指一些特定的位置,当线程运行到这些位置时,线程的一些状态可以被确定(the thread’s representation of it’s Java machine state is well described),比如记录OopMap 的状态,从而确定 GC Root 的信息,使 JVM 可以安全的进行一些操作,比如开始 GC 。

SafePoint 指的特定位置主要有:

  • 循环的末尾 (防止大循环的时候一直不进入 Safepoint ,而其他线程在等待它进入 Safepoint )。
  • 方法返回前。
  • 调用方法的 Call 之后。
  • 抛出异常的位置。

8.2、安全区域

安全点完美的解决了如何进入GC问题,实际情况可能比这个更复杂,但是如果程序长时间不执行,比如线程调用的sleep方法,这时候程序无法响应JVM中断请求这时候线程无法到达安全点,显然JVM也不可能等待程序唤醒,这时候就需要安全区域了。

安全区域是指一段代码片中,引用关系不会发生变化,在这个区域任何地方GC都是安全的,安全区域可以看做是安全点的一个扩展。线程执行到安全区域的代码时,首先标识自己进入了安全区域,这样GC时就不用管进入安全区域的线层了,线层要离开安全区域时就检查JVM是否完成了GC Roots枚举,如果完成就继续执行,如果没有完成就等待直到收到可以安全离开的信号。

9、JVM 垃圾回收器

收集器 串行、并行or并发 新生代/老年代 算法 目标 适用场景
Serial 串行 新生代 复制算法 响应速度优先 单CPU环境下的Client模式
Serial Old 串行 老年代 标记-整理 响应速度优先 单CPU环境下的Client模式、CMS的后备预案
ParNew 并行 新生代 复制算法 响应速度优先 多CPU环境时在Server模式下与CMS配合
Parallel Scavenge 并行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并发 老年代 标记-清除 响应速度优先 集中在互联网站或B/S系统 服务端上的Java应用
G1 并发 both 标记-整理+复制算法 响应速度优先 面向服务端应用,将来替换CMS
ZGC 并发 both 标记-整理+复制算法 响应速度优先 面向服务端应用,将来替换CMS

9.1、Serial (新生代)

  • 最基本的单线程垃圾收集器。使用一个CPU或一条收集线程去执行垃圾收集工作。
  • 工作时会Stop The World,暂停所有用户线程,造成卡顿。适合运行在Client模式下的虚拟机。
  • 用作新生代收集器,复制算法。

9.2、ParNew(新生代)

  • Serial收集器的多线程版本,和Serial的唯一区别就是使用了多条线程去垃圾收集。
  • 除了Serial,只有它可以和CMS搭配使用的收集器。
  • 用作新生代收集器,复制算法。

9.3、Parallel Scavenge(新生代)

用作新生代收集器,复制算法。 关注高吞吐量,可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。 Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

9.4、Serial Old(老年代)

  • Serial收集器的老年代版本,单线程,标记-整理 算法。
  • 一般用于Client模式的虚拟机。
  • 当虚拟机是Server模式时,有2个用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用 ,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

9.5、Parallel Old(老年代)

  • Parallel Scavenge收集器的老年代版本,使用多线程和 标记-整理 算法。在JDK 1.6中开始提供。 在注重吞吐量的场合,配合Parallel Scavenge收集器使用。

9.6、CMS(Concurrent Mark Sweep)(老年代)

  • 一种以获取最短回收停顿时间为目标的收集器。适合需要与用户交互的程序,良好的响应速度能提升用户体验。
  • 基于 标记—清除 算法。适合作为老年代收集器。
  • 收集过程分4步:
1、 初始标记(CMS initial mark):只是标记一下GC Roots能直接关联到的对象,速度很快,会Stop The World。
2、 并发标记(CMS concurrent mark):进行GC Roots Tracing(可达性分析)的过程。
3、 重新标记(CMS remark):会Stop The -World。为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般比初始标记阶段稍长些,但远比并发标记的时间短。
4、 并发清除(CMS concurrent sweep):回收内存。
复制代码

耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以时并发执行的。

缺点:

  • 并发阶段,虽然不会导致用户线程暂停,但会占用一部分线程(CPU资源),导致应用变慢,吞吐量降低。默认启动收集线程数是(CPU数量+3)/4。即当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个(譬如2个)时,CMS对用户程序的影响就可能变得很大。
  • 无法清除浮动垃圾。并发清除阶段,用户线程还在运行,还会产生新垃圾。这些垃圾不会在此次GC中被标记,只能等到下次GC被回收。
  • 标记-清除 算法会产生大量不连续内存,导致分配大对象时内存不够,提前触发Full GC。

9.7、G1

  • 在JDK1.7提供的先进垃圾收集器。
  • 既适合新生代,也适合老年代。
  • 空间整合:使用 标记-整理 算法,不产生碎片空间。
  • 整个Java堆被分为多个大小相同的的块(region)。新生代和老年代不再是物理隔离的,而是一部分region块组成的集合。
  • 默认把堆平均分成2048个region,最小1M,最大32M,必须是2的幂次方,可以通过-XX:G1HeapRegionSize参数指定。region分为4种:
E:eden区,新生代
S:survivor区,新生代
O:old区,老年代
H:humongous区,用来放大对象。当新建对象大小超过region大小一半时,直接在新的一个或多个连续region中分配,并标记为H
复制代码
  • 可预测的停顿时间:估算每个region内的垃圾可回收的空间以及回收需要的时间(经验值),记录在一个优先列表中。收集时,优先回收价值最大的region,而不是在整个堆进行全区域回收。这样提高了回收效率,得名:Garbage-First。 G1中有2种GC:

  • young GC:新生代eden区没有足够可用空间时触发。存活的对象移到survivor区或晋升old区。 mixed GC:当old区对象很多时,老年代对象空间占堆总空间的比值达到阈值(-XX:InitiatingHeapOccupancyPercent默认45%)会触发,它除了回收年轻代,也回收 部分 老年代(回收价值高的部分region)。

mixed GC回收步骤:

1、初始标记(Initial Marking):只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark 2、Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。这阶段需要停顿线程(STW),但耗时很短,共用YGC的停顿,所以一般伴随着YGC发生。
3、并发标记(Concurrent Marking):进行可达性分析,找出存活对象,耗时长,但可与用户线程并发执行。
4、最终标记(Final Marking):修正并发标记阶段用户线程运行导致的变动记录。会STW,但可以并行执行,时间不会很长。
5、筛选回收(Live Data Counting and Evacuation):根据每个region的回收价值和回收成本排序,根据用户配置的GC停顿时间开始回收。
复制代码
  • 当对象分配过快,mixed GC来不及回收,G1会退化,触发Full GC,它使用单线程的Serial收集器来回收,整个过程STW,要尽量避免这种情况。
  • 当内存很少的时候(存活对象占用大量空间),没有足够空间来复制对象,会导致回收失败。这时会保留被移动过的对象和没移动的对象,只调整引用。失败发生后,收集器认为存活对象被移动了,有足够空间让应用程序使用,于是用户线程继续工作,等待下一次触发GC。如果内存不够,就会触发Full GC。

9.8、ZGC

在JDK 11当中,加入了实验性质的ZGC。它的回收耗时平均不到2毫秒。它是一款低停顿高并发的收集器。

ZGC几乎在所有地方并发执行的,除了初始标记的是STW的。所以停顿时间几乎就耗费在初始标记上,这部分的实际是非常少的。那么其他阶段是怎么做到可以并发执行的呢?

ZGC主要新增了两项技术,

  • 着色指针Colored Pointer,
  • 读屏障Load Barrier。

ZGC 是一个并发、基于区域(region)、增量式压缩的收集器。Stop-The-World 阶段只会在根对象扫描(root scanning)阶段发生,这样的话 GC 暂停时间并不会随着堆和存活对象的数量而增加。

处理阶段:

  • 标记(Marking);
  • 重定位(Relocation)/压缩(Compaction);
  • 重新分配集的选择(Relocation set selection);
  • 引用处理(Reference processing);
  • 弱引用的清理(WeakRefs Cleaning);
  • 字符串常量池(String Table)和符号表(Symbol Table)的清理;
  • 类卸载(Class unloading)

着色指针Colored Pointer

ZGC利用指针的64位中的几位表示Finalizable、Remapped、Marked1、Marked0(ZGC仅支持64位平台),以标记该指向内存的存储状态。
相当于在对象的指针上标注了对象的信息。注意,这里的指针相当于Java术语当中的引用。

在这个被指向的内存发生变化的时候(内存在Compact被移动时),颜色就会发生变化。
复制代码

读屏障Load Barrier

由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),
若请求读的内存在被着色了,那么则会触发读屏障。读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。
复制代码

与标记对象的传统算法相比,ZGC在指针上做标记,在访问指针时加入Load Barrier(读屏障),比如当对象正被GC移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与GC一致而粗暴整体的Stop The World。

拓展

Java——七种垃圾收集器+JDK11最新ZGC