JVM - 垃圾回收相关概念
# 1. System.gc() 的理解与使用
# 1.1 基本功能与不确定性
在 Java 程序中,可以通过调用 System.gc()
或者 Runtime.getRuntime().gc()
方法来建议 JVM 执行垃圾回收 (GC)。在 HotSpot VM 的默认行为下,这类调用通常会显式触发一次 Full GC,这意味着会对整个堆内存(包括新生代和老年代)进行全面的垃圾检查和回收,试图释放不再被使用的对象所占用的内存。
然而,需要特别强调的是,System.gc()
的调用仅仅是一个建议,并非强制命令。JVM 实现者有权决定是否响应这个请求,以及何时响应。Java 规范并不保证调用 System.gc()
后会立即或必定执行垃圾回收。
# 1.2 为何不推荐手动调用 GC
通常情况下,JVM 的垃圾回收机制是自动运行的,它会根据堆内存的使用情况、GC 算法的策略等因素,在合适的时机自动触发 GC。开发者一般不需要,也不应该频繁地手动调用 System.gc()
,原因如下:
- 性能影响: 手动触发 Full GC 通常会导致较长时间的 "Stop-The-World" (STW) 停顿,严重影响应用程序的性能和响应性。
- 时机不当: 手动调用可能在并非必要的时候触发 GC,打乱 JVM 自身的优化节奏。
- 不可靠性: 如前所述,调用并不保证执行。
# 1.3 合理的使用场景
虽然不推荐在生产环境中常规使用 System.gc()
,但在某些特定场景下,它可能有其用途:
- 性能基准测试 (Benchmarking): 在进行性能测试的两次运行之间,调用
System.gc()
可以尝试确保每次测试都从一个相对干净的内存状态开始,减少上次运行残留对象对本次测试的影响。 - 特殊资源清理配合: 在一些需要显式管理堆外内存或其他需要 JNI 配合清理的场景,有时会在确认相关资源不再需要后,调用
System.gc()
作为一种辅助手段,希望能间接触发finalize()
或Cleaner
机制(但这仍然不是可靠的方式)。
# 1.4 代码示例:System.gc()
与 finalize()
下面的代码演示了调用 System.gc()
与对象 finalize()
方法的关系:
// 文件名: SystemGCTest.java
public class SystemGCTest {
public static void main(String[] args) {
// 创建一个对象,但没有强引用指向它(匿名对象)
new SystemGCTest();
// 建议 JVM 的垃圾回收器执行 GC
// 注意:这只是一个建议,JVM 不保证立即执行或一定会执行 GC
System.gc();
// Runtime.getRuntime().gc(); // 效果与 System.gc() 相同
// System.runFinalization(); // 这个方法会强制执行那些已经被判定为可终结 (finalizable)
// 并且其 finalize 方法尚未被运行的对象的 finalize() 方法。
// 但它并不直接触发 GC 来发现这些对象。
// 短暂休眠,增加 finalize 被调用的可能性(但仍不保证)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("程序执行完毕");
}
// 重写 finalize 方法
// 如果 JVM 决定对这个类的某个实例执行 GC,并且该实例符合 finalize 的条件,
// 那么这个 finalize() 方法理论上会被 Finalizer 线程调用。
@Override
protected void finalize() throws Throwable {
super.finalize(); // 调用父类的 finalize (好习惯)
System.out.println("SystemGCTest 实例的 finalize() 方法被调用");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
运行结果分析:
上述代码的运行结果是不确定的。有时你可能会看到 finalize()
方法被调用的输出,有时则可能看不到。这再次印证了 System.gc()
的不确定性,以及 finalize()
方法执行时机的不确定性。
# 1.5 手动 GC 与局部变量回收示例
以下代码通过几个方法 (localvarGC1
到 localvarGC5
) 演示了局部变量的作用域、局部变量表 (Local Variable Table) 的槽 (Slot) 复用如何影响对象的回收,以及 System.gc()
在这些场景下的作用。
// 文件名: LocalVarGC.java
/**
* 演示局部变量作用域和 GC 的关系
*/
public class LocalVarGC {
private static final int _10MB = 10 * 1024 * 1024;
/**
* 方法1: buffer 引用一直存在直到方法结束。
* System.gc() 触发时,buffer 仍然是 GC Root(栈帧中的局部变量)。
* 因此对象不会在本次 GC 被回收(可能会因为 Full GC 进入老年代)。
*/
public void localvarGC1() {
byte[] buffer = new byte[_10MB];
System.out.println("localvarGC1: 调用 System.gc()");
System.gc(); // 此时 buffer 还在作用域内,对象不会被回收
System.out.println("localvarGC1: System.gc() 调用完毕");
}
/**
* 方法2: buffer 引用被置为 null。
* System.gc() 触发时,堆中的 byte 数组对象不再被任何 GC Root 引用。
* 对象符合回收条件,可能在本次 GC 中被回收。
*/
public void localvarGC2() {
byte[] buffer = new byte[_10MB];
buffer = null; // 断开引用
System.out.println("localvarGC2: 调用 System.gc()");
System.gc(); // 此时对象无引用,可以被回收
System.out.println("localvarGC2: System.gc() 调用完毕");
}
/**
* 方法3: buffer 定义在代码块内部。
* 当代码块执行完毕后,理论上 buffer 变量不再可用。
* 但是,其在局部变量表中占用的 Slot 可能仍然持有对堆对象的引用。
* 如果后续没有其他局部变量复用这个 Slot,那么在 System.gc() 时,对象可能**不会**被回收。
*/
public void localvarGC3() {
{
byte[] buffer = new byte[_10MB];
System.out.println("localvarGC3: buffer 创建完毕");
} // buffer 理论上在此处失效
// 此时 buffer 变量虽然在代码层面不可访问,但其占用的 Slot 可能仍指向对象
System.out.println("localvarGC3: 调用 System.gc()");
System.gc(); // 对象可能不会被回收
System.out.println("localvarGC3: System.gc() 调用完毕");
}
/**
* 方法4: buffer 定义在代码块内部,代码块结束后定义了新的局部变量 value。
* 新的局部变量 value 很可能会**复用** buffer 之前占用的 Slot。
* 这会导致局部变量表中不再有任何引用指向之前的 byte 数组对象。
* 因此,在 System.gc() 时,对象会被回收。
*/
public void localvarGC4() {
{
byte[] buffer = new byte[_10MB];
System.out.println("localvarGC4: buffer 创建完毕");
} // buffer 理论上在此处失效
int value = 10; // value 很可能复用了 buffer 的 Slot
System.out.println("localvarGC4: 调用 System.gc()");
System.gc(); // 对象会被回收
System.out.println("localvarGC4: System.gc() 调用完毕");
}
/**
* 方法5: 调用 localvarGC1(),然后执行 GC。
* 当 localvarGC1() 执行完毕返回后,其栈帧被销毁,局部变量 buffer 消失。
* 此时再执行 System.gc(),原 localvarGC1 中创建的对象(如果还存在于堆中且无其他引用)
* 就可以被回收了。
*/
public void localvarGC5() {
System.out.println("localvarGC5: 调用 localvarGC1()");
localvarGC1();
System.out.println("localvarGC5: localvarGC1() 调用完毕,调用 System.gc()");
System.gc(); // 此时 localvarGC1 的栈帧已销毁,其 buffer 指向的对象可以被回收
System.out.println("localvarGC5: System.gc() 调用完毕");
}
public static void main(String[] args) {
LocalVarGC localVarGC = new LocalVarGC();
// 可以选择调用不同的方法进行测试
// localVarGC.localvarGC1();
// localVarGC.localvarGC2();
localVarGC.localvarGC3();
// localVarGC.localvarGC4();
// localVarGC.localvarGC5();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
JVM 参数配置 (用于观察 GC 日志):
# 设置最小堆、最大堆均为 256M,打印 GC 详细信息
# PretenureSizeThreshold 设置大对象阈值(这里设为 15M,大于 10MB,确保 buffer 可能直接在老年代分配或容易晋升)
# (注意:PretenureSizeThreshold 只对 Serial 和 ParNew 有效,对 Parallel Scavenge 无效)
# 如果使用 Parallel Scavenge (JDK8 默认),大对象直接分配主要看 Eden 空间
-Xms256m -Xmx256m -XX:+PrintGCDetails
2
3
4
5
运行结果分析 (重点关注 localvarGC3
和 localvarGC4
):
- 调用
localvarGC3()
: 观察 GC 日志,你会发现即使执行了System.gc()
(通常会触发 Full GC),那个 10MB 的byte[]
对象很可能没有被回收(例如,老年代的使用量在 GC 后没有显著减少)。- 原因: 虽然
buffer
变量在代码块结束后就无法访问了,但它在当前方法栈帧的局部变量表中占据的槽 (Slot) 可能没有被后续代码覆盖或清除。只要这个 Slot 还持有对堆中对象的引用,该对象就仍然是可达的(通过栈帧这个 GC Root),因此不会被回收。(图示显示了局部变量表,即使代码块结束,Slot 可能仍被占用)
- 原因: 虽然
- 调用
localvarGC4()
: 观察 GC 日志,你会发现那个 10MB 的byte[]
对象通常会被回收。- 原因: 在
buffer
的作用域结束后,代码定义了一个新的局部变量int value = 10;
。JVM 在编译时或运行时很可能会复用 (Reuse) 之前buffer
变量所使用的局部变量表槽位来存储value
。当这个槽位被value
覆盖后,就不再有任何引用指向原来的byte[]
对象了,因此在执行System.gc()
时,该对象就变成了不可达垃圾,可以被回收。(图示显示 Slot 被 value 复用)
- 原因: 在
结论: 对象的回收不仅取决于代码层面的引用是否存在,还与 JVM 底层的实现细节(如局部变量表槽位的管理和复用)有关。理解这些细节有助于解释一些看似奇怪的 GC 行为。
# 2. 内存溢出 (OutOfMemoryError, OOM)
内存溢出是 Java 程序中常见的严重错误,通常会导致程序崩溃。
# 2.1 OOM 的定义
java.lang.OutOfMemoryError
(OOM) 是 Java 虚拟机规范中定义的一种错误。Javadoc 中的解释是:当 JVM 没有足够的空闲内存来为对象分配空间,并且垃圾收集器也无法通过回收现有对象来腾出更多内存时,就会抛出此错误。
# 2.2 OOM 的原因
导致 OOM 的原因多种多样,主要可以归结为两大类:
1. 堆内存不足 (Heap Space OOM): 这是最常见的 OOM 类型。
- JVM 堆设置不合理:
-Xms
(初始堆大小) 和-Xmx
(最大堆大小) 设置得过小,无法满足程序正常运行所需的内存量,尤其是在处理大量数据或高并发场景下。
- 代码中创建了大量大对象: 程序逻辑本身需要创建非常大的对象(如巨大的数组、集合),超出了堆的承载能力。
- 内存泄漏 (Memory Leak): 程序中存在不再使用的对象,但由于错误的引用管理(例如长生命周期的对象持有短生命周期对象的引用),导致这些对象无法被 GC 回收,占用的内存持续累积,最终耗尽堆空间。这是最隐蔽也最难排查的原因之一。
2. 方法区/元空间不足 (PermGen Space / Metaspace OOM):
- JDK 7 及之前 (永久代 PermGen): 永久代用于存储类元信息、常量池、静态变量等。如果加载了大量的类(尤其是在动态生成类、大量使用 JSP 或框架反射的场景),或者
String.intern()
了过多的字符串导致字符串常量池膨胀,就可能耗尽有限的永久代空间 (java.lang.OutOfMemoryError: PermGen space
)。JVM 对永久代的垃圾回收通常不积极。 - JDK 8 及之后 (元空间 Metaspace): 元空间取代了永久代,使用本地内存 (Native Memory) 存储类元信息。虽然理论上受限于物理内存,但仍可通过
-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
控制其大小。如果加载的类过多,超出了元空间的限制,同样会发生 OOM (java.lang.OutOfMemoryError: Metaspace
)。
3. 虚拟机栈/本地方法栈溢出 (StackOverflowError vs OOM):
- 通常,栈空间不足表现为
StackOverflowError
(例如无限递归导致栈帧过多)。 - 但在某些情况下,如果无法为新的线程分配足够的虚拟机栈或本地方法栈空间(因为总内存不足,无法创建更多栈),也可能抛出 OOM (
java.lang.OutOfMemoryError: unable to create native thread
)。
4. 直接内存不足 (Direct Memory OOM):
- 使用 NIO (New I/O) 时,可以通过
ByteBuffer.allocateDirect()
在堆外分配直接内存。直接内存不受 JVM 堆大小限制,但受限于物理内存和操作系统限制,以及可以通过-XX:MaxDirectMemorySize
指定的最大值。如果分配的直接内存超限,会抛出 OOM (java.lang.OutOfMemoryError: Direct buffer memory
)。
# 2.3 GC 在 OOM 前的角色
在抛出 OOM 之前,JVM 通常会尽力进行垃圾回收。例如,会尝试回收软引用 (Soft Reference) 指向的对象。在 NIO 分配直接内存失败时,也会显式调用 System.gc()
尝试回收,以期释放与直接内存关联的 Cleaner 对象(间接释放直接内存)。
但是,并非所有 OOM 之前都会触发 GC。例如,如果要分配一个超级巨大的数组,其大小超过了当前堆的最大容量,JVM 可以直接判断出 GC 也无法解决问题,就会立即抛出 OOM,而不会徒劳地执行一次 GC。
# 3. 内存泄漏 (Memory Leak)
内存泄漏是导致 OOM 的常见原因之一,但它本身是一个独立的概念。
# 3.1 定义
- 严格定义: 指程序中某些对象已经不再被应用程序所使用(即逻辑上是垃圾),但由于存在错误的引用链(通常是长生命周期的对象无意中持有了短生命周期对象的引用),导致垃圾收集器无法识别它们为垃圾,因而不能回收它们所占用的内存。
- 宽泛定义: 有时,一些不良的编程实践导致对象的生命周期不必要地过长(即使最终会被回收),或者占用了远超实际需要的内存,也可以被视为广义上的内存泄漏,因为它们同样会导致可用内存减少,增加 GC 压力,甚至最终引发 OOM。
# 3.2 图示理解
(图中右侧部分展示了 "Forgotten Reference",即被遗忘的引用,导致本应被回收的对象无法回收)
核心问题在于,虽然程序逻辑上不再需要访问某些对象(图中的 Leaked Memory),但仍然存在一条从 GC Roots 可达的引用链指向它们(图中的 Forgotten Reference)。GC 只认引用链,不认逻辑,因此无法回收这些对象。
# 3.3 常见内存泄漏场景举例
- 长生命周期对象持有短生命周期对象的引用:
- 静态集合类: 如
static ArrayList
、static HashMap
等。如果不断向这些静态集合中添加对象,但没有相应的移除机制,这些对象将一直存在,无法被回收,即使它们在业务逻辑上已经无用。 - 单例模式: 单例对象的生命周期与应用程序一样长。如果单例对象持有了对外部(通常是短生命周期的)对象的引用(例如,通过注册监听器、设置回调等方式),并且没有在合适的时候解除这些引用,就会导致外部对象无法回收。
- 静态集合类: 如
- 资源未关闭:
- 数据库连接: 通过
DataSource.getConnection()
获取连接后,必须在使用完毕后调用connection.close()
。否则,连接池可能无法回收该连接对象,长期累积可能耗尽连接池资源,或导致关联对象泄漏。 - 网络连接 (Socket): 创建 Socket 连接后需要手动关闭。
- I/O 流: 文件流、网络流等
InputStream
/OutputStream
或Reader
/Writer
对象,使用后必须调用close()
方法。try-with-resources
语句是处理这类资源的最佳实践。
- 数据库连接: 通过
- 不当的集合使用:
- 例如,自定义栈实现时,只
pop
元素但不将数组对应位置置null
。虽然栈顶指针移动了,但数组仍然持有对已弹出对象的强引用,导致对象无法回收。
- 例如,自定义栈实现时,只
- 监听器和回调未移除:
- 向某个对象注册了监听器或回调,但在不再需要时没有取消注册。如果注册源对象的生命周期比监听器对象长,就会导致监听器对象无法回收。
# 3.4 内存泄漏的后果
内存泄漏不会立即导致程序崩溃,但它像一个“慢性病”:
- 可用内存逐渐减少。
- GC 频率增加,STW 时间可能变长,应用性能下降。
- 最终耗尽所有可用内存,抛出 OOM 异常,导致程序崩溃。
# 4. Stop The World (STW)
STW 是 Java GC 过程中一个非常重要的概念,直接关系到应用的暂停时间和用户体验。
# 4.1 定义
Stop-The-World (STW) 指的是在 GC 事件发生(特别是某些关键阶段)的过程中,JVM 会暂停所有正在运行的应用程序线程 (Application Threads)。在 STW 期间,Java 程序看起来就像完全“卡死”了,没有任何响应。只有当 GC 事件结束后,应用程序线程才会被恢复运行。
# 4.2 为何需要 STW?
STW 的主要目的是为了保证 GC 过程(尤其是标记阶段)的一致性。可达性分析算法要求在一个稳定的对象引用关系快照上进行。如果在分析过程中,应用程序线程还在并发地修改对象间的引用关系(比如,一个对象刚被标记为存活,下一刻引用就断开了),那么分析结果的准确性就无法保证,可能导致:
- 错标 (Mis-tagging): 把本应存活的对象标记为垃圾(导致程序错误)。
- 漏标 (Missing-tagging): 把本应是垃圾的对象标记为存活(导致内存无法回收)。
因此,在枚举 GC Roots、初始标记、重新标记等需要精确获取当前存活对象集合或引用关系的阶段,通常需要 STW 来“冻结”应用程序的状态。
# 4.3 STW 的影响与目标
- 用户体验: 频繁或长时间的 STW 会让用户感觉应用程序卡顿、响应迟缓。
- GC 调优目标: 现代垃圾收集器的一个核心优化目标就是减少 STW 的频率和持续时间。
# 4.4 STW 与 GC 算法
- 所有 GC 都有 STW: 没有任何一款 GC 能完全消除 STW。即使是像 G1、ZGC、Shenandoah 这样的并发或低暂停收集器,也仍然需要在某些短暂的阶段(如初始标记、最终标记)进行 STW。它们的优势在于将大部分繁重的工作(如并发标记、并发清理)与用户线程并发执行,从而极大地缩短了 STW 的时间。
- STW 时间因素: STW 的持续时间受多种因素影响,包括 GC 算法本身、堆大小、存活对象的数量和大小、GC 线程数等。
# 4.5 STW 示例代码
下面的代码模拟了一个工作线程不断分配内存并手动触发 GC,以及一个打印线程记录时间戳。通过观察打印线程输出的时间间隔,可以感知到 STW 的发生。
// 文件名: StopTheWorldDemo.java
import java.util.ArrayList;
import java.util.List;
public class StopTheWorldDemo {
// 工作线程:不断分配内存,达到阈值后清空并建议 GC
public static class WorkThread extends Thread {
List<byte[]> list = new ArrayList<>();
@Override
public void run() {
try {
while (true) {
// 每次循环分配约 1MB 内存
for (int i = 0; i < 1000; i++) {
byte[] buffer = new byte[1024]; // 1KB
list.add(buffer);
}
// 如果列表过大 (超过 10MB),清空并建议 Full GC
if (list.size() > 10000) {
System.out.println("WorkThread: Clearing list and suggesting GC...");
list.clear();
System.gc(); // 手动调用 System.gc(),可能触发长时间 STW 的 Full GC
}
// 短暂休眠,避免 CPU 占用过高
Thread.sleep(1);
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
// 打印线程:每秒打印一次程序运行时间
public static class PrintThread extends Thread {
public final long startTime = System.currentTimeMillis();
@Override
public void run() {
try {
while (true) {
// 计算并打印从启动开始经过的时间(秒.毫秒)
long t = System.currentTimeMillis() - startTime;
System.out.println("PrintThread: " + t / 1000 + "." + String.format("%03d", t % 1000));
// 每隔 1 秒执行一次
Thread.sleep(1000);
}
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
public static void main(String[] args) {
WorkThread w = new WorkThread();
PrintThread p = new PrintThread();
w.start(); // 启动工作线程
p.start(); // 启动打印线程
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
运行结果观察:
- 如果注释掉
w.start()
(只运行打印线程): 输出的时间戳间隔基本稳定在 1 秒左右 (e.g., 1.0xx, 2.0xx, 3.0xx, ...)。 - 如果同时运行两个线程: 当
WorkThread
中的System.gc()
被触发并导致 Full GC 时,你会观察到PrintThread
的输出时间戳出现明显的跳跃,间隔可能远超 1 秒(例如,从 5.xxx 直接跳到 7.xxx)。这个跳跃的时间就对应了 GC 导致的 STW 停顿时间。
这个例子直观地展示了 STW 对应用程序正常执行流程的影响。在实际开发中应避免不必要的 System.gc()
调用。
# 5. 垃圾回收的并行 (Parallel) 与并发 (Concurrent)
这两个概念在描述垃圾收集器的工作方式时非常重要,但容易混淆。
# 5.1 操作系统层面的并发与并行
- 并发 (Concurrency): 指宏观上,多个任务在同一个时间段内都在推进(从开始到结束)。在单核 CPU 上,这是通过 CPU 时间片轮转实现的,任务在微观上是交替执行的。
- 并行 (Parallelism): 指微观上,多个任务在同一时刻同时执行。这需要多核 CPU 或多个 CPU 的支持,每个核心可以独立执行一个任务。
关键区别: 并发是逻辑上的同时,并行是物理上的同时。并发任务间需要切换和抢占资源,并行任务间(在不同核心上时)可以互不干扰。
# 5.2 GC 上下文中的并行与并发
在讨论垃圾收集器时,这两个术语有特定的含义:
- 并行 (Parallel) GC: 指 多条垃圾收集线程同时工作 来执行 GC 任务(例如标记、清理、复制)。但是,在并行 GC 执行期间,用户线程 (Application Threads) 仍然处于等待状态 (STW)。并行是为了缩短 STW 的总时间。
- 例子: ParNew (Parallel New), Parallel Scavenge, Parallel Old 这些收集器都使用了多线程并行执行 GC。
- 类比: 多个人(GC线程)一起大扫除(GC任务),但打扫期间,房主(用户线程)不能进入房间。
- 串行 (Serial) GC: 与并行相对,指只有一条垃圾收集线程在工作。用户线程同样处于等待状态。
- 例子: Serial, Serial Old 收集器。
- 类比: 只有一个人(GC线程)在打扫,打扫期间房主也不能进入。
(上图为串行,下图为并行)
- 并发 (Concurrent) GC: 指垃圾收集线程与用户线程 (Application Threads) 同时执行(不一定是严格的物理并行,可能是在不同 CPU 核心上并行,也可能是在单核上交替执行)。并发 GC 的目标是减少或消除 STW 时间,让 GC 过程尽可能不打断用户程序的运行。
- 例子: CMS (Concurrent Mark Sweep), G1 (Garbage-First) 的部分阶段(如并发标记),ZGC, Shenandoah。
- 类比: 保洁员(GC线程)在打扫房间的同时,房主(用户线程)仍然可以在房间里活动(可能会有一些短暂的协调或限制)。
总结:
- 并行 GC:多个 GC 线程一起工作,但用户线程暂停。目标:缩短 STW 时长。
- 并发 GC:GC 线程与用户线程一起工作(可能交替或真并行)。目标:消除或减少 STW 时长。
现代先进的垃圾收集器通常会结合使用并行和并发技术,例如 G1 在 STW 阶段使用多线程并行处理(如 Evacuation 复制),而在大部分标记阶段则与用户线程并发执行。
# 6. 安全点 (Safepoint) 与安全区域 (Safe Region)
JVM 在执行 GC 时需要暂停用户线程 (STW),但并非可以在任意指令处随意暂停。暂停的位置需要满足特定条件,以确保 JVM 状态的一致性,这些特殊位置就是安全点和安全区域。
# 6.1 安全点 (Safepoint)
- 定义: 程序执行过程中的一些特定位置,在这些位置上,JVM 的状态是确定的,可以安全地开始执行垃圾回收。线程只有到达安全点后才能被暂停。
- 目的: 确保在 GC 开始时,所有用户线程都处于一个已知的、一致的状态,方便 GC 线程进行堆扫描和对象标记。
- 选择标准: 安全点不能太少(否则 GC 可能需要等待很久才能开始),也不能太密集(否则会增加运行时的性能开销)。通常选择在执行时间较长或可能导致程序状态发生显著变化的指令处,例如:
- 方法调用 (call 指令)
- 循环跳转(非 counted loop 的回边)
- 异常跳转
- JNI 调用返回前/后
- 线程如何到达安全点: JVM 不会强制中断正在执行任意指令的线程。而是采用主动式中断 (Active Suspension):
- JVM 设置一个全局的中断标志(表示需要进入 GC)。
- 用户线程在执行过程中,会周期性地轮询 (Polling) 这个中断标志(通常是在即将执行安全点指令时)。
- 如果发现中断标志为真,线程就在到达最近的安全点时主动挂起 (Suspend) 自己。
# 6.2 安全区域 (Safe Region)
- 问题: 安全点机制解决了正在执行代码的线程如何在 GC 时暂停的问题。但如果一个线程没有在执行(例如处于
Sleep
状态、Blocked
状态等待锁或 I/O),它就无法主动轮询中断标志并跑到安全点去挂起。JVM 不能无限期等待这些线程恢复执行。 - 定义: 安全区域是指代码中的一段区域,在这个区域内,对象的引用关系不会发生改变。因此,在这个区域的任何地方开始 GC 都是安全的。可以看作是扩展了的安全点。
- 工作流程:
- 当线程执行到安全区域的入口时,它会标识自己进入了安全区域。
- 如果在此期间 JVM 需要发起 GC,它会忽略那些处于安全区域内的线程(因为它们的引用关系是稳定的)。
- 当线程即将离开安全区域时,它会检查 JVM 是否已经完成了需要 STW 的 GC 阶段(例如根节点枚举或重标记)。
- 如果 GC 完成了: 线程可以安全地离开安全区域,继续执行。
- 如果 GC 尚未完成 (或正在进行中): 线程必须等待,直到收到可以安全离开的信号(即 GC 的 STW 阶段结束)。
通过安全区域机制,JVM 可以确保即使线程处于非活动状态,也能安全、及时地启动和完成 GC。
# 7. 再谈引用:引用类型概述
在 JDK 1.2 之前,Java 中的“引用”含义比较单一:如果 reference
类型的数据存储的数值代表的是另一块内存的起始地址,就称该 reference
数据是代表某块内存、某个对象的引用。这种定义下的对象只有“被引用”和“未被引用”两种状态。
这种简单的划分在很多场景下不够灵活。例如,我们希望实现内存敏感的缓存:当内存充足时,保留缓存对象以提高性能;当内存紧张时,自动回收这些缓存对象以避免 OOM。传统的强引用无法实现这一点。
因此,从 JDK 1.2 开始,Java 对引用的概念进行了扩充,引入了不同的引用强度,将引用分为四种类型:
- 强引用 (Strong Reference)
- 软引用 (Soft Reference)
- 弱引用 (Weak Reference)
- 虚引用 (Phantom Reference)
这四种引用的强度依次递减。除了默认的强引用外,其他三种引用类型都在 java.lang.ref
包下有对应的类实现,开发者可以在程序中直接使用它们来与垃圾收集器进行更灵活的交互。
(图中展示了 SoftReference, WeakReference, PhantomReference 以及内部使用的 FinalizerReference)
# 8. 再谈引用:强引用 (Strong Reference)
# 8.1 定义与特点
- 强引用是 Java 程序中最常见、默认的引用类型。
- 通过
new
操作符创建对象并将其赋值给一个变量时,这个变量就持有对该对象的强引用。例如:Object obj = new Object();
中的obj
就是一个强引用。 - 只要一个对象有任何强引用指向它,垃圾收集器就永远不会回收这个对象,即使内存即将耗尽,JVM 也会抛出
OutOfMemoryError
而不是回收强引用对象。 - 强引用的对象是可触及 (Strongly Reachable) 的。
# 8.2 生命周期管理
- 当一个强引用变量超出了其作用域(例如方法执行结束时的局部变量)。
- 或者显式地将强引用变量赋值为
null
(obj = null;
)。 这时,如果该对象不再有其他强引用指向它,它就可能成为垃圾,等待被 GC 回收(具体回收时机由 GC 决定)。
# 8.3 与内存泄漏的关系
由于强引用的特性(阻止回收),不恰当的强引用管理是导致 Java 内存泄漏的主要原因。如果长生命周期的对象(如静态变量、单例)持有了不再需要的短生命周期对象的强引用,就会阻止这些短生命周期对象被回收。
# 8.4 示例
// 创建一个 StringBuffer 对象,str 持有对它的强引用
StringBuffer str = new StringBuffer("hello kele");
2
内存结构示意:
// str1 也持有对同一个 StringBuffer 对象的强引用
StringBuffer str1 = str;
2
内存结构示意:
// 将 str 置为 null,断开 str 的强引用
str = null;
// 此时,由于 str1 仍然持有强引用,StringBuffer 对象不会被回收
System.gc(); // 即使建议 GC,对象也不会回收
2
3
4
总结强引用特点:
- 可以直接通过强引用访问对象。
- 强引用对象只要可达就不会被回收。
- 是内存泄漏的主要根源。
# 9. 再谈引用:软引用 (Soft Reference)
# 9.1 定义与特点
- 软引用用来描述那些有用但并非必需的对象。
- 内存敏感: 只被软引用关联的对象(即没有强引用指向它),只有在系统将要发生内存溢出 (OOM) 异常之前,才会被垃圾收集器列入回收范围进行回收。如果这次回收后内存仍然不足,才会抛出 OOM。
- 生命周期: 比强引用弱,比弱引用强。GC 会尽量让软引用对象存活得久一些,只有在内存真正不足时才回收它们。
- 可达性状态: 软可达 (Softly Reachable)。
# 9.2 应用场景
软引用非常适合用于实现内存敏感的高速缓存 (Memory-Sensitive Caches)。
- 工作方式: 使用软引用来包装缓存中的对象。
- 当内存充足时,缓存对象可以一直存在,提高访问速度。
- 当内存紧张时,JVM 会在 OOM 之前自动回收这些软引用对象,释放内存,从而保证了缓存功能的同时,避免了耗尽内存的风险。
- 例子: 网页缓存、图片缓存等。
# 9.3 使用方式
通过 java.lang.ref.SoftReference<T>
类来创建软引用。
// 1. 创建一个强引用的对象
Object strongRefObj = new Object();
// 2. 创建一个软引用,指向这个对象
SoftReference<Object> softRef = new SoftReference<>(strongRefObj);
// 3. 清除强引用 (关键步骤!)
// 使得对象只剩下软引用可达
strongRefObj = null;
// 之后,可以通过 softRef.get() 尝试获取对象
// 如果对象未被 GC 回收,get() 返回对象实例
// 如果对象已被 GC 回收(因为内存不足),get() 返回 null
Object retrievedObj = softRef.get();
if (retrievedObj != null) {
// 对象还在,可以使用
} else {
// 对象已被回收
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 9.4 与引用队列 (Reference Queue)
与弱引用和虚引用类似,创建软引用时也可以可选地关联一个 ReferenceQueue
。当软引用所引用的对象被 GC 回收时,该 SoftReference
对象本身(而不是被引用的对象)会被加入到这个引用队列中。应用程序可以监控这个队列,得知哪些软引用对象已被回收,并进行相应的清理工作(例如,从缓存结构中移除对应的条目)。
# 9.5 与弱引用的比较
软引用和弱引用都可用于缓存。主要区别在于回收时机:
- 软引用: 内存不足时才回收。
- 弱引用: 下次 GC 发生时就回收(无论内存是否充足)。
因此,弱引用对象比软引用对象更容易、更快地被回收。选择哪种取决于缓存策略:希望缓存尽可能持久(直到内存紧张)用软引用;希望缓存更及时地释放(只要不再强引用就尽快释放)用弱引用。
(注意:原文档在此处使用了 WeakReferenceTest
的代码,该代码实际演示的是弱引用的行为,见下一节。)
# 10. 再谈引用:弱引用 (Weak Reference)
# 10.1 定义与特点
- 弱引用也用来描述非必需对象,但其强度比软引用更弱。
- 生命周期极短: 只被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存空间是否足够,都会回收掉那些只被弱引用关联的对象。
- 回收时机: 只要 GC 运行,并且扫描到只被弱引用指向的对象,就会回收它。
- 可达性状态: 弱可达 (Weakly Reachable)。
# 10.2 注意事项
- GC 线程的优先级通常较低,所以从弱引用断开到对象真正被回收之间可能存在延迟。弱引用对象可能在内存中“残留”一段时间,直到下一次 GC 发生。
# 10.3 应用场景
- 缓存: 与软引用类似,但适用于希望缓存项在不再被强引用时能更快被清理的场景。
WeakHashMap
: 一个典型的应用。WeakHashMap
的键 (Key) 是弱引用包装的。当一个键对象不再有其他强引用指向它时,即使它还在WeakHashMap
中,也会在下次 GC 时被回收。WeakHashMap
会自动移除这些键已被回收的条目。这常用于实现需要将元数据关联到某个对象,但又不希望这种关联阻止对象被回收的场景(例如,为某个临时对象附加一些调试信息)。- 避免内存泄漏: 在监听器、回调等场景中,如果注册源可能比监听器活得长,使用弱引用包装监听器可以防止监听器对象无法回收。
# 10.4 使用方式
通过 java.lang.ref.WeakReference<T>
类来创建弱引用。
// 1. 创建强引用对象
User strongUser = new User(1, "kele"); // 假设 User 是一个自定义类
// 2. 创建弱引用指向对象
WeakReference<User> userWeakRef = new WeakReference<>(strongUser);
// 3. 清除强引用 (关键!)
strongUser = null;
// 尝试从弱引用获取对象 (GC 前通常可以获取到)
System.out.println("Before GC: " + userWeakRef.get());
// 4. 建议进行垃圾回收
System.gc();
System.out.println("After GC suggestion...");
// 短暂休眠,增加 GC 执行的可能性
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
// 5. 再次尝试从弱引用获取对象
// 此时对象很可能已被回收,get() 返回 null
System.out.println("After GC: " + userWeakRef.get());
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
示例代码 (WeakReferenceTest.java
from original doc):
// 文件名: WeakReferenceTest.java
import java.lang.ref.WeakReference;
public class WeakReferenceTest {
// 简单的 User 类
public static class User {
public int id;
public String name;
public User(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "[id=" + id + ", name=" + name + "] ";
}
// 为了观察回收,可以添加 finalize 方法 (可选,不推荐生产使用)
@Override
protected void finalize() throws Throwable {
System.out.println("User " + name + " finalized.");
}
}
public static void main(String[] args) {
// 1. 创建强引用对象,并用弱引用包装
WeakReference<User> userWeakRef = new WeakReference<>(new User(1, "kele"));
// 2. 尝试从弱引用获取 (GC 前)
System.out.println("Before GC: " + userWeakRef.get()); // 通常能获取到对象
// 3. 建议 GC
System.gc();
System.out.println("After GC suggestion...");
// 4. 短暂休眠等待 GC
try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
// 5. 再次尝试获取 (GC 后)
// 由于没有强引用指向 User 对象,它应该在 GC 中被回收了
System.out.println("After GC: " + userWeakRef.get()); // 应该输出 null
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
运行结果 (典型):
Before GC: [id=1, name=kele]
After GC suggestion...
User kele finalized. // finalize 被调用 (如果添加了)
After GC: null
2
3
4
结果表明,在没有强引用后,仅被弱引用关联的对象在 GC 后被清除了。
# 11. 再谈引用:虚引用 (Phantom Reference)
# 11.1 定义与特点
- 虚引用也称为幻影引用或幽灵引用,是所有引用类型中最弱的一种。
- 不影响对象生命周期: 一个对象是否持有虚引用,完全不对其生存时间构成影响。如果一个对象仅剩虚引用,那么它和没有任何引用一样,随时可能被 GC 回收。
- 无法获取对象: 不能通过虚引用来获取被引用的对象实例。虚引用的
get()
方法永远返回null
。 - 唯一目的: 为一个对象设置虚引用的唯一目的是跟踪该对象被垃圾收集器回收的状态。当对象被回收时,能够收到一个系统通知。
- 必须与引用队列关联: 虚引用必须和
ReferenceQueue
一起使用。创建虚引用时,必须提供一个引用队列作为参数。
# 11.2 工作机制
- 当垃圾收集器准备回收一个对象时,如果发现它还存在虚引用。
- 在回收该对象之后(即对象的内存被释放后),JVM 会将这个虚引用对象本身(而不是被回收的对象)加入到创建时关联的引用队列中。
- 应用程序可以通过轮询或阻塞的方式检查引用队列,如果从中取出了某个虚引用对象,就说明该虚引用之前指向的对象已经被回收了。
# 11.3 应用场景
- 精确的资源清理: 由于虚引用能在对象被确认回收后才发出通知,它可以用来实现比
finalize()
更可靠、更精确的资源清理。例如,管理堆外内存 (Direct Memory)。java.nio.DirectByteBuffer
就是通过Cleaner
(其内部使用了虚引用机制) 来确保在ByteBuffer
对象被回收后,其对应的堆外内存能够被释放。 - 跟踪对象回收: 用于需要精确知道对象何时被 GC 回收的场景。
# 11.4 使用方式
通过 java.lang.ref.PhantomReference<T>
类来创建虚引用。
// 1. 创建强引用对象
Object strongRefObj = new Object();
// 2. 创建一个引用队列
ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
// 3. 创建虚引用,必须关联引用队列
PhantomReference<Object> phantomRef = new PhantomReference<>(strongRefObj, refQueue);
// 4. 清除强引用
strongRefObj = null;
// --- 后续 ---
// 应用程序需要有一个线程来监控 refQueue
// 例如,在一个后台线程中:
Reference<?> r;
while ((r = refQueue.poll()) != null) { // 或使用 remove() 阻塞等待
if (r == phantomRef) {
System.out.println("检测到虚引用对象已被回收,执行清理操作...");
// 在这里执行与 strongRefObj 相关联的资源清理逻辑
}
}
// 尝试通过虚引用获取对象 (永远是 null)
System.out.println(phantomRef.get()); // 输出 null
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 11.5 结合 finalize
的示例
下面的代码演示了虚引用、引用队列和 finalize
的交互过程。
// 文件名: PhantomReferenceTest.java
import java.lang.ref.PhantomReference;
import java.lang.ref.Reference;
import java.lang.ref.ReferenceQueue;
public class PhantomReferenceTest {
// 静态变量,用于 finalize 复活对象
public static PhantomReferenceTest obj;
// 引用队列,用于接收虚引用通知
static ReferenceQueue<PhantomReferenceTest> phantomQueue = null;
// 后台线程,用于监控引用队列
static class CheckRefQueueThread extends Thread {
CheckRefQueueThread(String name) {
super(name);
}
@Override
public void run() {
while (true) {
if (phantomQueue != null) {
Reference<? extends PhantomReferenceTest> objRef = null;
try {
// remove() 会阻塞,直到队列中有可用的引用对象
objRef = phantomQueue.remove();
} catch (InterruptedException e) {
e.printStackTrace();
break; // 发生中断则退出循环
}
if (objRef != null) {
System.out.println("--- 引用队列通知 ---");
System.out.println("追踪到垃圾回收过程:PhantomReferenceTest 实例已被 GC 回收。");
System.out.println("从队列中取出的引用对象: " + objRef);
// 在这里可以执行与被回收对象相关的最终清理操作
}
}
// 防止 CPU 空转过快 (实际应用中可能不需要)
try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
}
}
}
// 重写 finalize 方法,用于第一次 GC 时复活对象
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("调用当前类的 finalize() 方法 (尝试复活)");
obj = this; // 将 this 赋值给静态变量,重新建立强引用
}
public static void main(String[] args) throws InterruptedException {
// 启动监控引用队列的后台线程
Thread checkThread = new CheckRefQueueThread("CheckRefQueueThread");
checkThread.setDaemon(true); // 设置为守护线程
checkThread.start();
// 创建引用队列
phantomQueue = new ReferenceQueue<>();
// 创建对象实例
obj = new PhantomReferenceTest();
// 创建虚引用,关联对象和引用队列
PhantomReference<PhantomReferenceTest> phantomRef = new PhantomReference<>(obj, phantomQueue);
try {
// 尝试通过虚引用获取对象 (永远是 null)
System.out.println("尝试获取虚引用的对象: " + phantomRef.get()); // 输出 null
// 第一次 GC: 断开强引用,建议 GC
System.out.println("--- 第一次 GC 操作 ---");
obj = null; // 断开 obj 对实例的强引用
System.gc();
Thread.sleep(1000); // 等待 GC 和 finalize 执行
// 检查对象是否复活
if (obj == null) {
System.out.println("第一次 GC 后: obj 是 null (未复活)");
} else {
System.out.println("第一次 GC 后: obj 不是 null (已复活)"); // finalize 使其复活
}
// 此时引用队列应该是空的,因为对象未被真正回收
System.out.println("\n--- 第二次 GC 操作 ---");
// 再次断开强引用 (finalize 不会再被调用)
obj = null;
System.gc(); // 建议 GC
Thread.sleep(1000); // 等待 GC 执行
// 检查对象是否最终被回收
if (obj == null) {
System.out.println("第二次 GC 后: obj 是 null (已被回收)");
} else {
// 理论上不应执行到这里
System.out.println("第二次 GC 后: obj 不是 null (???)");
}
// 等待一段时间,让后台线程有机会从队列中获取到通知
System.out.println("\n等待引用队列通知...");
Thread.sleep(2000);
} catch (Exception e) {
e.printStackTrace();
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
运行结果分析:
- 首次尝试
phantomRef.get()
输出null
,验证虚引用无法获取对象。 - 第一次 GC 后,
finalize()
被调用,对象复活,obj
不为null
。引用队列中没有内容。 - 第二次 GC 后,
finalize()
不再调用,对象被回收,obj
变为null
。 - 在对象被回收后,JVM 将
phantomRef
加入phantomQueue
。 - 后台线程
remove()
从队列中取到phantomRef
,打印出“实例已被 GC 回收”的通知。
这个例子清晰地展示了虚引用在对象确认被回收后才发出通知的特性,以及它与 finalize
和引用队列的协同工作方式。
# 12. 再谈引用:终结器引用 (Finalizer Reference)
终结器引用 (Finalizer Reference) 是 java.lang.ref
包下的一个内部类 (java.lang.ref.Finalizer
),它不是给开发者直接使用的。
- 目的: 它是 JVM 内部用于实现对象的
finalize()
方法机制的关键组件。 - 工作方式: 当一个对象需要执行
finalize()
方法时(即对象可达性分析后发现不可达,且重写了finalize()
且未执行过),JVM 会为该对象创建一个FinalizerReference
,并将这个引用注册到一个内部的引用队列中。有一个专门的、低优先级的 Finalizer 线程会不断地从这个队列中取出FinalizerReference
,然后通过该引用找到对应的对象,并调用其finalize()
方法。 - 与 GC 的关系: 这个过程确保了
finalize()
方法在对象被回收之前(通常是在下一次 GC 真正回收它之前)被调用。对象在finalize()
执行期间(以及执行后到下次 GC 前)仍然是存活的。只有当对象的finalize()
方法被调用过,并且在后续的 GC 中再次被判定为不可达时,该对象才会被最终回收。
简单来说,终结器引用是 JVM 实现 finalize()
逻辑的幕后工作者,它配合专门的引用队列和 Finalizer 线程,完成了对象“缓刑”和最终审判的过程。开发者无需关心其细节,只需了解 finalize()
的基本行为和弊端即可。