封面来源:由博主个人绘制,如需使用请联系博主。

参考链接:黑马程序员JVM快速入门

本文涉及的代码:mofan212/jvm-demo

除特别注明外,本文内容都基于 JDK 1.8

1. 如何判断对象可以被回收

1.1 引用计数法

简单来说:

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

使用「引用计数法」确实很容易判断一个对象是否可以被回收,但它简单也有简单的弊端,比如:

两个对象循环引用

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

1.2 可达性分析算法

  • JVM 中的垃圾回收器通过「可达性分析」来探索所有存活的对象

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

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

    1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
    2. 方法区中类静态属性引用的对象
    3. 方法区中常量引用的对象
    4. 本地方法栈中 JNI(即一般说的 native 方法)引用的对象
    5. 所有被同步锁(synchronized 关键字)持有的对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo_1 {
public static void main(String[] args) throws IOException {
List<Object> list = new ArrayList<>();
list.add("a");
list.add("b");
System.out.println(1);
System.in.read();

list = null;
System.out.println(2);
System.in.read();
System.out.println("end...");
}
}

上述代码中有两次 System.in.read() 对程序进行阻塞。

先运行 main() 方法,然后执行 jps 命令查看当前运行程序的进程号:

D:\Code\Java\MyCode\jvm-demo git:[master]
jps
23920 Main
26104 Jps
28216 Demo_1
20460 RemoteMavenServer36

Demo_1 使用的进程号是 28216

然后再使用 jmap -dump:format=b,live,file=./gc/target/1.bin 28216 命令:

  • 该命令能够将指定进程号对应的 Java 程序使用的堆内存信息转储成一个文件
  • -dump:format=b,live,file=./gc/target/1.bin 用于对转储文件进行格式化,其中 b 表示转储文件是二进制格式,live 表示只获取获取堆中存活的对象信息(在对堆信息进行快照前,还会进行一次垃圾回收),file 表示转储文件路径

回到程序运行的控制台,键入回车,控制台输出 2,然后使用 jmap -dump:format=b,live,file=./gc/target/2.bin 28216 命令再次转储堆内存信息。

下载 Eclipse Memory Analyzer 工具,解压后运行(依赖 JDK17 及其以上的环境)。成功运行后点击左上角 File,然后打开刚刚转储的两个 Heap Dump 文件。

按以下方式查看每个 Dump 文件对应时刻有哪些 GC Root 对象:

MAT的GC-Roots

1.bin 文件中,能够看到程序中使用的 ArrayList 对象(内部还存有 ab 字符串)作为 GC Root 对象:

查看1中的GC-Root对象

使用同样的方式查看 2.bin 文件中的 GC Root 对象时,其中并不存在 ArrayList 对象。

这是因为那时已经将 list 变量设置为 nullArrayList 对象已经被回收。

1.3 四种非强引用

对象的引用关系示例

软引用、弱引用、虚引用和终结器引用并不是 4 种引用关系,而是 4 种真实的对象实例:

  • 软引用:SoftReference
  • 弱引用:WeakReference
  • 虚引用:PhantomReference
  • 终结器引用:FinalReference

如果沿着 GC Root 对象的引用链能够找到对应的对象,那么它就不会被垃圾回收。比如 C 对象作为 GC Root 对象,引用了 A1 对象,那么 A1 对象就不会被垃圾回收,当然,B 对象也作为了 GC Root 对象,且也应用了 A1 对象。

软引用

以 A2 对象为例:

  • C 对象作为 GC Root 对象,直接强引用了「软引用」对象,「软引用」对象再引用了 A2 对象;
  • B 对象作为 GC Root 对象,直接强引用了 A2 对象

假设断开 B 对象对 A2 对象的强引用:

断开B对象对A2对象的强引用

此时 A2 对象 间接 被 C 对象应用,直接 被「软引用」对象引用。

当发生垃圾回收时,且内存不充裕, 再次触发垃圾回收,A2 对象就会被垃圾回收。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
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
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[] 数组已经被回收四个。

[B@6bc7c054
1
[B@232204a1
2
[B@4aa298b7
3
[B@7d4991ad
4
[B@28d93b30
5
循环结束: 5
null
null
null
null
[B@28d93b30

执行前添加 -XX:+PrintGCDetails -verbose:gc 虚拟机参数可以看到更多 GC 细节。

经过 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
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());
}
}

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

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

弱应用

A3 对象与 A2 对象的引用关系类似,只不过它是被「弱引用」对象直接引用。

假设同样断开 B 对象对 A3 对象的强引用:

断开B对象对A3对象的强引用

与 A2 对象不同的是,只要发生垃圾回收,无论内存是否充裕, A3 对象都会被回收。

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

依旧设置虚拟机参数 -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());
}
[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

最大堆内存 10M。

进行 10 次循环,每次循环增加一个 4M 的 byte 数组。

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

虚引用

与前两种引用不同,使用「虚引用」对象时 必须 关联「引用队列」。

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

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

终结器引用

与虚引用类似,终结器引用也需要搭配「引用队列」。

Object 类中存在一个 finalize() 方法(已在 JDK9 标记为过时),当对象 A4 重写了该方法,并且未被其他对象强引用时,JVM 会创建该 A4 的「终结器引用」对象。

当 A4 对象将要被垃圾回收时,「终结器引用」对象进入「引用队列」,注意此时 A4 对象还没有被垃圾回收,后续会由一个优先级很低的 Finalizer 线程在「引用队列」中通过「终结器引用」找到需要被回收的 A4 对象,再调用重写的 finalize() 方法对其进行回收。

不推荐使用 finalize() 方法:

  • 对象将要被回收时,并没有直接被回收,而是先将「终结器引用」对象入队;
  • 后续会由一个优先级很低的 Finalizer 线程进行处理,实际调用 finalize() 方法的概率很小。

总结

  1. 强引用
    • 只有所有 GC Roots 对象都不通过「强引用」引用该对象,该对象才能被垃圾回收
  2. 软引用(SoftReference):
    • 仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次触发垃圾回收,回收软引用对象
    • 可以配合引用队列来释放软引用自身
  3. 弱引用(WeakReference)
    • 仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象
    • 可以配合引用队列来释放弱引用自身
  4. 虚引用(PhantomReference)
    • 必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
  5. 终结器引用(FinalReference)
    • 无需手动编码,其内部配合引用队列使用,在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize() 方法,第二次 GC 时才能回收被引用对象

2. 垃圾回收算法

2.1 标记清除

标记清除

标记清除,Mark Sweep。

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

2.2 标记整理

标记整理

标记整理,Mark Compact,它与「标记清除」很类似。

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

2.3 复制

复制

复制,Copy。

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

3. 分代垃圾回收

3.1 垃圾回收过程

新生代和老年代

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

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

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

新生代发生第一次MinorGC

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

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

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

新生代发生第二次MinorGC

伊甸园空间再次不足时,按照先前相同的步骤触发第二次 Minor GC。

对象从新生代晋升到老年代

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

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

触发FullGC

新生代触发 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。

3.2 相关 VM 参数

含义 参数
堆初始大小 -Xms
堆最大大小 -Xmx-XX:MaxHeapSize=size
新生代(初始、最大)大小 -Xmn:同时指定新生代的初始、最大大小
-XX:NewSize=size:指定新生代的初始大小
-XX:MaxNewSize=size:指定新生代的最大大小
幸存区比例(动态) -XX:InitialSurvivorRatio=ratio -XX:+UseAdaptiveSizePolicy
幸存区比例 -XX:SurvivorRatio=ratio
晋升阈值 -XX:MaxTenuringThreshold=threshold
晋升详情 -XX:+PrintTenuringDistribution
GC 详情 -XX:+PrintGCDetails -verbose:gc
Full GC 前 Minor GC -XX:+ScavengeBeforeFullGC

3.3 GC 分析

编写一个空的 main() 方法:

1
2
3
public static void main(String[] args) {

}

添加以下虚拟机参数:

1
-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc

其中:

  • -Xms20M -Xmx20M:堆初始大小 20M,堆最大大小 20M
  • -Xmn10M:新生代大小 10M
  • -XX:+UseSerialGC:使用串行回收器进行回收,在新生代使用复制,在老年代使用标记-整理
  • -XX:+PrintGCDetails -verbose:gc:打印 GC 信息

直接运行空的 main() 方法,控制台输出:

Heap
 def new generation   total 9216K, used 1975K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  24% used [0x00000000fec00000, 0x00000000fededf20, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 3417K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

可以看到:

  • 整个堆空间被分成 3 部分:新生代、老年代和元空间
  • 伊甸园占 8M,幸存区 from 和幸存区 to 的空间相同,各占 1M
  • 新生代共 9216K,即 9M,相比指定的 10M 少了 1M,这是为什么呢?幸存区 to 需要始终保持空闲,占据 1M 空间,因此少 1M
  • 伊甸园已经被使用 24%,大约 1.97M,剩余 6.03M,这是因为只要 Java 程序一运行,就会有一些对象产生
  • 整个堆空间 20M,新生代 10M,老年代占剩下的 10M

创建一个 byte[] 列表,并增加一个 7M 的 byte[]

1
2
3
4
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7M]);
}

运行 main() 方法后,控制台输出:

[GC (Allocation Failure) [DefNew: 1811K->617K(9216K), 0.0015622 secs] 1811K->617K(19456K), 0.0018151 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 8031K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  90% used [0x00000000fec00000, 0x00000000ff33d8c0, 0x00000000ff400000)
  from space 1024K,  60% used [0x00000000ff500000, 0x00000000ff59a5b0, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 3418K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

进行了一个 Minor GC,GC 完毕后,伊甸园已被使用 90%,里面显然是 7M 的字节数组,并且幸存区 from 被使用 60%,存放了一些系统其他对象。

再向列表中添加一个 512k 的字节数组:

1
2
3
4
5
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7M]);
list.add(new byte[_512KB]);
}

运行 main() 方法后,控制台输出:

[GC (Allocation Failure) [DefNew: 1811K->617K(9216K), 0.0013511 secs] 1811K->617K(19456K), 0.0013960 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 8543K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  96% used [0x00000000fec00000, 0x00000000ff3bd8d0, 0x00000000ff400000)
  from space 1024K,  60% used [0x00000000ff500000, 0x00000000ff59a5b0, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,   0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
 Metaspace       used 3417K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

依旧只执行了一次 GC,但在这次 GC 后,伊甸园已被使用了 96%。

这很好理解,先前被使用 90%,大概 7.2M,再添加一个 512k 的字节数组,伊甸园还有剩余,不会触发垃圾回收,因此依旧只执行了一次 GC。

那再添加一个 512k 的字节数组呢?

1
2
3
4
5
6
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_7M]);
list.add(new byte[_512KB]);
list.add(new byte[_512KB]);
}

这超出了伊甸园的剩余空间。

运行 main() 方法后,控制台输出:

[GC (Allocation Failure) [DefNew: 1811K->617K(9216K), 0.0009856 secs] 1811K->617K(19456K), 0.0010215 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 8461K->512K(9216K), 0.0024029 secs] 8461K->8295K(19456K), 0.0024244 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 1350K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  10% used [0x00000000fec00000, 0x00000000fecd1a30, 0x00000000ff400000)
  from space 1024K,  50% used [0x00000000ff400000, 0x00000000ff480048, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 7783K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  76% used [0x00000000ff600000, 0x00000000ffd99c80, 0x00000000ffd99e00, 0x0000000100000000)
 Metaspace       used 3418K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

有额外触发了一次 GC,在这次 GC 后,新生代仅被使用了一小部分,但是老年代被使用了 76%,这是因为新生代已经无法容纳全部对象,便将「大对象」,即 7M 的字节数组,从新生代晋升到老年代。

大对象直接晋升到老年代

沿用先前的虚拟机参数,伊甸园保持 8M 大小。

尝试向一个空列表中添加 8M 的字节数组,由于运行程序也需要消耗一定的伊甸园空间,显然无法直接将 8M 的字节数组添加到伊甸园里,那么此时会发生什么呢?

1
2
3
4
private static void bigObject() {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8M]);
}

此时并没有发生 GC,而是直接将 8M 的大对象放到了老年代里。

那再放一个 8M 的字节数组呢?

1
2
3
4
5
private static void bigObject() {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8M]);
list.add(new byte[_8M]);
}

此时会出现 OutOfMemoryError,提示堆内存溢出。

这很好理解,新添加的对象无论是新生代还是老年代都放不下,自然会内存溢出。

在提示错误前,JVM 还会进行最后一次 Full GC 尝试,只不过这依旧无法解决问题,所以最终出现 OOM。

子线程中发生的内存溢出

1
2
3
4
5
6
7
8
9
10
private static void childThread() throws Exception {
new Thread(() -> {
List<byte[]> list = new ArrayList<>();
list.add(new byte[_8M]);
list.add(new byte[_8M]);
}).start();

System.out.println("sleep...");
System.in.read();
}

触发 Full GC 后,出现 OOM,但程序并没有立即终止,依旧在运行,直到程序接受到键入的信息后程序才终止。

也就是说,子线程的 OOM 并不会导致主线程的意外结束。

4. 垃圾回收器

串行 吞吐量优先 响应时间优先
单线程 多线程 多线程
适用于堆内存较小,比如个人电脑 适用于堆内存较大,多核 CPU 适用于堆内存较大,多核 CPU
/ 让单位时间内,STW 的时间最短 尽可能让单词 STW 的时间最短

一个单位时间内:

  • 「吞吐量优先」举例:进行了 2 次 GC,每次消耗 0.2s,因此一个单位时间内 GC 总耗时 0.4s
  • 「响应时间优先」举例:进行了 10 次 GC,每次消耗 0.1s,但一个单位时间内 GC 总耗时 1s

4.1 串行

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

1
-XX:+UseSerialGC

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

  1. Serial:发生在新生代,使用「复制」算法
  2. SerialOld:发生在老年代,使用「标记-整理」算法

串行垃圾回收器在运行时只有一个垃圾回收线程在运行,其他用户线程都会被阻塞,直到垃圾回收线程运行完毕。

串行垃圾回收器工作原理

4.2 吞吐量优先

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

1
-XX:+UseParallelGC -XX:+UseParallelOldGC
  • -XX:+UseParallelGC:在新生代开启吞吐量优先垃圾回收器,使用「复制」算法
  • -XX:+UseParallelOldGC:在老年代开启吞吐量优先垃圾回收器,使用「标记-整理」算法

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

JDK 1.8 默认使用这两个参数。

吞吐量优先垃圾回收器工作原理

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

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

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

4.3 响应时间优先

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

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

1
-XX:+UseConcMarkSweepGC

即:Use Concurrent Mark Sweep GC,并发标记清除垃圾回收。

-XX:+UseParallelGC 相比,其中的 Parallel 表示并行,而此处使用的是 Concurrent,即并发。

使用吞吐量优先垃圾回收器时,多个垃圾回收线程并行运行,但阻塞用户线程;使用 CMS 时,某些时候垃圾回收线程能够与用户线程并发执行,进一步减少 STW 的时间。

CMS 是一款 工作在老年代 的垃圾回收器,与之配合的是一款工作在新生代、基于复制算法的垃圾回收器:

1
-XX:+UseParNewGC

CMS 在某些时候可能会发生并发失败,此时会让在老年代工作的 CMS 退化为 SerialOld(串行,标记-整理)。

响应时间优先垃圾回收器工作原理

  • 老年代内存不足,达到「安全点」,CMS 开始工作,执行「初始标记」,此时依旧会阻塞用户线程。初始标记是一个并行操作,上图并未体现,在这个阶段只会标记能被 GC Roots 直接 关联到的对象,因此尽管会阻塞用户线程,但时间也很短;
  • 用户线程恢复运行,垃圾回收线程执行「并发标记」,找出更多的「垃圾对象」;
  • 上一阶段用户线程不断执行,引用可能发生变化,进而出现漏标(该被回收的没被标记)和错标(不该回收的却被标记了),因此还需要「重新标记」来进行校正,这个阶段也会阻塞用户线程;
  • 用户线程再次恢复运行,CMS 执行并发清理。

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

VM 参数 含义
-XX:ParallelGCThreads=n 指定并行运行的垃圾回收线程数为 n,通常与 CPU 核心数一样
-XX:ConcGCThreads=threads 指定并发运行的垃圾回收线程数为 threads,
建议设置为 ParallelGCThreads 的四分之一
-XX:CMSInitiatingOccupancyFraction=percent 指定老年代使用占比达到 percent 时才触发 CMS[1]
-XX:CMSInitiatingOccupancyFraction=70
-XX:+CMSScavengeBeforeRemark 在重新标记阶段前进行一次新生代的 GC[2]

5. G1 垃圾回收器

5.1 概述

G1,即 Garbage First。

timeline
    title G1 垃圾回收器
    2004 : 论文发布
    2009 : JDK 6u14 体验
    2012 : JDK 7u4 官方支持
    2017 : JDK 9 默认

适用场景:

  • 同时注重吞吐量(Throughout)和低延迟(Low latency),默认的暂停目标是 200ms
  • 超大堆内存,会将堆划分为多个大小相等的 Region
  • 整体采用「标记-整理」算法,两个 Region 之间是「复制」算法
VM 参数 含义
-XX:+UseG1GC 使用 G1 垃圾回收器,在 JDK8 中需要手动开启
-XX:G1HeapRegionSize=size 指定 Region 的大小
-XX:MaxGCPauseMillis=time 指定最大暂停时间

5.2 回收阶段

G1垃圾回收阶段

一共三个阶段:

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

Young Collection

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

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

Young-Collection-1

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

Young-Collection-2

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

Young-Collection-3

Young Collection + CM

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

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

    1
    -XX:InitiatingHeapOccupancyPercent=percent

Young-Collection-and-CM

Mixed Collection

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

  • 最终标记(Remark)会 STW
  • 拷贝存活(Evacuation)也会 STW

Mixed-Collection

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

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

5.3 Full GC

针对 Serial GC 和 Parallel GC:

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

针对 CMS 和 G1:

  • 新生代内存不足发生的垃圾收集:Minor GC
  • 老年代内存不足时:
    • 如果垃圾产生速度慢于垃圾回收速度,不触发 Full GC,而是并发地进行清理
    • 如果垃圾产生速度快于垃圾回收速度,并发失败,退化为 Serial Old 串行地进行收集,触发 Full GC,STW 增长

5.4 Young Collection 跨代引用

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

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

卡表

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

脏卡

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

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

协作流程

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

5.5 Remark

GC三色标记法步骤

上图是利用三色标记进行可达性分析的基本步骤,在并发执行标记过程中,程序可能重新分配新对象或修改对象的引用,导致不该被回收的对象被回收了,或该被回收的对象而没被回收。

比如对象 A 已经处理完,接下来准备处理对象 B:

并发标记-1

在处理对象 B 的过程中,用户线程不会被阻塞,用户线程移除对象 B 对对象 C 的引用,并新增对象 A 对对象 C 的引用:

并发标记-2

如果就这么结束,对象 C 还是白色,最后会被回收,导致不该回收的对象被回收了。

为了解决这个问题,在并发标记完,还需要进行一次重新标记,即 Remark。

那怎么进行 Remark 呢,总不能又把对象全扫一遍吧?

当对象的引用发生变化时,使用 Pre-Write Barrir(写前屏障,类似 AOP)将这种变化保存到一个队列(SATB Mark Queue,SATB 即 Snapshot At The Beginning)里:

对象引用变化保存到satb_mark_queue里

并发标记完成后,进入 Remark 阶段,触发 STW 重新标记队列里的对象。

5.6 G1 的优化

JDK 8u20 字符串去重

假设有如下两个字符串对象:

1
2
String s1 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}
String s2 = new String("hello"); // char[]{'h', 'e', 'l', 'l', 'o'}

这两个字符串对象是不同的对象,但是它们底层对应的 char[] 的内容是一样的。

为了降低内存消耗,所有新分配的字符串会放入一个队列中,当新生代发生回收时,G1 并发检查是否有重复字符串。

如果他们的 一样,让它们引用同一个 char[] 以减少内存的使用。

使用 VM 参数 -XX:+UseStringDeduplication 开启字符串去重,只适用于 G1。

注意: 此时 s1s2 仍然是不同的字符串对象,进行 == 比较时仍返回 false,只不过它们底层使用的 char[] 是同一个。

String.intern() 相比:

  • String.intern() 关注的是字符串对象
  • 字符串去重关注的是 char[]
  • 在 JVM 内部,使用了不同的字符串表

优缺点:

  • 优点:节省大量内存
  • 缺点:新生代回收时间增加,略微多占用了 CPU 时间

JDK 8u40 并发标记类卸载

所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸载它所加载的所有类。

默认情况下,该优化是启用的:

1
-XX:+ClassUnloadingWithConcurrentMark

JDK 8u60 回收巨型对象

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

巨型对象

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

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

JDK 9 并发标记起始时间的调整

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

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

在 JDK 9 可以动态调整:

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

JDK 9 之后更高效的回收

根据官方资料,在 JDK 9 中对 GC 进行了 250+ 功能增强、180+ BUG 修复,

JDK 25 Oracle HotSpot VM GC 调优指南:JDK 25 HotSpot VM GC Tuning Guide

6. 垃圾回收调优

6.1 调优前

命令、工具、理念

  • 掌握 GC 相关 VM 参数,会基本的空间调整:

    • 以 JDK25 为例,可以在 The java Command 中找到 java 命令的相关参数,其中就有与 GC 相关的参数。

    • 使用 java -XX:+PrintFlagsFinal -version 命令查看 JVM 默认参数:

      1
      2
      3
      4
      5
      # Windows 下使用以下命令过滤 JVM 默认参数中的 GC 相关信息
      java -XX:+PrintFlagsFinal -version | findstr "GC"

      # Linux 下使用以下命令
      java -XX:+PrintFlagsFinal -version | grep "GC"
  • 掌握相关工具:jampjconsole、MAT 等等

  • 明白:调优与实际应用、环境有关,没有放之四海而皆准的法则

调优领域

GC 调优是众多调优中的一个方向,应用程序性能全面的提升离不开各个领域的分析与调优,包括:

  • 内存
  • 锁竞争
  • CPU 占用
  • IO

确定目标

  • 选择合适的垃圾回收器,「低延迟」还是「高吞吐量」
    • 低延迟(响应时间优先):CMS(JDK 9 废弃,JDK 14 移除)、G1、ZGC
    • ParallelGC

6.2 最快的 GC 是不发生 GC

查看 Full GC 前后的内存占用,考虑:

  • 数据是否太多?没有按需请求、组装数据,限制数据量。
  • 数据是否太臃肿?
    • 对象图 —— 按需处理数据
    • 对象大小 —— 对象瘦身
  • 是否存在内存泄漏?

总结:代码写得垃圾… 🤣

写代码时多思考,避免因代码问题导致的「调优」。

6.3 新生代调优

新生代的特点

  • new 一个对象时,这个对象会被分配(速度快且廉价)到新生代的伊甸园中。
    • TLAB(Thread-Local Allocation Buffer)是虚拟机在新生代的伊甸园划分出来的一块专用空间,是线程专属的。在虚拟机的 TLAB 功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块 TLAB 空间,只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,不会存在竞争的情况,大大提升分配效率。
  • 死亡对象的回收代价是 0
  • 大部分对象用过即死
  • Minor GC 消耗的时间远远低于 Full GC

新生代是越大越好吗?

-Xmn参数

-Xmn 可用于指定新生代的初始、最大空间。

如果新生代太小,会频繁触发 Minor GC,频繁 STW;如果新生代太大,老年代的空间就很小,而当老年代空间不足时就会触发 Full GC。

Oracle 官方建议在使用 G1 时不要设置新生代的大小,保持新生代的大小占整个堆空间的 25%50%25\% \sim 50\%

新生代空间大小与系统吞吐量之间的关系:

xychart-beta
    title "新生代空间大小与系统吞吐量的关系"
    x-axis "新生代大小 (Young Generation Size)" [ "很小", "偏小", "最优", "偏大", "过大" ]
    y-axis "系统吞吐量 (%)" 0 --> 100
    line [25, 65, 95, 85, 70]

理想状态下,新生代要能够容纳所有「并发量 * (请求-响应)」的数据。比如一次请求中创建的对象大小为 512K,要求并发量是 1000,那么新生代就需要 512M。

幸存区的调优

幸存区的空间需要能够保存「当前活跃的对象」与「需要晋升的对象」。

幸存区太小,JVM 会动态调整对象的晋升阈值,就可能让本不该晋升的对象晋升到老年代,等到老年代内存不足触发 Full GC 时才回收这些对象,延长了本该被回收对象的存活时间。

晋升阈值需要配置得当,让长时间存活的对象尽快晋升。如果阈值太高,该晋升的对象一直不晋升,就会占据幸存区的空间,新生代每次 GC 时还要复制这些对象,增加性能损耗。

  • 使用 -XX:MaxTenuringThreshold=threshold 设置晋升阈值
  • 使用 -XX:+PrintTenuringDistrubution 输出每次 GC 时幸存区中对象的年龄

6.4 老年代调优

以 CMS 为例:

  • 老年代内存越大越好
  • 优先考虑新生代的调优。如果没有发生 Full GC,自然无需对老年代进行调优;就算发生 Full GC,也应该优先考虑新生代的调优
  • 如果要对老年代进行调优,观察 Full GC 时老年代的内存占用,然后将老年代的空间调大 1413\frac{1}{4} \sim \frac{1}{3}
    • 使用 -XX:CMSInitiatingOccupancyFraction=percent 设置老年代达到多少比例时才进行 CMS,通常设置为 75%80%75\% \sim 80\%

6.5 简单案例

Minor GC 和 Full GC 触发频繁

原因:内存空间紧张,新生代空间紧张

方案:增加新生代空间,使其能够容纳更多的对象;增大对象晋升阈值,让该被回收的对象在新生代被回收

使用 CMS,请求高峰期发生 Full GC,单次暂停时间长

原因:查看 GC 日志,分析 CMS 的哪个阶段耗时长,通常是「重新标记(Remark)」阶段耗时长

方案:使用 -XX:+CMSScavengeBeforeRemark 参数,在 Remark 前对新生代进行一次 GC

老年代空间充裕的情况下发生 Full GC(CMS JDK1.7)

原因:JDK 1.8 使用元空间作为方法区的实现,JDK 1.7 使用永久代作为方法区的实现。在 JDK 1.7 中,当永久代空间不足时也会导致 Full GC。

方案:增加永久代空间,使用 -XX:MaxPermSize=size 设置永久代的最大空间。


  1. CMS 执行过程中,应用程序还会不断地产生垃圾,这些垃圾被称为「浮动垃圾」,CMS 无法处理浮动垃圾,只能在下一次 GC 时处理。为了避免浮动垃圾的产生速度比 CMS 的清理还快,需要预留一定的内存空间给这些浮动垃圾,防止内存溢出。 ↩︎

  2. 由于新生代的对象可能会引用老年代的对象,因此在重新标记时需要扫描整个堆。在重新标记之前对新生代进行一次 Minor GC 能够降低新生代中存活对象的数量,缩小重新标记的扫描范围,降低重新标记的时间开销。 ↩︎