Java GC垃圾收集
概述
GC的三个问题:
- 哪些内存需要回收
- 什么时候回收
- 如何回收
Java 是自动垃圾回收的语言,所回收的内存区域指堆和方法区,主要面向占用大部分内存的堆区。类如线程栈这种,执行过程中随着栈帧的入栈和出栈,自然不必进行垃圾回收了。
对象是否存活?
两种方法:
- 引用计数法
- 可达性分析法
引用计数法
-
思路
对象中添加一个计数器,有一个地方引用它时就+1,引用失效时就-1
-
优缺点
原理简单、判定效率高;单纯的引用计数,不好解决对象相互循环引用,需要额外工作处理
-
例子
微软的COOM技术、python语言等采用这种判断方式
可达性分析
-
思路
通过一系列称为 “GC Roots” 的根对象作为起始点集,从这些节点开始根据引用关系向下搜索,搜索过程中走过的路径称为引用链,如果某个对象到GC Roots间没有任何引用链相连,就说明这个对象不可达,不再被使用了。
-
Java中的GC Roots对象
- 虚拟机栈 中引用的对象,如方法参数、局部变量、临时变量等
- 本地方法栈 中引用的对象
- 方法区 中类静态属性引用的对象、常量引用的对象
- 虚拟机内部的引用 如基本类型对应的Class对象,常驻异常等
- 被同步锁(synchronized)持有的对象
- 虚拟机内部的JMXBean等注册的回调、本地代码缓存等
引用强度
按照引用强度从强到弱:强软弱虚
-
强引用
普遍存在的new 都是强引用
-
软引用
发生内存溢出异常前,会把这些对象纳入回收范围 进行第二次回收,如果内存还是不够则会抛出溢出异常。
-
弱引用
不论内存是否够用,只要GC的时候就会把这些对象纳入回收范围,注意对象只有被弱引用引用着才会被回收。
-
虚引用
唯一目的是,在这个对象被回收的时候能得收到系统通知
finalize()
真正死亡要经历两次标记过程
- 可达性分析,链路上没有这个对象的引用 找到垃圾
- 判断它是否要执行finalize() 方法,如果没覆盖这个方法,或者执行过一次了,则不需要执行,可以回收
表示finalize() 方法可以让这个对象逃离死亡,可以重新将对象与引用链上的对象挂上
回收方法区
比如堆中新生代的回收可能一次回收能回收70-99% 的空间,反观方法区的回收性价比比较低,它的回收判定条件也比较苛刻。
方法区回收对象主要是两部分:废弃的常量 和 不再使用的类型
判定一个类型不再使用:
- 该类对应的所有实例都已经被回收
- 加载该类的类加载器已经被回收
- 该类对应的Class对象没有地方被引用
大量使用反射、动态代理、CGLib等字节码框架,动态生成类,频繁自定义类加载器的场景中,需要Java虚拟机具备类卸载的能力。
虚拟机参数
一个值得一提的虚拟机参数,查看某个类是否进行了类加载 可以使用 -XX:+TraceClassLoading
垃圾收集算法
分代收集理论
- 弱分代假说:绝大部分对象都是朝生夕死(如新生代的设计)
- 强分代假说:熬过越多次垃圾收集过程的对象就越难消亡(如老年代的设计)
- 跨代引用假说:跨代引用相对于同代引用是占极少数
概念
-
部分收集
- 新生代收集 (Minor GC/ Young GC)
- 老年代收集 (Major GC/ Old GC)
- 混合收集 (Mixed GC) 整个新生代和部分老年代,如G1
-
整堆收集 (Full GC) 收集整个Java 堆和方法区
标记-清除
算法简单直接,分为标记和清除两个阶段,可以有两种方式:
- 标记所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象
- 标记存活的对象,统一回收所有未被标记的对象
缺点
- 执行效率不稳定:随着对象数量的增加,标记和清除效率降低
- 内存碎片问题:可能造成分配较大对象而内存不够,导致提前触发GC
标记-复制
解决标记-清除算法面对大量可回收对象执行效率低的问题。将可用内存按容量分为大小相等的两块,每次只用一块,当这块内存将用完,再将存活的对象拷贝到另一块,再把这块清除了。
一个问题:如果存活的多?死亡的多?
- 存活的多 需要拷贝的就多,开销大
- 死亡的多 合适
缺点
- 可用内存缩小一半,空间浪费
Java 年轻代垃圾适合用标记-复制。如一种实现方式:新生代分为一块较大的 Eden空间,和两块较小的 Survivor空间(8:1:1)。每次分配内存只占用 Eden和其中一块的 Survivor。垃圾收集时,将 Eden和 Survivor中仍然存活的拷贝到另一块 Survivor,然后清理掉 Eden和该 Survivor。如果这块 Survivor容纳不下,则会借助老年代内存区域。
标记-整理
如上标记-复制中,当存活对象多,复制开销大,效率降低;并且还会浪费一部分空间(或者有预留担保空间)。因此老年代不会采用复制算法。
根据老年代的特征,采用标记-整理算法,标记过程和标记-清除一样,但不直接清除,而是把存活对象都向内存空间一端移动,然后再清理边界以外的内存。
标记-清除是非移动式的,标记-整理是移动式的。显然是否移动也是优缺点共存:
- 移动:当存活对象很多时,移动对象改变引用自然降低效率 且要暂停用户线程 STW
- 不移动:造成内存碎片问题
移动,内存回收复杂;不移动,内存分配复杂。
如关注吞吐量的回收器 Parallel Old 采用标记-整理,关注低延迟的 CMS 采用标记-清除。
HotSpot 算法实现细节
这里涉及到的概念比较多
根节点枚举 - OopMap
根节点如上介绍的 GC Roots,虽然目标明确但查找起来并不容易。又因为枚举根节点要暂停用户线程STW,因此这个动作必须要快。
并不需要一个不漏的检查完所有执行上下文(线程栈)和全局(静态对象等)的引用位置,应用有办法直接得到哪些地方存放着对象引用。HotSpot解决方案中,使用一组称为 OopMap 的数据结构来达到这个目的。
一旦类加载动作完成的时候,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,JIT中也会在特定位置记录下栈里和寄存器里哪些位置是引用,这样收集器在扫描时就可以直接得知这些信息了。
安全点
可能导致引用关系变化,或者说导致OopMap 内容变化的指令很多,不可能为每一条指令都生成对应的 OopMap。其实也确实不是这样,只是在特定的位置记录了这些信息,这些位置就是安全点。
因此垃圾收集的时候也不能在指令流的任意位置,需要强制到达安全点位置才行,所以安全点的位置不能太少,也不能太频繁。选择的标准就是是否具有让程序长时间执行的特征。例如 方法跳转、循环、抛出异常跳转等位置。
抢先式中断
系统要GC 时让业务线程中断,没到安全点的线程再执行直到安全点再中断。现在几乎没有这么做的
主动式中断
系统要GC 时,设置一个全局标志。业务线程执行时不停轮询这个标志(轮询位置和安全点位置重合),当发现标志时,到安全点主动中断挂起线程。
要求轮询代码的执行必须高效,就是一条汇编指令。
安全区域
典型如业务线程处于Sleep或者Blocked状态,不能主动执行中断,那么就引入安全区域。指 能够确保在某一段代码片段中,引用关系不会发生变化。因此这个区域可以让GC 放心执行。
记忆集与卡表
如上存在跨代引用的问题,垃圾收集器在新生代建立了名为记忆集(Remembered Set)的数据结构,用来避免把整个老年代都加入GC Roots 扫描。
记忆集:用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记忆集实现的时候,对指针集合的精度有个选择:
- 字长精度
- 对象精度
- 卡精度(内存区域精度)
卡表(Card Table)的方式是最常用的记忆集的实现形式。卡表最简单的形式是一个字节数组,如:
CARD_TABLE [address >> 9] = 0;
地址向右移9位(除以 512),那么就表示卡表 以 512byte 为一个页。将老年代内存分成一个一个的512byte的内存区域。默认是0 表示这块内存区域没有跨代引用,如果是1表示存在跨代。
写屏障
如何维护卡表(存在跨代引用时,需要变脏)? 用写屏障技术(不同于并发中的写屏障指令)。
应用写屏障后,虚拟机会为每个赋值动作生成相应指令,这也会出现 无论是不是老年代引用了新生代都会执行更新卡表。这里也可以先判断一下是否标记过脏,再执行更新脏。
伪共享
缓存行(Cache Line)导致的性能问题。
三色标记法 (Tri-color Marking)
把遍历对象图过程中遇到的对象,按照『是否访问过』这个条件标记为三个颜色之一:
-
白色
初始颜色,表示对象尚未被垃圾收集器访问过。如果分析结束仍然是白色的,表示该对象不可达了。
-
灰色
表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
-
黑色
表示对象已经被垃圾收集器访问过,并且这个对象的所有引用都已经扫描过。表示存活的对象,如果有对象引用指向了这个黑色的,那表示后边就不用再扫描一遍了。黑色对象不可能直接(不经过灰色对象)指向某个白色对象(得先变灰再变黑的)。
存在的问题
如果扫描时和业务线程并发执行,即收集器在对象图上标记颜色,同时用户线程在修改引用关系。那会出现两种情况:
-
原本消亡的对象错误标记为存活
这个case很好理解,如引用链上,某个对象已经标记完黑色,后业务线程把这个黑色的引用弃用了。这个还好,不至于致命,这些浮动垃圾可以下次GC 时清除掉。
-
原本存活的对象错误标记为垃圾
这个就致命了,如何发生的这种情况,我们看一张图很好理解:
如图举例:D 是黑色的,E 是灰色的,同时业务线程执行时,E 断开了指向 G,D 又引用了 G。 因为 D 已经标记了是黑色,E 也认为所引用的对象都扫描完了。所以 G 就当做了是白色的。这个时候 GC 时就会把 G 错杀掉。
- 导致 case 的两个条件
-
新增了 一条或多条 黑色到白色 对象的新引用 (D -> G)
-
删除了 灰色 对象 到该白色对象 的直接 引用或间接引用 (E -> G)
当这两种情况都满足的时候就会出现这种问题了。为了解决这个问题,引入了 增量更新 (Incremental Update)和 原始快照 (SATB)的方案:
-
增量更新破坏了第一个条件:增加黑色到白色的新引用时,记录该引用信息,在后续 STW 扫描中重新扫描这些黑色为根的引用,这下引用链上就能知道这个白色变成黑色。(CMS 的使用方案)
-
原始快照破坏了第二个条件:删除引用时记录下来这个引用关系(快照),在后续 STW 扫描时将这些记录过的灰色对象为根再扫描一次,这个白色的也会被标记黑色非垃圾了。(G1 的使用方案)
经典垃圾收集器
HotSpot 虚拟机的垃圾收集器:
Serial/Serial Old 收集器
特点
单线程收集器,Java客户端模式下的默认新生代收集器,适用于内存资源受限的环境。
垃圾收集算法
新生代采用复制算法,老年代搭配 Serial Old 采用标记-整理算法。
新老搭配图示
Serial/Serial Old 收集器运行示意图:
ParNew 收集器
特点
ParNew 收集器实质是 Serial 收集器的多线程并行版本,其他类似。值得一提的是,ParNew 是目前仅有能搭配 CMS使用的年轻代收集器了。JDK 9 以后,取消了 Serial 和 CMS 的搭配。而且尴尬的是,JDK9 以后也不建议使用 ParNew + CMS 的搭配了。
垃圾收集算法
新生代采用复制算法,老年代搭配 Serial Old 采用标记-整理算法。
新老搭配图示
ParNew/Serial Old 收集器运行示意图:
Parallel Scavenge/Parallel Old 收集器
特点
Parallel Scavenge 同样是新生代垃圾收集器,同样是标记-复制算法,同样是多线程版本,看起来这和 ParNew 很类似。
Parallel Scavenge 的设计目标是,达到一个可控制的吞吐量(Throughput)。
吞吐量 = 用户代码执行时间 / (用户代码执行时间 + GC消耗时间)
高吞吐适合场景:后台运算,不需要太多用户交互的分析任务。
三个重要参数
-
-XX:MaxGCPauseMillis 控制最大垃圾收集停顿时间
MaxGCPauseMillis 参数不能任意调小,会牺牲吞吐量和年轻代空间为代价
-
-XX:GCTimeRation 设置吞吐量大小
默认99,即允许最大 1% (1/99+1) 的垃圾收集时间
-
-XX:+UseAdaptiveSizePolicy
指定这个动态调整参数,就不再需要人工指定 Eden、SurvivorRation 等细节参数了。
这个动态调整的能力,也是区别于 ParNew 的关键特征。
Parallel Scavenge/Parallel Old 搭配,是吞吐量优先的收集器组合。
垃圾收集算法
新生代采用复制算法,老年代搭配 Parallel Old 采用标记-整理算法。
新老搭配图示
CMS 收集器
特点
CMS 收集器是一种以获取最短回收停顿时间为目标的收集器,适合关注响应速度,提升交互体验。
四个步骤
-
初始标记
仅标记GC Roots直接关联到的对象。需要STW,速度很快。
-
并发标记
遍历整个对象图进行标记。这个过程耗时长,但GC线程和用户线程可以并发执行。
-
重新标记
修正并发标记期间,因用户线程继续运作而导致标记产生变动的对象的标记记录(如上介绍的增量更新机制)。需要STW,但速度很快。
-
并发清除
清理如上的垃圾。因为标记-清除算法,不需要移动对象,可以和用户线程并发执行。
其他
CMS 搭配的年轻代收集器是 ParNew(Serial 不建议用),并发模式失败后 使用 Serial Old。CMS 无法与 新生代 Parallel Scavenge 收集器搭配,一个原因是代码框架不兼容,同样道理 CMS 也无法搭配老年代的 Parallel Old。另外 CMS 在 JDK9以后也逐渐被G1 取代了。
缺点
-
CMS 收集器对处理器资源非常敏感
默认回收线程数 = (cpu数 + 3)/4 ,当 cpu数少于4时,并发执行显然对用户线程的执行影响很大。
-
CMS 收集器无法处理『浮动垃圾』,有可能出现『Concurrent Mode Failure』进而导致另一次 Full GC出现。
并发标记时与用户线程并发执行,导致可能会出现浮动垃圾(如上三色标记里的问题描述过),只能下次GC时再清理。因为GC 时和用户线程并发执行,因此要预留一部分空间给新对象生成。但如果预留的空间不足,则会出现并发失败的情况,需要临时启用 Serial Old 收集器来重新进行老年代的收集。
-
这里有个问题,为什么重新标记环节也没解决垃圾碎片呢?
因为重新标记环节,不会再重头把所有黑色的GC Roots再都扫描一遍了,只会扫描增量更新的对象了。
-
-
垃圾碎片产生
标记-清除算法的一个问题。如果空间不够,会提前触发Full GC。
两个参数:
- -XX:+UseCMSCompactAtFullCollection 用于Full GC时开启碎片的合并整理。
- -XX:CMSFullGCsBeforeCompaction 经历几次不整理空间的Full GC后,下一次进入Full GC会碎片整理。
垃圾收集算法
新生代搭配 ParNew 采用复制算法,CMS 是标记-清除算法(目标是低延迟),并发失败时使用 Serial Old 采用标记-整理算法。
新老搭配图示
G1 收集器
特点
『停顿时间模型』的收集器,意思是能够支持指定在一个长度位 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。
G1 可以面向堆内存任何部分来组成回收集(Collection Set,简称CSet)进行回收,衡量标准不再是属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是 G1 收集器的 Mixed GC 模式。
基于 Region 的堆内存布局,把连续的Java堆划分为多个大小相等的独立区域,每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。超过一个Region 容量一半的对象即可判定为大对象。超大对象会存放在 N 个连续的 Humongous Region,G1 大部分会把 Humongous Region 视为老年代一部分看待。
每个Region 大小可以通过参数 -XX:G1HeapRegionSize 设定,范围为 1-32MB,如4M。
思路
G1 收集器去跟踪各个Region 里垃圾堆积的『价值』大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定的最大允许停顿时间(-XX:MaxGCPauseMillis,默认200ms),优先处理回收价值最大的 Region。
G1 收集器 Region 分区示意图
G1 要妥善解决的问题
- Region 间跨代引用问题
相比新老分代回收模式不同,新老只需要维护一个跨代数据结构即可,Region 模式需要为每个 Region 都维护一个跨代引用数据结构,并且是双向的卡表(我的 Region 指向谁,谁的 Region 指向我)。内存负担更大。
- 并发标记中错误标记、新对象内存分配问题
- SATB(原始快照)方式解决并发标记中的误标记问题。
- 每个Region 维护两个指针,划分出来专门给新对象分配内存。默认这个指针之上是隐式标记存活的,不纳入回收范围。
- 回收速度赶不上分配速度,引发Full GC
- 怎么建立可靠的停顿预测模型
怎么满足用户设置的 -XX:MaxGCPauseMillis ?G1 模型是以衰减均值为理论基础实现的,G1 收集器会记录每个 Region 的回收耗时、脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准差、置信度等统计信息。(衰减均值比平均值更可靠,接近新的状态 类似滑动窗口)。然后根据这些信息,判断哪些 Region 组成回收集收益最高。
四个步骤
-
初始标记
仅标记GC Roots直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能正确的在可用 Region 中分配新对象。需要STW,速度很快。
-
并发标记
遍历整个对象图进行标记。这个过程耗时长,但GC线程和用户线程可以并发执行。对象图扫描完成后,还要重新处理 SATB(原始快照)记录下的在并发时有引用变动的对象。
-
最终标记
用户线程短暂暂停 STW,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。
-
筛选回收
负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,决定选择哪些 Region进行回收。
存活的对象复制到空的Region,然后清理整个旧的 Region的空间。涉及移动对象,需要 STW 暂停用户线程。
-
STW
从 G1 四个步骤看,只有并发标记是和用户线程并发执行,其他阶段均需要 STW。看出来 G1 也并非纯粹追求低延迟,也会考虑吞吐(毕竟暂停用户线程,能提升垃圾收集的效率,保证吞吐量)。
-
设置期望暂停时间
期望值要符合实际,默认200ms,不能任意调小。譬如设置20ms,很可能出现的结果是由于停顿目标时间太短,导致每次选出来的回收集只占很小的一部分,这样收集的速度赶不上分配的速度,导致垃圾慢慢堆积。应用运行时间长了,最终引发Full GC,反而降低性能。
垃圾收集算法
G1 从整体来看是 标记-整理的收集器,从局部(两个 Region之间)上看又是基于 标记-复制算法的。
没有内存碎片,利于长时间运行。
G1 收集示意图
G1 和 CMS 对比
G1 现在基本取代了 CMS,目前Java8 的服务器上很多在跑了 G1,9以后更是不建议用 CMS 了。比较下两者:
- 创新式基于 Region 回收,实践下来证明更优势。
- 标记-整理,没有内存碎片,利于长时间运行的服务器;CMS 标记-清除 内存碎片问题。
- 维护卡表的内存消耗更大;CMS 内存占用更少。
- 负载上 G1 更高,如 原始快照上 需要写前写后都跟踪引用变化,异步处理等更复杂消耗。
ZGC
JDK11 新加入的实验性质的垃圾收集器,表现更卓越,未来大有可为。
特点
目标
尽可能对吞吐量影响不大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟。
基于 Region
同样基于 Region,但这个Region 可以动态创建和销毁,并且支持容量大小也是动态的。如:
- 小型 Region : 固定容量 2M
- 中型 Region : 固定容量 32M
- 大型 Region : 动态变化,2M 整数倍
参考
- 《深入理解Java虚拟机》