JAVA虚拟机-Ⅱ

垃圾回收算法、收集器与引用类型

Posted by Ekko on August 4, 2020

JVM 垃圾回收部分主要涉及三个层面:如何判断对象是否存活、如何选择合适的回收算法、不同收集器在吞吐量与停顿时间之间如何取舍。

本篇内容包括对象存活判断、经典垃圾回收算法、HotSpot 中常见垃圾收集器、Minor GC / Full GC 等术语,以及强引用、软引用、弱引用、虚引用四种引用类型。

本文参考微笑陈树义头条科技及其他资料,并结合 HotSpot 的常见实现进行整理。

[TOC]


JVM垃圾回收机制

内存总是有限的,因此虚拟机必须持续回收已经不再使用的对象,才能维持程序长期稳定运行。

在 JVM 中,程序计数器、虚拟机栈、本地方法栈都随着线程创建和销毁而变化,栈帧也会随着方法调用结束自动出栈,因此垃圾回收的重点主要集中在 Java 堆以及方法区相关区域。因为这些区域中的对象和类元数据在运行期间会动态分配和释放,所以需要专门的回收机制。

《Java 虚拟机规范》定义了运行时数据区,但并没有强制规定垃圾回收器必须如何实现。因此不同虚拟机可能采用不同策略,下面内容默认以 HotSpot 虚拟机为例。

对象存活判断

对象存活判断,就是确定哪些对象已经不可能再被程序访问,可以进入回收流程。

判断对象存活的一般有两种方式:

  • 引用计数
  • 可达性分析(Reachability Analysis)

引用计数: 在一个对象被引用时加一,被去除引用时减一,这样就可以通过判断引用计数是否为零来判断一个对象是否为垃圾,但是无法解决对象相互循环引用的问题

对象相互引用:A引用B,B引用C,C引用A,三个对象各自的引用计数都是 1 ,但是三个对象都没有被其他对象引用,形成闭环,计数法无法解决

可达性分析: 从 GC Roots 出发向下搜索,搜索所经过的路径称为引用链。如果某个对象到 GC Roots 之间不存在任何引用链,则该对象会被判定为不可达。

GC Roots 是一组特殊的根对象集合,它们代表“当前线程或运行时仍然直接持有的活跃引用”。

在Java语言中,GC Roots包括:

  • 虚拟机栈中引用的对象
  • 类静态属性引用的对象
  • 运行时常量池中引用的对象
  • 本地方法栈中 JNI 引用的对象
  • 被同步锁持有的对象
  • 由 JVM 内部持有的其他根对象

标记-清除算法

标记清除(Mark-Sweep)算法分为两个阶段:标记、清除

标记阶段:标记所有由GC Root触发的可达对象,此时未被标记的对象就是垃圾对象

清除阶段:回收所有未被标记的对象

标记清除算法.png

缺点:

  • 标记和清除过程的效率都不高
  • 空间碎片问题,标记清除之后会产生大量不连续的内存碎片,将会导致程序在以后的运行过程中需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作(大对象也可以分配在不连续的空间中,但是效率要低于连续的内存空间)

复制算法

复制(Copying)算法,将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉,之后交换两个内存块的角色,完成垃圾回收

这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效

复制算法.png

缺点:

  • 将内存缩小为原来的一半,极大地浪费了内存空间
  • 持续复制长生存期的对象则导致效率降低

标记-压缩算法

标记-压缩算法(mark-compact)算法同样分为两个阶段:标记、压缩

标记阶段:标记所有由GC Root触发的可达对象,此时未被标记的对象就是垃圾对象(同标记-清除算法)

压缩阶段:不是直接清理可回收对象,而是先让所有存活对象向一端移动,再清理边界之外的内存

标记压缩算法.png

缺点:

  • 多次遍历堆,时间换空间

分代思想

单独依赖某一种算法,往往难以同时兼顾吞吐量、停顿时间和空间利用率。

分代回收建立在一个经验事实上:绝大部分对象生命周期很短,只有少量对象会长期存活。

因此在经典 HotSpot 设计中,堆通常会按对象年龄和存活特征进行分代。

分代回收: 把 Java 堆划分为新生代和老年代,并针对不同区域使用更合适的算法。如果在老年代也机械使用复制算法,那么在对象存活率很高时复制成本会非常大。

新生代中 大量对象会在一次或几次 GC 后死亡,因此常使用复制算法。实际 HotSpot 新生代并不是简单的 1:1 对半复制,而是划分为 Eden、From Survivor、To Survivor 三个区域。

经典 HotSpot 配置中常见的比例是 Eden : From : To = 8 : 1 : 1。发生 Young GC 时,会把 Eden 和一个 Survivor 区中仍然存活的对象复制到另一个 Survivor 区或晋升到老年代。

老年代中 因为对象存活率较高,复制成本往往太大,因此更适合使用标记-清除或标记-整理一类算法。

上述“新生代 / 老年代”划分主要用于理解经典分代模型。G1、ZGC、Shenandoah 等现代收集器在实现上已经比传统分代图更灵活。


垃圾收集器

收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

常见的 HotSpot 收集器可以按演进脉络理解为:SerialParNewParallel Scavenge / Parallel OldCMSG1。不同收集器侧重的目标不同,有的偏向吞吐量,有的偏向低停顿。

串行收集器

Serial 收集器 是最基础的收集器之一,使用单线程执行垃圾回收,结构简单,实现稳定。

Serial 可以覆盖新生代和老年代,但回收过程中会发生 Stop-The-World,因此停顿时间通常较长。它更适合单核环境、小堆、客户端程序或资源受限场景。

新生代通常配合复制算法,老年代通常配合标记-整理算法。

  • -XX:+UseSerialGC:参数可以指定使用新生代串行收集器和老年代都使用串行收集器

串行收集器.png

并行收集器

ParNew 收集器 本质上是 Serial 的多线程版本,主要用于新生代,最常见的搭配对象是 CMS 老年代收集器。

ParNew 使用复制算法,回收过程中同样会触发 Stop-The-World。在多核环境下,它通常比 Serial 具有更好的停顿表现。

在单核或并行能力较弱的环境中,多线程回收未必总能胜过单线程回收,因为线程切换本身也有成本。

  • -XX:+UseParNewGC:新生代使用 ParNew 回收器,老年代使用串行回收器
  • -XX:ParallelGCThreads:指定 ParNew 回收器的工作线程数量
  • -XX:+UseConcMarkSweepGC:老年代使用 CMS 时,新生代通常会配合 ParNew

并行收集器.png

Parallel Scavenge 收集器 同样用于新生代,也采用复制算法,但它更强调系统吞吐量,而不是尽量压低单次停顿时间。

Parallel 系列可以配合 -XX:+UseAdaptiveSizePolicy 进行自适应调节,在一定程度上自动平衡年轻代大小、Survivor 比例、对象晋升年龄和停顿目标。

新生代复制算法、老年代标记-压缩

Parallel 系列常见参数包括:

  • -XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间。在 Parallel 工作时,其会自动调整响应参数,将停顿时间控制在设置范围内。为了达到目的,其可能会使用较小的堆,但这会导致 GC 较为频繁
  • -XX:GCTimeRatio:设置 GC 时间占总时间的目标比例,例如值为 19 时,GC 时间目标是不超过总时间的 1 / (1 + 19) = 5%

新生代 Parallel GC 回收器可以使用以下参数启用:

  • -XX:+UseParallelGC:新生代使用 Parallel 回收器,老年代使用串行回收器
  • -XX:+UseParallelOldGC:新生代使用 ParallelGC 回收器,老年代使用 ParallelOldGC 回收器

Parallel Old 收集器 是 Parallel Scavenge 的老年代版本,同样偏向吞吐量,采用多线程标记-整理算法。

  • -XX:+UseParallelOldGC:新生代使用 ParallelGC,老年代使用 ParallelOldGC
  • -XX:ParallelGCThreads:设置垃圾回收时的线程数量

CMS 回收器

与 Parallel 系列不同,CMS(Concurrent Mark Sweep) 更关注较短的停顿时间,主要用于老年代。它采用标记-清除思路,因此可能带来内存碎片问题。

CMS 适合对响应时间比较敏感的服务端应用,但它的设计复杂度更高,也更容易受到碎片、并发失败等问题影响。

运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为4个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中初始标记和重新标记仍然需要 Stop-The-World,而并发标记、并发清除阶段可以与用户线程同时进行。

初始标记 仅仅只是标记一下GC Roots能直接关联到的对象,速度很快

并发标记 是进行GC Roots Tracing的过程

重新标记 是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短

由于耗时最长的并发标记和并发清除阶段能够与用户线程同时执行,所以 CMS 的整体停顿通常短于传统串行老年代回收方式。CMS 只负责老年代,新生代通常配合 ParNew。

CMS 的典型问题包括:

  • 并发阶段会与用户线程竞争 CPU
  • 采用标记-清除算法,容易产生内存碎片
  • 在并发回收期间如果老年代增长过快,可能发生 Concurrent Mode Failure,退化为更重的停顿式回收

  • -XX:+UseConcMarkSweepGC:使用CMS收集器
  • -XX:ConcGCThreads 或 -XX:ParallelCMSThreads:线程并发数量
  • -XX:CMSInitiatingOccupancyFraction:指定老年代空间使用阈值,当老年代空间使用率达到这个阈值时,会执行一次 CMS 回收,而不像其他回收器一样等到内存不够用的时候才进行 GC
  • -XX:+UseCMSCompactAtFullCollection:让 CMS 在完成垃圾回收后,进行一次内存碎片整理,整理过程是独占的,会引起停顿时间变长
  • -XX:CMSFullGCsBeforeCompaction:设置进行多少次 CMS 回收后,进行一次内存压缩
  • -XX:+CMSClassUnloadingEnabled:允许在 CMS 周期中回收类元数据

CMS 在 JDK 9 中被标记为废弃,在 JDK 14 中被移除。现代 HotSpot 默认更常见的是 G1。


G1 回收器

G1(Garbage-First) 是面向服务器场景设计的低停顿收集器。它在 JDK 7 中引入,在 JDK 9 起成为 HotSpot 默认垃圾收集器。

G1 的两个核心特征是:

  1. 分区化管理:堆被拆成多个 Region,而不是固定连续的新生代 / 老年代大块空间
  2. 可预测停顿目标:可以根据 Region 的回收收益优先回收“最值得回收”的区域,并尽量把停顿控制在目标范围内

从逻辑上看,G1 仍然保留分代思想;但从物理布局上看,新生代和老年代不再要求各自是一整块连续内存,而是由多个 Region 组成。

在 G1 回收器之前,所有的垃圾回收器其内存分配都是连续的一块内存,如下图所示

8之前的内存分配.png

而在 G1 回收器中,其将一大块的内存分为许多细小的区块,从而不要求内存是连续的

8之后的内存分配.png

从上图可以看到,每个Region被标记了 E、S、O 和 H,说明每个 Region 在运行时都充当了一种角色。所有标记为 E 的都是 Eden 区的内存,它们散落在内存的各个角落,并不要求内存连续。同理,Survivor 区、老年代(Old)也是如此

图中的 H 表示 Humongous Region,用来存放巨型对象。当对象大小超过一个 Region 的一半时,G1 会把它放入一个或多个连续的 Humongous Region。

Region 大小可以通过 -XX:G1HeapRegionSize 指定,取值通常是 1M32M 的 2 次幂。默认情况下,JVM 会根据堆大小自动选择合适的 Region 大小。

G1 还依赖两个重要机制:

  • Remembered Set:记录跨 Region 引用,避免每次回收都扫描整个堆
  • SATB(Snapshot At The Beginning):并发标记阶段用于维持对象图快照一致性

收集步骤:

  1. Initial Mark:初始标记,短暂停顿,标记 GC Roots 直接可达对象
  2. Root Region Scan:扫描与根相关的 Region
  3. Concurrent Marking:并发标记整个堆中的存活对象,并统计各 Region 的回收价值

ConcurrentMarking.png

  1. Remark:重新标记,短暂停顿,用于修正并发标记期间发生变化的引用关系

  2. Cleanup / Evacuation:筛选回收收益高的 Region,把存活对象复制到新 Region,回收旧 Region 空间

CopyClean.png

  1. 回收完成:存活对象被压缩到新的 Region 中,原 Region 重新加入空闲列表

AfterCopyClean.png

相关参数:

  • -XX:+UseG1GC:打开 G1 收集器
  • -XX:MaxGCPauseMillis:设置目标最大停顿时间
  • -XX:ParallelGCThreads:设置 GC 工作线程数量
  • -XX:InitiatingHeapOccupancyPercent:设置堆使用率触发并发标记周期的执行
  • -XX:G1HeapRegionSize:设置 Region 大小

G1 通常更依赖自适应策略,不建议像传统分代收集器那样频繁手工固定年轻代大小。


Minor GC

从年轻代回收内存通常称为 Minor GC,也常写作 Young GC

  • 当 JVM 无法为一个新的对象分配空间时会触发 Minor GC,比如当 Eden 区满了。所以 Eden 区越小,越频繁执行 Minor GC
  • 当年轻代中的 Eden 区分配满的时候,年轻代中的部分对象会晋升到老年代,所以 Minor GC 后老年代的占用量通常会有所升高
  • Minor GC 一般会触发 Stop-The-World
  • 多数情况下它比 Full GC 轻,因为年轻代里很多对象本来就会很快死亡
  • 如果 Young GC 后仍然有大量对象需要复制或晋升到老年代,停顿时间就会明显增加

Major GC

Major GC 这个术语通常指老年代回收,但在不同资料和不同收集器日志里,它并不是一个严格统一的官方术语。

很多资料会把 Major GCOld GC 混用,但在实际分析日志时,更可靠的方式通常是直接看收集器名称和事件类型。

可以粗略理解为:

  • Minor GC 主要处理年轻代
  • Major GC 主要处理老年代
  • 年轻代回收后如果需要晋升的对象放不下,就可能进一步触发更重的老年代或全堆回收

Full GC

Full GC 一般指对整个堆执行的一次更重型回收。对于旧版 HotSpot,还可能伴随永久代回收;在 JDK 8+ 语境下,则更常涉及堆与类元数据相关区域。

触发 Full GC 的常见原因包括:

  • 老年代空间不足
  • 对象晋升失败或分配担保失败
  • 显式调用 System.gc()(仅表示建议,不保证立即发生)
  • 元空间或永久代空间不足
  • 某些收集器在并发回收失败后退化为停顿式全堆回收

Full GC 的停顿通常明显高于 Young GC,因此排障和调优时通常会优先关注为什么会频繁触发 Full GC。


Stop-The-World

Stop-The-World 指的是在某些 GC 阶段,应用线程必须全部暂停,只让垃圾回收线程独占执行。

在这段时间里,业务线程无法继续处理请求,因此 Stop-The-World 的长短会直接影响应用响应时间。低停顿收集器的优化重点,本质上就是尽量缩短或分散这部分暂停时间。


四种引用

从 Java 1.2 开始,Java 提供了四种不同强度的引用:强引用、软引用、弱引用、虚引用。它们的意义在于:

  • 让程序可以用不同方式表达“对象的重要程度”
  • 让垃圾回收器在内存紧张时有更多处理策略

强引用

强引用是最常见的引用类型,下面代码中的 objectstr 都属于强引用:

1
2
Object object = new Object();
String str = "StrongReference";

如果一个对象具有强引用,那就类似于必不可少的物品,不会被垃圾回收器回收。 当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不回收这种对象

例如:

1
2
3
4
5
6
7
8
9
public class StrongReference {
    public static void main(String[] args) {
        new StrongReference().method1();
    }
    public void method1(){
        Object object = new Object();
        Object[] objArr = new Object[Integer.MAX_VALUE];
    }
}

当运行至Object[] objArr = new Object[Integer.MAX_VALUE]时,如果内存不足,JVM会抛出OOM(OutOfMemoryError)错误也不会回收object指向的对象。不过要注意的是,当method1运行完之后,object和objArr都已经不存在了,所以它们指向的对象都会被JVM回收

如果想中断强引用和某个对象之间的关联,可以显式地将引用赋值为 null,这样对象在失去其他可达路径后,就可以在后续 GC 中被回收。

例如 java.util.ArrayListclear() 方法就会把数组槽位设为 null,从而解除对原有元素的强引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* Removes all of the elements from this list.  The list will
* be empty after this call returns.
*/
public void clear() {
    modCount++;

    // clear to let GC do its work
    for (int i = 0; i < size; i++)
        elementData[i] = null;

    size = 0;
}

软引用

软引用用于描述“有用但不是必须长期保留”的对象。在 Java 中使用 java.lang.ref.SoftReference 表示。软引用对象通常会在内存紧张时被回收,因此常被用来实现对内存较敏感的缓存。

软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被JVM回收,这个软引用就会被加入到与之关联的引用队列中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import java.lang.ref.SoftReference;

public class SoftRef { 

    public static void main(String[] args){ 
        System.out.println("start"); 
        Obj obj = new Obj(); 
        SoftReference<Obj> sr = new SoftReference<Obj>(obj); 
        obj = null; 
        System.out.println(sr.get()); 
        System.out.println("end"); 
    } 
} 

class Obj{ 
    int[] obj ; 
    public Obj(){ 
    obj = new int[1000]; 
    } 
}

当内存足够大时可以把数组存入软引用,取数据时就可从内存里取数据,提高运行效率

软引用在实际中有重要的应用,例如浏览器的后退按钮,这个后退时显示的网页内容可以重新进行请求或者从缓存中取出: (1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建 (2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出这时候就可以使用软引用


弱引用

弱引用同样用于描述非必需对象,但它比软引用更弱。只要发生 GC,被弱引用关联的对象通常就会被回收。在 Java 中使用 java.lang.ref.WeakReference 表示。

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

被软引用关联的对象只有在内存不足时才会被回收,而被弱引用关联的对象在JVM进行垃圾回收时总会被回收

1
2
3
4
5
6
7
8
9
10
import java.lang.ref.WeakReference;

public class WeakRef {
    public static void main(String[] args) {
        WeakReference<String> sr = new WeakReference<String>(new String("hello"));
        System.out.println(sr.get());
        System.gc(); //通知JVM的gc进行垃圾回收
        System.out.println(sr.get());
    }
}

在示例中经常会配合 System.gc() 观察行为,但它只表示向 JVM 提出一次 GC 建议,并不保证立即执行。

弱引用还可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中

1
2
Object o = new Object(); //只要o还指向对象就不会被回收
WeakReference<Object> wr = new WeakReference<Object>(o);

当需要通过弱引用访问对象时,应先判断它是否已经被回收。如果 wr.get() 返回 null,说明该弱引用关联的对象已经被回收。

应用场景: 弱引用适合引用那些“希望尽量保留、但一旦发生 GC 就可以直接丢弃”的对象,例如规范化映射、缓存键、ThreadLocalMap 中的键等场景。它不会显著延长对象生命周期。


虚引用

虚引用与软引用、弱引用不同,它几乎不用于“访问对象”,而主要用于在对象被回收前后跟踪回收事件。在 Java 中使用 java.lang.ref.PhantomReference 表示。

虚引用必须和引用队列一起使用。 当垃圾回收器准备回收对象时,会把对应的虚引用放入关联队列。程序可以据此感知对象回收事件,常见用途是管理堆外资源或做更底层的资源释放协作。

需要注意的是,PhantomReference.get() 永远返回 null,因此它的意义不在于“拿到对象”,而在于“监听对象即将被回收”。

1
2
3
4
5
6
7
8
9
10
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomRef {
    public static void main(String[] args) {
        ReferenceQueue<String> queue = new ReferenceQueue<String>();
        PhantomReference<String> pr = new PhantomReference<String>(new String("hello"), queue);
        System.out.println(pr.get());
    }
}