垃圾收集
1. 对象已死吗
垃圾收集器在对堆进行回收前,需要确定这些对象中那些是‘活着的’,那些是‘死去’的(即不可能再被任何途径使用的对象)。判断的算法有两种:引用计数算法和可达性分析算法。
1.1 引用计数算法(ReferenceCounting)
引用计数器算法是给每个对象设置一个计数器,当有地方引用这个对象的时候,计数器+1,当引用失效的时候,计数器-1,当计数器为0的时候,JVM就认为对象不再被使用,是“垃圾”了。
引用计数器实现简单,效率高;但是不能解决循环引用问问题(A对象引用B对象,B对象又引用A对象,但是A,B对象已不被任何其他对象引用),同时每次计数器的增加和减少都带来了很多额外的开销,所以在JDK1.1之后,这个算法已经不再使用了。
1.2 可达性分析算法(Reachability Analysis)
通过一些“GCRoots”对象作为起点,从这些节点开始往下搜索,搜索通过的路径成为引用(ReferenceChain),当一个对象没有被GCRoots的引用链连接的时候,说明这个对象是不可用的。
GCRoots对象包括:
- 虚拟机栈(栈帧中的本地变量表)中的引用的对象。
- 方法区域中的类静态属性引用的对象。
- 方法区域中常量引用的对象。
- 本地方法栈中JNI(Native方法)的引用的对象。
从图中可以看出,object5、object6、object7 对象是没有GC-Roots 引用(间接引用),因此,这三个对象可以被垃圾收集器回收。
1.3 对象的自我拯救
参考《深入理解Java虚拟机第2版》66页
2. 垃圾收集算法
垃圾收集算法有两种思想分代收集和分区收集(保留逻辑分代),无论是分区还是分代最终都会使用到3中算法,分别是:
- 复制算法
- 标记-清除算法
- 标记-整理算法
2.1 分代收集
当前主流虚拟机的垃圾收集算法都采用了‘分带收集’的算法,根据对象存活周期的不同,将内存划分为多块,一般是新生代和老年代,这样就可以根据各个年代的特点采用最适合的收集算法。
在新生代中,每次垃圾收集时都会有大批的对象死去,只有少量存活,并且存在S0和S1,还有老年代的分配担保,所以采用复制算法最为合适。
在老年代中,对象存活率高、没有额外的空间对它进行分配担保,就必须使用标记-清除或者标记-整理算法。
2.2 分区收集
分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收。这样做的好处是可以控制一次回收多少个小区间。
在相同条件下, 堆空间越大, 一次GC耗时就越长, 从而产生的停顿也越长。 为了更好地控制GC产生的停顿时间, 将一块大的内存区域分割为多个小块, 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次GC所产生的停顿。
G1收集器就是采用分区收集算法的典型应用。
2.1.1 复制算法
复制算法可以解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉。
这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可(还可使用TLAB进行高效分配内存)。
- 优点:
- 效率高
- 没有内存碎片
- 缺点:
- 浪费一半的内存空间;
- 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
2.1.2 标记-清除算法
标记-清除(Mark-Sweep)算法是现代垃圾回收算法的思想基础。标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。
在标记阶段,通过可达性分析算法,标记所有需要回收的对象。然后,在清除阶段,清除所有未被标记的对象。
- 缺点:
- 效率问题,标记和清除两个过程的效率都不高;
- 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2.1.3 标记-整理算法
标记整理算法类似与标记清除算法,不过它标记完对象后,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
-
优点:
- 相对标记清除算法,解决了内存碎片问题。
- 没有内存碎片后,对象创建内存分配也更快速了(可以使用TLAB进行分配)。
-
缺点:
- 效率问题,(同标记清除算法)标记和整理两个过程的效率都不高;
3. 垃圾收集器
图中展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。虚拟机所处的区域,则表示它是属于新生代收集器还是老年代收集器。
时间紧迫,面向offer学习,这里学习CMS和G1两种收集器,其余收集器可以参考:《深入理解Java虚拟机第2版》75页。其中**停顿(Stop The World STW)、OopMap、安全点(Safepoint)、安全区域(Safe Region)**等知识,也可以在该书中学习。
3.1 CMS 垃圾收集器(Concurrent Mark Sweep)
CMS是一款并发的、使用标记-清除算法的垃圾回收器,其GC过程短暂停,适合对时延要求较高的服务,用户线程不允许长时间的停顿。
从名称上可以看出,它基于‘标记-清除’算法实现的。它的运作过程比较复杂,整个过程分为4步
-
初始标记:
仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”
-
并发标记:
进行GC Roots Tracing的过程
-
重新标记
为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短,仍然需要“Stop The World”。
-
并发清除
并发清除阶段会清除对象。
根据GC的触发机制分为:周期性Old GC(被动)和主动Old GC。
3.1.1 周期性Old GC
执行的逻辑也叫Background Collect
,对老年代进行回收,在GC日志中比较常见,由后台线程ConcurrentMarkSweepThread循环判断(默认2s)是否需要触发。
-
触发条件
- 如果没有设置
-XX:+UseCMSInitiatingOccupancyOnly
,虚拟机会根据收集的数据决定是否触发(建议线上环境带上这个参数,不然会加大问题排查的难度)。 - 老年代使用率达到阈值
CMSInitiatingOccupancyFraction
,默认92%。 - 永久代的使用率达到阈值
CMSInitiatingPermOccupancyFraction
,默认92%,前提是开启CMSClassUnloadingEnabled
。 - 新生代的晋升担保失败。
- 如果没有设置
-
晋升担保失败
老年代是否有足够的空间来容纳全部的新生代对象或历史平均晋升到老年代的对象,如果不够的话,就提早进行一次老年代的回收,防止下次进行YGC的时候发生晋升失败。
3.1.2 周期性Old GC过程
当条件满足时,采用“标记-清理”算法对老年代进行回收,过程可以说很简单,标记出存活对象,清理掉垃圾对象,但是为了实现整个过程的低延迟,实际算法远远没这么简单,其大体执行流程如下
对象在标记过程中,根据标记情况,分成三类:
- 白色对象,表示自身未被标记;
- 灰色对象,表示自身被标记,但内部引用未被处理;
- 黑色对象,表示自身被标记,内部引用都被处理;
假设发生Background Collect时,Java堆的对象分布如下:
3.1.2.1 初始化标记(InitialMarking)
该阶段单线程执行,主要分分为两步:
- 标记GC Roots可达的老年代对象;
- 遍历新生代对象,标记可达的老年代对象;
该过程结束后,对象分布如下:
3.1.2.2 并发标记(Marking)
该阶段GC线程和应用线程并发执行,遍历InitialMarking阶段标记出来的存活对象,然后继续递归标记这些对象可达的对象。
因为该阶段并发执行的,在运行期间可能发生新生代的对象晋升到老年代、或者是直接在老年代分配对象、或者更新老年代对象的引用关系等等,对于这些对象,都是需要进行重新标记的,否则有些对象就会被遗漏,发生漏标的情况。
为了提高重新标记的效率,该阶段会把上述对象所在的Card标识为Dirty,后续只需扫描这些Dirty Card的对象,避免扫描整个老年代。
3.1.2.3 预清理(Precleaning)
通过参数CMSPrecleaningEnabled
选择关闭该阶段,默认启用,主要做两件事情:
- 处理新生代已经发现的引用,比如在并发阶段,在Eden区中分配了一个A对象,A对象引用了一个老年代对象B(这个B之前没有被标记),在这个阶段就会标记对象B为活跃对象。
- 在并发标记阶段,如果老年代中有对象内部引用发生变化,会把所在的Card标记为Dirty(其实这里并非使用CardTable,而是一个类似的数据结构,叫ModUnionTalble),通过扫描这些Table,重新标记那些在并发标记阶段引用被更新的对象(晋升到老年代的对象、原本就在老年代的对象)
3.1.2.4 可中断的预清理(AbortablePreclean)
该阶段发生的前提是,新生代Eden区的内存使用量大于参数CMSScheduleRemarkEdenSizeThreshold
默认是2M,如果新生代的对象太少,就没有必要执行该阶段,直接执行重新标记阶段。
3.1.2.5 为什么需要这个阶段,存在的价值是什么?
因为CMS GC的终极目标是降低垃圾回收时的暂停时间,所以在该阶段要尽最大的努力去处理那些在并发阶段被应用线程更新的老年代对象,这样在暂停的重新标记阶段就可以少处理一些,暂停时间也会相应的降低。
在该阶段,主要循环的做两件事:
- 处理 From 和 To 区的对象,标记可达的老年代对象
- 和上一个阶段一样,扫描处理Dirty Card中的对象
当然了,这个逻辑不会一直循环下去,打断这个循环的条件有三个:
- 可以设置最多循环的次数
CMSMaxAbortablePrecleanLoops
,默认是0,意思没有循环次数的限制。 - 如果执行这个逻辑的时间达到了阈值
CMSMaxAbortablePrecleanTime
,默认是5s,会退出循环。 - 如果新生代Eden区的内存使用率达到了阈值
CMSScheduleRemarkEdenPenetration
,默认50%,会退出循环。(这个条件能够成立的前提是,在进行Precleaning时,Eden区的使用率小于十分之一)
如果在循环退出之前,发生了一次YGC,对于后面的Remark阶段来说,大大减轻了扫描年轻代的负担,但是发生YGC并非人为控制,所以只能祈祷这5s内可以来一次YGC。
...
1678.150: [CMS-concurrent-preclean-start]
1678.186: [CMS-concurrent-preclean: 0.044/0.055 secs]
1678.186: [CMS-concurrent-abortable-preclean-start]
1678.365: [GC 1678.465: [ParNew: 2080530K->1464K(2044544K), 0.0127340 secs]
1389293K->306572K(2093120K),
0.0167509 secs]
1680.093: [CMS-concurrent-abortable-preclean: 1.052/1.907 secs]
....
在上面GC日志中,1678.186启动了AbortablePreclean阶段,在随后不到2s就发生了一次YGC。
3.1.2.6 并发重新标记(FinalMarking)
该阶段并发执行,在之前的并行阶段(GC线程和应用线程同时执行,好比你妈在打扫房间,你还在扔纸屑),可能产生新的引用关系如下:
- 老年代的新对象被GC Roots引用
- 老年代的未标记对象被新生代对象引用
- 老年代已标记的对象增加新引用指向老年代其它对象
- 新生代对象指向老年代引用被删除
- 也许还有其它情况…
上述对象中可能有一些已经在Precleaning阶段和AbortablePreclean阶段被处理过,但总存在没来得及处理的,所以还有进行如下的处理:
- 遍历新生代对象,重新标记
- 根据GC Roots,重新标记
- 遍历老年代的Dirty Card,重新标记,这里的Dirty Card大部分已经在clean阶段处理过
在第一步骤中,需要遍历新生代的全部对象,如果新生代的使用率很高,需要遍历处理的对象也很多,这对于这个阶段的总耗时来说,是个灾难(因为可能大量的对象是暂时存活的,而且这些对象也可能引用大量的老年代对象,造成很多应该回收的老年代对象而没有被回收,遍历递归的次数也增加不少),如果在AbortablePreclean阶段中能够恰好的发生一次YGC,这样就可以避免扫描无效的对象。
如果在AbortablePreclean阶段没来得及执行一次YGC,怎么办?
CMS算法中提供了一个参数:CMSScavengeBeforeRemark
,默认并没有开启,如果开启该参数,在执行该阶段之前,会强制触发一次YGC,可以减少新生代对象的遍历时间,回收的也更彻底一点。
不过,这种参数有利有弊,利是降低了Remark阶段的停顿时间,弊的是在新生代对象很少的情况下也多了一次YGC,最可怜的是在AbortablePreclean阶段已经发生了一次YGC,然后在该阶段又傻傻的触发一次。
3.1.3 主动Old GC
这个主动Old GC的过程,触发条件比较苛刻:
- YGC过程发生Promotion Failed,进而对老年代进行回收
- 比如执行了
System.gc()
,前提是没有参数ExplicitGCInvokesConcurrent
- 其它情况…
如果触发了主动Old GC,这时周期性Old GC正在执行,那么会夺过周期性Old GC的执行权(同一个时刻只能有一种在Old GC在运行),并记录 concurrent mode failure 或者 concurrent mode interrupted。
主动GC开始时,需要判断本次GC是否要对老年代的空间进行Compact(因为长时间的周期性GC会造成大量的碎片空间),判断逻辑实现如下:
*should_compact =
UseCMSCompactAtFullCollection &&
((_full_gcs_since_conc_gc >= CMSFullGCsBeforeCompaction) ||
GCCause::is_user_requested_gc(gch->gc_cause()) ||
gch->incremental_collection_will_fail(true /* consult_young */));
在三种情况下会进行压缩:
- 其中参数
UseCMSCompactAtFullCollection
(默认true)和CMSFullGCsBeforeCompaction
(默认0),所以默认每次的主动GC都会对老年代的内存空间进行压缩,就是把对象移动到内存的最左边。 - 当然了,比如执行了
System.gc()
,前提是没有参数ExplicitGCInvokesConcurrent
,也会进行压缩。 - 如果新生代的晋升担保会失败。
带压缩动作的算法,称为MSC,标记-清理-压缩,采用单线程,全暂停的方式进行垃圾收集,暂停时间很长很长…
那不带压缩动作的算法是什么样的呢?
不带压缩动作的执行逻辑叫Foreground Collect
,整个过程相对周期性Old GC来说,少了Precleaning和AbortablePreclean两个阶段,其它过程都差不多。
如果执行System.gc(),而且添加了参数ExplicitGCInvokesConcurrent
,这时并不属于主动GC,它会推进周期性Old GC的进行,比如刚刚执行过一次,并不会等2s后检查条件,而是立马启动周期性Old GC。
3.1.4 优缺点
-
优点
- 并发收集
- 低停顿
-
缺点
-
对CPU资源非常敏感
在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
-
无法处理浮动垃圾,可能会出现“Concurrent Mode Failure(并发模式故障)”失败而导致Full GC产生
浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随着程序运行自然就会有新的垃圾不断产生,这部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC中再清理。这些垃圾就是“浮动垃圾”。
-
容易出现大量空间碎片
CMS是一款“标记–清除”算法实现的收集器,容易出现大量空间碎片。当空间碎片过多,将会给大对象分配带来很大的麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。
-
3.2 G1 垃圾收集器
G1 的主要关注点在于达到可控的停顿时间,在这个基础上尽可能提高吞吐量,这一点非常重要。
G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,G1 没有 CMS 的碎片化问题(或者说不那么严重),同时提供了更加可控的停顿时间。
如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。
G1收集器单独学习一节
3.3 垃圾收集器参数总结
参数 | 描述 |
---|---|
UseSerialGC | 虚拟机运行在Client模式下的默认值,打开此开关后,使用Serial + Serial Old的收集器组合进行内存回收 |
UseParNewGC | 打开此开关后,使用ParNew + Serial Old的收集器组合进行内存回收 |
UseConcMarkSweepGC | 打开此开关后,使用ParNew+ CMS + Serial Old的收集器组合进行内存回收。Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用 |
UseParallelGC | 虚拟机运行在Server模式下的默认值,打开此开关后,使用Parallel Scavenge + Serial Old (PS Mark Sweep)的收集器组合进行内存回收 |
UserParallelOldGC | 打开此开关后,使用Parallel Scavenge + Parallel Old的收集器组合进行内存回收 |
SurvivorRatio | 新生代中Eden区域与Survivor区域的容量比值,默认为8,代表Eden: Survivor = 8:1 |
PretenureSizeThreshold | 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配 |
MaxTenuringThreshold | 晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC之后,年龄就增加1,当超过这个参数值时就进入老年代 |
UseAdaptiveSizePolicy | 动态调整Java堆中各个区域的大小以及进入老年代的年龄 |
HandlePromotionFailure | 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden和Survivor区的所有对象都存活的极端情况 |
ParallelGCThreads | 设置并行GC时进行内存回收的线程数 |
GCTimeRatio | GC时间占总时间的比率,默认值是99, 即允许1%的GC时间。仅在使用Parallel Scavenge收集器时生效 |
MaxGCPauseMillis | 设置GC的最大停顿时间。仅在使用Parallel Scavenge收集器时生效 |
CMSInitiatingOccupancyFraction | 设置CMS收集器在老年代时间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS收集器时生效 |
UseCMSCompactAtFullCollection | 设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS收集器时生效 |
CMSFullGCsBeforeCompaction | 设置CMS收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用CMS收集器时生效 |
Reference: