基础知识
性能指标
在调优Java应用程序时,重点通常放在两个主要目标上:响应性 或 吞吐量。
响应性Responsiveness
是指应用程序对请求的数据做出响应的速度:
- 桌面用户界面对事件的响应速度
- 网站返回页面的速度
- 数据库查询的返回速度
吞吐量Throughput
专注于最大程度地提高应用程序在特定时间段内的工作量:
- 在给定时间内完成的事务次数
- 批处理程序在一小时内可以完成的作业数
- 一小时内可以完成的数据库查询数
较长的暂停时间Pause Time
对于注重响应性的应用程序是不可接受的,但对于注重吞吐量的应用程序来说可以接受的。前者重点是在短时间内做出响应,后者则侧重与长时间运行的处理效率。
GC 基础
GC Root
可达性分析是 Java GC 算法的基础,基本思路就是以一系列名为 GC Roots
对象作为起始点,通过引用关系遍历对象图,如果一个对象到 GC Roots
间没有任何可达路径相连时,则说明此对象可以被回收。
可以作为 GC Roots
的对象:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 本地方法栈中JNI(即一般说的native方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
三色标记
可达性分析中重要的一环就是遍历整个堆,并标记其中的存活对象。一种常用的标记算法是 三色标记法tri-color marking
:
每个对象可能为以下 3 种颜色之一:
- white — 未被标记
- gray — 本身已标记,但部分引用的对象完成标记(动图的黄色对象)
- black — 本身已标记,且所有引用的对象完成标记(动图的蓝色对象)
标记算法从 GC Roots 出发遍历堆,可达对象先标记 gray,然后再标记 为 black。
遍历完成之后所有可达对象都是 black 的,此时所有标记为 white 的对象都是可以回收的。
当实现并发标记算法时,必须防止 white 对象被漏标,否则可能导致不该回收的对象被回收。
分代收集
传统垃圾收集器将堆分成三个部分:年轻代YoungGen = Eden + Survivor
,老年代OldGen
和永久代PermGen
,每个区域内存连续且大小固定。
- 年轻代:一次性使用的临时对象(例如:方法中构造的临时对象)
- 老年代:被长期引用的常驻对象(例如:缓存对象、单例对象)
- 永久代:JVM 运行过程中一直存在的对象(例如:字符串常量、类信息)
将堆内存进行划分后,可以按照对象生命周期长短,在不同区域使用不同的回收算法,提高 GC 的效率。
算法分类
Mark and Sweep标记-清除
用一个空闲列表free-list
记录失效对象占用的内存区域,方便后续重新分配给新对象。
- 回收原理简单,GC 停顿时间短
- 维护空闲列表需要一定的空间开销
- 内存碎片较多,可能导致内存分配失败
Mark-Sweep-Compact标记-整理
将所有存活对象移动到内存区域的开头,剩余的连续内存区域都是可用的空闲空间。
- 通过指针碰撞查找空闲空间,分配速度快
- 内存碎片少,内存分配失败概率低
- 复制对象会导致较长时间的 GC 停顿
Mark and Copy标记-复制
将内存划分为活动区间与空闲区间,前者用于动态分配对象,后者用于容纳 GC 存活对象。
GC 时只需将存活对象从前者复制到后者,然后交换两者的角色即可。
- 标记和复制在同一阶段同时进行,当存活对象少时回收效率极高
- 需要预留一个空闲空间用于容纳存活对象,造成内存浪费
CMS 回顾
CMS Concurrent Mark-Sweep
是一个采用 标记-清除 算法的老年代收集器。
它通过与应用程序线程并发执行大多数垃圾回收工作,来最大程度地减少由于 GC 导致的暂停。
通常情况下,CMS 收集器不会复制或压缩活动对象,这意味着无需移动活动对象即可完成垃圾回收。
然而过多的内存碎片可能造成分配失败,最终导致 FullGC。可以通过分配更大的堆来规避这一问题。
CMS 对老年代的回收可以分为以下几个步骤:
Initial Mark (STW)
初始标记
- 标记 GC Roots 直接可达的老年代对象
- 遍历新生代存活对象,标记直接可达的老年代对象
Concurrent Mark
并发标记
GC 线程遍历 Initial Mark 阶段标记出来存活的老年代对象,然后递归标记这些可达的对象。
该阶段与应用线程并发运行,期间会发生新生代对象晋升、老年代对象引用关系更新,需要对这些对象进行重新标记,避免发生遗漏。
CMS 用一个
card-table
管理老年代,并发标记过程中,某个对象的引用关系发生了变化,则将对象所在的内存块标记为 Dirty Card。CMS 使用增量更新
incremental update
解决并发修改导致的漏标问题:把 black 对象重新标记为 grey,下次重新扫描其引用。Preclean
预清理
这一阶段主要是处理 Concurrent Mark 阶段中引用关系改变,导致没有标记到的存活对象的。通过并发地重新扫描这些对象,预清理阶段可以减少 Remark 阶段的 STW。
这个阶段会处理前一个阶段被标记为 Dirty Card 的部分,将其中变化了的对象作为 GC Root 再进行扫描并重新标记。
Abortable Preclean
可终止的预清理
这个阶段作用与 Preclean 类似,但可以通过设置 扫描时长(默认5秒)或 Eden 区使用占比(默认50%)控制本阶段的结束时机。
增加这一阶段的原因,是期待这期间能发生一次 YoungGC 清理无效的年轻代对象,减少 Remark 阶段扫描年轻代的时间。
Remark (STW)
重新标记
:这个阶段同时扫描 YoungGen 与 OldGen,重新标记整个老年代中所有存活对象。
由于之前的 Concurrent Mark 与 Preclean 阶段是与用户线程并发执行的,年轻代对老年代的引用可能已经发生了改变,Remark 要花很多时间处理这些改变,会导致长时间的 STW。
此外,即使新生代的对象已经不可达了,CMS 也会使用这些不可达的对象当做的 GC Roots 来扫描老年代,导致部分失效的老年代对象无法被及时回收。
可以加入参数 -XX:+CMSScavengeBeforeRemark,在重新标记之前,先执行一次 YoungGC,回收掉年轻代的对象无用的对象。这样进行年轻代扫描时,只需要扫描 Survivor 区的对象即可,一般 Survivor 区非常小,这大大减少了扫描时间。
Concurrent Sweep
并发清理
至此,老年代所有存活的对象已经被标记完成。这个阶段主要是清除那些没有标记的对象并且回收空间。
被回收的空间会被添加到 空闲列表中,以供以后分配。这一过程可能会对空闲空间进行合并,但是不会移动存活对象。
由于该阶段是与应用线程并发运行的,自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,无法在当次收集中处理掉它们。只好留待下一次GC时再清理掉。这一部分垃圾就称为 浮动垃圾。
Resetting
重置
清除数据结构,并重置定时器,为下一轮 GC 做准备。
G1 算法
设计目的
G1 Garbage-First
是一种服务器端的垃圾收集器:
- 可以与应用程序线程并行运行,减少 STW
- 整理空闲空间减少内存碎片,但不引入较长的 GC 暂停时间
- 提供可预测的GC暂停时间,无需牺牲很多吞吐量
G1 能够在大内存的多处理器计算机上,保证 GC 暂停时间可控,并实现高吞吐量。
其最终目的是取代 CMS 成为服务端 GC 更好的解决方案:
- 采用 标记-整理 算法,可以避免使用细粒度的空闲列表进行分配。简化了收集器设计并消除了潜在的碎片问题。
- 使用 增量回收
incremental collecting
算法,其 GC 暂停时间比 CMS 更具可预测性,并允许用户指定期望的暂停时间。
基本概念
G1 将堆划分为一组大小相等的且连续的堆区域Region
:
G1 中新生代与老年代不再连续,每个区域可以在 Eden、Survivor 与 Old 之间切换角色。此外,还有一类被称为 Humongous 的巨型区域,用于容纳体积 ≥ 标准区域大小的50%的对象。JVM 通常会将内存划分为 2000个区域,每个大小从 1 到 32Mb 不等,由 JVM 在启动时通过 -XX:G1HeapRegionSize 指定。
每个区域会被进一步细分成多个卡片Card
,每个大小为 512Kb,用于实现细粒度的引用统计。
分区设计可以避免一次收集整个堆,每次 GC 只收集区域的一个子集 CSetcollection set
,其中必然包含所有 Young 区域,同时可能包括部分 Old 区域:
根据回收区域的不同,可以将 GC 分为:
- YoungGC:CSet 只包含 Young 区域
- MixedGC: CSet 同时包含 Young 与 Old 区域
- FullGC: 回收整个堆(可用空间耗尽时触发,单线程执行)
G1 根据存活对象的字节数统计每个区域的 活跃度liveness
,然后根据期望停顿时间来确定该 CSet 的大小,并保证那些垃圾多(活跃度低)的区域会被优先回收,故此得名 垃圾优先。
G1 的执行过程可以表示为由 3 个阶段组成的循环:
Young GC
堆中一开始只有 YoungGen,因此只会触发 YoungGC,将 Eden 与 Survivor 区域中的活动对象复制到另一个空闲的 Survivor 区域。
G1 中将 将存活对象复制到其他区域 的过程称为 疏散Evacuation
。为了减少停顿时间,疏散工作由多个 GC 线程并行完成。
YoungGC 过程中会根据预期目标停顿时间 -XX:MaxGCPauseMillis 动态调整新生代的大小,通过 -XX:G1NewSizePercent 参数可以人为干预这一过程,但会让预期停顿时间参数失效。
当堆的整体占用空间足够大时(超过45%),就会进入 Concurrent Marking 阶段。通过 -XX:InitiatingHeapOccupancyPercent 选项可以配置这一行为。
Concurrent Marking
与 CMS 类似,G1 中的并发标记包括多个阶段,其中一些阶段是并发的,另一些阶段则会 STW。
Initial Mark (STW)
初始标记
扫描并标记 GC Root 对象直接可达的老年代存活对象。
Initial Mark 并没有独立的执行阶段,而是嵌入 YoungGC 中执行的,其停顿时间会被分摊,因此实际的开销非常低。
Root Region Scan
扫描根区域
扫描 Root Region 并标记所有可达的老年代存活对象。
此处的 Root Region 就是先前 YoungGC 中生成的 Survivor 区域,其包含的对象都会被视为 GC Root。
为了避免移动对象对标记产生影响,该过程必须在下次 YongGC 启动前完成。
Concurrent Mark
并发标记
启动并发标记线程,扫描并标记整个堆中的存活对象(线程数可以通过 -XX:ConcGCThread 进行配置)。
为了避免重复标记,G1 使用 SATB
snapshot-at-the-beginning
算法解决漏标问题:应用线程对在 Concurrent Mark 执行期间进行的所有并发更新,都应保留先前的已知标记信息。该约束是通过预写屏障
pre-write barrier
实现:Concurrent Mark 扫描过程中,当应用线程修改某个字段时,会将先前的引用对象存储在日志缓冲区log buffers
中,然后交由并发标记线程处理。为了避免移动对象对标记产生影响,该过程必须在下次 YoungGC 启动前完成。所有的标记任务必须在堆满前完成,如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行 FullGC。
Remark (STW)
重新标记
启动并行标记线程,完成对整个堆中存活对象的标记(线程数可以通过 -XX:ParallelGCThread 进行配置)。
该阶段会暂停所有应用线程,避免发生引用更新,并完成对SATB 日志缓冲区中剩余对象的标记,找出所有未被访问的存活对象。
该阶段还执行一些额外的清理操作,例如:
- 卸载不可达的类(通过 -XX:+ClassUnloadingWithConcurrentMark 开启)
- 处理引用对象(弱引用、软引用、虚引用、最终引用)
Cleanup
清理垃圾
整理统计信息并识别出高收益的老年代分区,为 MixedGC 做准备。
主要工作有:
- RSet 梳理(后续说明)
- 识别回收收益高的老年代分区 (基于释放空间和暂停目标)
- 直接回收的没有活跃对象的空闲分区
此外还会执行一些清理工作,为下一次 Concurrent Marking 做好准备。
Mixed GC
MixedGC 主要流程与 YoungGC 类似,不同的地方在于 CSet 中包含了 Old 区域。
需要注意的是,Concurrent Marking 结束后,并不一定会立即触发 MixedGC,中间可能会穿插多次的 YoungGC。
当收集某个区域时,我们必须知道是否有来自非收集区域引用,来确定它们的活动性:
- 从非收集区域到收集区域的 incoming reference 是重要的(被非收集区引用的对象必须存活)
- 从收集区域到非收集区域的 outgoing reference 是可忽略的(非收集区域不参与GC)
但查找整个堆非常耗时,同时也失去了增量收集的优势。为了解决这一问题,G1 为每个区域维护了一个 RSetremembered set
,用于记忆从其他区域指向自己的引用。
收集过程
在执行收集时,RSet 中引用信息会扮演局部 GC Roots 的角色,避免耗时的引用查找,保证每个区域的 GC 能够独立进行:
注意,象如果 Old 区域中对在 Concurrent Marking 阶段被确定为垃圾,即使有外部引用,该对象也会被作为垃圾回收。
接下来发生的事情与其他收集器所做的相同:多个并行GC线程找出哪些对象是活动的,哪些对象是垃圾:
最后,释放空闲区域,将活动对象移到 Survivor 区域,并在必要时创建新对象:
RSet 维护
为了维护 RSet,在应用线程对字段执行写操作时,会触发写后屏障post-write barrier
:
为了减少写屏障带来的开销,该过程是异步的:
Dirty Card Queue
,然后由 Refine 线程将其拾取并将信息传播到被引用区域的 RSet。如果应用线程插入速度过快,会导致 Refine 线程来不及处理,那么应用线程将接管 RSet 更新的任务,从而导致性能下降。
总结
并发标记 与 增量收集 是 G1 实现高性能与可预测回收的关键。
对于 CPU 资源充足且对延迟敏感的服务端应用来说,G1 算法能够在大堆上提供良好的响应速度。
作为代价,额外的写屏障与更活跃GC线程,会对应用的吞吐量产生负面影响。
参考资料
- https://www.oracle.com/technetwork/tutorials/tutorials-1876574.html
- https://plumbr.io/handbook/garbage-collection-algorithms
- https://medium.com/@hansrajchoudhary_88463/evolution-of-garbage-collection-on-java-garbage-first-garbage-collection-a3f39b1a9ae0
- https://juejin.cn/post/6844903960550047757#heading-10
- https://segmentfault.com/a/1190000021761004