文章目录
经典垃圾收集器
- 标记-清除算法:CMS,
- 标记-复制算法:G1(回收 Region 时,用的是标记-复制算法)
- 标记-整理算法:G1,ZGC(<10ms)
- 与堆的容量、堆中对象的数量有正比例关系;
- 与堆的容量、堆中对象的数量没有正比例关系:ZGC
CMS 老年代收集器(最短回收停顿时间为目标的收集器:因此使用的算法是 标记-清除)
分为四步:
- 初始标记(CMS initial mark) :标记一下GC Roots能直接关联到的对象,速度很快
- 并发标记(CMS concurrent mark) :从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
- 重新标记(CM S remark) :为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录**(三色标记与增量更新)**
- 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
有一个不懂的点就是:在并发清理的过程中,万一需要清理的引用关系被改变,本来该清理的对象被正在运行中的线程引用了该怎么办呐?或者就是不能被引用????
标记阶段需要存在一个一致性的视图:三色标记
比如我们在 并发标记阶段标记完成之后的 ABC 引用关系如下(黑色代表它及其引用全部被扫描,灰色代表它及其引用部分被扫描,白色代表从没被扫描过)。
因为在并发标记阶段是与用户线程一起运行的,所以他们之间的引用关系是会被用户程序改变的。如下图:
从上图看到,如果不进行处理,那么就会导致 白色C 被GC掉,出现重大问题。
CMS:采用增量更新来解决。总体上来理解的话,就是把黑色的重新标记为灰色。这样就能解决 漏标记的问题。
G1:原始快照来解决。记录变化并且重新扫描一遍右侧的所有变化。
优势
并发收集、低停顿
劣势
- 占用 CPU 资源较多。
- CMS收集器无法处理“浮动垃圾”(FloatingGarbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。(浮动垃圾是指:在CMS的并发标记和并发清理阶段,用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,但这一部分 垃圾对象是出现在标记过程结束以后,CM S无法在当次收集中处理掉它们,只好留待下一次垃圾收集 时再清理掉。这一部分垃圾就称为“浮动垃圾”。)
- 因为在清理时与用户线程一起运行,所以需要预留程序运行所需的内存。(-XX:CMSInitiatingOccupancyFraction 参数控制)
- 会引起内存碎片,大对象无法找到足够大的连续空间来分配当前对象,就会不得不提前触发一次 Full GC。
G1 收集器(Mixed GC模式,全堆收集)
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(M ajor GC),再要么就是整个Java堆(Full GC)。而 G1 跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而 是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
。
G1不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能够对扮演不同角色的 Region采用不同的策略去处理
,这样无论是新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。
停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段 内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标,这几乎已经是实时Java(RT SJ)的中 软实时垃圾收集器特征了。
Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1 认为只要大小超过了一个Region容量一半的对象即可判定为大对象
。 每 个 R e gi o n 的 大 小 可 以 通 过 参 数 - X X : G 1 H e a p R e gi o n Si z e 设 定,取值范围为1M B~32M B,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待
。
其实这样划分后处理的思路就是:
- G1收集器去跟踪各个Region里面的垃 圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值
- 在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:M axGCPauseM illis指定,默 认值是200毫秒),优先处理回收价值收益最大的那些Region
(G1:2004~2012年,整整8年多时间)这种思想所带来的问题与解决思路:
- 将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?
解决的思路是(暂时没太看明白,后续继续想一下
):使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记 忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。G1的记忆集在存储结构的本质上是一 种 哈 希 表 , K e y 是 别 的 R e g i o n 的 起 始 地 址 , Va l u e 是 一 个 集 合 , 里 面 存 储 的 元 素 是 卡 表 的 索 引 号 。 这 种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更 复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃 圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额 外内存来维持收集器工作。 - 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
这里首先要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题 的解决办法笔者已经抽出独立小节来讲解过(见3.4.6节):CM S收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SAT B)算法
来实现的。此外,垃圾收集对用户线程的影响还体现在回收过 程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设 计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过 程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上
。G1收集器默认在 这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。与CM S中 的“Concurrent M ode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。 - 怎样建立起可靠的停顿预测模型?
G1收集器的停顿 预测模型是以衰减均值(Decaying Average)
为理论基础来实现的,在垃圾收集过程中,G1收集器会记 录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。这里强调的“衰减平均值”是指它会比普通的平均值更容易 受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句 话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由 哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
G1 收集器的运行流程
- 初始标记(Initial M arking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。
- 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SAT B记录下的在并发时有引用变动的对象
- 最终标记(Final M arking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SAT B记录。
- 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的
通常 把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
优势
劣势
ZGC 收集器
特征–相较于G1 实现了在筛选回收阶段的并发收集垃圾:实现原理是(染色指针和读屏障)
ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。
其实我觉得这个也很好解决,就是搞个 读屏障,难点在于当对象被移动后,如何动态的让引用记录下这个动作,其实就类似于 观察者模式。ZGC 其实就是将这个移动动作,记录在了引用指针上。然后在读取对象的过程中,设置读屏障,慢慢改变引用指针中包含的地址。
而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针
。下面介绍着色指针和读屏障技术细节。
着色指针—将信息存储在指针中的技术(具体原理略,个人感觉也没必要完全搞懂)
- 优势
- ·染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用 掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的 目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些 专门的记录操作。
- ·染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以 便日后进一步提高性能。
读屏障
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
ZGC的运行流程
ZGC只有三个STW阶段:初始标记,再标记,初始转移
。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加
当然,着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
优势
-
低停顿,高吞吐量,ZGC 收集过程中额外耗费的内存小。
- 低停顿,几乎所有过程都是并发的,只有短暂的STW。
- 内存小,ZGC 没有写屏障,卡表之类的。
- 吞吐量方面,在ZGC的‘弱项’吞吐量方面,因为和用户线程并发,还是有影响的。但是!但是!,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。
-
G1通过写屏障维护记忆集,才能处理跨代指针,得以实现增量回收。记忆集占用大量内存,写屏障对正常程序造成额外负担。
-
并发停顿方面:ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发整理过程。
ZGC中没有引入分代,也就没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。 -
并发的标记-整理算法。没有内存碎片
劣势
劣势:ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
无法承受的对象分配速率太高的场景,因为ZGC要对一个很大的堆做一次完整的并发收集并发时需要很长(10分钟),于是这期间产生了很多的浮动垃圾。比如10分钟回收了10M,但是产生了 14M,所以如果继续持续就会导致 OOM