前言
本文主要介绍常用的JVM各种垃圾回收器,比较CMS与Parallel、G1与CMS的区别,简单介绍ZGC以及各种垃圾回收器使用的场景 调优参数使用等;另外介绍记忆集与卡表的关系,深入剖析JVM底层原理,实际生产环境调优参数等。
垃圾回收器
图中展示了8种作用于不同分代的收集器,如果两个收集器之间存在连线,则说明它们可以搭配使用。虚拟机所处的区域则表示它是属于新生代还是老年代收集器。
如果说收集算法是内存回收的方法论,那么垃圾回收器就是内存回收的具体实现。
历史迭代
随着时代的进步机器设备不断迭代,原先单线程、小内存的分代模型不能满足客户的体验逐步进化为分区模型,本质上就是减少吞吐量,降低停顿时间,增加客户体验。
几个相关概念:
并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上。
分代模型:(分年轻代、老年代)吞吐量高,但GC时停顿时间长 给客户体验较差(假设一个内存是50G,触发GC时会停顿很长时间,给客户体验极度不好)。
分区模型:(没有年轻代、老年代概念)吞吐量低,但GC时停顿时间短 给客户体验好(同样是内存50G,以G1为例设置停顿时间为200ms,也就是说内部嵌入了一套算法,如果说该内存已经达到了需要200ms才能清理,就会触发GC 这样给客户体验就特别好;不像分代回收满了才能触发回收;但内部包含了一系列处理逻辑 所以总体吞吐量会降低)。
吞吐量: 即CPU用于运行用户代码的时间与CPU总消耗时间的比值 假如分代模型回收总时长是60s,在60s内回收完,那么就要一下子停顿60s;而分区模型假设回收总时长是100s,1小时里慢慢回收 也就是1小时总共停顿了100s,大大增加了客户的体验。
Serial 收集器(-XX:+UseSerialGC(年轻代), -XX:+UseSerialOldGC(老年代))
Serial收集器是最基本的、发展历史最悠久的收集器。
特点:单线程、简单高效(与其他收集器的单线程相比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)。
新生代采用复制算法(Serial),老年代采用标记-整理算法(Serial Old)。
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器。
主要有两大用途:
- JDK1.5以及以前的版本中与Parallel Scavenge收集器搭配使用。
- 另一种用途是作为CMS收集器的后备方案。
应用场景:适用于单核心CPU的场景(避免线程切换,浪费开销)。
Parallel Scavenge 收集器(-XX:+UseParallelGC(年轻代),-XX:+UseParallelOldGC(老年代))
Parallel收集器其实就是Serial收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和Serial收集器类似。默认收集线程数与CPU核心数相同,当然也可以使用参数(-XX:ParallelGCThreads)指定收集线程数,但一般不推荐。
Parallel Scavenge收集器关注点是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的关注点更多的是用户线程停顿的时间(提高用户体验)。
新生代采用复制算法(Parallel Scavenge),老年代采用标记-整理算法(Parallel Old)。
Parallel Old 收集器是Parallel Scavenge 收集器的老年代版本 (JDK8默认)
应用场景:适用于注重高吞吐量且多核心CPU的场景,内存较小(4G以下)。
ParNew收集器(-XX:+UseParNewGC)
ParNew与Parallel收集器类似,区别主要在于它可以和CMS搭配使用。
新生代采用复制算法(ParNew),老年代采用标记-整理算法(Serial Old)/ 标记-清除算法(CMS)。
应用场景:适用于注重高吞吐量且多核心CPU的场景,并配合与CMS使用。
CMS收集器(-XX:+UseConcMarkSweepGC)
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用,它是hotspot虚拟机第一款真正意义上的的并发收集器,它第一次实现了让垃圾线程与用户线程(基本上)同时工作。
特点:基于标记-清除算法实现。并发收集、低停顿。
运行过程分为下列几步:
- 初始标记:暂停所有线程(STW)标记GC Roots能直接到的对象,速度很快。
- 并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行,这个过程很长(约占整个GC回收时长的80%),但不会发生STW。
- 重新标记:暂停所有线程(STW),为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段耗时会比初始标记稍长,但远远比并发标记短。主要用到三色标记里的增量更新算法(另开篇博客讲)做重新标记
- 并发清除:对标记的对象进行清除回收。
- 并发重置:重置本次GC过程中的标记数据。
CMS收集器的缺点:
- 对CPU资源非常敏感。
- 执行过程不确定性,会存在上一次垃圾回收还没执行完,然后垃圾回收又被触发的情况,出现Concurrent Model Failure失败,此时会进入STW,使用Serial Old垃圾回收器来回收。
- 因为采用标记-清除算法所以会存在空间碎片的问题,导致大对象无法分配空间,不得不提前触发一次Full GC。
cms相关核心参数
- -XX:+UseConcMarkSweepGC【启用CMS垃圾回收器】
- -XX:ConcGCThreads【并发的GC线程数】
- -XX:+UseCMSCompactAtFullCollection【FullGC之后做压缩整理,减少碎片】
- -XX:CMSFullGCsBeforeCompaction【多少次FullGC之后压缩一次,默认0,代表每次FullGC之后都会压缩一次】
- -XX:CMSInitiatingOccupancyFraction【当老年代使用达该比例时会出发FullGC,默认是92,这是百分比】
- -XX:+UseCMSInitatingOccupancyOnly【只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续会自动调整】
- -XX:+CMSScavengeBeforeRemark【在CMS GC前启动一次minor gc,目的在于减少老年代的引用,降低CMS GC的标记阶段时的开销,一般CMS的GC耗时80%都在标记阶段】
- -XX:+CMSParallelInitialMarkEnabled【表示在初始标记的时候多线程执行,缩短STW】
- -XX:+CMSParallelRemarkEnabled【在重新标记的时候多线程执行,缩短STW】
CMS收集器优化建议
因为CMS使用标记清除算法,因此会产生内存碎片,需要配合开启-XX:+UseCMSCompactAtFullCollection,压缩整理,减少碎片,以及使用-XX:CMSFullGCsBeforeCompaction参数控制压缩次数(毕竟每一次压缩也会造成性能损失)。
应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务。并且内存4G-8G之间(推荐值)
G1收集器(-XX:+UseG1GC)
一款面向服务端应用的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特性。
内存模型
G1与其他垃圾收集器不同之处在于,heap被划分为一系列大小相等的“小堆区”,也称为region。每个小堆区(region)的大小为1~32MB,整个堆默认划分出2048个小堆区。与上一代的垃圾收集器一样在逻辑上被划分Eden、Survivor和老年代,但是各种角色的region个数都不是固定的。下图中的绿色代表Eden小堆区、黄色为Survivor小堆区、蓝色则为老年代小堆区、而灰色则会未被使用的区域。
在G1中,还有一种特殊的区域,叫Humongous区域。 如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会被分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
运行过程分为下列几步:
- 初始标记:暂停所有线程(STW)仅标记GC Roots能直接到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象;速度很快。
- 并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象;这个过程很长(约占整个GC回收时长的80%),但可与用户程序并发执行,不会发生STW。
- 最终标记:暂停所有线程(STW),为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这个阶段耗时会比初始标记稍长,但远远比并发标记短。主要用到三色标记里的原始快照算法(另开篇博客讲)做重新标记
- 筛选回收:对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。(可并发执行)
G1垃圾收集分类
Young GC
Young GC 并不是Eden区存放满了马上就触发,G1会计算现在Eden区回收需要多少时间,如果回收时间远远小于参数-XX:MaxGCPauseMillis 设定值,那么增加年轻代region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区存放满,G1计算回收时间接近参数-XX:MaxGCPauseMillis 设定值,那么触发Young GC。
Mix GC
不是FullGC,当整个堆大小在jvm堆栈空间中占比达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。回收所有 Young和部分Old(根据期望GC停顿时间确定old区垃圾收集的优先顺序)以及对大对象区,正常情况G1的垃圾回收器先做Mix GC,主要采用复制算法,需要把各个region中存活的对象拷贝到别的region里去,拷贝过程中如果发现没有足够的region能承载拷贝对象就会触发一次FullGC。
FullGC
停止系统程序,然后采用单线程进行标记、清理和压缩整理,好空闲出来一批region来供下一次MixedGC使用,这个过程非常耗时。
G1相关核心参数
-XX:+UseG1GC 采用 Garbage First (G1) 收集器
-XX:MaxGCPauseMillis=n 设置最大GC 暂停时间。这是一个大概值,JVM 会尽可能的满足此值
-XX:InitiatingHeapOccupancyPercent=n 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。默认值 45.
-XX:NewRatio=n new/old 年代的大小比例. 默认值 2.
-XX:SurvivorRatio=n eden/survivor 空间的大小比例. 默认值 8.
-XX:MaxTenuringThreshold=n 对象晋升年代的最大阀值。默认值 15.这个参数需要注意的是:最大值是15,不要超过这个数啊,要不然会被人笑话。原因为:JVM内部使用 4 bit (1111)来表示这个数。
-XX:ParallelGCThreads=n 设置在垃圾回收器的并行阶段使用的线程数。默认值因与 JVM 运行的平台而不同。
-XX:ConcGCThreads=n 并发垃圾收集器使用的线程数。默认值因与 JVM 运行的平台而不同。
-XX:G1ReservePercent=n 设置作为空闲空间的预留内存百分比以降低晋升失败的可能性。默认值10
-XX:G1HeapRegionSize=n 使用G1,Java堆被划分为大小均匀的区域。这个参数配置各个子区域的大小。此参数的默认值根据堆大小的人工进行确定。最小值为 1Mb 且最大值为 32Mb。
-XX:G1PrintRegionLivenessInfo 默认值false, 在情理阶段的并发标记环节,输出堆中的所有 regions 的活跃度信息
-XX:G1PrintHeapRegions 默认值false, G1 将输出那些 regions 被分配和回收的信息
-XX:+PrintSafepointStatistics 输出具体的停顿原因
-XX: PrintSafepointStatisticsCount=1 输出具体的停顿原因
-XX:+PrintGCApplicationStoppedTime 停顿时间输出到GC日志中
-XX:-UseBiasedLocking 取消偏向锁
-XX:+UseGCLogFileRotation 开启滚动日志输出,避免内存被浪费
-XX:+PerfDisableSharedMem 关闭 jstat 性能统计输出特性,使用 jmx 代替
G1收集器优化建议
核心在于调节-XX:MaxGCPauseMillis 这个参数值,在保证它年轻代GC别太频繁的同时,还得考虑每次GC过后存活对象有多少,避免存活对象太多进入老年代,频繁触发Mix GC。
应用场景:大内存8G以上(推荐值)可大大增加用户体验。
ZGC(-XX:+UseZGC)
(仅了解)
参考文章
https://wiki.openjdk.java.net/display/zgc/Main
https://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf
Z Garbage Collector,也称为ZGC,是一种可扩展的低延迟垃圾收集器,旨在满足以下目标:
- 毫秒最大暂停时间
- 暂停时间不会随着堆、live-set 或 root-set 的大小而增加
- 处理大小从8MB到16TB的堆
ZGC目标
如图所示,ZGC的目标主要有4个:
- 支持TB级的堆。满足未来几十年内,所有java应用需求。
- 最大GC停顿时间不超过10ms。
- 奠定未来GC特性的基础。
- 最糟糕的情况下吞吐量降低15%。这都不是事,停顿足够优秀。至于吞吐量可通过扩容解决。
记忆集与卡表
为解决扫描GC ROOT时遇到对象跨代引用所带来的问题,收集器在新生代上建立一个全局的称为记忆集(Remembered Set)的数据结构
这个结构把老年代划分为若干个小块,标识出老年代哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存中的老年代对象才会加入到 GC Roots 扫描中,避免整个老年代加入到 GC Roots 中。
记忆集与卡表关系
HotSpot虚拟机是用记忆集来记录某块内存区域是否包含跨代引用的对象。记忆集是抽象概念,而卡表是记忆集的实现。
卡表是用字节数组实现的,卡表数组的每个元素都是代表某块具体内存区域,这个内存区域叫卡页。
卡页的大小是512字节,代表一块特定大小的内存块,若在这块内存块中有一个或多个的跨代指针,则将对应的卡表元素标为1,代表“变脏”,否则为0。当虚拟机扫描卡表元素为1时,便将对应的卡页内存区域加入到GC ROOT中一并扫描。
备注
为什么G1用SATB?CMS用增量更新?
我的理解:SATB相对增量更新效率会高(当然SATB可能造成更多的浮动浮动垃圾),因为不需要在重新标记阶段深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新扫描的话G1代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单的标记,等到下一轮GC再做深度扫描。
其他常见参数优化建议
朝生夕死的对象在年轻代minorGC掉,老不死的对象早点退休进入老年代,尽量减少FullGC次数
- 如果年轻代比较活跃,可适当调大年轻代内存大小,朝生夕死的对象在年轻代minorGC掉,减少FullGC次数。
- 设置分代年龄-XX:PretenureSizeThreshold参数(默认15)可以根据业务 计算年轻代对象在多少次GC后可以被回收掉,适当调小目的是让老不死的对象早点进入老年代,不要占用活跃的年轻代空间。
- 定义大对象的大小 -XX:PretenureSizeThreshold参数(只对Serial和ParNew两款新生代收集器有效)比如设置1M,大于1M的对象会进入老年代,目的是不要占用活跃的年轻代空间。
- Oracle 官方推荐:生产环境中把Xms(初始化堆)和Xmx(最大堆)设为相同值;原因:生产环境往往一台服务器或一个容器只有一个服务,独占服务器意味着没有必要调整 JVM 大小,每次调整反而会加大开销。