G1垃圾收集器(下)
前两个章节,我们对G1的回收流程和关键技术,进行了阐述。本文我们解析一下G1垃圾回收的日志,进而更深入的理解G1垃圾回收器。理解这些日志,也为我们在生产中,排查GC故障,提供一些思路。
一、测试代码
1.1 测试代码
java
public class GCTest {
//executor
private static final ThreadPoolExecutor EXECUTOR = new ThreadPoolExecutor(4, 4,
10, TimeUnit.MINUTES, new ArrayBlockingQueue<>(100),
new HellohuThreadFactory("hello"), new ThreadPoolExecutor.DiscardPolicy());
//全局队列,用于浪费内存
private static final Queue<WasteMemory> GLOBAL_WASTE = new LinkedBlockingQueue<>();
//无意义的浪费内存的任务
private static class WasteMemory implements Runnable {
private final int byteArrayLength;//浪费内存大小,单位byte
private final int percentIntoGlobalWaste;//进入全局队列的概率
private final int percentReleaseGlobalWaste;//释放全局队列的概率
private byte[] waste;//用于浪费内存的数组
private WasteMemory(int byteArrayLength, int percentIntoGlobalWaste, int percentReleaseGlobalWaste) {
this.byteArrayLength = byteArrayLength;
this.percentIntoGlobalWaste = percentIntoGlobalWaste;
this.percentReleaseGlobalWaste = percentReleaseGlobalWaste;
}
@Override
public void run() {
//申请内存
waste = new byte[byteArrayLength];
//做些无意义的事情
for (int i = 2; i < waste.length; i++) {
waste[i] = (byte) (waste[i - 1] + waste[i - 2]);
}
//释放内存
ThreadLocalRandom random = ThreadLocalRandom.current();
int randomReleasePercent = random.nextInt(100);
if (percentReleaseGlobalWaste > randomReleasePercent) {
GLOBAL_WASTE.poll();
}
//浪费内存
int randomWastePercent = random.nextInt(100);
if (percentIntoGlobalWaste > randomWastePercent) {
GLOBAL_WASTE.add(this);
}
}
}
public static void main(String[] args) throws InterruptedException {
ThreadLocalRandom random = ThreadLocalRandom.current();
while (true) {
int waste = random.nextInt(1024, 640 * 1024);
EXECUTOR.execute(new WasteMemory(waste, 20, 15));
Thread.sleep(1);
}
}
}
- 简单的写了一个浪费内存的任务
- 构造一个byte数组,用于浪费内存;
- 做一些无意义的事情;
- 以一定的概率,释放1个全局内存;
- 以一定的概率,浪费1个全局内存;
- 主函数,死循环构造上述任务,加入线程池
- 生成一个1k-640k的随机数,作为浪费内存的大小;
- 20%的概率浪费内存,15%的概率,释放内存;
- 1ms执行一次。
从上面代码中可以看出,以概率学的角度来讲,最终一定会因为OOM挂掉。你可以通过调整上面的浪费内存大小、浪费回收的概率,得到不同的垃圾回收日志。
1.2 测试参数
ruby
-XX:+UseG1GC -Xmx1g -Xms1g -XX:-PrintCommandLineFlags -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCApplicationStoppedTime -XX:+PrintGCApplicationConcurrentTime `
参数含义
- -XX:+UseG1GC 使用G1垃圾收集器
- -Xmx1g -Xms1g 最大最小内存均为1g
- -XX:-PrintCommandLineFlags 打印GC的一些参数
- -XX:+PrintGCDetails 打印GC详情
- -XX:+PrintGCDateStamps 打印GC的时间戳
- -XX:+PrintGCApplicationStoppedTime 打印应用在GC时暂停的时间(确切说是安全点)
- -XX:+PrintGCApplicationConcurrentTime 打印应用在GC之前的并发运行的时间(确切说是安全点)
这些日志默认打印到控制台上,生产上,最好配置打印到日志文件中:
- -Xloggc:/path/to/gc.log 日志文件路径
- -XX:+UseGCLogFileRotation 开启滚动日志
- -XX:NumberOfGCLogFiles= 保留日志的最大数量
- -XX:GCLogFileSize= 每个日志文件的大小
建议
建议在生产上,这些GC参数都设置上,假如GC出现问题,可以给我们提供一些日志排查的思路。
二、日志介绍
2.1 年轻代GC
以下为一段年轻代GC的日志,看起来很多,如果对G1的垃圾回收流程理解深入的话,就比较好理解。
less
2024-07-13T11:23:45.090+0800: [GC pause (G1 Evacuation Pause) (young), 0.0104548 secs]
[Parallel Time: 7.2 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 11735.8, Avg: 11736.0, Max: 11736.8, Diff: 1.0]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 1.8]
[Update RS (ms): Min: 0.0, Avg: 0.4, Max: 0.5, Diff: 0.5, Sum: 3.0]
[Processed Buffers: Min: 0, Avg: 2.9, Max: 4, Diff: 4, Sum: 23]
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.9]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 5.4, Avg: 5.5, Max: 5.6, Diff: 0.3, Sum: 43.9]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.2, Sum: 1.0]
[Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 14]
[GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.5]
[GC Worker Total (ms): Min: 5.6, Avg: 6.4, Max: 6.7, Diff: 1.1, Sum: 51.2]
[GC Worker End (ms): Min: 11742.4, Avg: 11742.4, Max: 11742.5, Diff: 0.1]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.5 ms]
[Other: 2.7 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.6 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 1.0 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.5 ms]
[Free CSet: 0.3 ms]
[Eden: 464.0M(464.0M)->0.0B(439.0M) Survivors: 47.0M->64.0M Heap: 750.3M(1024.0M)->99.3M(1024.0M)]
[Times: user=0.11 sys=0.00, real=0.01 secs]
下面我们逐行解读。
1. 2024-07-13T11:23:45.090+0800: [GC pause (G1 Evacuation Pause) (young), 0.0104548 secs]
2024-07-13T11:23:45.090+0800
: 发生GC时间;GC pause (G1 Evacuation Pause) (young)
G1 Evacuation Pause
G1垃圾回收暂停,如果申请了大对象,这里会提示Humongous Allocation
;(young)
GC类型,年轻代回收;
0.0104548 secs
: 用时0.0104548 secs。
2. [Parallel Time: 7.2 ms, GC Workers: 8] 第二大块,为GC线程并行的部分
Parallel Time: 7.2 ms
: 并行耗时7.2msGC Workers: 8
: GC并发线程数量,可以使用-XX:ParallelGCThreads=指定,默认值与cpu支持的线程数有关,支持线程数小于8,为cpu线程数,超过8个,则为线程数的5/8。
注意:这个数量并不是越大越好,假如cpu仅支持8线程并发,而你设置为16,不仅不会让垃圾回收变快,反而会因为线程的上下文切换变得更慢。
2.1 [GC Worker Start (ms): Min: 11735.8, Avg: 11736.0, Max: 11736.8, Diff: 1.0]
GC线程启动的最小/最大/平均时间,这个时间是相对(相对虚拟机启动)时间,单位ms,理想情况他们几乎同时启动,Diff趋近与0。
2.2 [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.4, Diff: 0.4, Sum: 1.8]
堆外Root(栈、全局变量等)引用当前回收集(collection set)扫描所用时间。
所谓回收集(collection set)就是垃圾回收分区集合。young gc包含所有年轻代,mixed gc包含所有年轻代和部分选中的老年代。
2.3 Update RS (ms)
yaml
[Update RS (ms): Min: 0.0, Avg: 0.4, Max: 0.5, Diff: 0.5, Sum: 3.0]
[Processed Buffers: Min: 0, Avg: 2.9, Max: 4, Diff: 4, Sum: 23]
之前的文章讲到过,G1的每个区域,都有一个记忆集,用于记录其他分区到本分区的引用。使用写后屏障维护,放入到日志缓冲区,异步处理。 这里日志,显示的是,在GC暂停前,还没有处理完成的日志缓存区花费的时间和处理缓冲区的数量。
- Update RS (ms) : 更新记忆集花费的时间。
- Processed Buffers : 处理的日志数量。
2.4 [Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.9]
扫描记忆集花费的时间。也就是扫描非回收集(Collection Set)到回收集的引用的时间
2.5 [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
扫描编译源代码到回收集花费的时间,这个显示的位置有误,应该是Ext Root Scanning的一部分。
参考: AOT code root scanning shows in the wrong position in the logs
2.6 [Object Copy (ms): Min: 5.4, Avg: 5.5, Max: 5.6, Diff: 0.3, Sum: 43.9]
存活对象,复制到其他区域花费的时间。
2.7 Termination (ms)
yaml
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.2, Sum: 1.0]
[Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 14]
Termination (ms)
: 线程停止花费的时间。当一个线程完成本线程的任务后,就会进入一个临界区,并尝试帮助其他垃圾线程完成任务(steal outstanding tasks)。这个时间代表它尝试停止,与真正停所花费的时间。Termination Attempts
: 一个线程尝试暂停的次数,每成功偷取一次任务,该次数就+1。
2.8 [GC Worker Other (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.5]
GC线程花费在其他(也就是上述流程之外的)地方所花费的时间。
2.9 [GC Worker Total (ms): Min: 5.6, Avg: 6.4, Max: 6.7, Diff: 1.1, Sum: 51.2]
GC所有线程花费的总耗时。
2.10 [GC Worker End (ms): Min: 11742.4, Avg: 11742.4, Max: 11742.5, Diff: 0.1]
GC线程结束的相对时间,理想情况下,几乎同时停止,也就是Diff趋近于0。
3 串行任务
以上编号2开头的,为并行任务,之后的第三大块,就都是串行的任务了。 3.1 [Code Root Fixup: 0.0 ms]
修复代码中指定堆的指针,因为这些指针,可能已经移动了。
3.2 [Code Root Purge: 0.0 ms]
清理code Root相关的数据结构。
3.3 [Clear CT: 0.5 ms]
清理卡表(card table),已经清理的区域的卡表,需要重置,这里是重置这些卡表需要的时间。
3.4 [Other: 2.7 ms]
其他没有统计到上面的任务耗时。
- [Choose CSet: 0.0 ms] : 从内存分区中,选择分区到收集区(CSet)所花费的时间;
- [Ref Proc: 0.6 ms] : 处理各种引用(soft/weak/final/phantom/JNI)花费的时间;
- [Ref Enq: 0.0 ms] :引用处理时,有些需要进入队列,这里记录进入队列花费的时间;
- [Redirty Cards: 1.0 ms] :存活对象复制到其他分区,重置这种分区的卡表(记忆集);
- [Humongous Register: 0.1 ms] :前文提到过,在JDK 8u60之后,部分大对象可以在年轻代期间回收。这个时间为评估大对象是否能回收花费的时间;
- [Humongous Reclaim: 0.5 ms] : 回收大对象花费的时间;
- [Free CSet: 0.3 ms] :释放被完全清理的分区,到free 列表所花费的时间。
4. [Eden: 464.0M(464.0M)->0.0B(439.0M) Survivors: 47.0M->64.0M Heap: 750.3M(1024.0M)->99.3M(1024.0M)]
垃圾回收后,对内存状态:
Eden: 464.0M(464.0M)->0.0B(439.0M)
: Eden分区,从(464.0M)缩容到(439.0M),对象或者回收,或者复制到其他分区;Survivors: 47.0M->64.0M
: Survivors分区,由47.0M 增长到 64.0M;Heap: 750.3M(1024.0M)->99.3M(1024.0M)
: 堆存活对象由750.3M降到99.3M,堆大小没有改变。
5. [Times: user=0.11 sys=0.00, real=0.01 secs]
总耗时
- user=0.11 :用户线程花费的时间,注意这里是多线程的累计值,通常要比real时间大很多;
- sys=0.00 :内核态花费时间,也是累计时间;
- real=0.01 : 真正的暂停时间;
2.2 并发标记
当老年代占用的比例大于阈值后,会触发并发标记流程,并发标记流程不会立即开始,而是与下一次的young gc一起进行,在日志中,我们可以看到一条,带有(initial-mark)
的young gc日志:
less
2024-07-13T11:34:30.897+0800: [GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0021755 secs]
[Parallel Time: 1.1 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 46258.7, Avg: 46258.8, Max: 46258.9, Diff: 0.2]
[Ext Root Scanning (ms): Min: 0.0, Avg: 0.3, Max: 0.8, Diff: 0.8, Sum: 2.1]
[Update RS (ms): Min: 0.0, Avg: 0.2, Max: 0.8, Diff: 0.8, Sum: 1.8]
[Processed Buffers: Min: 0, Avg: 1.4, Max: 5, Diff: 5, Sum: 11]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 0.0, Avg: 0.3, Max: 0.5, Diff: 0.4, Sum: 2.2]
[Termination (ms): Min: 0.0, Avg: 0.2, Max: 0.3, Diff: 0.3, Sum: 1.7]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 8]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Total (ms): Min: 0.8, Avg: 1.0, Max: 1.1, Diff: 0.2, Sum: 7.8]
[GC Worker End (ms): Min: 46259.8, Avg: 46259.8, Max: 46259.8, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.3 ms]
[Other: 0.7 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.2 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.1 ms]
[Free CSet: 0.1 ms]
[Eden: 1024.0K(225.0M)->0.0B(237.0M) Survivors: 5120.0K->5120.0K Heap: 513.1M(1024.0M)->512.1M(1024.0M)]
[Times: user=0.00 sys=0.00, real=0.00 secs]
整体上与普通的young gc并没有什么区别,其实mixed gc的日志也没什么区别,因为这里垃圾回收,可以抽象为对回收集(Collection CSet)进行回收,大致流程,没有多大区别。
我们带着日志,回顾下并发标记流程。
1. 初始标记(Initial Mark)
借助年轻代回收停顿,扫描所GCRoots能直接关联到的对象。同时对堆打一个逻辑快照,其实就是每个region设置了两个指针,称之为SATB(Snapshot-At-The-Beginning ),在快照外的,都被认为是存活对象。
2. 根分区扫描(Root Region Scanning)
扫描其他分区(Root Region)到需要扫描分区的引用,也就是年轻代到老年代引用,刚刚经历过一次young gc,并且SATB外的对象也不扫描,那么,这个扫描扫的,也就是Survivor分区到老年代的引用。
ini
2024-07-13T11:34:30.899+0800: [GC concurrent-root-region-scan-start]
2024-07-13T11:34:30.899+0800: [GC concurrent-root-region-scan-end, 0.0001801 secs]
3. 并发标记(Concurrent Marking)
- 从上述两个阶段找到的直接关联到老年代的引用,继续扫描,使用bitmap标记所有存活对象,所有SATB外的都被认为是存活对象,因此只需要扫描SATB内的即可。
- 使用写前屏障,记录堆中引用的修改,到SATB BUFFER中。BUFFER满了之后,会被加入全局链表中,标记线程会周期处理这些被修改的引用。
- 与用户线程并发执行的线程数,默认值是上一小节中垃圾回收Worker数量的四分之一,也可以用参数-XX:ConcGCThreads设置。
ini
2024-07-13T11:34:30.899+0800: [GC concurrent-mark-start]
2024-07-13T11:34:30.901+0800: [GC concurrent-mark-end, 0.0017811 secs]
4. 重新标记(Remark)
- 处理上一阶段的SATB BUFFER以及全局链表中的引用修改。
- 处理引用(弱引用、软引用、虚引用、最终引用)
- 类卸载
yaml
2024-07-13T11:34:30.901+0800: [GC remark
2024-07-13T11:34:30.901+0800: [Finalize Marking, 0.0006084 secs]
2024-07-13T11:34:30.902+0800: [GC ref-proc, 0.0002658 secs]
2024-07-13T11:34:30.902+0800: [Unloading, 0.0007649 secs], 0.0026664 secs]
[Times: user=0.03 sys=0.00, real=0.00 secs]
5. 清除(Cleanup) 停止用户线程(STW),为mixed gc做准备工作
- 计算老年代各个Region的回收价值和成本进行排序。
- 对辅助垃圾回收的一些数据结构进行状态维护。
- 回收完全没有存活对象的老年代或者大对象,日志中可以看到堆大小变化,也就可以计算出这个阶段回收垃圾的大小。
ini
2024-07-13T11:34:30.904+0800: [GC cleanup 515M->478M(1024M), 0.0012239 secs]
[Times: user=0.00 sys=0.00, real=0.00 secs]
6. 并发清除(Concurrent-cleanup)
与用户线程并发执行,完成上一步剩余的清理工作;清理上一步回收的分区相关的记忆集等数据结构,归还到内存空闲链表。
ini
2024-07-13T11:34:30.906+0800: [GC concurrent-cleanup-start]
2024-07-13T11:34:30.906+0800: [GC concurrent-cleanup-end, 0.0000509 secs]
2.3 混合回收
在并发标记阶段完成后,通常会开启混合回收,你会看到先进行了一次年轻代回收,然后才是混合回收。
less
2024-07-13T11:34:34.124+0800: [GC pause (G1 Evacuation Pause) (young), 0.0075060 secs]
...
[Eden: 181.0M(181.0M)->0.0B(27.0M) Survivors: 8192.0K->24.0M Heap: 800.6M(1024.0M)->593.0M(1024.0M)]
[Times: user=0.00 sys=0.00, real=0.01 secs]
原因,我们在其他文章中已经介绍过:
G1会试图在一次不大于用户期望的暂停时间内,回收尽可能多的老年代,因此在混合回收阶段,年轻代的大小被设置为允许的最小值,由-XX:G1NewSizePercent确定,默认为5%。也正是因为需要先调整年轻代大小,在标记流程结束后,G1需要先进行一次年轻代的垃圾回收,回收后改变年轻代大小,才能开启混合回收。
在上面日志上可以看到,年轻代有181M缩小到27M。
之后可以看到mixed gc,日志与年轻代回收,没有大的区别。
less
2024-07-13T11:34:32.770+0800: [GC pause (G1 Evacuation Pause) (mixed), 0.0068461 secs]
[Parallel Time: 4.9 ms, GC Workers: 8]
[GC Worker Start (ms): Min: 48132.2, Avg: 48132.2, Max: 48132.2, Diff: 0.1]
[Ext Root Scanning (ms): Min: 0.1, Avg: 0.1, Max: 0.2, Diff: 0.1, Sum: 1.0]
[Update RS (ms): Min: 0.7, Avg: 0.7, Max: 0.7, Diff: 0.1, Sum: 5.6]
[Processed Buffers: Min: 6, Avg: 11.9, Max: 15, Diff: 9, Sum: 95]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Object Copy (ms): Min: 3.7, Avg: 3.8, Max: 3.8, Diff: 0.1, Sum: 30.1]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.6]
[Termination Attempts: Min: 1, Avg: 1.4, Max: 2, Diff: 1, Sum: 11]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 4.7, Avg: 4.7, Max: 4.7, Diff: 0.1, Sum: 37.5]
[GC Worker End (ms): Min: 48136.9, Avg: 48136.9, Max: 48136.9, Diff: 0.0]
[Code Root Fixup: 0.0 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.6 ms]
[Other: 1.3 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.5 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.5 ms]
[Humongous Register: 0.1 ms]
[Humongous Reclaim: 0.1 ms]
[Free CSet: 0.1 ms]
[Eden: 23.0M(23.0M)->0.0B(188.0M) Survivors: 28.0M->7168.0K Heap: 616.0M(1024.0M)->576.6M(1024.0M)]
[Times: user=0.00 sys=0.00, real=0.01 secs]
2.4 Full GC
在G1中,Full GC是需要极力避免的,日志里,主要关注引起Full GC的原因,这里是(Allocation Failure)
,无法分配新对象。因为G1垃圾回收与用户线程是并行的,如果垃圾回收的速度没有用户快,就会退回到Full GC。
css
2024-07-13T11:35:02.210+0800: [Full GC (Allocation Failure) 745M->697M(1024M), 0.0390765 secs]
[Eden: 0.0B(51.0M)->0.0B(51.0M) Survivors: 0.0B->0.0B Heap: 745.5M(1024.0M)->697.8M(1024.0M)], [Metaspace: 4002K->4002K(1056768K)]
[Times: user=0.05 sys=0.00, real=0.04 secs]
参考: