[Java] 理解JVM之三:垃圾回收机制

时间:2022-12-25 04:19:13

JVM内存中的各个区域都会回收吗?

首先我们知道 Java 栈和本地方法栈在方法执行完成后对应的栈帧就立刻出栈销毁,两者的回收率可以认为是100%;Java 堆中的对象在没有被引用后,即使用完成后会被回收;方法区中的数据一般不会回收,只有在同时满足:所有实例被回收、加载该类的类加载器被回收、Class对象无法通过任何途径访问(包括反射)时才会回收;而程序计数器主要是记录指令执行的信息,在 HostSpot 虚拟机中是不会被回收的;

对于堆中的垃圾回收机制如下:

一、何时触发对象的回收

  • 对象没有被引用时
  • 作用域发生未捕获的异常
  • 程序在作用域正常执行完毕
  • 程序执行了System.exit()
  • 程序被意外终止(被杀进程)

二、检测垃圾

1、引用计数法(JDK1.2之前)

当这个类被加载到内存以后,就会产生方法区,堆栈、程序计数器等一系列信息,当创建对象的时候,为这个对象在堆栈空间中分配对象,同时会产生一个引用计数器,同时引用计数器+1,当有新的引用的时候,引用计数器继续+1,而当其中一个引用销毁的时候,引用计数器-1,当引用计数器被减为零的时候,标志着这个对象已经没有引用了,可以回收了。但是如果有两个对象相互引用,则这种方式无法检测。

2、可达性分析法

把所有的引用关系看作一张图,从一个 GC Root 节点开始搜索,搜索完成后没有被引用到的节点,即无用的节点,将其标记为垃圾。

可作为 GC Root 节点的对象有

  • Java 栈(局部变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象

对于引用在 Java 中存在四种引用类型

  • 强引用:直接 new 出来的对象是强引用,是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。

  • 软引用:一些有用但非必需的对象,通过类 SoftReference 进行实现。当JVM中的内存不足的时候,垃圾回收器会释放那些只被软引用所指向的对象,如果内存还不足,才会抛出OutOfMemory错误。使用软引用对象时要判断是否还存活。软引用非常适合于创建缓存,当内存不足的时释放缓存。

  • 弱引用:非必需的对象,通过类 WeakReference 进行实现。如果一个对象的所有引用都是弱引用,则这个对象会被回收。弱引用解决了对象在存活关系上的耦合关系。弱引用在集合中最常见,尤其是在哈希表中,哈希表的接口允许使用任何对象作为键,当一个键值被放入哈希表中后,哈希表就有了这些键和值对象的引用。如果是强引用,只要哈希表本身还存活则键和值对象不会被回收,弱引用就是为了解决这个问题。Java 中提供了 WeakHashMap 来满足这个需求。

  • 虚引用:通过类 PhantomReference 进行实现,无法通过虚引用来获取对象实例,唯一目的是用于对象被清除前收到一个系统通知。

三、垃圾回收

在 JDK1.2 之前是通过引用计数法来标记垃圾,在之后是使用可达性分析法来标记垃圾。但对象即使是不可达的,也并非一定会被回收,在对象正式回收前至少要经历两次标记过程:如果对象不可达则会被第一次标记,若此对象重写了 finalize() 方法,它会被入 F-Queue 队列中,然后虚拟机会建立一个线程去执行每个对象的 finalize() 方法,但不保证会等待方法执行结束(因为如果方法中发生死循环会使队列阻塞,而导致内存回收崩溃)。GC 会对 F-Queue 队列中的对象进行第二次标记,如果对象这时候仍然没有被引用则会被回收。另外每个对象的 finalize() 方法只会被执行一次,当下次面临被回收时,这个对象会被回收,它的 finalize() 方法不会被执行。

当垃圾标记后有三种方式来回收垃圾,因为不同垃圾收集器对其实现不相同,这里只说一下这三种算法的思想:

1、标记-清除

标记-清除算法是当垃圾被标记后,直接回收其所占空间。这种方式不需要进行对象的移动,在存活对象较多的情况下效率很高,但是会造成内存碎片。

[Java] 理解JVM之三:垃圾回收机制

2、复制

复制算法是在搜索垃圾过程中将存活对象复制到一块新的空间中,然后再清除垃圾。这种方式在存活对象比较少的情况下效率很高,但是需要一块内存空间用于对象的移动。

[Java] 理解JVM之三:垃圾回收机制

3、标记-整理

标记-整理算法与标记-清除算法一样对对象进行清除,但是在标记-清除算法的基础上要将所有存活对象往同一区域移动,并更新对应的引用。这种方式成本较高,但是解决了内存碎片的问题。

[Java] 理解JVM之三:垃圾回收机制

四、堆中分代的垃圾回收策略

因为不同对象的生命周期是不一样的,所以根据对象的生命周期不同可以采取不同的回收算法,以便提高回收效率。

[Java] 理解JVM之三:垃圾回收机制

1、年轻代

年轻代中 Eden 区和两个 Survivor 区按照 8:1:1 的比例分配 。我们指定所有对象都在年轻代的 Eden 中创建,在 Eden 中每个线程都有一个固定分区用来分配对象,不需要同步,如果这个区域空间不足以分配对象,会尝试在 Eden 中分配,如果创建对象的大小超过 Eden 的总大小,或者是超过了 -XX:PretenureSizeThreshold 参数配置的大小,就直接在年老代中创建了。

当 Eden 空间不足时触发 Minor GC,触发 Minor GC 时首先会检查年老代空间是否足够,如果不够就触发 Full GC,如果够则要看 -XX:-HandlePromotionFailure 参数来判断是仅触发 Minor GC 还是触发 Full GC。

Minor GC 会将 Eden 区和非空闲 Survivor 区存活对象复制到空闲的 Survivor 区中。当 Survivor 区中的对象足够老时会被放到年老代中,通过 -XX:MaxTenuringThreshold 来设置对象放入年老区的年龄。另外如果 Survivor 区空间不足时,也会被放入到年老代中。

2、年老代

年老代存放对象的生命周期较长,因为对象在年轻代经历了多次回收后仍幸存。年老代与年轻代的内存比大概是1:2,当年老代也满了,将触发 Major GC 即 Full GC,对整个堆(包括年轻代、年老代和持久代)进行垃圾回收。

3、持久代

即方法区,存放的是常量、字节码文件信息,相对较为稳定,因为不会频繁创建对象。持久代垃圾回收主要包含两部分:废弃常量和无用的类。

  • 废弃常量:回收常量与回收堆中的对象类似,比如常量池中有一个字符串,但没有被引用,在持久代回收时会被清理。
  • 无用的类:判断一个类是否无用要同时满足三个条件,该类的所有实例都已经被回收、加载该类的类加载器已经被回收、类的 Class 对象没有被引用(即无法通过反射访问到该类的方法)。只有满足上述三个条件才可以被收回。

大量使用反射、动态代理、GCLib 等 ByteCode 框架、动态生成 JSP 及 OSGi 这类频繁自定义 ClassLoader 的场景都需要进行持久代的回收。

五、GC的类型

在年轻代的 GC 被称为 Minor GC,在年老代的 GC 被称为 Major GC,另外还有 Full GC 是指回收整个堆(包括年轻代、年老代和持久代)。像 Minor GC、Major GC 及 Full GC 我的理解是这仅仅是个称呼,来代表 GC 作用的区域。而关于 GC 的类型,有以下几种:

1、Serial GC(串行收集器)

Serial GC 是单线程收集器,在 Client 模式(java -version)下默认使用。另外可以通过 -XX:+UseSerialGC 参数来指使用。

Serial GC 分为两种:

  • Serial GC:用于新生代,使用复制算法。

  • SerialOld GC:用于旧生代,使用标记-整理算法。

2、ParNew GC (多线程版的Serial GC)

ParNew GC 用于新生代,使用复制算法。在单 CPU 中效率远低于 Serial GC。通过 -XX:+UseParNewGC 参数开启。

3、Parallel GC(并行收集器)

Parallel GC 收集器也称为吞吐量收集器。可以通过 -XX:+UseParallelGC 参数和 -XX:+UseParallelOldGC 参数来开启。

Parallel GC 分为两种:

  • Parallel GC:用于新生代,使用复制算法,追求高吞吐量。

  • ParallelOld GC:用于旧生代,使用标记-整理算法。

4、CMS GC(并发收集器)

CMS 是专门针对旧生代的,追求的是最短回收停顿时间,使用标记-清除算法,通过 -XX:+UseConcMarkSweepGC 参数来开启。

1) CMS 执行过程可以分为:

  1. 初始标记(STW)
  2. 并发标记
  3. 重新标记(STW)
  4. 并发清理

[Java] 理解JVM之三:垃圾回收机制

初始标记:这个阶段进行可达性分析,标记 GC Root 能直接到达的对象,这个过程是 STW 的。

并发标记:这个阶段恢复用户线程继续运行,然后从已经标记的对象出发,找到所有可以达到的对象。

并发预处理:这一步与下一步的重标记功能相似,但是重标记是 STW 的,为了尽量缩短 GC 时间,所以重标记工作尽可能多的在并发阶段完成。此阶段标记新进入旧生代的对象(从新生代晋升的、直接分配到旧生代的和被修改了引用的)。

重新标记:这个阶段会暂停用户线程,从 CG Root 开发,扫描在堆中剩余对象。

并发清理:这个阶段恢复用户线程继续运行,然后对垃圾对象进行清理。

2) CMS缺点

CMS 为了节约垃圾回收时间,采用的标记-清除算法,所以会产生空间碎片。未来解决这个问题,CMS 会把一些空闲空间汇总成一个列表,当 JVM 创建对象时,会搜索这个列表,找到合适的空间来放置这个对象。

需要多核 CPU,并且在重标记阶段为了保证快速完成,甚至使用所有 CPU。

需要更大的堆空间,因为 CMS 在执行的时候会继续有对象在创建,所以应该保证 CMS 执行完之前旧生代不会满。CMS 默认旧生代使用68%的时候就开始执行。也可以使用– XX:CMSInitiatingOccupancyFraction = n 来设置这个值。

4、G1 GC(GarbageFirst,垃圾优先收集器)

开启方式 -XX:+UseG1GC,设置堆大小 -Xms32g,设置GC最大暂停时间为200ms -XX:MaxGCPauseMillis=200,设置G1区域大小-XX:G1HeapReginSize

G1 收集器将新生代和旧生代的物理划分取消了,这样就不用单独设置每个代的大小了,也不用担心每个代的内存是否够用。取而代之的是,将堆划分为若干区域,这些区域有的是新生代、有的是旧生代。G1 在清理时仍需要暂停应用线程,但可以用相当较少的时间优先回收包含垃圾最多的区域。

除了新生代和旧生代外,还有一个特殊区域 Humongous,它是用来存放一些大型对象的,并且如果一个H区装不开,G1 会寻找连续的H分区来存储,有时候为了能找到连续的H分区会启动 Full GC。

[Java] 理解JVM之三:垃圾回收机制

G1 相对于 CMS 的优势:

  1. G1 在压缩空间方面有优势
  2. G1 通过内存空间分成区域的方式避免了内存碎片
  3. Eden、Survivor、Old 区域不再固定,在内存使用效率上更灵活
  4. G1 可以通过预设停顿时间来控制收集时间,避免雪崩现象
  5. G1 回收内存后会马上做合并空闲内存的工作,而 CMS 是在 STW 的时候
  6. G1 可以在新生代和旧生代使用,而 CMS 只能在旧生代使用

G1 中提供了两种 GC 模式,Young GC 和 Mixed GC,两种都是 Stop The World(STW)的。

G1 的旧生代垃圾回收执行过程可以分为(有些阶段是年轻代回收的一部分):

  1. 初始标记(STW)
  2. 根区域扫描
  3. 并发标记
  4. 重标记(STW)
  5. 并发清理
  6. 复制

初始标记:这个阶段会暂停用户线程,标记有引用到旧生代对象的Survivor区,作为根区

根区域扫描:这个阶段恢复用户线程继续运行,扫描到旧生代的引用,只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。

并发标记:在整个堆中查找可达的对象,可以被 STW 新生代垃圾回收中断。

重新标记:这个阶段会暂停用户线程,从 CG Root 开发,扫描在堆中剩余对象,比CMS快。

清理:这个阶段会暂停用户线程,进行垃圾回收

复制:这个阶段会暂停用户线程,将存活对象复制到一个集中的区域。

1) G1 Young GC

Young GC 主要是对 Eden 区域进行 GC,当 Eden 区域不足时触发。此时,Eden 的数据被移动到 Survivor 空间中,如果 Survivor 空间不够,Eden 的部分数据会被直接移动到旧生代中。Survivor 区的数据被移动到新的 Survivor 区中,也有部分数据进入旧生代中。最终 Eden 空间数据为空,GC 停止工作,应用线程继续执行。

我们仅仅是对 Eden 区域和 Survivor 区域进行GC,如何找到所有的需清理的对象呢?如果全部扫描会很浪费时间,G1 引进了 RSet 的概念,全称 Remembered Set,是跟踪指向对象的引用。

[Java] 理解JVM之三:垃圾回收机制

在 CMS 中,也有 RSet 的概念,在旧生代有一块区域用来记录指向新生代的引用,称为 point-out,在进行新生代的 GC 时,仅仅需要扫描这一块区域,不需要扫描整个旧生代。

在 G1 中,没有使用 point-out,因为一个分区太小,分区数量太多,使用 point-out 的话,会造成大量的扫描浪费,有些不需要 GC 的分区也扫描了。于是 G1 使用了 point-in 来解决,即哪些分区引用了当前分区的对象,避免了无效的扫描。G1 还引入了卡表(Card table)。

Young GC的阶段

  1. 根扫描:静态和本地对象被扫描
  2. 更新RS:处理 dirty card 队列更新RS
  3. 处理RS:检测从新生代指向旧生代的对象
  4. 对象拷贝:将存活对象拷贝到survivor区域活旧生代中
  5. 处理引用队列:软引用、弱引用、虚引用处理

2) G1 Mix GC

Mix GC 不仅进行新生代的垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。它的 GC 分为两步:

  1. 全局并发标记
  2. 拷贝存活对象

在 Mix GC 之前,会先进行“全局并发标记”,为 Mixed GC 提供标记服务,并不是一次 GC 的必备过程,它的执行过程如下分为五个步骤:

  • 初始标记:G1 GC 对根进行标记,该阶段与常规的(STW)新生代垃圾回收密切相关。
  • 根区域扫描:G1 GC 在初始标记的存活区扫描对旧生代的引用,并标记被引用对象。该阶段非STW,并且只有完成该阶段后,才能开始下一次 STW 年轻代垃圾回收。
  • 并发标记:G1 GC 在整个堆中查找可达的对象,该阶段非STW,可以被STW新生代垃圾回收中断。
  • 最终标记:STW回收,帮助完成标记周期。G1 GC 清空SATB缓冲区,跟踪违背访问的存活对象,并执行引用处理。
  • 清除垃圾:G1 GC 执行统计和 RSet 净化的STW操作。

提到并发标记,我们要了解一下并发标记的三色标记法,它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。首先我们将对象分成三种类型:

  • 黑色:根对象,或者该对象与它的子对象都被扫描;
  • 灰色:对象本身被扫描,还没有扫描完该对象中的子对象;
  • 白色:未被扫描的对象,扫描完成后,白色为不可达对象,即垃圾对象。

当 GC 开始扫描对象时,按照下图步骤进行对象的扫描:

根对象被置为黑色,子对象被置为灰色

[Java] 理解JVM之三:垃圾回收机制

继续由灰色遍历,将以扫描了子对象的对象置为黑色

[Java] 理解JVM之三:垃圾回收机制

遍历所以可达对象后,可达对象变为黑色,不可达对象变为白色

[Java] 理解JVM之三:垃圾回收机制

但是如果在标记过程中,程序也在运行,就可能遇到一个问题,如下

[Java] 理解JVM之三:垃圾回收机制

这时候,应用程序将 C 的引用交给了 A,则会出现以下情况

[Java] 理解JVM之三:垃圾回收机制

然后标记完成后会变成这样

[Java] 理解JVM之三:垃圾回收机制

那么,如何保证不出现这样的问题呢:

  1. 在插入的时候记录对象
  2. 在删除的时候记录对象

这刚好对应 CMS 和 G1 的两种不同实现方式:

CMS 采用的是增量更新,只要在写屏障里发现要有一个白对象的引用被赋值到一个黑对象字段里,就把这个白对象变成灰色,即插入的时候记录下来。

G1 使用的是 STAB(snapshot-at-the-beginning)方式,删除的时候记录所有对象,有三个步骤:

  1. 在开始标记的时候生成一个快照图标标记存活对象
  2. 在并发标记的时候所有被改变的对象入队
  3. 可能存在游离垃圾,将在下次被收集

六、GC 日志

1 -XX:+PrintGC

参数-XX:+PrintGC(或者-verbose:gc)开启了简单GC日志模式,为每一次新生代(young generation)的GC和每一次的Full GC打印一行信息。

2 -XX:PrintGCDetails

如果不是使用-XX:+PrintGC,而是-XX:PrintGCDetails,就开启了详细GC日志模式。在这种模式下,日志格式和所使用的GC算法有关。我们首先看一下使用Throughput垃圾收集器在young generation中生成的日志。

3 -XX:+PrintGCTimeStamps和-XX:+PrintGCDateStamps

使用-XX:+PrintGCTimeStamps可以将时间和日期也加到GC日志中。

4 -Xloggc

缺省的GC日志时输出到终端的,使用-Xloggc:也可以输出到指定的文件。需要注意这个参数隐式的设置了参数-XX:+PrintGC和-XX:+PrintGCTimeStamps,但为了以防在新版本的JVM中有任何变化,我仍建议显示的设置这些参数。