JVM内存溢出排查指南

写在文章开头

本文蒋针对常见的几种GC情况结合日志进行分析介绍,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

JVM常见命令介绍

篇幅原因关于JVM常见指令的使用与介绍,需要了解的读者可以移步参考笔者写的这篇文章 ,JVM指令集概览:基础与应用:mp.weixin.qq.com/s/wKBAHPflL...

详解JVM的GC

定位GC情况的两种方式

获取GC日志方式大抵有两种,第一种就是设定JVM参数在程序启动时查看,具体的命令参数为:

ruby 复制代码
-XX:+PrintGCDetails # 打印GC日志
-XX:+PrintGCTimeStamps # 打印每一次触发GC时发生的时间

第二种则是在服务器上监控:使用jstat查看,如下所示,命令格式为jstat -gc pid 输出间隔时长 输出次数,例如笔者希望每隔1秒输出1次,并且打印5次,对应的指令如下:

yaml 复制代码
jstat -gc 21608 1000 5

拆解与分析JVM的GC日志

为了演示如何查看GC日志,笔者给出下面这样一段代码并结合JVM参数配置(后文会给到)展示JVM如何进行垃圾回收:

arduino 复制代码
public static void main(String[] args) {
        //分配1M内存空间
        byte[] bytes = new byte[1024 * 1024];
        //触发minor gc,剩余512k,然后将1M空间存放至新生代,堆空间大约剩下1.5M
        bytes = new byte[1024 * 1024];
        //分配至新生代约2.5M
        bytes = new byte[1024 * 1024];

        //新生代空间不足,触发full gc,新生代空间全回收,并执行CMS GC,完成后将对象存放至新生代
        byte[] byte2 = new byte[2 * 1024 * 1024];
    }

对应我们也给出如下JVM配置参数指明新生代、老年代堆空间大小为5M,并指明新生代Eden和survivor区的比例为8:1:1,同时我们也指定的新生代和老年代垃圾回收算法分别是ParNewGCCMS

ruby 复制代码
-XX:NewSize=5M -XX:MaxNewSize=5M -XX:InitialHeapSize=10M -XX:MaxHeapSize=10M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=10M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

详细解释的含义如下,读者可自行参阅:

ruby 复制代码
-XX:NewSize=5M:设置新生代的初始大小为 5MB。
-XX:MaxNewSize=5M:设置新生代的最大大小为 5MB。
-XX:InitialHeapSize=10M:设置 JVM 堆的初始大小为 10MB。
-XX:MaxHeapSize=10M:设置 JVM 堆的最大大小为 10MB。
-XX:SurvivorRatio=8:设置 Eden 区与 Survivor 区的比例为 8,即 Eden 占用 8/10 的新生代空间,两个 Survivor 各占 1/10。
-XX:PretenureSizeThreshold=10M:设置对象直接进入老年代的阈值为 10MB,超过这个大小的对象会直接分配到老年代。
-XX:+UseParNewGC:启用并行新生成收集器(Parallel New Generation Collector),用于多线程环境下的新生代垃圾回收。
-XX:+UseConcMarkSweepGC:启用并发标记清除收集器(Concurrent Mark Sweep Collector),用于多线程环境下的老年代垃圾回收。
-XX:+PrintGCDetails:打印详细的垃圾回收日志信息。
-XX:+PrintGCTimeStamps:在垃圾回收日志中添加时间戳。

此时我们就以逐行执行的方式讲解GC过程:

  1. 首先代码执行到byte[] bytes = new byte[1024 * 1024];,此时新生代空间充裕,没有任何输出。
  2. 执行第二行代码bytes = new byte[1024 * 1024];再次进程内存分配时,发现新生代空间不足出现以此minor gc

对应输出结果如下,我们大体可以看出GC原因是Allocation Failure即新生代不能分配对象,触发一次新生代GC,新生代GC前后空间由3348K变为512K,整堆空间由3348K变为1692K,最后输出了GC耗时、系统响应耗时以及应用程序暂停时间:

css 复制代码
2.938: [GC (Allocation Failure) 2.938: [ParNew: 3348K->512K(4608K), 0.0016244 secs] 3348K->1692K(9728K), 0.0016904 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

完成上述GC,将1M的数组存放至新生代,此时新生代的堆空间大约是1.5M:

然后第三行再次分配数组,新生代空间充裕,直接存入:

最后一次分配2M数组时,从日志中我们可以看到minor gc直接将上述的所有字节数组都回收了:

css 复制代码
9.689: [GC (Allocation Failure) 9.689: [ParNew: 2626K->0K(4608K), 0.0021520 secs] 3806K->2746K(9728K), 0.0021903 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

在新生代空间不足尝试到老年代分配对象时,因为堆空间且空间分配担保失败,不够直接触发FULL GC。从日志中可以看到CMS老年代GC,首先进行初始标记阶段该阶段为STW并找到所有的GC root,从日志中我们看到:

  1. 老年代使用的容量为2718K且总容量为5120K
  2. 当前堆的实际大小和总容量为4766K(9728K)标记:
sql 复制代码
2.057: [GC (CMS Initial Mark) [1 CMS-initial-mark: 2718K(5120K)] 4766K(9728K), 0.0005690 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

然后进入并发标记阶段该阶段不会STW,执行如下步骤:

  1. CMS-concurrent-mark标记 gc root可达的老年代对象
  2. CMS-concurrent-preclean该阶段会处理那些dirty card中标记的对象,dirty card即脏卡,本质上那个就是处理那些并发标记阶段引用关系发生变化的区域,通过在这个阶段尽可能的标记可以减少最终标记即remark阶段的耗时
  3. CMS-concurrent-abortable-preclean即并发可终止的预清理阶段,继续执行步骤2的任务,同样是为了减小remark阶段的工作量,默认情况下CMS要求这一步必须完成Eden剩余50%或耗时超过5s才能进入下一个阶段:
less 复制代码
2.058: [CMS-concurrent-mark-start]
2.059: [CMS-concurrent-mark: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2.059: [CMS-concurrent-preclean-start]
2.059: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
2.059: [CMS-concurrent-abortable-preclean-start]
 CMS: abort preclean due to time 7.163: [CMS-concurrent-abortable-preclean: 0.005/5.105 secs] [Times: user=0.00 sys=0.00, real=5.10 secs] 

最后就到了最终标记阶段,该阶段会STW,从日志输出可以看出新生代占用2048k,当前这个重新标记阶段Rescan 花费了0.0004620 secs,其余就是处理弱引用、卸载无用的类以及清理元数据等花费时间和耗时:

ini 复制代码
7.164: [GC (CMS Final Remark) [YG occupancy: 2048 K (4608 K)]7.164: [Rescan (parallel) , 0.0004620 secs]7.164: [weak refs processing, 0.0001727 secs]7.164: [class unloading, 0.0005772 secs]7.165: [scrub symbol table, 0.0011975 secs]7.166: [scrub string table, 0.0003404 secs][1 CMS-remark: 2718K(5120K)] 4766K(9728K), 0.0030256 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

最后就是并发的清理垃圾会重置标记,等待下一个周期的GC:

ini 复制代码
7.167: [CMS-concurrent-sweep-start]
7.168: [CMS-concurrent-sweep: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
7.168: [CMS-concurrent-reset-start]
7.168: [CMS-concurrent-reset: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

最后我们查看内存使用情况可以看到,新生代的2M就是我们最后分配的数组,在eden区,而老年代使用了1677K:

kotlin 复制代码
Heap
 par new generation   total 4608K, used 2089K [0x00000000ff600000, 0x00000000ffb00000, 0x00000000ffb00000)
  eden space 4096K,  51% used [0x00000000ff600000, 0x00000000ff80a558, 0x00000000ffa00000)
  from space 512K,   0% used [0x00000000ffa00000, 0x00000000ffa00000, 0x00000000ffa80000)
  to   space 512K,   0% used [0x00000000ffa80000, 0x00000000ffa80000, 0x00000000ffb00000)
 concurrent mark-sweep generation total 5120K, used 1677K [0x00000000ffb00000, 0x0000000100000000, 0x0000000100000000)
 Metaspace       used 3124K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 327K, capacity 386K, committed 512K, reserved 1048576K

了解JVM几种GC的区别

  1. Minor GC:发生在年轻代的空间回收,包含edensurvivor,也叫做Young GC
  2. Major GC:在老年代堆区进行空间回收。
  3. Full GC:清理所有堆区的内存空间的垃圾内存,包括年轻代和老年代。

频繁的minor gc问题与解决思路

问题复现

我们尝试编写一个程序,设置该程序的堆内存新生代为5M,按照8:1:1的比例分配,这也就意为着Eden区内存大小为4M,然后S区分别是512K,这也就意味着在待分配对象加Eden区堆空间超过4M就会触发minor gc

基于上述说法,我们给出下面这段代码:

arduino 复制代码
public static void main(String[] args) throws Exception {
        while (true) {
            //分配3M数组
            byte[] bytes = new byte[1024 * 1024];
            bytes = new byte[1024 * 1024];
            bytes = new byte[1024 * 1024];

            //创建2M的新对象触发GC
            byte[] byte2 = new byte[2 * 1024 * 1024];

            Thread.sleep(1000);
        }

    }

为了演示年轻代的回收行为,我们需要在对这个应用程序的年轻代堆内存改为5M,且Eden区和S区的比例为8:1:1,同时也打印GC日志信息:

ruby 复制代码
-XX:NewSize=5M -XX:MaxNewSize=5M -XX:InitialHeapSize=10M -XX:MaxHeapSize=10M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=10M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

输出结果如下,GC日志显示每秒基本都会触发一次Minor GC,进而间接导致频繁的major gc:

问题拆解与修复

结合我们的配置可知,我们频繁分配对象导致新生代进行频繁的GC,又因为S区大小无法容纳存活的对象,进而使得这些对象提前进入老年代,导致major GC也随之频繁,所以解决的办法也比较简单,按照等比例调整大堆空间,即将新生代堆空间调整至10M,保证S区各有2M空间以容纳新生代存活的对象:

ruby 复制代码
-XX:NewSize=10M -XX:MaxNewSize=10M -XX:InitialHeapSize=100M -XX:MaxHeapSize=100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=10M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

可以看到经过调整之后,基本上Minor gc就能解决问题:

将年轻代空间调大,是否会更加耗时?

答案是不会的,原因有以下2点:

  1. JVM操作本质上都是内存操作,相对而言不会太慢。
  2. 我们将一次GC的时间拆分为t1t2t1是扫描年轻代空间是否有垃圾的时间,这个时间的几乎可以忽略不计。而t2则是将eden空间存活的对象复制到survivor区的时间,这个复制操作则是t1时间的10倍。

由此可以看出,避免耗时的正确做法是合理评估新生代堆空间,减少非必要的复制操作,所以说调整新生代的空间并不会导致进一步的耗时问题。

频繁的FULL GC

复现问题

我们现在模拟一个场景,我们的应用中有一个定时任务,这个定时任务每隔1s会想另一个定时任务线程池中提交100个任务,每个任务都会针对Obj 对象进行方法调用:

java 复制代码
@Component
public class Task {
    private static Logger logger = LoggerFactory.getLogger(Task.class);


    private static final ScheduledThreadPoolExecutor executor =
            new ScheduledThreadPoolExecutor(50,
                    new ThreadPoolExecutor.DiscardOldestPolicy());

 
    private static class Obj {
        private String name = "name";
        private int age = 18;
        private String gender = "man";
        private LocalDate birthday = LocalDate.MAX;

        public void func() {
            //这个方法什么也不做
        }

  //返回count个Obj对象
        private static List<Obj> getObjList(int count) {

            List<Obj> objList = new ArrayList<>(count);

            for (int i = 0; i != count; ++i) {
                objList.add(new Obj());
            }
            return objList;
        }
    }

    @Scheduled(cron = "0/1 * *  * * ? ")   //每1秒执行一次
    public void execute() {
        logger.info("1s一次定时任务");
        //向线程池提交100个任务
        Obj.getObjList(100).forEach(i -> executor.scheduleWithFixedDelay(
                i::func, 2, 3, TimeUnit.SECONDS
        ));
    }
}

完成后我们设置下面这段JVM参数后,将其启动:

ruby 复制代码
-Xms20M -Xmx20M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCTimeStamps

不久后,控制台出现频繁的full gc,如果在生产环境,频繁的full gc导致stw会导致系统吞吐量下降:

sql 复制代码
.......
1288.133: [Full GC (Allocation Failure) 1288.133: [CMS1288.142: [CMS-concurrent-preclean: 0.012/0.012 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] 
 (concurrent mode failure): 13695K->13695K(13696K), 0.0610050 secs] 19839K->19836K(19840K), [Metaspace: 29026K->29026K(1077248K)], 0.0610521 secs] [Times: user=0.06 sys=0.00, real=0.06 secs] 
1288.258: [Full GC (Allocation Failure) 1288.258: [CMS: 13695K->13695K(13696K), 0.0612134 secs] 19839K->19836K(19840K), [Metaspace: 29026K->29026K(1077248K)], 0.0612676 secs] [Times: user=0.06 sys=0.00, real=0.06 secs] 
1288.320: [GC (CMS Initial Mark) [1 CMS-initial-mark: 13695K(13696K)] 19836K(19840K), 0.0041303 secs] [Times: user=0.03 sys=0.00, real=0.00 secs] 
......

排查思路

我们定位到程序号后,使用jstat -gc pid10000 10观察其gc情况,可以看到每隔10s,就会增加大量的full gc:

yaml 复制代码
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
640.0  640.0   0.0   640.0   5504.0   665.5    13696.0    11176.2   31488.0 28992.6 4352.0 3889.5     39    0.084  15      0.100    0.184
640.0  640.0   0.0   640.0   5504.0   1487.2   13696.0    11176.2   31488.0 28992.6 4352.0 3889.5     39    0.084  25      0.142    0.227
640.0  640.0   0.0   640.0   5504.0   1697.8   13696.0    11176.2   31488.0 28992.6 4352.0 3889.5     39    0.084  35      0.185    0.269
......

再查看jmap -heap pid查看堆区使用情况,可以看到老年代的使用率还是蛮高的:

ini 复制代码
 
Attaching to process ID 26176, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.212-b10

using parallel threads in the new generation.
using thread-local object allocation.
Concurrent Mark-Sweep GC

Heap Configuration:
   MinHeapFreeRatio         = 40
   MaxHeapFreeRatio         = 70
   MaxHeapSize              = 20971520 (20.0MB)
   NewSize                  = 6946816 (6.625MB)
   MaxNewSize               = 6946816 (6.625MB)
   OldSize                  = 14024704 (13.375MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
New Generation (Eden + 1 Survivor Space):
   capacity = 6291456 (6.0MB)
   used     = 5088288 (4.852569580078125MB)
   free     = 1203168 (1.147430419921875MB)
   80.87615966796875% used
Eden Space:
   capacity = 5636096 (5.375MB)
   used     = 5088288 (4.852569580078125MB)
   free     = 547808 (0.522430419921875MB)
   90.28036428052326% used
From Space:
   capacity = 655360 (0.625MB)
   used     = 0 (0.0MB)
   free     = 655360 (0.625MB)
   0.0% used
To Space:
   capacity = 655360 (0.625MB)
   used     = 0 (0.0MB)
   free     = 655360 (0.625MB)
   0.0% used
concurrent mark-sweep generation:
   capacity = 14024704 (13.375MB)
   used     = 13819664 (13.179458618164062MB)
   free     = 205040 (0.1955413818359375MB)
   98.53800836010514% used

12064 interned Strings occupying 1120288 bytes.

在排除内存泄漏的问题后,我们通过jmap定位进程中导致是什么对象导致老年代堆区被大量占用:

bash 复制代码
jmap -histo 7476 | head -n 20

可以看到前20名中的对象都是和定时任务相关,有一个Task$Obj对象非常抢眼,很明显就是因为它的数量过多导致的,此时我们就可以通过定位代码确定如何解决,常见方案无非是: 优化代码、增加空间两种方式,一般来说我们都会采用代码优化的方式去解决。

ruby 复制代码
$ 

 num     #instances         #bytes  class name
----------------------------------------------
   1:         50760        3654720  java.util.concurrent.ScheduledThreadPoolExecutor$ScheduledFutureTask
   2:         30799        2901552  [C
   3:         88986        2847552  java.util.concurrent.locks.AbstractQueuedSynchronizer$Node
   4:         50700        1622400  com.example.jstackTest.Task$Obj
   5:         50760        1218240  java.util.concurrent.Executors$RunnableAdapter
   6:         50700         811200  com.example.jstackTest.Task$$Lambda$587/1605553313
   7:          6391         707928  java.lang.Class
   8:         29256         702144  java.lang.String
   9:         13577         434464  java.util.concurrent.ConcurrentHashMap$Node
  10:          6363         341016  [Ljava.lang.Object;
  11:          1722         312440  [B
  12:          3414         230424  [I
  13:             4         210680  [Ljava.util.concurrent.RunnableScheduledFuture;
  14:          5223         208920  java.util.LinkedHashMap$Entry
  15:          2297         202136  java.lang.reflect.Method
  16:          2262         193760  [Ljava.util.HashMap$Node;
  17:          5668         181376  java.util.HashMap$Node

而本次问题也很明显,任务是一个个提交到定时任务线程池中,是由于定时任务队列DelayedWorkQueue不断堆积任务导致内存被打满。所以最终改成将一个批处理一次性提交到定时任务中立刻将这一批对象回收从而避免耗时任务堆积一堆对象:

typescript 复制代码
 @Scheduled(cron = "0/1 * *  * * ? ")   //每1秒执行一次
    public void execute() {
        logger.info("1s一次定时任务");
        //向线程池提交100个任务


        executor.scheduleWithFixedDelay(() -> {
                    Obj.getObjList(100).forEach(i -> i.func());
                }, 2, 3, TimeUnit.SECONDS
        );


    }

频繁FULL GC的原因和解决对策

总的来说原因可以频繁FULL GC分为3个:

  1. 用户频繁调用System.gc():这种情况需要修改代码即可,我们不该频繁调用这个方法的。
  2. 老年区空间过小:视情况适当扩大空间。
  3. 大对象过多:这种情况视情况决定是扩大老年代空间或者将大对象拆分。

一般来说,我们优先考虑调整堆内存空间,其次才是针对业务逻辑的代码处理进行更进一步的优化。

小结

本文通过几个比较经典的示例演示了JVM进行垃圾回收的过程以及如何进行JVM内存调优,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

参考

JVM学习(7)Stop-The-World:www.jianshu.com/p/d686e108d...

JVM调优------之CMS GC日志分析 :www.cnblogs.com/onmyway20xx...

从实际案例聊聊Java应用的GC优化:tech.meituan.com/2017/12/29/...

Spring Boot定时任务详解(线程池方式):blog.csdn.net/Alian_1223/...

SpringBoot使用@Scheduled注解实现定时任务:blog.csdn.net/pan_junbiao...

面渣逆袭(Java 虚拟机-JVM面试题八股文)必看👍:tobebetterjavaer.com/sidebar/san...

java8添加并查看GC日志(ParNew+CMS):segmentfault.com/a/119000002... JVM: GC过程总结(minor GC 和 Full GC):juejin.cn/post/724923...

JVM调优实战:解决CMS concurrent-abortable-preclean LongGC的问题:developer.aliyun.com/article/776...

相关推荐
编程乐学(Arfan开发工程师)4 小时前
56、原生组件注入-原生注解与Spring方式注入
java·前端·后端·spring·tensorflow·bug·lua
Elcker7 小时前
Springboot+idea热更新
spring boot·后端·intellij-idea
南玖yy8 小时前
深入理解 x86 汇编中的符号扩展指令:从 CBW 到 CDQ 的全解析
开发语言·汇编·arm开发·后端·架构·策略模式
江梦寻9 小时前
软件工程教学评价
开发语言·后端·macos·架构·github·软件工程
美好的事情能不能发生在我身上10 小时前
苍穹外卖Day11代码解析以及深入思考
java·spring boot·后端·spring·架构
不良手残10 小时前
Redisson + Lettuce 在 Spring Boot 中的最佳实践方案
java·spring boot·redis·后端
一线大码11 小时前
SpringBoot 和 MySQL 的事务隔离级别关系
spring boot·后端·mysql
罗政12 小时前
基于 SpringBoot + Vue 在线点餐系统(前后端分离)
vue.js·spring boot·后端
曼岛_12 小时前
[架构之美]深入优化Spring Boot WebFlux应用
spring boot·后端·架构