如何判断对象可以被回收

引用计数法

当一个对象被一个其他变量引用时,该对象的计数加一;当一个对象不再被某一个对象引用时,该对象的计数减一;
当一个对象的计数为 0 时,即没有被任何变量引用,那么该对象就可以被回收。

image-20260305182839358

弊端:当两个对象发生「循环引用」时,每个对象的计数始终为 1,从而导致两个对象始终无法被回收。

可达性分析算法

扫描堆中的对象,判断能否以 GC Root 对象为起点的引用链找到待回收的对象,如果找不到,表示它可以被回收。

哪些对象可以作为 GC Root 对象呢?

虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中 JNI(即一般说的 native 方法)引用的对象
所有被同步锁(synchronized 关键字)持有的对象

引用类型对回收的影响

强引用
只有所有 GC Roots 对象都不通过「强引用」引用该对象,该对象才能被垃圾回收

软引用(SoftReference)
仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
可以配合引用队列来释放软引用自身

弱引用(WeakReference)
仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
可以配合引用队列来释放弱引用自身

虚引用(PhantomReference)
必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存。

在创建 ByteBuffer 的实现类对象时,会创建一个 Cleaner 虚引用对象。ByteBuffer 会将分配的直接内存地址传给「虚引用」对象。当 ByteBuffer 未被强引用时,它可以被垃圾回收,但分配的直接内存并不在 JVM 中,无法被垃圾回收,因此此时需要借助「引用队列」。

当 ByteBuffer 将要被回收时,「虚引用」对象进入引用队列,后续 Reference Handler 线程会根据引用队列中的相关信息调用 Unsafe.freeMemory() 方法释放直接内存。

终结器引用(FinalReference)
无需手动编码,其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize() 方法,第二次 GC 时才能回收被引用对象

eg:

设置虚拟机参数 -Xmx20m 指定最大堆内存为 20M,运行以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) throws IOException {
soft();
}

private static void method() throws IOException {
List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
System.in.read();
}


列表 list 强引用了 5 个 byte[] 数组,每个数组 4M,总共 20M,已经达到最大堆内存,导致堆内存不足,出现 OutOfMemoryError 错误。

-Xmx20m 虚拟机参数不变,改写为以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) throws IOException {
soft();
}

private static void soft() {
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}

System.out.println("循环结束: " + list.size());
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}

}

list 中不再直接引用 byte[] 数组,而是引用 SoftReference 弱引用对象。当内存不足时,触发 GC,内存依旧不足,触发 Full GC,byte[] 数组被回收,循环结束后,list 中仍有 5 个元素,它们都是 SoftReference 对象,但 SoftReference 引用的 byte[] 数组已经被回收四个。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[B@6bc7c054
1
[B@232204a1
2
[B@4aa298b7
3
[B@7d4991ad
4
[B@28d93b30
5
循环结束: 5
null
null
null
null
[B@28d93b30

经过 Full GC 后,软引用中的对象已经被回收,为了从 list 中清除软引用本身,需要借助引用队列 ReferenceQueue。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private static void referenceQueue() {
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

for (int i = 0; i < 5; i++) {
// 关联引用队列。当软引用关联的 byte[] 被回收时,软引用自身会加入到引用队列里
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}

Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}

System.out.println("循环结束: " + list.size());
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}

}

软引用关联引用队列,当软引用关联的对象被回收时,自身会被添加到引用队列里,后续可以借助引用队列完成软引用自身的回收。

1
2
3
4
5
6
7
8
9
10
11
12
[B@6bc7c054
1
[B@232204a1
2
[B@4aa298b7
3
[B@7d4991ad
4
[B@28d93b30
5
循环结束: 1
[B@28d93b30

软,弱引用的引用队列的作用:

如果在创建「软引用」对象、「弱引用」对象时,为它们分配了一个「引用队列」。当它们直接引用的对象被回收后,这两种对象就会进入「引用队列」中。这是因为「软引用」对象和「弱引用」对象本身也会消耗一定的内存,如果需要进一步对它们进行回收,就需要借助「引用队列」。

eg:

依旧设置虚拟机参数 -Xmx20m 指定最大堆内存为 20M,运行以下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
weak();
}

private static void weak() {
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> x : list) {
System.out.print(x.get() + " ");
}
System.out.println();
}
System.out.println("循环结束: " + list.size());
}
1
2
3
4
5
6
7
8
9
10
11
[B@6bc7c054 
[B@6bc7c054 [B@232204a1
[B@6bc7c054 [B@232204a1 [B@4aa298b7
[B@6bc7c054 [B@232204a1 [B@4aa298b7 [B@7d4991ad
[B@6bc7c054 [B@232204a1 [B@4aa298b7 null [B@28d93b30
[B@6bc7c054 [B@232204a1 [B@4aa298b7 null null [B@1b6d3586
[B@6bc7c054 [B@232204a1 [B@4aa298b7 null null null [B@4554617c
[B@6bc7c054 [B@232204a1 [B@4aa298b7 null null null null [B@74a14482
[B@6bc7c054 [B@232204a1 [B@4aa298b7 null null null null null [B@1540e19d
null null null null null null null null null [B@677327b6
循环结束: 10

循环到第 5 次,堆内存不足,触发垃圾回收,弱引用引用的对象被回收;从第 5 次循环开始,到第 9 次循环,每次循环时都会将前一次循环创建的 byte 数组回收;到第 10 次循环时,由于程序中还有其他对象(比如弱引用对象本身)也会占用堆内存,触发了一次 Full GC,使得先前所有 byte 数组都被回收。

垃圾回收算法

标记清除

image-20260305191907615

优点:速度快
缺点:易产生内存碎片

标记整理

image-20260305191948925

优点:相比于「标记清除」,它不会产生内存碎片
缺点:涉及对象在内存中的移动,导致效率较低

复制

复制

优点:不会产生内存碎片
缺点:需要占用双倍的内存空间

分代垃圾回收

垃圾回收过程

image-20260305193534578

将堆内存划分为「新生代」和「老年代」,其中「新生代」又划分为「伊甸园」、「幸存区 FROM」和「幸存区 TO」。

程序中长时间存活的对象放入「老年代」,用完就丢弃、朝生夕死的对象放入「新生代」,后续可根据对象生命周期的不同特点使用不同的垃圾回收策略。

「老年代」中的垃圾回收很久才发生一次,「新生代」中的垃圾回收发生地更加频繁。

image-20260305193936490

新创建的对象 默认 采用「伊甸园」中的内存空间。

当「伊甸园」中的空间不足时,就会触发一次垃圾回收。发生在「新生代」中的垃圾回收被称为「Minor GC」。

触发 Minor GC 后,通过可达性分析,将伊甸园和幸存区 FROM 中存活的对象复制到幸存区 TO 中,并让这些对象的「年龄」加一,最后交换幸存区 FROM 和幸存区 TO。

对象不会一直待在新生代,当幸存区中对象的「年龄」达到一个阈值(默认 15,4 bit)时,就会将其从新生代晋升到老年代。

「年龄」的 最大 阈值是 15,但这不代表只有达到 15 时对象才会晋升,在某些情况下,即使未达到阈值,对象也可能晋升。

image-20260305194128953

新生代触发 Minor GC 后,对象需要晋升到老年代,但是老年代内存不足,触发 Full GC。

Full GC 是一种重量级的垃圾回收,它会长时间暂停应用程序中所有线程(Stop the World),并清理整个堆内存,包括新生代和老年代。

在实际应用程序中,应当谨慎处理 Full GC 的情况,以维持较好的应用程序性能。

(Stop the World:

简称 STW,指的是 GC 过程中应用程序产生的停顿。此时应用程序中所有的线程都会被暂停,直到垃圾回收完成。

STW 与 GC 类型无关,所有的 GC 都会产生 STW,无论是 Minor GC 还是 Full GC,也包括 G1。)

相关VM参数

image-20260305194327843

垃圾回收器

串行

开启串行垃圾回收器的 VM 参数:

-XX:+UseSerialGC

串行垃圾回收器分成两部分:

Serial:发生在新生代,使用「复制」算法
SerialOld:发生在老年代,使用「标记-整理」算法
串行垃圾回收器在运行时只有一个垃圾回收线程在运行,其他用户线程都会被阻塞,直到垃圾回收线程运行完毕。

image-20260305195854624

吞吐量优先

开启吞吐量优先垃圾回收器的 VM 参数:

-XX:+UseParallelGC -XX:+UseParallelOldGC

-XX:+UseParallelGC:在新生代开启吞吐量优先垃圾回收器,使用「复制」算法
-XX:+UseParallelOldGC:在老年代开启吞吐量优先垃圾回收器,使用「标记-整理」算法(JDK 1.8 默认使用这两个参数)

使用其中任一参数,自动追加使用另外一个参数。

核心目标: 单位时间内,让用户代码运行的时间最长。 比如它不在乎单次清理停顿了 200 毫秒还是 100 毫秒,只要总的停顿占比低就行。

吞吐量优先垃圾回收器使用的「垃圾回收线程」个数默认与 CPU 的核心数一样。

image-20260307140759662

VM 参数 含义
-XX:ParallelGCThreads=n 指定使用的垃圾回收线程数为 $n$
-XX:+UseAdaptiveSizePolicy 自适应调整新生代大小、对象晋升阈值等信息
-XX:GCTimeRatio=ratio 调整吞吐量目标(垃圾回收时间与总时间的占比) 1. 计算公式为 $1 / (1 + ratio)$,其中 $ratio$ 默认值为 99 2. 这相当于 100 分钟内只有 1 分钟能用于垃圾回收 3. 如果无法达到目标,则会动态调整堆(如增加堆的大小) 4. 达到默认目标通常比较困难,建议将 $ratio$ 设置为 19
-XX:MaxGCPauseMillis=ms 调整最大 GC 暂停时间,即每次 GC 使用的时间,默认值为 200ms

可以认为 -XX:GCTimeRatio=ratio 和 -XX:MaxGCPauseMillis=ms 两个参数是互斥的。当吞吐量目标过大且无法达成时,动态增加堆的大小,但堆变大了后,GC 的时间也会增加,因此应当在吞吐量和最大暂停时间之间找到一个平衡点。

响应时间优先(CMS)

CMS 在 JDK9 被废弃,在 JDK14 中被移除

响应时间优先垃圾回收器,即 CMS,使用以下 VM 参数开启:

-XX:+UseConcMarkSweepGC

它不再追求总的吞吐量,而是力求让单次停顿(STW)的时间尽可能短。

CMS 的回收过程分为四个主要步骤,其中最关键的是只有第一和第三阶段需要暂停业务线程:

  1. 初始标记 (Initial Mark) STW
    • 仅仅标记一下 GC Roots 能直接关联到的对象。速度极快。
  2. 并发标记 (Concurrent Mark)
    • 从 GC Roots 开始遍历整个对象图。这个过程耗时较长,但可以与用户线程并发执行。
  3. 重新标记 (Remark) STW
    • 上一阶段用户线程不断执行,引用可能发生变化,进而出现漏标(该被回收的没被标记)和错标(不该回收的却被标记了),因此还需要「重新标记」来进行校正,这个阶段也会阻塞用户线程;
  4. 并发清除 (Concurrent Sweep)
    • 清理删除掉标记阶段判断的已死亡对象。由于不需要移动存活对象,这一阶段也可以与用户线程并发。

image-20260307145922514

VM 参数 含义 补充说明与调优建议
-XX:ParallelGCThreads=n 指定并行运行的垃圾回收线程数 通常设置为与 CPU 核心数相同,主要影响 STW(停顿)阶段的处理能力。
-XX:ConcGCThreads=threads 指定并发运行的垃圾回收线程数 建议设置为 ParallelGCThreads1/4,主要影响并发标记和清除阶段的效率。
-XX:CMSInitiatingOccupancyFraction=percent 指定老年代使用占比达到 percent 时触发 CMS 例如设置为 70;设置过高易导致“并发失败”,过低则会导致频繁回收。
-XX:+CMSScavengeBeforeRemark 重新标记阶段前进行一次新生代的 GC 开启此项可减少跨代引用扫描,从而有效降低重新标记阶段的停顿时间。

CMS 基于「标记-清除」算法,会产生大量的内存碎片,当出现大对象而无法找到连续的内存空间时,出现并发失败,CMS 退化为 SerialOld(串行,标记-整理),触发一次 Full GC,导致 STW 变长。

辨析:CMS的内存碎片化和并发失败

内存碎片化:

  • 触发原理:CMS 使用**标记-清除(Mark-Sweep)**算法,回收后不会进行内存压缩(整理)。随着运行时间增长,老年代虽然有总剩余空间,但都是不连续的小木块。
  • 后果:当一个大对象(如长字符串或大数组)进入老年代时,找不到足够大的连续空间进行分配。
  • Full GC 介入:此时 CMS 别无选择,只能触发一次带有压缩整理功能的 Full GC(通常由 Serial Old 收集器执行),这会极大地增加停顿时间。

并发失败:

  • 触发原理:CMS 的清除阶段是与用户线程并发执行的。如果在回收过程中,用户线程新产生的对象直接进入老年代,而老年代空间已被占满或不足,就会发生“并发失败”。
  • 关键参数:这通常与 -XX:CMSInitiatingOccupancyFraction 设置过高有关(如设为 92%),留给并发收集期间的预留空间太少。
  • 解决方法:调低上述参数,或者通过 -XX:+UseCMSCompactAtFullCollection 开关让 Full GC 强制进行碎片整理。
关于更多多标,漏标和三色标记

https://www.bilibili.com/video/BV1T4WGzoEiF/?spm_id_from=333.337.search-card.all.click&vd_source=499c257f0104fdb6ce035bd58aedbdf9

G1垃圾回收器

同时注重吞吐量(Throughout)和低延迟(Low latency),默认的暂停目标是 200ms
超大堆内存,会将堆划分为多个大小相等的 Region
整体采用「标记-整理」算法,两个 Region 之间是「复制」算法

VM 参数 含义
-XX:+UseG1GC 使用 G1 垃圾回收器,在 JDK8 中需要手动开启。
-XX:G1HeapRegionSize=size 指定 Region 的大小。
-XX:MaxGCPauseMillis=time 指定最大暂停时间
回收阶段:

image-20260307160238687

一共三个阶段:

Young Collection:新生代的垃圾收集
Young Collection + Concurrent Mark:老年代内存不足时,对新生代垃圾收集,同时执行并发标记
Mixed Collection:对新生代、老年代进行混合收集,之后又会重新进入新生代收集

Young Collection:

G1 将堆内存划分为若干个大小相等的 Region,每个 Region 都可独立作为伊甸园、幸存区或老年代。

新创建的对象会存放在 Region 的伊甸园中:

image-20260307160353916

当伊甸园内存不足,触发新生代的垃圾回收(也会 STW),通过「复制」算法将存活的对象拷贝到幸存区:

image-20260307160415932

当幸存区内存不足,或达到晋升阈值,对象晋升到老年代:

image-20260307160433939

Young Collection + CM:

在 Young GC 时会进行 GC Root 的初始标记

老年代占用堆空间比例达到阈值(默认 45%)时,进行并发标记(不会 STW),阈值可通过下列 VM 参数修改:

-XX:InitiatingHeapOccupancyPercent=percent

image-20260307161201802

Mixed Collection:

对 E、S、O 进行全面垃圾回收:

最终标记(Remark)会 STW(和CMS的重新标记一样)
拷贝存活(Evacuation)也会 STW

image-20260307161432369

伊甸园中的对象复制到幸存区,幸存区中的对象也会复制到幸存区,符合晋升条件的对象还会从幸存区复制到老年代。

如果对所有老年代进行回收,耗时可能会很高,为了保证不超过设置的最大暂停时间(通过 -XX:MaxGCPauseMillis=time 设置),选择性地回收最有价值的老年代(回收后得到更多的内存),没被回收的老年代继续复制。

Full GC辨析:

针对 Serial GC 和 Parallel GC:

新生代内存不足发生的垃圾收集:Minor GC
老年代内存不足发生的垃圾收集:Full GC

CMS:

新生代内存不足发生的垃圾收集:Minor GC

碎片过多或并发失败时:Full GC

G1:

新生代内存不足发生的垃圾收集:Minor GC

如果 G1 在进行回收时,发现没有足够的空闲 Region 来接收晋升的对象或进行垃圾拷贝,G1 会放弃并发模式,直接退化为 Full GC。

Young Collection 跨代引用

在 Young Collection 时需要找到新生代中对象的根对象,这些根对象可能在老年代(老年代的对象引用新生代的对象),因此需要扫描老年代中的对象,但扫描整个老年代的效率很低,基于这个问题,老年代会被进一步细分。

老年代维护一个 Card Table(卡表),内部划分为多个 Card(512K)

如果老年代中的对象引用了新生代的对象,其对应的 Card 会被标记为「脏卡」,扫描老年代时就只关注「脏卡」区域:

image-20260307170046582

新生代内部存在一个 Remembered Set(RSet),记录外部对它的引用,其中包括「脏卡」区域。

后续对新生代进行垃圾回收时,通过 RSet 定位到「脏卡」区域,只扫描这些区域中的对象,而无需扫描整个老年代,从而提高扫描效率。

流程:

老年代里对象 A 的字段被修改,指向了一个位于新生代中的对象 B(A.field = B);
触发 Post-Write Barrier(写屏障);
写屏障发现这是跨代引用(老年代引用了新生代),于是将 A 所在的卡表的 Card 标记为「脏卡」;
同时将该「脏卡」的相关信息存放在当前线程的 Dirty Card Queue(DCQ)中;
Concurrent Refinement Threads 在后台不断消费 DCQ 中的记录,找到对象 B 所在的 Region,并将对象 A 对它的引用信息存放在该 Region 的 Rset 中。

三色标记

image-20260307170353677

漏标解决方案

image-20260307170922159

G1 方案:原始快照(SATB, Snapshot At The Beginning)。

image-20260307171219120

CMS 方案:增量更新(Incremental Update)。

G1优化

大对象

当一个对象大于 Region 的一半时,称之为「巨型对象」。

G1 不会对巨型对象进行拷贝,回收时优先考虑巨型对象。

G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为 0 的巨型对象就可以在新生代垃圾回收时处理掉。(就是老年代没有根对象引用它)

image-20260307164711126

动态调整阈值

并发标记必须在堆空间占满前完成,否则退化为 Full GC。

JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent 指定老年代占用堆空间的比例阈值(默认 45%),大于这个阈值时,执行并发标记。

在 JDK 9 可以动态调整:

使用 -XX:InitiatingHeapOccupancyPercent 用来设置初始值
使用过程中会进行数据采样并动态调整比例,总会添加一个安全的空档空间,避免 Full GC

垃圾回收调优

使用 java -XX:+PrintFlagsFinal -version | findstr “GC”命令查看 JVM 默认参数

示例:

频率过高:Minor GC 频繁

  • 现象:每秒触发多次 Minor GC。
  • 原因:Eden 区过小,或高并发下短命对象产生极快。
  • 对策
    • 增大新生代大小:-Xmn-XX:NewRatio=2
    • 优化代码:减少局部大对象的创建。

效率低下:过早晋升 (Premature Promotion)

  • 现象:许多对象还没到回收年龄就进入了老年代,导致 Full GC。
  • 原因:Survivor 区空间不足,对象被直接踢进老年代。
  • 对策
    • 增大 Survivor 区:-XX:SurvivorRatio=8
    • 适当调大晋升阈值:-XX:MaxTenuringThreshold=15

系统卡顿:Full GC 时间长或频繁

  • 原因:老年代空间不足、大对象直接进入老年代、或者使用了 System.gc()
  • 对策
    • 禁止显式 GC:-XX:+DisableExplicitGC
    • 调整老年代触发阈值(针对 CMS):-XX:CMSInitiatingOccupancyFraction=70
    • 换用 G1ZGC 以获得更短的停顿。