一、什么情况下GC会对程序产生影响
无论 Minor GC/Young GC 还是 Full GC,都会造成一定程度的程序卡顿,即Stop The World
:JVM 因为执行 GC 线程,其他工作线程被挂起。它会在任何一种 GC 算法中发生,即使采用 ParNew、CMS 或者 G1 这些更先进的垃圾回收算法,也只是在减少卡顿时间,而并不能完全消除卡顿。当stop-the-world
发生时,除 GC 所需的线程外,所有的线程都进入等待状态,直到 GC 任务完成。GC 优化很多时候就是减少 stop-the-world 的发生。
根据 GC 对程序产生影响的严重程度,从高到低包括以下四种情况:
1️⃣【Full GC 过于频繁】Full GC 通常是比较慢的,少则几百毫秒,多则几秒,正常情况 Full GC 每隔几个小时甚至几天才执行一次,对系统的影响还能接受。一旦 Full GC 频繁出现(比如几十分钟就会执行一次),这种肯定是存在问题的,它会导致工作线程频繁被停止,让系统看起来一直有卡顿现象,也会使得程序的整体性能变差。
2️⃣【Minor GC/Young GC 耗时过长】一般 Minor GC/Young GC 的总耗时在几十或者上百毫秒是比较正常的,即便会引起系统卡顿几毫秒或者几十毫秒,但这种情况几乎对用户无感知,对程序的影响可以忽略不计。如果 Minor GC/Young GC 耗时达到了 1 秒甚至几秒(都快赶上 Full GC 的耗时了),那卡顿时间就会增大,加上 Minor GC/Young GC 本身比较频繁,就会导致比较多的服务超时问题。
3️⃣【Full GC 耗时过长】Full GC 耗时增加,卡顿时间也会随之增加,尤其对于高并发服务,可能导致 Full GC 期间比较多的超时问题,可用性降低,这种也需要关注。
4️⃣【Minor GC/Young GC 过于频繁】即使 Minor GC/Young GC 不会引起服务超时,但是 Minor GC/Young GC 过于频繁也会降低服务的整体性能,对于高并发服务也是需要关注的。
其中,「Full GC 过于频繁」和「Minor GC/Young GC 耗时过长」,这两种情况属于比较典型的 GC 问题,大概率会对程序的服务质量产生影响。剩余两种情况的严重程度低一些,但是对于高并发或者高可用的程序也需要关注。
二、JVM性能调优方法和步骤
对 JVM 内存的系统级的调优策略主要是减少 GC 的频率,尤其是 Full GC,从而减少 stop-the-world 的发生。
1️⃣监控 GC 的状态
使用各种 JVM 工具,查看当前日志,分析当前 JVM 参数设置,并且分析当前堆内存快照和 GC 日志,根据实际的各区域内存划分和 GC 执行时间,分析是否进行优化。
系统崩溃前的一些现象:
- 每次垃圾回收的时间越来越长,由之前的 10ms 延长到 50ms 左右,Full GC 的时间也由之前的 0.5s 延长到 4、5s。
- Full GC 的次数越来越多,最频繁时隔不到 1 分钟就进行一次 Full GC。
- 老年代的内存越来越大并且每次 Full GC 后老年代没有内存被释放。
之后系统会无法响应新的请求,逐渐逼近 OutOfMemoryError 的临界值,这个时候就需要分析 JVM 内存快照 dump。
2️⃣生成堆的 dump 文件
通过 JMX 的 MBean 生成当前的 Heap 信息,大小为一个 3G(整个堆的大小)的 hprof 文件,如果没有启动 JMX 可以通过 Java 的 jmap 命令来生成该文件。
3️⃣分析 dump 文件
打开这个 3G 的堆信息文件,显然一般的 Window 系统没有这么大的内存,必须借助高配置的几种 Linux 工具打开该文件:
①Visual VM
②IBM HeapAnalyzer
③JDK 自带的Hprof工具
④Mat(Eclipse专门的静态内存分析工具)推荐使用
说明:文件太大,建议使用Eclipse专门的静态内存分析工具Mat打开分析。
4️⃣分析结果,判断是否需要优化
如果各项参数设置合理,系统没有超时日志出现,GC 频率不高,GC 耗时不高,那么没有必要进行 GC 优化。如果 GC 时间超过 1-3 秒,或者频繁 GC,则必须优化。
注意:如果满足下面的指标,则一般不需要进行 GC 优化:
①Minor GC 执行时间不到 50ms。
②Minor GC 执行不频繁,约 10 秒一次。
③Full GC 执行时间不到 1s。
④Full GC 执行频率不算频繁,不低于 10 分钟 1 次。
5️⃣调整 GC 类型和内存分配
如果内存分配过大或过小,或者采用的 GC 收集器比较慢,则应该优先调整这些参数,并且先找 1 台或几台机器进行 beta,然后比较优化过的机器和没有优化的机器的性能对比,并有针对性的做出最后选择。
6️⃣不断的分析和调整
通过不断的试验和试错,分析并找到最合适的参数,如果找到了最合适的参数,则将这些参数应用到所有服务器。
三、JVM调优参数参考
1️⃣针对 JVM 堆的设置,一般可以通过 -Xms -Xmx 限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值。
2️⃣新生代和老年代将根据默认的比例(1:2)分配堆内存, 可以通过调整二者之间的比率来调整二者之间的大小,也可以针对回收代。
比如新生代,通过-XX:newSize -XX:MaxNewSize
来设置其绝对大小。同样,为了防止新生代的堆收缩,通常会把-XX:newSize -XX:MaxNewSize
设置为同样大小。
-
更大的新生代必然导致更小的老年代,大的新生代会延长 Minor GC/Young GC 的周期,但会增加每次的时间;小的老年代会导致更频繁的 Full GC。
-
更小的新生代必然导致更大老年代,小的新生代会导致 Minor GC/Young GC 很频繁,但每次的时间会更短;大的老年代会减少 Full GC 的频率。
如何选择应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更大的新生代;如果存在相对较多的持久对象,老年代应该适当增大。但很多应用都没有这样明显的特性。
在抉择时应该根据以下两点:
- 本着 Full GC 尽量少的原则,让老年代尽量缓存常用对象,JVM 的默认比例(1:2)也是这个道理 。
- 观察应用一段时间,看其在峰值时老年代会占多少内存,在不影响 Full GC 的前提下,根据实际情况加大新生代,比如可以把比例控制在(1:1)。但应该给年老代至少预留 1/3 的增长空间。
4️⃣在配置较好的机器上(比如多核、大内存),可以为老年代选择并行收集算法:-XX:+UseParallelOldGC
。
5️⃣线程堆栈的设置:每个线程默认会开启 1M 的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般 256K 就够用。理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
四、排查Full GC问题的实践指南
1️⃣清楚从程序角度,有哪些原因导致 Full GC
- 大对象:系统一次性加载了过多数据到内存中(比如 SQL 查询未做分页),导致大对象进入了老年代。
- 内存泄漏:频繁创建了大量对象,但是无法被回收(比如 IO 对象使用完后未调用 close 方法释放资源),先引发 Full GC,最后导致 OOM)
- 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发 Full GC。
- 程序 BUG 导致动态生成了很多新类,使得 Metaspace 不断被占用,先引发 Full GC,最后导致 OOM。
- 代码中显式调用了 gc 方法,包括自己的代码甚至框架中的代码。
- JVM 参数设置问题:包括总内存大小、新生代和老年代的大小、Eden 区和S区的大小、元空间大小、垃圾回收算法等等。
2️⃣清楚排查问题时能使用哪些工具
-
公司的监控系统:大部分公司都会有,可全方位监控 JVM 的各项指标。
-
JDK 的自带工具,包括 jmap、jstat 等常用命令:
- 查看堆内存各区域的使用率以及GC情况
jstat -gcutil -h20 pid 1000
- 查看堆内存中的存活对象,并按空间排序
jmap -histo pid | head -n20
- dump堆内存文件
jmap -dump:format=b,file=heap pid
- 可视化的堆内存分析工具:JVisualVM、MAT等
3️⃣排查指南
- 查看监控,以了解出现问题的时间点以及当前 Full GC 的频率(可对比正常情况看频率是否正常)。
- 了解该时间点之前有没有程序上线、基础组件升级等情况。
- 了解 JVM 的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用了哪些垃圾收集器,然后分析 JVM 参数设置是否合理。
- 再对可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用 gc 方法比较容易排查。
- 针对大对象或者长生命周期对象导致的 Full GC,可通过
jmap -histo
命令并结合 dump 堆内存文件作进一步分析,需要先定位到可疑对象。 - 通过可疑对象定位到具体代码再次分析,这时候要结合 GC 原理和 JVM 参数设置,弄清楚可疑对象是否满足了进入到老年代的条件才能下结论。