前言
GC 对于Java 来讲重要性不言而喻,不管是平日里对 JVM 的调优仍是面试中的无情轰炸。面试
这篇文章我会以一问一答的方式来展开有关 GC 的内容。算法
不过在此以前强烈建议先看这篇文章深度揭秘垃圾回收底层。segmentfault
由于这篇文章解释了不少有关垃圾回收的基本知识,能从源头上理解垃圾回收和日益发展的垃圾收集器演进的方向,这很重要。数组
本文章所说的 GC 实现没有特殊说明的话,默认指的是 HotSpot 的。多线程
我先将十八个问题都列出来,若是都清楚的话那就能够关闭这篇文章了。并发
好了,开始表演。oracle
young gc、old gc、full gc、mixed gc 傻傻分不清?
这个问题的前置条件是你得知道 GC 分代,为何分代。这个在以前文章提了,不清楚的能够去看看。框架
如今咱们来回答一下这个问题。异步
其实 GC 分为两大类,分别是 Partial GC 和 Full GC。分布式
Partial GC 即部分收集,分为 young gc、old gc、mixed gc。
- young gc:指的是单单收集年轻代的 GC。
- old gc:指的是单单收集老年代的 GC。
- mixed gc:这个是 G1 收集器特有的,指的是收集整个年轻代和部分老年代的 GC。
Full GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,若是有永久代的话还包括永久代。
其实还有 Major GC 这个名词,在《深刻理解Java虚拟机》中这个名词指代的是单单老年代的 GC,也就是和 old gc 等价的,不过也有不少资料认为其是和 full gc 等价的。
还有 Minor GC,其指的就是年轻代的 gc。
young gc 触发条件是什么?
大体上能够认为在年轻代的 eden 快要被占满的时候会触发 young gc。
为何要说大体上呢?由于有一些收集器的回收实现是在 full gc 前会让先执行如下 young gc。
好比 Parallel Scavenge,不过有参数能够调整让其不进行 young gc。
可能还有别的实现也有这种操做,不过正常状况下就当作 eden 区快满了便可。
eden 快满的触发因素有两个,一个是为对象分配内存不够,一个是为 TLAB 分配内存不够。
full gc 触发条件有哪些?
这个触发条件稍微有点多,咱们来看下。
- 在要进行 young gc 的时候,根据以前统计数据发现年轻代平均晋升大小比如今老年代剩余空间要大,那就会触发 full gc。
- 有永久代的话若是永久代满了也会触发 full gc。
- 老年代空间不足,大对象直接在老年代申请分配,若是此时老年代空间不足则会触发 full gc。
- 担保失败即 promotion failure,新生代的 to 区放不下从 eden 和 from 拷贝过来对象,或者新生代对象 gc 年龄到达阈值须要晋升这两种状况,老年代若是放不下的话都会触发 full gc。
- 执行 System.gc()、jmap -dump 等命令会触发 full gc。
知道 TLAB 吗?来讲说看
这个得从内存申请提及。
通常而言生成对象须要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是经过一个指针来划分的。
内存是紧凑的,新对象建立指针就右移对象大小 size 便可,这叫指针加法(bump [up] the pointer)。
可想而知若是多个线程都在分配对象,那么这个指针就会成为热点资源,须要互斥那分配的效率就低了。
因而搞了个 TLAB(Thread Local Allocation Buffer),为一个线程分配的内存申请区域。
这个区域只容许这一个线程申请分配对象,容许全部线程访问这块内存区域。
TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每一个线程只须要在本身的那亩地申请对象内存,不须要争抢热点指针。
当这块内存用完了以后再去申请便可。
这种思想其实很常见,好比分布式发号器,每次不会一个一个号的取,会取一批号,用完以后再去申请一批。
能够看到每一个线程有本身的一块内存分配区域,短一点的箭头表明 TLAB 内部的分配指针。
若是这块区域用完了再去申请便可。
不过每次申请的大小不固定,会根据该线程启动到如今的历史信息来调整,好比这个线程一直在分配内存那么 TLAB 就大一些,若是这个线程基本上不会申请分配内存那 TLAB 就小一些。
还有 TLAB 会浪费空间,咱们来看下这个图。
能够看到 TLAB 内部只剩一格大小,申请的对象须要两格,这时候须要再申请一块 TLAB ,以前的那一格就浪费了。
在 HotSpot 中会生成一个填充对象来填满这一块,由于堆须要线性遍历,遍历的流程是经过对象头得知对象的大小,而后跳过这个大小就能找到下一个对象,因此不能有空洞。
固然也能够经过空闲链表等外部记录方式来实现遍历。
还有 TLAB 只能分配小对象,大的对象仍是须要在共享的 eden 区分配。
因此总的来讲 TLAB 是为了不对象分配时的竞争而设计的。
那 PLAB 知道吗?
能够看到和 TLAB 很像,PLAB 即 Promotion Local Allocation Buffers。
用在年轻代对象晋升到老年代时。
在多线程并行执行 YGC 时,可能有不少对象须要晋升到老年代,此时老年代的指针就“热”起来了,因而搞了个 PLAB。
先从老年代 freelist(空闲链表) 申请一块空间,而后在这一块空间中就能够经过指针加法(bump the pointer)来分配内存,这样对 freelist 竞争也少了,分配空间也快了。
大体就是上图这么个思想,每一个线程先申请一块做为 PLAB ,而后在这一块内存里面分配晋升的对象。
这和 TLAB 的思想类似。
产生 concurrent mode failure 真正的缘由
《深刻理解Java虚拟机》:因为CMS收集器没法处理“浮动垃圾”(FloatingGarbage),有可能出现“Con-current Mode Failure”失败进而致使另外一次彻底“Stop The World”的Full GC的产生。
这段话的意思是由于抛这个错而致使一次 Full GC。
而其实是 Full GC 致使抛这个错,咱们来看一下源码,版本是 openjdk-8。
首先搜一下这个错。
再找找看 report_concurrent_mode_interruption
被谁调用。
查到是在 void CMSCollector::acquire_control_and_collect(...)
这个方法中被调用的。
再来看看 first_state : CollectorState first_state = _collectorState;
看枚举已经很清楚了,就是在 cms gc 还没结束的时候。
而 acquire_control_and_collect
这个方法是 cms 执行 foreground gc 的。
cms 分为 foreground gc 和 background gc。
foreground 其实就是 Full gc。
所以是 full gc 的时候 cms gc 还在进行中致使抛这个错。
究其缘由是由于分配速率太快致使堆不够用,回收不过来所以产生 full gc。
也有多是发起 cms gc 设置的堆的阈值过高。
CMS GC 发生 concurrent mode failure 时的 full GC 为何是单线程的?
如下的回答来自 R 大。
由于没足够开发资源,偷懒了。就这么简单。没有任何技术上的问题。 大公司都本身内部作了优化。
因此最初怎么会偷这个懒的呢?多灾多难的CMS GC经历了屡次动荡。它最初是做为Sun Labs的Exact VM的低延迟GC而设计实现的。
但 Exact VM在与 HotSpot VM争抢 Sun 的正牌 JVM 的内部斗争中失利,CMS GC 后来就做为 Exact VM 的技术遗产被移植到了 HotSpot VM上。
就在这个移植还在进行中的时候,Sun 已经开始略显疲态;到 CMS GC 彻底移植到 HotSpot VM 的时候,Sun 已经处于快要不行的阶段了。
开发资源减小,开发人员流失,当时的 HotSpot VM 开发组可以作的事情并很少,只能挑重要的来作。而这个时候 Sun Labs 的另外一个 GC 实现,Garbage-First GC(G1 GC)已经面世。
相比可能在长时间运行后受碎片化影响的 CMS,G1 会增量式的整理/压缩堆里的数据,避免受碎片化影响,于是被认为更具潜力。
因而当时原本就很少的开发资源,一部分还投给了把G1 GC产品化的项目上——结果也是进展缓慢。
毕竟只有一两我的在作。因此当时就没能有足够开发资源去打磨 CMS GC 的各类配套设施的细节,配套的备份 full GC 的并行化也就耽搁了下来。
但确定会有同窗抱有疑问:HotSpot VM不是已经有并行GC了么?并且还有好几个?
让咱们来看看:
- ParNew:并行的young gen GC,不负责收集old gen。
- Parallel GC(ParallelScavenge):并行的young gen GC,与ParNew类似但不兼容;一样不负责收集old gen。
- ParallelOld GC(PSCompact):并行的full GC,但与ParNew / CMS不兼容。
因此…就是这么一回事。
HotSpot VM 确实是已经有并行 GC 了,但两个是只负责在 young GC 时收集 young gen 的,这俩之中还只有 ParNew 能跟 CMS 搭配使用;
而并行 full GC 虽然有一个 ParallelOld,但却与 CMS GC 不兼容因此没法做为它的备份 full GC使用。
为何有些新老年代的收集器不能组合使用好比 ParNew 和 Parallel Old?
这张图是 2008 年 HostSpot 一位 GC 组成员画的,那时候 G1 还没问世,在研发中,因此画了个问号在上面。
里面的回答是 :
"ParNew" is written in a style... "Parallel Old" is not written in the "ParNew" style
HotSpot VM 自身的分代收集器实现有一套框架,只有在框架内的实现才能互相搭配使用。
而有个开发他不想按照这个框架实现,本身写了个,测试的成绩还不错后来被 HotSpot VM 给吸取了,这就致使了不兼容。
我以前看到一个回答解释的很形象:就像动车组车头带不了绿皮车箱同样,电气,挂钩啥的都不匹配。
新生代的 GC 如何避免全堆扫描?
在常见的分代 GC 中就是利用记忆集来实现的,记录可能存在的老年代中有新生代的引用的对象地址,来避免全堆扫描。
上图有个对象精度的,一个是卡精度的,卡精度的叫卡表。
把堆中分为不少块,每块 512 字节(卡页),用字节数组来中的一个元素来表示某一块,1表示脏块,里面存在跨代引用。
在 Hotspot 中的实现是卡表,是经过写后屏障维护的,伪代码以下。
cms 中须要记录老年代指向年轻代的引用,可是写屏障的实现并无作任何条件的过滤。
即不判断当前对象是老年代对象且引用的是新生代对象才会标记对应的卡表为脏。
只要是引用赋值都会把对象的卡标记为脏,固然YGC扫描的时候只会扫老年代的卡表。
这样作是减小写屏障带来的消耗,毕竟引用的赋值很是的频繁。
那 cms 的记忆集和 G1 的记忆集有什么不同?
cms 的记忆集的实现是卡表即 card table。
一般实现的记忆集是 points-out 的,咱们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,它的主语实际上是非收集区域,因此是 points-out 的。
在 cms 中只有老年代指向年轻代的卡表,用于年轻代 gc。
而 G1 是基于 region 的,因此在 points-out 的卡表之上还加了个 points-into 的结构。
由于一个 region 须要知道有哪些别的 region 有指向本身的指针,而后还须要知道这些指针在哪些 card 中。
其实 G1 的记忆集就是个 hash table,key 就是别的 region 的起始地址,而后 value 是一个集合,里面存储这 card table 的 index。
咱们来看下这个图就很清晰了。
像每次引用字段的赋值都须要维护记忆集开销很大,因此 G1 的实现利用了 logging write barrier(下文会介绍)。
也是异步思想,会先将修改记录到队列中,当队列超过必定阈值由后台线程取出遍从来更新记忆集。
为何 G1 不维护年轻代到老年代的记忆集?
G1 分了 young GC 和 mixed gc。
young gc 会选取全部年轻代的 region 进行收集。
midex gc 会选取全部年轻代的 region 和一些收集收益高的老年代 region 进行收集。
因此年轻代的 region 都在收集范围内,因此不须要额外记录年轻代到老年代的跨代引用。
cms 和 G1 为了维持并发的正确性分别用了什么手段?
以前文章分析到了并发执行漏标的两个充分必要条件是:
-
将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用。
-
删除了灰色对象到白色对象的引用。
cms 和 g1 分别经过增量更新和 SATB 来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性。
cms 用了增量更新(Incremental update),打破了第一个条件,经过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标状况。
G1 用了 SATB(snapshot-at-the-beginning),打破了第二个条件,会经过写屏障把旧的引用关系记下来,以后再把旧引用关系再扫描过。
这个从英文名词来看就已经很清晰了。讲白了就是在 GC 开始时候若是对象是存活的就认为其存活,等于拍了个快照。
并且 gc 过程当中新分配的对象也都认为是活的。每一个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置。
Top 指针就是 region 中最新分配对象的位置,因此 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的便可。
而利用增量更新的 cms 在 remark 阶段须要从新全部线程栈和整个年轻代,由于等于以前的根有新增,因此须要从新扫描过,若是年轻代的对象不少的话会比较耗时。
要注意这阶段是 STW 的,很关键,因此 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段以前来一次 YGC。
而 g1 经过 SATB 的话在最终标记阶段只须要扫描 SATB 记录的旧引用便可,从这方面来讲会比 cms 快,可是也由于这样浮动垃圾会比 cms 多。
什么是 logging write barrier ?
写屏障其实耗的是应用程序的性能,是在引用赋值的时候执行的逻辑,这个操做很是的频繁,所以就搞了个 logging write barrier。
把写屏障要执行的一些逻辑搬运到后台线程执行,来减轻对应用程序的影响。
在写屏障里只须要记录一个 log 信息到一个队列中,而后别的后台线程会从队列中取出信息来完成后续的操做,其实就是异步思想。
像 SATB write barrier ,每一个 Java 线程有一个独立的、定长的 SATBMarkQueue,在写屏障里只把旧引用压入该队列中。满了以后会加到全局 SATBMarkQueueSet。
后台线程会扫描,若是超过必定阈值就会处理,开始 tracing。
在维护记忆集的写屏障也用了 logging write barrier 。
简单说下 G1 回收流程
G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝。
并发标记是基于 STAB 的,能够分为四大阶段:
一、初始标记(initial marking),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象便可。在G1中标记对象是利用外部的bitmap来记录,而不是对象头。
二、并发阶段(concurrent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描全部可达对象。 STAB 也会在这个阶段记录着变动的引用。
三、最终标记(final marking), 这个阶段是 STW 的,处理 STAB 中的引用。
四、清理阶段(clenaup),这个阶段是 STW 的,根据标记的 bitmap 统计每一个 region 存活对象的多少,若是有彻底没存活的 region 则总体回收。
对象拷贝阶段(evacuation),这个阶段是 STW 的。
根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),而后将 CSet 存活对象拷贝到新 region 中。
G1 的瓶颈在于对象拷贝阶段,须要花较多的瓶颈来转移对象。
简单说下 cms 回收流程
其实从以前问题的 CollectorState 枚举能够得知几个流程了。
一、初始标记(initial mark),这个阶段是 STW 的,扫描根集合,标记根直接可达的对象便可。
二、并发标记(Concurrent marking),这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描全部可达对象。
三、并发预清理(Concurrent precleaning),这个阶段和应用线程并发,就是想帮从新标记阶段先作点工做,扫描一下卡表脏的区域和新晋升到老年代的对象等,由于从新标记是 STW 的,因此分担一点。
四、可中断的预清理阶段(AbortablePreclean),这个和上一个阶段基本上一致,就是为了分担从新标记标记的工做。
五、从新标记(remark),这个阶段是 STW 的,由于并发阶段引用关系会发生变化,因此要从新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记。
六、并发清理(Concurrent sweeping),这个阶段和应用线程并发,用于清理垃圾。
七、并发重置(Concurrent reset),这个阶段和应用线程并发,重置 cms 内部状态。
cms 的瓶颈就在于从新标记阶段,须要较长花费时间来进行从新扫描。
cms 写屏障又是维护卡表,又得维护增量更新?
卡表其实只有一份,又得用来支持 YGC 又得支持 CMS 并发时的增量更新确定是不够的。
每次 YGC 都会扫描重置卡表,这样增量更新的记录就被清理了。
因此还搞了个 mod-union table,在并发标记时,若是发生 YGC 须要重置卡表的记录时,就会更新 mod-union table 对应的位置。
这样 cms 从新标记阶段就能结合当时的卡表和 mod-union table 来处理增量更新,防止漏标对象了。
GC 调优的两大目标是啥?
分别是最短暂停时间和吞吐量。
最短暂停时间:由于 GC 会 STW 暂停全部应用线程,这时候对于用户而言就等于卡顿了,所以对于时延敏感的应用来讲减小 STW 的时间是关键。
吞吐量:对于一些对时延不敏感的应用好比一些后台计算应用来讲,吞吐量是关注的重点,它们不关注每次 GC 停顿的时间,只关注总的停顿时间少,吞吐量高。
举个例子:
方案一:每次 GC 停顿 100 ms,每秒停顿 5 次。
方案二:每次 GC 停顿 200 ms,每秒停顿 2 次。
两个方案相对而言第一个时延低,第二个吞吐高,基本上二者不可兼得。
因此调优时候须要明确应用的目标。
GC 如何调优
这个问题在面试中很容易问到,抓住核心回答。
如今都是分代 GC,调优的思路就是尽可能让对象在新生代就被回收,防止过多的对象晋升到老年代,减小大对象的分配。
须要平衡分代的大小、垃圾回收的次数和停顿时间。
须要对 GC 进行完整的监控,监控各年代占用大小、YGC 触发频率、Full GC 触发频率,对象分配速率等等。
而后根据实际状况进行调优。
好比进行了莫名其妙的 Full GC,有多是某个第三方库调了 System.gc。
Full GC 频繁多是 CMS GC 触发内存阈值太低,致使对象分配不过来。
还有对象年龄晋升的阈值、survivor 太小等等,具体状况仍是得具体分析,反正核心是不变的。
最后
其实还有关于 ZGC 的内容没有分析,别急, ZGC 的文章已经写了一半了,以后会发。
有关 GC 的问题在面试中仍是很常见的,其实来来回回就那么几样东西,记得我提到的抓住核心便可。
固然若是你有实际调优经历那更可,因此要抓住工做中的机会,若是发生异常状况请积极参与,而后勤加思考,这可都是实打实的实战经历。
固然若是你想知道更多的 GC 细节那就看源码吧,源码之中无秘密。
我的能力有限,若是有纰漏的地方请抓紧联系我,也欢迎私信联系我
巨人的肩膀
https://segmentfault.com/a/1190000021394215?utm_source=tag-newest
https://blogs.oracle.com/jonthecollector/our-collectors
https://www.iteye.com/blog/user/rednaxelafx R大的博客
https://www.jianshu.com/u/90ab66c248e6 占小狼的博客
欢迎关注个人公众号:「yes的练级攻略」,更多硬核文章等你来阅,还有走心的20w字算法笔记、精心挑选的进阶必备500本PDF。