文章目录
- 从面试角度来谈谈你了解的JVM调优
- GC调优的步骤
- 1.确定目标:
- 2.优化参数
- 3.验收优化结果
- GC优化案例
- 一、Major GC和Minor GC频繁
- 优化Minor GC频繁问题:
- 1.可以适当增大新生代的内存
- 二、请求高峰期发生GC,导致服务可用性降低
- 优化标记停顿时间长的问题:
- 转换成如何减少新生代对象数量
- 三、发生Stop-The-World的GC
- 永久代空间不足导致容量拓展的问题
- Java中9中常见的CMS GC问题分析与解决
- 2.GC基础
- 2.3 分配对象
- 2.4收集对象
- 2.4.1 收集算法
- 2.5 收集器
- 2.5.1 分代收集器
- 2.5.2 分区收集
- 2.5.3 常用的收集器
- 3.1 判断GC有没有问题
- 3.1.1评价标准
- 3.1.2 读懂对应的GC Cause
- 3.3.2 GC问题分类
- 常用的JVM参数设置
从面试角度来谈谈你了解的JVM调优
GC调优的步骤
GC优化一般步骤可以概括为:确定目标、优化参数、验收结果
1.确定目标:
譬如:- 高可用,可用性达到多少个;- 低延迟 请求必须多少毫秒内完成响应 高吞吐,每秒完成多少事务
2.优化参数
以下参考美团技术团队的做法,注重高可用和低延迟的话,那么就需要有个量化指标,比如时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T。可见无论降低单次GC或者降低GC次数都可以有效减少GC响应时间的影响。
具体优化:
通过收集GC日志信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等
3.验收优化结果
将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。
接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。
GC优化案例
一、Major GC和Minor GC频繁
面试假如给了我们一个场景:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms
- 首先我们可以看到Minor GC是很频繁的,因此首先从Minor GC开始。
优化Minor GC频繁问题:
1.可以适当增大新生代的内存
通常情况下,由于新生代空间较小,Eden区很快被填满,就会导致频繁Minor GC,因此可以通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半;同样**Minor GC更多取决于GC后存活对象的数量,而非Eden区的大小。**因此如果我们新生代短期对象很多,扩容新生代,单次Minor GC时间不会显著增加。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wG0FRgRp-1678358886599)(null)]
- 如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。
二、请求高峰期发生GC,导致服务可用性降低
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。
优化标记停顿时间长的问题:
解决问题前,先回顾一下CMS的四个主要阶段,以及各个阶段的工作内容。下图展示了CMS各个阶段可以标记的对象,用不同颜色区分。
- 1.Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。
- 2.Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
- 3.Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。**因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,**如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
- 4.并发清理,进行并发的垃圾清理
Remark阶段主要是通过扫描堆来判断对象是否存活。那么准确判断对象是否存活,需要扫描哪些对象?
- 注意跨代引用:新生代持有对老年代的引用
因它的存在,Remark阶段必须扫描整个堆来判断对象是否存活,包括图中灰色的不可达对象
新生代GC和老年代的GC是各自分开独立进行的,只有Minor GC时才会使用根搜索算法,标记新生代对象是否可达,也就是说虽然一些对象已经不可达,但在Minor GC发生前不会被标记为不可达,CMS也无法辨认哪些对象存活,只能全堆扫描(新生代+老年代)。由此可见堆中对象的数目影响了Remark阶段耗时。 分析GC日志可以得出同样的规律,Remark耗时>500ms时,新生代使用率都在75%以上。这样降低Remark阶段耗时问题转换成如何减少新生代对象数量
转换成如何减少新生代对象数量
生代中对象的特点是“朝生夕灭”,这样**如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),**该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断
除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark;对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC
优化了后经过增加CMSScavengeBeforeRemark
参数,单次执行时间>200ms的GC停顿消失,从监控上观察,GCtime和业务波动保持一致,不再有明显的毛刺。
小结
通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。
思考:如果老年代可能持有新生代对象引用,所以Minor GC时也必须扫描老年代。
JVM是如何避免Minor GC时扫描全堆的? 经过统计信息显示,老年代持有新生代对象引用的情况不足1%,根据这一特性JVM引入了卡表(card table)来实现这一目的
卡表具体策略就是将老年代的空间分成大小为512B的若干张card。卡表本身是单字节数组,数组中的每个元素对应着一张卡,**当发生老年代引用新生代时,虚拟机将该卡对应的卡表元素设置为适当的值。**如上图所示,卡表3被标记为脏(卡表还有另外的作用,标识并发标记阶段哪些块被修改过),之后Minor GC时通过扫描卡表就可以很快的识别哪些卡中存在老年代指向新生代的引用。这样虚拟机通过空间换时间的方式,避免了全堆扫描
三、发生Stop-The-World的GC
- 确定目标
根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性
问题分析:
首先,什么时候可能会触发STW的Full GC呢?
-
Perm空间不足;
-
CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
-
统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;
-
主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题
然后,我们来逐一分析一下: - 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。 - 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。 - 排除原因4:因为当时没有相关命令执行。 - 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的
永久代空间不足导致容量拓展的问题
- 通过把
-XX:PermSize
参数和-XX:MaxPermSize
设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。 - CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收
Java中9中常见的CMS GC问题分析与解决
2.GC基础
- TLAB: Thread Local Allocation Buffer 的简写,基于 CAS 的独享线程(Mutator Threads)可以优先将对象分配在 Eden 中的一块内存,因为是 Java 线程独享的内存区没有锁竞争,所以分配速度更快,每个 TLAB 都是一个线程独享的。
- Card Table: 中文翻译为卡表,主要是用来标记卡页的状态,每个卡表项对应一个卡页。当卡页中一个对象引用有写操作时,写屏障将会标记对象所在的卡表状态改为 dirty,卡表的本质是用来解决跨代引用的问题
2.3 分配对象
Java 中对象地址操作主要使用 Unsafe 调用了 C 的 allocate 和 free 两个方法,分配方法有两种:
- 空闲链表(free list): 通过额外的存储记录空闲的地址,将随机 IO 变为顺序 IO,但带来了额外的空间消耗。
- 碰撞指针(bump pointer): 通过一个指针作为分界点,需要分配内存时,仅需把指针往空闲的一端移动与对象大小相等的距离,分配效率较高,但使用场景有限。
2.4收集对象
- 引用计数法(Reference Counting): 对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。虽然循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。
- 可达性分析,又称引用链法(Tracing GC): 从 GC Root 开始进行对象搜索,可以被搜索到的对象即为可达对象,此时还不足以判断对象是否存活/死亡,需要经过多次标记才能更加准确地确定,整个连通图之外的对象便可以作为垃圾被回收掉。目前 Java 中主流的虚拟机均采用此算法。
GC Root的对象有哪些?
虚拟机栈中引用的对象、方法区中类静态属性的常量、运行时方法区的常量、本地方法栈中JNI引用的对象
2.4.1 收集算法
- Mark-Sweep(标记-清除): 回收过程主要分为两个阶段,第一阶段为追踪(Tracing)阶段,即从 GC Root 开始遍历对象图,并标记(Mark)所遇到的每个对象,第二阶段为清除(Sweep)阶段,即回收器检查堆中每一个对象,并将所有未被标记的对象进行回收,整个过程不会发生对象移动。整个算法在不同的实现中会使用三色抽象(Tricolour Abstraction)、位图标记(BitMap)等技术来提高算法的效率,存活对象较多时较高效。
- Mark-Compact (标记-整理): 这个算法的主要目的就是解决在非移动式回收器中都会存在的碎片化问题,也分为两个阶段,第一阶段与 Mark-Sweep 类似,第二阶段则会对存活对象按照整理顺序(Compaction Order)进行整理。主要实现有双指针(Two-Finger)回收算法、滑动回收(Lisp2)算法和引线整理(Threaded Compaction)算法等。
- Copying(复制): 将空间分为两个大小相同的 From 和 To 两个半区,同一时间只会使用其中一个,每次进行回收时将一个半区的存活对象通过复制的方式转移到另一个半区。有递归(Robert R. Fenichel 和 Jerome C. Yochelson提出)和迭代(Cheney 提出)算法,以及解决了前两者递归栈、缓存行等问题的近似优先搜索算法。复制算法可以通过碰撞指针的方式进行快速地分配内存,但是也存在着空间利用率不高的缺点,另外就是存活对象比较大时复制的成本比较高。
2.5 收集器
2.5.1 分代收集器
- ParNew: 一款多线程的收集器,采用复制算法,主要工作在 Young 区,可以通过
-XX:ParallelGCThreads
参数来控制收集的线程数,整个过程都是 STW 的,常与 CMS 组合使用。 - CMS: 以获取最短回收停顿时间为目标,采用“标记-清除”算法,分 4 大步进行垃圾收集,其中初始标记和重新标记会 STW
2.5.2 分区收集
- G1: 一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。
- ZGC: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
- Shenandoah: 由 Red Hat 的一个团队负责开发,与 G1 类似,基于 Region 设计的垃圾收集器,但不需要 Remember Set 或者 Card Table 来记录跨 Region 引用,停顿时间和堆的大小没有任何关系。停顿时间与 ZGC 接近,下图为与 CMS 和 G1 等收集器的 benchmark。
2.5.3 常用的收集器
3.1 判断GC有没有问题
3.1.1评价标准
- 延迟(Latency): 也可以理解为最大停顿时间,即垃圾收集过程中一次 STW 的最长时间,越短越好,一定程度上可以接受频次的增大,GC 技术的主要发展方向。
- 吞吐量(Throughput): 应用系统的生命周期内,由于 GC 线程会占用 Mutator 当前可用的 CPU 时钟周期,吞吐量即为 Mutator 有效花费的时间占系统总运行时间的百分比,
目前各大互联网公司的系统基本都更追求低延时,避免一次 GC 停顿的时间过长对用户体验造成损失,衡量指标需要结合一下应用服务的 SLA,主要如下两点来判断:
3.1.2 读懂对应的GC Cause
重点需要关注的几个GC Cause:
- System.gc(): 手动触发GC操作。
- CMS: CMS GC 在执行过程中的一些动作,重点关注 CMS Initial Mark 和 CMS Final Remark 两个 STW 阶段。
- Promotion Failure: Old 区没有足够的空间分配给 Young 区晋升的对象(即使总可用内存足够大)。
- Concurrent Mode Failure: CMS GC 运行期间,Old 区预留的空间不足以分配给新的对象,此时收集器会发生退化,严重影响 GC 性能,下面的一个案例即为这种场景。
- GCLocker Initiated GC: 如果线程执行在 JNI 临界区时,刚好需要进行 GC,此时 GC Locker 将会阻止 GC 的发生,同时阻止其他线程进入 JNI 临界区,直到最后一个线程退出临界区时触发一次 GC。
3.3.2 GC问题分类
- Unexpected GC: 意外发生的 GC,实际上不需要发生,我们可以通过一些手段去避免。
- Space Shock: 空间震荡问题,参见“场景一:动态扩容引起的空间震荡”。
- Explicit GC: 显示执行 GC 问题,参见“场景二:显式 GC 的去与留”。
- Partial GC: 部分收集操作的 GC,只对某些分代/分区进行回收。
- Young GC: 分代收集里面的 Young 区收集动作,也可以叫做 Minor GC。
- ParNew: Young GC 频繁,参见“场景四:过早晋升”。
- Old GC: 分代收集里面的 Old 区收集动作,也可以叫做 Major GC,有些也会叫做 Full GC,但其实这种叫法是不规范的,在 CMS 发生 Foreground GC 时才是 Full GC,CMSScavengeBeforeRemark 参数也只是在 Remark 前触发一次Young GC。
- CMS: Old GC 频繁,参见“场景五:CMS Old GC 频繁”。
- CMS: Old GC 不频繁但单次耗时大,参见“场景六:单次 CMS Old GC 耗时长”。
- Young GC: 分代收集里面的 Young 区收集动作,也可以叫做 Minor GC。
- Full GC: 全量收集的 GC,对整个堆进行回收,STW 时间会比较长,一旦发生,影响较大,也可以叫做 Major GC,参见“场景七:内存碎片&收集器退化”。
- MetaSpace: 元空间回收引发问题,参见“场景三:MetaSpace 区 OOM”。
- Direct Memory: 直接内存(也可以称作为堆外内存)回收引发问题,参见“场景八:堆外内存 OOM”。
- JNI: 本地 Native 方法引发问题,参见“场景九:JNI 引发的 GC 问题”。
常用的JVM参数设置
参数 | 说明 |
---|---|
-Xms | 设置JVM初始内存大小。建议与-Xmx 相同,避免每次垃圾回收完成后JVM重新分配内存。 |
-Xmx | 设置JVM最大可用内存大小。为避免容器OOM,请为系统预留足够的内存大小。 |
-XX:+PrintGCDetails | 输出GC详细信息。 |
-XX:+PrintGCDateStamps | 输出GC时间戳。日期形式,例如2019-12-24T21:53:59.234+0800。 |
-Xloggc:/home/admin/nas/gc-${POD_IP}-$(date '+%s').log | GC日志文件路径。需保证Log文件所在容器路径已存在,建议您将该容器路径挂载到NAS目录或收集到SLS,以便自动创建目录以及实现日志的持久化存储。 |
-XX:+HeapDumpOnOutOfMemoryError | JVM发生OOM时,自动生成DUMP文件。 |
-XX:HeapDumpPath=/home/admin/nas/dump-${POD_IP}-$(date '+%s').hprof | DUMP文件路径。需保证DUMP文件所在容器路径已存在,建议您将该容器路径挂载到NAS目录,以便自动创建目录以及实现日志的持久化存储。 |
-Xms2048m -Xmx2048m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/home/admin/nas/gc-${POD_IP}-$(date '+%s').log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/admin/nas/dump-${POD_IP}-$(date '+%s').hprof
m -Xmx2048m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/home/admin/nas/gc- P O D I P − {POD_IP}- PODIP−(date ‘+%s’).log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/admin/nas/dump- P O D I P − {POD_IP}- PODIP−(date ‘+%s’).hprof