1
+ [ toc]
2
+
1
3
---
2
4
layout: post
3
5
title: "JVM垃圾收集"
@@ -70,12 +72,16 @@ GC Roots很好理解,**栈上的对象引用、方法区引用的常量和静
70
72
71
73
> 反过来想,如果不分代,jvm内存还是会很快就被大量朝生夕死的对象充斥,每次就要对整个jvm区域的对象进行可达性标记,gc频率并不会比minor gc低多少。如果不让对象晋升到老年代,每次minor gc都需要标记那些暂时不死的对象,浪费时间。
72
74
73
- ** 那么新生代和老年代谁更大呢** ?新生代由` -Xmn ` 控制大小,sun建议配置为整个堆大小的3/8,所以显然老年代更大。因为它还要为新生代做担保。
75
+ ** 那么新生代和老年代谁更大呢** ?新生代由` -Xmn ` 控制大小,sun建议配置为整个堆大小的3/8,** 所以显然老年代更大。因为它还要为新生代做担保** 。
74
76
75
- ## 跨代引用
77
+ ## 跨代引用 - RSet
76
78
分代说起来简单,但实际上对象并不是孤立的,老年代的对象就不会引用新生代的对象了吗?这么一来,想收集新生代,依然需要遍历老年代,以确定新生代的哪些对象是有用的。** 这不相当于还是要遍历整个heap** ?
77
79
78
- 但是根据经验,** 跨代引用相当于本代引用来说是少数** 。所以** 没必要为了少量的跨代引用扫描整个老年代** ,只需要在新生代上建立一个remembered set,** 记录老年代的哪一小块区域存在跨代引用,在收集这个新生代的时候,把它的remembered set里记录的那些小块老年代里的对象加入到gc roots就行了** 。
80
+ 但是根据经验,** 跨代引用相当于本代引用来说是少数** 。所以** 没必要为了少量的跨代引用扫描整个老年代** ,只需要在新生代上建立一个RSet(Remembered Set),** 记录老年代的哪一小块区域存在跨代引用,在收集这个新生代的时候,把它的remembered set里记录的那些小块老年代里的对象加入到gc roots就行了** 。
81
+
82
+ > 其实这个思路也是很简单直白的。就像MySQL的表锁,为了加表锁,要判断每一行是不是加了行锁,有行写锁则无法加表锁。怎么判断是否有行写锁?难道要遍历整个表?不可能的。只需要提前记录一下哪些行有什么样的行锁就行了,所以有了意向表锁这个概念,加行锁之前,要先加个意向锁。** 典型的以空间换时间** 。
83
+
84
+ ** RSet避免了对整个老年代的扫描。况且,老年代比新生代大得多** 。
79
85
80
86
### 大内存就好吗?
81
87
先定义两个指标:
@@ -139,6 +145,8 @@ gc的工作方式就两步:
139
145
140
146
需要另一块内存,作为担保:如果这个Survivor放不下,就先放到他那里去。这个担保就是** 老年代** 。
141
147
148
+ ** 适用于新生代** 。
149
+
142
150
## Mark-Compact
143
151
144
152
> 标记-整理(压缩) 算法
@@ -149,9 +157,9 @@ Mark-Compact和Mark-Sweep类似,先标记所有垃圾,接下来是把存活
149
157
150
158
而且Copying算法如果不想1:1浪费50%的空间,就要有额外的担保空间,总不能给老年代再来一块担保空间吧?
151
159
152
- 老年代一般使用Mark-Sweep或者Mark-Compact:
153
- - 显然Copying不适合存活率高的老年代 ;
154
- - 而且另Mark-xxx算法不需要额外的分配担保 ;
160
+ ** 老年代一般使用Mark-Sweep或者Mark-Compact** :
161
+ - 显然Mark-Copying里的copy不适合存活率高的老年代 ;
162
+ - 而且Mark-Sweep/Compact算法不需要额外的分配担保,但是Mark-Copying需要 ;
155
163
156
164
# 垃圾收集器
157
165
基于上述思想和算法,实践中产生了[ 多种垃圾收集器] ( https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html ) 。
@@ -240,10 +248,10 @@ Concurrent Mark Sweep,专注于**最短回收停顿时间**。
240
248
它基于Mark-Sweep算法,尝试减少major gc的暂停时间,分为四个步骤:
241
249
- ** (STW) 初始标记** :
242
250
- 标记GC Roots对象;
243
- - ** 并发** 标记:
244
- - ** 根据GC Roots对象trace reachable object,和应用线程并发执行,所以不会STW** ;
245
- - (STW) 重新标记:
246
- - 上一步由于应用线程也在执行,有些对象也许不可达了,需要重新标一下 。** 类似于非并发标记的STW流程,但由于不需要全部标记,只需要调整一些标记,所以肯定比非并发的STW短多了** ;
251
+ - ** 并发** 标记( ** 可能产生浮动垃圾,也可能导致有用对象漏标 ** ) :
252
+ - 根据GC Roots对象trace reachable object,** 和应用线程并发执行,所以不会STW。但是可能产生浮动垃圾,也可能导致有用对象漏标 ** ;
253
+ - (STW) 重新标记( ** remark pause需要引入SATB技术,来对并发标记期间对象的变动进行重新标记 ** ) :
254
+ - 上一步由于应用线程也在执行,有些通过gc roots可达的对象现在也许不可达了,变成了浮动垃圾;更离谱的是,有些需要被标记的对象可能漏标了,这就需要SATB技术来解决(见下文) 。** 类似于非并发标记的STW流程,但由于不需要全部标记,只需要调整一些标记,所以肯定比非并发的STW短多了** ;
247
255
- ** 并发** 清除:
248
256
- sweep unreachable object,也是和应用线程并发执行,不会STW。** 但是类似于并发标记会产生浮动垃圾,并发清除也会,而且后续没有类似“重新标记给并发标记擦屁股”的步骤** 。
249
257
@@ -289,27 +297,34 @@ G1将多个region的存活对象拷贝到另一个region里,既收集了region
289
297
290
298
G1的分代不同于CMS(两个survivor,一个Eden,一个Old Gen)。G1保留了新生代老年代的概念,但** 不再在物理上指定新生代老年代** ,而是将一些region指定为逻辑上的新生代老年代,所以survivor/eden/老年代都不再连续了。垃圾收集时会将新生代region的存活对象拷贝到survivor或者old region区域(根据对象的存活年龄)。还有一部分region作为H(humongous),专门存放大对象。
291
299
292
- #### 浮动垃圾 - SATB
293
- 类似CMS。** 罪魁祸首是并发标记** :gc线程和应用线程并发执行,gc线程标记存活对象的时候,应用线程将某些对象的引用关系改变了。
300
+ #### 并发标记的问题 - SATB
301
+ 并发标记的时候,gc线程和应用线程并发执行,** gc线程标记存活对象的时候,应用线程将某些对象的引用关系改变了** ,最终会引入两个问题:
302
+ 1 . 垃圾漏标:俗称浮动垃圾;
303
+ 2 . 非垃圾没有被标记,导致被当成垃圾清理掉了;
304
+
305
+ CMS也有并发标记,所以也有类似问题。
294
306
295
307
比如Root为对象A,指向B和C,C还指向D。gc线程标记完B为存活对象,开始标记C,此时应用线程将C指向D的引用改为B指向D,gc线程标记完C之后发现标记流程结束了。** 此时D未被标记,被误认为是垃圾** 。但此时D是被B引用的,是有用的对象,如果把D回收了,程序就出错了。
296
308
297
309
> 漏标存活对象,会导致存活对象被当成垃圾回收,程序出错。
298
310
299
311
jvm用三种颜色标识对象:
300
312
- black:标记完了该对象以及该对象的引用;
313
+ > 可以理解为彻底涂黑了
301
314
- grey:只标记了该对象,还没标记完其引用;
315
+ > 可以理解为还没完全涂黑
302
316
- white:未标记该对象。
317
+ > 可以理解为还没开始涂黑
303
318
304
319
显然标记完后如果对象还为white,就是垃圾。
305
320
306
321
漏标的充要条件是:** 删除所有grey对象到某white对象的引用,并将其插入到black对象上** 。直接剥夺了white对象被标记的机会。
307
322
308
323
上述场景将gery对象(C)到white对象(D)的引用删除了,并插入到了black对象(B)上,导致D始终是white。
309
324
310
- 防止漏标的方式也很简单:记录下这些更改即可。G1使用SATB(snapshot at the beginning ),记录删掉所有grey对象到white对象的引用的情况,所有这些被删掉的引用指向的white对象,不再认为是垃圾。从而达到了“** 并发标记开始阶段不是垃圾的对象,就不认为是垃圾** ”的效果。
325
+ 防止漏标的方式也很简单:记录下这些更改即可。G1使用 ** SATB(Snapshot At The Beginning ),记录删掉所有grey对象到white对象的引用的情况** ,所有这些被删掉的引用指向的white对象,要被标记上 ,不再认为是垃圾。从而达到了“** 并发标记开始阶段不是垃圾的对象,就不认为是垃圾** ”的效果。
311
326
312
- 这些white对象被重新标记,** 是在最终标记阶段做的事情 ,这个阶段也需要STW** ,要不然还会出现上述情况,没有终结了。
327
+ 这些white对象被重新标记,** 是在重新标记阶段做的事情 ,这个阶段也需要STW** ,要不然还会出现上述情况,没有终结了。
313
328
314
329
> 但这么搞明显是“宁愿放过垃圾,不能错杀对象”:如果C到D的引用被删了之后,并没有插到B上,那D的确应该是垃圾。但是按照STAB的处理方式,D一开始不是垃圾,所以D在并发标记阶段结束后,也不被认为是垃圾。相当于在并发标记阶段产生的垃圾(** 浮动垃圾** )可能被错误地保留下来,本次gc就回收不了这种垃圾了。
315
330
>
@@ -318,19 +333,13 @@ jvm用三种颜色标识对象:
318
333
所以G1的流程里有两个阶段会STW:
319
334
- ** STW1** :初始标记(根据gc roots);
320
335
- ** 并发** 标记;
321
- - ** STW2** :最终标记, 处理SATB里的记录;
336
+ - ** STW2** :重新标记, ** 处理SATB里的记录** ;
322
337
323
338
但其实,最后清理垃圾的阶段也会STW:
324
- - ** STW3** :筛选回收。根据用户的期望,选择回收效益最高的几个region,清理对象;
325
-
326
- 清理对象涉及到对象的移动,之所以要STW,是因为简单。考虑到G1只回收一部分region,所以stw的时间是可控的,因此这里简单处理了,使用了stw。** ZGC则避免了这一点** 。
339
+ - ** STW3** :筛选回收。根据用户的期望,选择回收效益最高的几个region,清理(compact)对象;清理(compact)对象涉及到对象的移动,** 移动对象到新地址后,也要更新引用该地址的对象里的旧地址信息** ,这一步骤没法在一瞬间完成。** 所以G1在这一步要STW,这样处理起来会比较简单** 。考虑到G1只回收一部分region,所以这里stw的时间是可控的。** ZGC则在这一步避免了STW** !
327
340
328
341
#### Remembered Set
329
- 如果垃圾回收一次不清理整个heap,垃圾回收器需要知道不回收的部分有没有指向回收部分的指针。在分代的heap里,一次垃圾回收,不回收的部分一般是老年代,回收的部分是新生代。
330
-
331
- ** 如果不事先标记老年代是否指向新生代,就要遍历扫描整个老年代,太耗时间** 。G1使用Rset(remembered set)来做这个记录,典型的以空间换时间。** RSet记录了哪些老年代有指向该新生代的指针** ,通过RSet,只需要扫描该老年代区域,就能知道新生代里哪些对象能被清理,哪些不能被清理。
332
-
333
- ** RSet避免了对整个老年代的扫描。况且,老年代比新生代大得多** 。
342
+ G1垃圾回收一次不清理整个heap,垃圾回收器需要知道不回收的部分有没有指向回收部分的指针。在分代的heap里,一次垃圾回收,不回收的部分一般是老年代,回收的部分是新生代。G1使用Rset(remembered set)来记录哪些老年代有指向该新生代的指针,通过RSet,** 只需要扫描该老年代区域(而不是整个老年代)** ,就能知道新生代里哪些对象能被清理,哪些不能被清理。
334
343
335
344
参数:
336
345
- ` -XX:G1HeapRegionSize=n `
@@ -351,15 +360,15 @@ jvm用三种颜色标识对象:
351
360
352
361
另外** 在标记之后的垃圾清理阶段** :
353
362
- ** CMS使用标记清除算法,虽然不会导致STW,但会产生空间碎片** 。如果碎片过多,最后依然要STW处理一下;
354
- - ** G1使用标记复制算法,能避免产生空间碎片,但就会涉及到存活对象移动 ,所以这一阶段也使用STW的方式清理** 。全靠它使用了二八定律,所以能让停顿时间符合用户预期 ;
363
+ - ** G1使用标记复制算法,虽然能避免产生空间碎片,但会涉及到存活对象移动 ,所以这一阶段也使用STW的方式清理** 。由于充分遵循Garbage First原则,它能让停顿时间符合用户预期 ;
355
364
356
365
## 低延迟垃圾收集器
357
366
低延迟垃圾收集器(Low Latency Garbage Collector)几乎整个工作过程都是并发的,只有初始标记、最终标记会STW,且是O(1)时间。所以即使在大内存的情况下,也能达到非常低的延迟,所以被命名为low latency garbage collector,也叫low pause time garbage collector。
358
367
359
- 和并发垃圾收集器最大的差别,** 在于他们在清理垃圾的阶段,既能避免内存碎片,又能避免STW** !当前(jdk20)ZGC能做到1ms以内的STW时间。
368
+ 和并发垃圾收集器最大的差别,** 在于他们在清理垃圾的阶段,既能避免内存碎片(CMS的Sweep做不到) ,又能避免STW(G1的Compact做不到) ** !当前(jdk20)ZGC能做到1ms以内的STW时间。
360
369
361
370
### Z collector
362
- 为什么G1在清理垃圾的阶段需要STW?因为需要移动对象。虽然移动对象比较简单,但所有引用该对象的地址还都是旧对象地址,没法在一瞬间同时更新到新地址。如果此时旧地址被另一个对象用了,别的对象里还未更新的旧地址就会访问到错误的对象 ,出错了。
371
+ ** 为什么G1在清理垃圾的阶段需要STW?因为需要移动对象。虽然移动对象比较简单,但所有引用该对象的地址还都是旧对象地址,没法在一瞬间同时更新到新地址** 。如果此时旧地址被用了,就会访问到错误的对象 ,出错了。
363
372
364
373
ZGC使用染色指针标记对象是否被移动:64bit虚拟机,虽然使用64bit地址(如果不压缩指针),** 但64bit并没有全都用完** ,因为64bit理论上虽然能支持16EB地址空间,但os都不支持这么大,linux只支持128TB的进程虚拟地址空间,windows更是只支持到16TB,也就是说高位还有不少没用到。zgc就用了其中4bit来标记对象的状态(比如是否被移动)。
365
374
@@ -369,7 +378,7 @@ ZGC使用染色指针标记对象是否被移动:64bit虚拟机,虽然使用
369
378
370
379
> 此时的可达性分析,与其说是在遍历对象图来标记对象,不如说是在遍历引用图来标记指针。
371
380
372
- 所以zgc在使用标记复制算法移动对象的时候,可以使用转发表记录把对象从哪里移动到哪里了(hash表),** 在通过地址访问对象的时候,直接就可以通过对象地址看出该对象是否被移动了** 。如果移动了,就去转发表里找对象的新地址,并更新地址的值。
381
+ 所以zgc在使用标记复制算法移动对象的时候,可以使用转发表记录把对象从哪里移动到哪里了(hash表),** 在通过地址访问对象的时候,直接就可以通过对象地址看出该对象是否被移动了。如果移动了,就去转发表里找对象的新地址,并更新地址的值** 。
373
382
374
383
> 因为移动对象会破坏别的对象引用里的地址,所以zgc的这一步也被形象地称为自愈能力self-healing。
375
384
@@ -394,18 +403,21 @@ ZGC也要通过二八定律让自己变得更高效。
394
403
垃圾收集说难也难,但是如果搞清了来龙去脉,仅作为吃瓜群众,理解起gc来是很简单的。
395
404
396
405
- 怎么判断垃圾?
397
- + 用不到的就是垃圾。所以需要从一个起点开始标记,这个起点就是gc roots。
398
- - 怎么标记和清理 ?
399
- + stop the world,不然会出错。
406
+ + 用不到的就是垃圾。所以需要从一个起点开始标记有用对象,这个起点就是 ** gc roots** 。
407
+ - 怎么标记 ?
408
+ + ** stop the world** ,不然会出错。
400
409
- STW太慢了啊,能不能快点儿?
401
- + 可以,搞出了并发标记。
410
+ + 可以,搞出了** 并发标记** 。
411
+ - 并发标记时用户线程会改变对象的状态怎么办?
412
+ + 使用** SATB,绝不漏标任何一个有用对象** (但是可能会保留某些无用垃圾(浮动垃圾));
402
413
- 能不能再快点儿?
403
- + 二八定律,所以不如分代吧 ,优先收集young。
414
+ + 二八定律,所以不如 ** 分代 ** 吧 ,优先收集young;
404
415
- 分代以后会有跨代引用,怎么办?
405
- + G1的remembered set就是一个解决方案。只遍历跨代引用该新生代的老年代,其他老年代不遍历了。
406
- - 标记完了,怎么清理?
407
- + STW,可以但太慢了。
408
- + 于是zgc发明了染色指针和转发表,能够支持和用户线程并发。通过地址直接看出对象是否被移动了,然后去转发表找新地址。
416
+ + G1的** remembered set** 就是一个解决方案。只遍历跨代引用该新生代的老年代,其他老年代不遍历了。
417
+ - 标记完了,怎么** 清理** ?
418
+ + STW。挪动完对象以后,** 更新对象的引用为新的地址** 。
419
+ - 清理的时候使用STW太慢了,怎么办?
420
+ + 于是zgc发明了** 染色指针和转发表** ,能够支持清理垃圾的时候也和用户线程并发。通过地址直接看出对象是否被移动了,然后去转发表找新地址。
409
421
410
422
这些理念是递进的,实现起来也是越来越复杂的,但都是很合理的。一个垃圾收集器未必会实现所有的方面,比如G1支持二八定律但清理垃圾的时候用的是STW,很先进但又不完全先进。zgc当前还不支持分代,jdk21版本才支持。等等。
411
423
@@ -512,4 +524,3 @@ Rolling log的弊端:
512
524
- https://www.baeldung.com/jvm-parameters#handling-out-of-memory
513
525
514
526
515
-
0 commit comments