Skip to content

Commit 66412cc

Browse files
committed
modify jvm gc collector
1 parent 4f921d8 commit 66412cc

File tree

1 file changed

+48
-37
lines changed

1 file changed

+48
-37
lines changed

_posts/2019-11-19-jvm-gc-collector.md

+48-37
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
[toc]
2+
13
---
24
layout: post
35
title: "JVM垃圾收集"
@@ -70,12 +72,16 @@ GC Roots很好理解,**栈上的对象引用、方法区引用的常量和静
7072

7173
> 反过来想,如果不分代,jvm内存还是会很快就被大量朝生夕死的对象充斥,每次就要对整个jvm区域的对象进行可达性标记,gc频率并不会比minor gc低多少。如果不让对象晋升到老年代,每次minor gc都需要标记那些暂时不死的对象,浪费时间。
7274
73-
**那么新生代和老年代谁更大呢**?新生代由`-Xmn`控制大小,sun建议配置为整个堆大小的3/8,所以显然老年代更大。因为它还要为新生代做担保。
75+
**那么新生代和老年代谁更大呢**?新生代由`-Xmn`控制大小,sun建议配置为整个堆大小的3/8,**所以显然老年代更大。因为它还要为新生代做担保**
7476

75-
## 跨代引用
77+
## 跨代引用 - RSet
7678
分代说起来简单,但实际上对象并不是孤立的,老年代的对象就不会引用新生代的对象了吗?这么一来,想收集新生代,依然需要遍历老年代,以确定新生代的哪些对象是有用的。**这不相当于还是要遍历整个heap**
7779

78-
但是根据经验,**跨代引用相当于本代引用来说是少数**。所以**没必要为了少量的跨代引用扫描整个老年代**,只需要在新生代上建立一个remembered set,**记录老年代的哪一小块区域存在跨代引用,在收集这个新生代的时候,把它的remembered set里记录的那些小块老年代里的对象加入到gc roots就行了**
80+
但是根据经验,**跨代引用相当于本代引用来说是少数**。所以**没必要为了少量的跨代引用扫描整个老年代**,只需要在新生代上建立一个RSet(Remembered Set),**记录老年代的哪一小块区域存在跨代引用,在收集这个新生代的时候,把它的remembered set里记录的那些小块老年代里的对象加入到gc roots就行了**
81+
82+
> 其实这个思路也是很简单直白的。就像MySQL的表锁,为了加表锁,要判断每一行是不是加了行锁,有行写锁则无法加表锁。怎么判断是否有行写锁?难道要遍历整个表?不可能的。只需要提前记录一下哪些行有什么样的行锁就行了,所以有了意向表锁这个概念,加行锁之前,要先加个意向锁。**典型的以空间换时间**
83+
84+
**RSet避免了对整个老年代的扫描。况且,老年代比新生代大得多**
7985

8086
### 大内存就好吗?
8187
先定义两个指标:
@@ -139,6 +145,8 @@ gc的工作方式就两步:
139145

140146
需要另一块内存,作为担保:如果这个Survivor放不下,就先放到他那里去。这个担保就是**老年代**
141147

148+
**适用于新生代**
149+
142150
## Mark-Compact
143151

144152
> 标记-整理(压缩) 算法
@@ -149,9 +157,9 @@ Mark-Compact和Mark-Sweep类似,先标记所有垃圾,接下来是把存活
149157

150158
而且Copying算法如果不想1:1浪费50%的空间,就要有额外的担保空间,总不能给老年代再来一块担保空间吧?
151159

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需要
155163

156164
# 垃圾收集器
157165
基于上述思想和算法,实践中产生了[多种垃圾收集器](https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/collectors.html)
@@ -240,10 +248,10 @@ Concurrent Mark Sweep,专注于**最短回收停顿时间**。
240248
它基于Mark-Sweep算法,尝试减少major gc的暂停时间,分为四个步骤:
241249
- **(STW) 初始标记**
242250
- 标记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短多了**
247255
- **并发**清除:
248256
- sweep unreachable object,也是和应用线程并发执行,不会STW。**但是类似于并发标记会产生浮动垃圾,并发清除也会,而且后续没有类似“重新标记给并发标记擦屁股”的步骤**
249257

@@ -289,27 +297,34 @@ G1将多个region的存活对象拷贝到另一个region里,既收集了region
289297
290298
G1的分代不同于CMS(两个survivor,一个Eden,一个Old Gen)。G1保留了新生代老年代的概念,但**不再在物理上指定新生代老年代**,而是将一些region指定为逻辑上的新生代老年代,所以survivor/eden/老年代都不再连续了。垃圾收集时会将新生代region的存活对象拷贝到survivor或者old region区域(根据对象的存活年龄)。还有一部分region作为H(humongous),专门存放大对象。
291299

292-
#### 浮动垃圾 - SATB
293-
类似CMS。**罪魁祸首是并发标记**:gc线程和应用线程并发执行,gc线程标记存活对象的时候,应用线程将某些对象的引用关系改变了。
300+
#### 并发标记的问题 - SATB
301+
并发标记的时候,gc线程和应用线程并发执行,**gc线程标记存活对象的时候,应用线程将某些对象的引用关系改变了**,最终会引入两个问题:
302+
1. 垃圾漏标:俗称浮动垃圾;
303+
2. 非垃圾没有被标记,导致被当成垃圾清理掉了;
304+
305+
CMS也有并发标记,所以也有类似问题。
294306

295307
比如Root为对象A,指向B和C,C还指向D。gc线程标记完B为存活对象,开始标记C,此时应用线程将C指向D的引用改为B指向D,gc线程标记完C之后发现标记流程结束了。**此时D未被标记,被误认为是垃圾**。但此时D是被B引用的,是有用的对象,如果把D回收了,程序就出错了。
296308

297309
> 漏标存活对象,会导致存活对象被当成垃圾回收,程序出错。
298310
299311
jvm用三种颜色标识对象:
300312
- black:标记完了该对象以及该对象的引用;
313+
> 可以理解为彻底涂黑了
301314
- grey:只标记了该对象,还没标记完其引用;
315+
> 可以理解为还没完全涂黑
302316
- white:未标记该对象。
317+
> 可以理解为还没开始涂黑
303318
304319
显然标记完后如果对象还为white,就是垃圾。
305320

306321
漏标的充要条件是:**删除所有grey对象到某white对象的引用,并将其插入到black对象上**。直接剥夺了white对象被标记的机会。
307322

308323
上述场景将gery对象(C)到white对象(D)的引用删除了,并插入到了black对象(B)上,导致D始终是white。
309324

310-
防止漏标的方式也很简单:记录下这些更改即可。G1使用SATB(snapshot at the beginning),记录删掉所有grey对象到white对象的引用的情况,所有这些被删掉的引用指向的white对象,不再认为是垃圾。从而达到了“**并发标记开始阶段不是垃圾的对象,就不认为是垃圾**”的效果。
325+
防止漏标的方式也很简单:记录下这些更改即可。G1使用**SATB(Snapshot At The Beginning),记录删掉所有grey对象到white对象的引用的情况**,所有这些被删掉的引用指向的white对象,要被标记上,不再认为是垃圾。从而达到了“**并发标记开始阶段不是垃圾的对象,就不认为是垃圾**”的效果。
311326

312-
这些white对象被重新标记,**是在最终标记阶段做的事情,这个阶段也需要STW**,要不然还会出现上述情况,没有终结了。
327+
这些white对象被重新标记,**是在重新标记阶段做的事情,这个阶段也需要STW**,要不然还会出现上述情况,没有终结了。
313328

314329
> 但这么搞明显是“宁愿放过垃圾,不能错杀对象”:如果C到D的引用被删了之后,并没有插到B上,那D的确应该是垃圾。但是按照STAB的处理方式,D一开始不是垃圾,所以D在并发标记阶段结束后,也不被认为是垃圾。相当于在并发标记阶段产生的垃圾(**浮动垃圾**)可能被错误地保留下来,本次gc就回收不了这种垃圾了。
315330
>
@@ -318,19 +333,13 @@ jvm用三种颜色标识对象:
318333
所以G1的流程里有两个阶段会STW:
319334
- **STW1**:初始标记(根据gc roots);
320335
- **并发**标记;
321-
- **STW2**最终标记,处理SATB里的记录;
336+
- **STW2**重新标记,**处理SATB里的记录**
322337

323338
但其实,最后清理垃圾的阶段也会STW:
324-
- **STW3**:筛选回收。根据用户的期望,选择回收效益最高的几个region,清理对象;
325-
326-
清理对象涉及到对象的移动,之所以要STW,是因为简单。考虑到G1只回收一部分region,所以stw的时间是可控的,因此这里简单处理了,使用了stw。**ZGC则避免了这一点**
339+
- **STW3**:筛选回收。根据用户的期望,选择回收效益最高的几个region,清理(compact)对象;清理(compact)对象涉及到对象的移动,**移动对象到新地址后,也要更新引用该地址的对象里的旧地址信息**,这一步骤没法在一瞬间完成。**所以G1在这一步要STW,这样处理起来会比较简单**。考虑到G1只回收一部分region,所以这里stw的时间是可控的。**ZGC则在这一步避免了STW**
327340

328341
#### 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,**只需要扫描该老年代区域(而不是整个老年代)**,就能知道新生代里哪些对象能被清理,哪些不能被清理。
334343

335344
参数:
336345
- `-XX:G1HeapRegionSize=n`
@@ -351,15 +360,15 @@ jvm用三种颜色标识对象:
351360

352361
另外**在标记之后的垃圾清理阶段**
353362
- **CMS使用标记清除算法,虽然不会导致STW,但会产生空间碎片**。如果碎片过多,最后依然要STW处理一下;
354-
- **G1使用标记复制算法,能避免产生空间碎片,但就会涉及到存活对象移动,所以这一阶段也使用STW的方式清理**全靠它使用了二八定律,所以能让停顿时间符合用户预期
363+
- **G1使用标记复制算法,虽然能避免产生空间碎片,但会涉及到存活对象移动,所以这一阶段也使用STW的方式清理**由于充分遵循Garbage First原则,它能让停顿时间符合用户预期
355364

356365
## 低延迟垃圾收集器
357366
低延迟垃圾收集器(Low Latency Garbage Collector)几乎整个工作过程都是并发的,只有初始标记、最终标记会STW,且是O(1)时间。所以即使在大内存的情况下,也能达到非常低的延迟,所以被命名为low latency garbage collector,也叫low pause time garbage collector。
358367

359-
和并发垃圾收集器最大的差别,**在于他们在清理垃圾的阶段,既能避免内存碎片,又能避免STW**!当前(jdk20)ZGC能做到1ms以内的STW时间。
368+
和并发垃圾收集器最大的差别,**在于他们在清理垃圾的阶段,既能避免内存碎片(CMS的Sweep做不到),又能避免STW(G1的Compact做不到)**!当前(jdk20)ZGC能做到1ms以内的STW时间。
360369

361370
### Z collector
362-
为什么G1在清理垃圾的阶段需要STW?因为需要移动对象。虽然移动对象比较简单,但所有引用该对象的地址还都是旧对象地址,没法在一瞬间同时更新到新地址。如果此时旧地址被另一个对象用了,别的对象里还未更新的旧地址就会访问到错误的对象,出错了。
371+
**为什么G1在清理垃圾的阶段需要STW?因为需要移动对象。虽然移动对象比较简单,但所有引用该对象的地址还都是旧对象地址,没法在一瞬间同时更新到新地址**。如果此时旧地址被用了,就会访问到错误的对象,出错了。
363372

364373
ZGC使用染色指针标记对象是否被移动:64bit虚拟机,虽然使用64bit地址(如果不压缩指针),**但64bit并没有全都用完**,因为64bit理论上虽然能支持16EB地址空间,但os都不支持这么大,linux只支持128TB的进程虚拟地址空间,windows更是只支持到16TB,也就是说高位还有不少没用到。zgc就用了其中4bit来标记对象的状态(比如是否被移动)。
365374

@@ -369,7 +378,7 @@ ZGC使用染色指针标记对象是否被移动:64bit虚拟机,虽然使用
369378

370379
> 此时的可达性分析,与其说是在遍历对象图来标记对象,不如说是在遍历引用图来标记指针。
371380
372-
所以zgc在使用标记复制算法移动对象的时候,可以使用转发表记录把对象从哪里移动到哪里了(hash表),**在通过地址访问对象的时候,直接就可以通过对象地址看出该对象是否被移动了**。如果移动了,就去转发表里找对象的新地址,并更新地址的值。
381+
所以zgc在使用标记复制算法移动对象的时候,可以使用转发表记录把对象从哪里移动到哪里了(hash表),**在通过地址访问对象的时候,直接就可以通过对象地址看出该对象是否被移动了。如果移动了,就去转发表里找对象的新地址,并更新地址的值**
373382

374383
> 因为移动对象会破坏别的对象引用里的地址,所以zgc的这一步也被形象地称为自愈能力self-healing。
375384
@@ -394,18 +403,21 @@ ZGC也要通过二八定律让自己变得更高效。
394403
垃圾收集说难也难,但是如果搞清了来龙去脉,仅作为吃瓜群众,理解起gc来是很简单的。
395404

396405
- 怎么判断垃圾?
397-
+ 用不到的就是垃圾。所以需要从一个起点开始标记,这个起点就是gc roots。
398-
- 怎么标记和清理
399-
+ stop the world,不然会出错。
406+
+ 用不到的就是垃圾。所以需要从一个起点开始标记有用对象,这个起点就是**gc roots**
407+
- 怎么标记
408+
+ **stop the world**,不然会出错。
400409
- STW太慢了啊,能不能快点儿?
401-
+ 可以,搞出了并发标记。
410+
+ 可以,搞出了**并发标记**
411+
- 并发标记时用户线程会改变对象的状态怎么办?
412+
+ 使用**SATB,绝不漏标任何一个有用对象**(但是可能会保留某些无用垃圾(浮动垃圾));
402413
- 能不能再快点儿?
403-
+ 二八定律,所以不如分代吧,优先收集young
414+
+ 二八定律,所以不如**分代**,优先收集young
404415
- 分代以后会有跨代引用,怎么办?
405-
+ G1的remembered set就是一个解决方案。只遍历跨代引用该新生代的老年代,其他老年代不遍历了。
406-
- 标记完了,怎么清理?
407-
+ STW,可以但太慢了。
408-
+ 于是zgc发明了染色指针和转发表,能够支持和用户线程并发。通过地址直接看出对象是否被移动了,然后去转发表找新地址。
416+
+ G1的**remembered set**就是一个解决方案。只遍历跨代引用该新生代的老年代,其他老年代不遍历了。
417+
- 标记完了,怎么**清理**
418+
+ STW。挪动完对象以后,**更新对象的引用为新的地址**
419+
- 清理的时候使用STW太慢了,怎么办?
420+
+ 于是zgc发明了**染色指针和转发表**,能够支持清理垃圾的时候也和用户线程并发。通过地址直接看出对象是否被移动了,然后去转发表找新地址。
409421

410422
这些理念是递进的,实现起来也是越来越复杂的,但都是很合理的。一个垃圾收集器未必会实现所有的方面,比如G1支持二八定律但清理垃圾的时候用的是STW,很先进但又不完全先进。zgc当前还不支持分代,jdk21版本才支持。等等。
411423

@@ -512,4 +524,3 @@ Rolling log的弊端:
512524
- https://www.baeldung.com/jvm-parameters#handling-out-of-memory
513525

514526

515-

0 commit comments

Comments
 (0)