关于后端:老大难的GC原理及调优这下全说清楚了

42次阅读

共计 9213 个字符,预计需要花费 24 分钟才能阅读完成。

概述
本文介绍 GC 根底原理和实践,GC 调优办法思路和办法,基于 Hotspot jdk1.8,学习之后将理解如何对生产零碎呈现的 GC 问题进行排查解决
浏览时长约 30 分钟,内容次要如下:

GC 根底原理,波及调优指标,GC 事件分类、JVM 内存调配策略、GC 日志剖析等
CMS 原理及调优
G1 原理及调优
GC 问题排查和解决思路

GC 根底原理
1 GC 调优指标
大多数状况下对 Java 程序进行 GC 调优, 次要关注两个指标:响应速度、吞吐量

响应速度 (Responsiveness)
响应速度指程序或系统对一个申请的响应有多迅速。比方,用户订单查问响应工夫,对响应速度要求很高的零碎,较大的进展工夫是不可承受的。调优的重点是在短的工夫内疾速响应

吞吐量 (Throughput)
吞吐量关注在一个特定时间段内利用零碎的最大工作量,例如每小时批处理零碎能实现的工作数量,在吞吐量方面优化的零碎,较长的 GC 进展工夫也是能够承受的,因为高吞吐量利用更关怀的是如何尽可能快地实现整个工作,不思考疾速响应用户申请

GC 调优中,GC 导致的利用暂停工夫影响零碎响应速度,GC 解决线程的 CPU 使用率影响零碎吞吐量
2 GC 分代收集算法
古代的垃圾收集器根本都是采纳分代收集算法,其次要思维:
将 Java 的堆内存逻辑上分成两块:新生代、老年代,针对不同存活周期、不同大小的对象采取不同的垃圾回收策略

新生代(Young Generation)

新生代又叫年老代,大多数对象在新生代中被创立,很多对象的生命周期很短。每次新生代的垃圾回收(又称 Young GC、Minor GC、YGC)后只有大量对象存活,所以应用复制算法,只需大量的复制操作老本就能够实现回收
新生代内又分三个区:一个 Eden 区,两个 Survivor 区(S0、S1,又称 From Survivor、To Survivor),大部分对象在 Eden 区中生成。当 Eden 区满时,还存活的对象将被复制到两个 Survivor 区(中的一个)。当这个 Survivor 区满时,此区的存活且不满足降职到老年代条件的对象将被复制到另外一个 Survivor 区。对象每经验一次复制,年龄加 1,达到降职年龄阈值后,转移到老年代

老年代(Old Generation)

在新生代中经验了 N 次垃圾回收后依然存活的对象,就会被放到老年代,该区域中对象存活率高。老年代的垃圾回收通常应用“标记 - 整顿”算法
3 GC 事件分类
依据垃圾收集回收的区域不同,垃圾收集次要通常分为 Young GC、Old GC、Full GC、Mixed GC
(1) Young GC
新生代内存的垃圾收集事件称为 Young GC(又称 Minor GC),当 JVM 无奈为新对象调配在新生代内存空间时总会触发 Young GC,比方 Eden 区占满时。新对象调配频率越高, Young GC 的频率就越高
Young GC 每次都会引起全线进展(Stop-The-World),暂停所有的利用线程,进展工夫绝对老年代 GC 的造成的进展,简直能够忽略不计
(2) Old GC、Full GC、Mixed GC
Old GC,只清理老年代空间的 GC 事件,只有 CMS 的并发收集是这个模式
Full GC,清理整个堆的 GC 事件,包含新生代、老年代、元空间等

Mixed GC,清理整个新生代以及局部老年代的 GC,只有 G1 有这个模式

4 GC 日志剖析
GC 日志是一个很重要的工具,它精确记录了每一次的 GC 的执行工夫和执行后果,通过剖析 GC 日志能够调优堆设置和 GC 设置,或者改良应用程序的对象分配模式,开启的 JVM 启动参数如下:
-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps
复制代码常见的 Young GC、Full GC 日志含意如下:

收费的 GC 日志图形剖析工具举荐上面 2 个:

GCViewer,下载 jar 包间接运行
gceasy,web 工具,上传 GC 日志在线应用

5 内存调配策略
Java 提供的主动内存治理,能够归结为解决了对象的内存调配和回收的问题,后面曾经介绍了内存回收,上面介绍几条最广泛的内存调配策略

对象优先在 Eden 区调配
大多数状况下,对象在先新生代 Eden 区中调配。当 Eden 区没有足够空间进行调配时,虚拟机将发动一次 Young GC

大对象间接进入老年代
JVM 提供了一个对象大小阈值参数(-XX:PretenureSizeThreshold,默认值为 0,代表不论多大都是先在 Eden 中分配内存),大于参数设置的阈值值的对象间接在老年代调配,这样能够防止对象在 Eden 及两个 Survivor 间接产生大内存复制

长期存活的对象将进入老年代
对象每经验一次垃圾回收,且没被回收掉,它的年龄就减少 1,大于年龄阈值参数 (-XX:MaxTenuringThreshold,默认 15) 的对象,将降职到老年代中

空间调配担保
当进行 Young GC 之前,JVM 须要预估:老年代是否可能包容 Young GC 后新生代降职到老年代的存活对象,以确定是否须要提前触发 GC 回收老年代空间,基于空间调配担保策略来计算:

continueSize:老年代最大可用间断空间

Young GC 之后如果胜利(Young GC 后降职对象能放入老年代),则代表担保胜利,不必再进行 Full GC,进步性能;如果失败,则会呈现“promotion failed”谬误,代表担保失败,须要进行 Full GC

动静年龄断定
新生代对象的年龄可能没达到阈值 (MaxTenuringThreshold 参数指定) 就降职老年代,如果 Young GC 之后,新生代存活对象达到雷同年龄所有对象大小的总和大于任一 Survivor 空间 (S0 或 S1 总空间) 的一半,此时 S0 或者 S1 区行将包容不了存活的新生代对象,年龄大于或等于该年龄的对象就能够间接进入老年代,毋庸等到 MaxTenuringThreshold 中要求的年龄

另外,如果 Young GC 后 S0 或 S1 区不足以包容:未达到降职老年代条件的新生代存活对象,会导致这些存活对象间接进入老年代,须要尽量避免
CMS 原理及调优
1 名词解释
可达性剖析算法:用于判断对象是否存活,根本思维是通过一系列称为“GC Root”的对象作为终点(常见的 GC Root 有零碎类加载器、栈中的对象、处于激活状态的线程等),基于对象援用关系,从 GC Roots 开始向下搜寻,所走过的门路称为援用链,当一个对象到 GC Root 没有任何援用链相连,证实对象不再存活
Stop The World:GC 过程中剖析对象援用关系,为了保障剖析后果的准确性,须要通过进展所有 Java 执行线程,保障援用关系不再动态变化,该进展事件称为 Stop The World(STW)
Safepoint:代码执行过程中的一些非凡地位,当线程执行到这些地位的时候,阐明虚拟机以后的状态是平安的,如果有须要 GC,线程能够在这个地位暂停。HotSpot 采纳被动中断的形式,让执行线程在运行期轮询是否须要暂停的标记,若须要则中断挂起
2 CMS 简介
CMS(Concurrent Mark and Sweep 并发 - 标记 - 革除),是一款基于并发、应用标记革除算法的垃圾回收算法,只针对老年代进行垃圾回收。CMS 收集器工作时,尽可能让 GC 线程和用户线程并发执行,以达到升高 STW 工夫的目标
通过以下命令行参数,启用 CMS 垃圾收集器:
-XX:+UseConcMarkSweepGC
复制代码值得补充的是,上面介绍到的 CMS GC 是指老年代的 GC,而 Full GC 指的是整个堆的 GC 事件,包含新生代、老年代、元空间等,两者有所辨别
3 新生代垃圾回收
能与 CMS 搭配应用的新生代垃圾收集器有 Serial 收集器和 ParNew 收集器。这 2 个收集器都采纳标记复制算法,都会触发 STW 事件,进行所有的利用线程。不同之处在于,Serial 是单线程执行,ParNew 是多线程执行

4 老年代垃圾回收
CMS GC 以获取最小进展工夫为目标,尽可能减少 STW 工夫,能够分为 7 个阶段

阶段 1: 初始标记(Initial Mark)

此阶段的指标是标记老年代中所有存活的对象, 包含 GC Root 的间接援用, 以及由新生代中存活对象所援用的对象,触发第一次 STW 事件
这个过程是反对多线程的(JDK7 之前单线程,JDK8 之后并行,可通过参数 CMSParallelInitialMarkEnabled 调整)

阶段 2: 并发标记(Concurrent Mark)

此阶段 GC 线程和利用线程并发执行,遍历阶段 1 初始标记进去的存活对象,而后持续递归标记这些对象可达的对象

阶段 3: 并发预清理(Concurrent Preclean)

此阶段 GC 线程和利用线程也是并发执行,因为阶段 2 是与利用线程并发执行,可能有些援用关系曾经产生扭转。
通过卡片标记(Card Marking),提前把老年代空间逻辑划分为相等大小的区域(Card),如果援用关系产生扭转,JVM 会将产生扭转的区域标记位“脏区”(Dirty Card),而后在本阶段,这些脏区会被找进去,刷新援用关系,革除“脏区”标记

阶段 4: 并发可勾销的预清理(Concurrent Abortable Preclean)

此阶段也不进行利用线程. 本阶段尝试在 STW 的 最终标记阶段 (Final Remark) 之前尽可能地多做一些工作,以缩小利用暂停工夫
在该阶段一直循环解决:标记老年代的可达对象、扫描解决 Dirty Card 区域中的对象,循环的终止条件有:
1 达到循环次数
2 达到循环执行工夫阈值
3 新生代内存使用率达到阈值

阶段 5: 最终标记(Final Remark)

这是 GC 事件中第二次(也是最初一次)STW 阶段,指标是实现老年代中所有存活对象的标记。在此阶段执行:
1 遍历新生代对象,从新标记
2 依据 GC Roots,从新标记
3 遍历老年代的 Dirty Card,从新标记

阶段 6: 并发革除(Concurrent Sweep)

此阶段与应用程序并发执行,不须要 STW 进展,依据标记后果革除垃圾对象

阶段 7: 并发重置(Concurrent Reset)

此阶段与应用程序并发执行,重置 CMS 算法相干的外部数据, 为下一次 GC 循环做筹备
5 CMS 常见问题
最终标记阶段进展工夫过长问题
CMS 的 GC 进展工夫约 80% 都在最终标记阶段(Final Remark),若该阶段进展工夫过长,常见起因是新生代对老年代的有效援用,在上一阶段的并发可勾销预清理阶段中,执行阈值工夫内未实现循环,来不及触发 Young GC,清理这些有效援用
通过增加参数:-XX:+CMSScavengeBeforeRemark。在执行最终操作之前先触发 Young GC,从而缩小新生代对老年代的有效援用,升高最终标记阶段的进展,但如果在上个阶段 (并发可勾销的预清理) 已触发 Young GC,也会反复触发 Young GC
并发模式失败 (concurrent mode failure) & 降职失败(promotion failed) 问题

并发模式失败:当 CMS 在执行回收时,新生代产生垃圾回收,同时老年代又没有足够的空间包容降职的对象时,CMS 垃圾回收就会进化成单线程的 Full GC。所有的利用线程都会被暂停,老年代中所有的有效对象都被回收

降职失败:当新生代产生垃圾回收,老年代有足够的空间能够包容降职的对象,然而因为闲暇空间的碎片化,导致降职失败,此时会触发单线程且带压缩动作的 Full GC
并发模式失败和降职失败都会导致长时间的进展,常见解决思路如下:

升高触发 CMS GC 的阈值,即参数 -XX:CMSInitiatingOccupancyFraction 的值,让 CMS GC 尽早执行,以保障有足够的空间
减少 CMS 线程数,即参数 -XX:ConcGCThreads,
增大老年代空间
让对象尽量在新生代回收,防止进入老年代

内存碎片问题
通常 CMS 的 GC 过程基于标记革除算法,不带压缩动作,导致越来越多的内存碎片须要压缩,常见以下场景会触发内存碎片压缩:

新生代 Young GC 呈现新生代降职担保失败 (promotion failed)
程序被动执行 System.gc()

可通过参数 CMSFullGCsBeforeCompaction 的值,设置多少次 Full GC 触发一次压缩,默认值为 0,代表每次进入 Full GC 都会触发压缩,带压缩动作的算法为下面提到的单线程 Serial Old 算法,暂停工夫 (STW) 工夫十分长,须要尽可能减少压缩工夫
G1 原理及调优
1 G1 简介
G1(Garbage-First)是一款面向服务器的垃圾收集器,反对新生代和老年代空间的垃圾收集,次要针对装备多核处理器及大容量内存的机器,G1 最次要的设计指标是: 实现可预期及可配置的 STW 进展工夫
2 G1 堆空间划分

Region

为实现大内存空间的低进展工夫的回收,将划分为多个大小相等的 Region。每个小堆区都可能是 Eden 区,Survivor 区或者 Old 区,然而在同一时刻只能属于某个代
在逻辑上, 所有的 Eden 区和 Survivor 区合起来就是新生代,所有的 Old 区合起来就是老年代,且新生代和老年代各自的内存 Region 区域由 G1 自动控制,一直变动

巨型对象

当对象大小超过 Region 的一半,则认为是巨型对象(Humongous Object),间接被调配到老年代的巨型对象区(Humongous regions),这些巨型区域是一个间断的区域集,每一个 Region 中最多有一个巨型对象,巨型对象能够占多个 Region
G1 把堆内存划分成一个个 Region 的意义在于:

每次 GC 不用都去解决整个堆空间,而是每次只解决一部分 Region,实现大容量内存的 GC
通过计算每个 Region 的回收价值,包含回收所需工夫、可回收空间,在无限工夫内尽可能回收更多的内存,把垃圾回收造成的进展工夫管制在预期配置的工夫范畴内,这也是 G1 名称的由来: garbage-first

3 G1 工作模式
针对新生代和老年代,G1 提供 2 种 GC 模式,Young GC 和 Mixed GC,两种会导致 Stop The World

Young GC
当新生代的空间有余时,G1 触发 Young GC 回收新生代空间
Young GC 次要是对 Eden 区进行 GC,它在 Eden 空间耗尽时触发,基于分代回收思维和复制算法,每次 Young GC 都会选定所有新生代的 Region,同时计算下次 Young GC 所需的 Eden 区和 Survivor 区的空间,动静调整新生代所占 Region 个数来管制 Young GC 开销

Mixed GC
当老年代空间达到阈值会触发 Mixed GC,选定所有新生代里的 Region,依据全局并发标记阶段 (上面介绍到) 统计得出收集收益高的若干老年代 Region。在用户指定的开销指标范畴内,尽可能抉择收益高的老年代 Region 进行 GC,通过抉择哪些老年代 Region 和抉择多少 Region 来管制 Mixed GC 开销

4 全局并发标记
全局并发标记次要是为 Mixed GC 计算找出回收收益较高的 Region 区域,具体分为 5 个阶段

阶段 1: 初始标记 (Initial Mark)
暂停所有利用线程(STW),并发地进行标记从 GC Root 开始间接可达的对象(原生栈对象、全局对象、JNI 对象),当达到触发条件时,G1 并不会立刻发动并发标记周期,而是期待下一次新生代收集,利用新生代收集的 STW 时间段,实现初始标记,这种形式称为借道(Piggybacking)

阶段 2: 根区域扫描(Root Region Scan)
在初始标记暂停完结后,新生代收集也实现的对象复制到 Survivor 的工作,利用线程开始沉闷起来;
此时为了保障标记算法的正确性,所有新复制到 Survivor 分区的对象,须要找出哪些对象存在对老年代对象的援用,把这些对象标记成根 (Root);
这个过程称为根分区扫描(Root Region Scanning),同时扫描的 Suvivor 分区也被称为根分区(Root Region);
根分区扫描必须在下一次新生代垃圾收集启动前实现(接下来并发标记的过程中,可能会被若干次新生代垃圾收集打断),因为每次 GC 会产生新的存活对象汇合

阶段 3: 并发标记(Concurrent Marking)
标记线程与应用程序线程并行执行,标记各个堆中 Region 的存活对象信息,这个步骤可能被新的 Young GC 打断
所有的标记工作必须在堆满前就实现扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经验了几次新生代收集

阶段 4: 再次标记 (Remark)
和 CMS 相似暂停所有利用线程(STW),以实现标记过程短暂地进行利用线程, 标记在并发标记阶段发生变化的对象,和所有未被标记的存活对象,同时实现存活数据计算

阶段 5: 清理 (Cleanup)
为行将到来的转移阶段做筹备, 此阶段也为下一次标记执行所有必须的整顿计算工作:

整顿更新每个 Region 各自的 RSet(remember set,HashMap 构造,记录有哪些老年代对象指向本 Region,key 为指向本 Region 的对象的援用,value 为指向本 Region 的具体 Card 区域,通过 RSet 能够确定 Region 中对象存活信息,防止全堆扫描)
回收不蕴含存活对象的 Region
统计计算回收收益高(基于开释空间和暂停指标)的老年代分区汇合

5 G1 调优留神点
Full GC 问题
G1 的失常解决流程中没有 Full GC,只有在垃圾回收解决不过去(或者被动触发) 时才会呈现,G1 的 Full GC 就是单线程执行的 Serial old gc,会导致十分长的 STW,是调优的重点,须要尽量避免 Full GC,常见起因如下:

程序被动执行 System.gc()
全局并发标记期间老年代空间被填满(并发模式失败)
Mixed GC 期间老年代空间被填满(降职失败)
Young GC 时 Survivor 空间和老年代没有足够空间包容存活对象

相似 CMS,常见的解决是:

增大 -XX:ConcGCThreads=n 选项减少并发标记线程的数量,或者 STW 期间并行线程的数量:-XX:ParallelGCThreads=n
减小 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期
增大预留内存 -XX:G1ReservePercent=n,默认值是 10,代表应用 10% 的堆内存为预留内存,当 Survivor 区域没有足够空间包容新降职对象时会尝试应用预留内存

巨型对象调配
巨型对象区中的每个 Region 中蕴含一个巨型对象,残余空间不再利用,导致空间碎片化,当 G1 没有适合空间调配巨型对象时,G1 会启动串行 Full GC 来开释空间。能够通过减少 -XX:G1HeapRegionSize 来增大 Region 大小,这样一来,相当一部分的巨型对象就不再是巨型对象了,而是采纳一般的调配形式
不要设置 Young 区的大小
起因是为了尽量满足指标进展工夫,逻辑上的 Young 区会进行动静调整。如果设置了大小,则会笼罩掉并且会禁用掉对进展工夫的管制
均匀响应工夫设置
应用利用的均匀响应工夫作为参考来设置 MaxGCPauseMillis,JVM 会尽量去满足该条件,可能是 90% 的申请或者更多的响应工夫在这之内,然而并不代表是所有的申请都能满足,均匀响应工夫设置过小会导致频繁 GC
调优办法与思路
如何剖析零碎 JVM GC 运行状况及正当优化?
GC 优化的外围思路在于:尽可能让对象在新生代中调配和回收,尽量避免过多对象进入老年代,导致对老年代频繁进行垃圾回收,同时给零碎足够的内存缩小新生代垃圾回收次数,进行系统分析和优化也是围绕着这个思路开展
1 剖析零碎的运行状况

零碎每秒申请数、每个申请创立多少对象,占用多少内存
Young GC 触发频率、对象进入老年代的速率
老年代占用内存、Full GC 触发频率、Full GC 触发的起因、长时间 Full GC 的起因

常用工具如下:

jstat
jvm 自带命令行工具,可用于统计内存调配速率、GC 次数,GC 耗时,常用命令格局

jstat -gc <pid> < 统计间隔时间 > < 统计次数 >
复制代码输入返回值代表含意如下:

例如:jstat -gc 32683 1000 10,统计 pid=32683 的过程,每秒统计 1 次,统计 10 次

jmap
jvm 自带命令行工具,可用于理解零碎运行时的对象散布,常用命令格局如下

// 命令行输入类名、类数量数量,类占用内存大小,
// 依照类占用内存大小降序排列
jmap -histo <pid>

// 生成堆内存转储快照,在当前目录下导出 dump.hrpof 的二进制文件,
// 能够用 eclipse 的 MAT 图形化工具剖析
jmap -dump:live,format=b,file=dump.hprof <pid>
复制代码
jinfo
命令格局

jinfo <pid>
复制代码用来查看正在运行的 Java 应用程序的扩大参数,包含 Java System 属性和 JVM 命令行参数
其余 GC 工具

监控告警零碎:Zabbix、Prometheus、Open-Falcon
jdk 主动实时内存监控工具:VisualVM
堆外内存监控:Java VisualVM 装置 Buffer Pools 插件、google perf 工具、Java NMT(Native Memory Tracking)工具
GC 日志剖析:GCViewer、gceasy
GC 参数检查和优化:xxfox.perfma.com/

2 GC 优化案例

数据分析平台零碎频繁 Full GC

平台次要对用户在 APP 中行为进行定时剖析统计,并反对报表导出,应用 CMS GC 算法。数据分析师在应用中发现零碎页面关上常常卡顿,通过 jstat 命令发现零碎每次 Young GC 后大概有 10% 的存活对象进入老年代。
原来是因为 Survivor 区空间设置过小,每次 Young GC 后存活对象在 Survivor 区域放不下,提前进入老年代,通过调大 Survivor 区,使得 Survivor 区能够包容 Young GC 后存活对象,对象在 Survivor 区经验屡次 Young GC 达到年龄阈值才进入老年代,调整之后每次 Young GC 后进入老年代的存活对象稳固运行时仅几百 Kb,Full GC 频率大大降低

业务对接网关 OOM

网关次要生产 Kafka 数据,进行数据处理计算而后转发到另外的 Kafka 队列,零碎运行几个小时候呈现 OOM,重启零碎几个小时之后又 OOM,通过 jmap 导出堆内存,在 eclipse MAT 工具剖析才找出起因:代码中将某个业务 Kafka 的 topic 数据进行日志异步打印,该业务数据量较大,大量对象沉积在内存中期待被打印,导致 OOM

账号权限管理系统频繁长时间 Full GC

零碎对外提供各种账号鉴权服务,应用时发现零碎常常服务不可用,通过 Zabbix 的监控平台监控发现零碎频繁产生长时间 Full GC,且触发时老年代的堆内存通常并没有占满,发现原来是业务代码中调用了 System.gc()
总结
GC 问题能够说没有捷径,排查线上的性能问题自身就并不简略,除了将本文介绍到的原理和工具死记硬背,还须要咱们一直去积攒教训,真正做到性能最优

正文完
 0