一、问题描述:

线上混合云连续两天出现了OOM异常,导致应用程序运行异常挂掉,服务器上的Tomcat日志如下图:

img

关键的日志是:java.lang.OutOfMemoryError——GC overhead limit exceeded,这行日志揭示了OOM发生的原因:当前已经没有可用内存,经过多次GC之后仍然没能有效释放内存。JVM抛出这个错误的原因是,当经过几次GC之后,只有少于2%的内存被释放,也就是很少的空闲内存,可能会再次被快速填充,这样就会触发再一次的GC。这就形成恶性循环,CPU大部分的时间在做GC操作,没有时间做具体的业务操作,可能几毫秒的任务需要几分钟都无法完成,整个应用程序就会被卡死。解决这种问题的方案有:(1)、添加启动参数:-XX:-UseGCOverheadLimit 禁用JVM的这种机制,但是这样做并不能解决内存不足的问题,只是延后了错误发生的时间,最终会抛出java.lang.OutOfMemoryError: Java heap space错误。(2)、加大堆内存,如果系统存在内存泄漏问题,最终内存也会耗尽。(3)、治标治本的方案,找出具体哪些对象占用了大量内存,排查是否存在内存泄漏的情况。

二、业务场景描述

混合云分两个模块:bill-data-selsyn-server和bill-data-selsyn-client,server端消费公有云账单MQ消息,获取账单数据BillInfo组装成RemotingBillCommand对象,通过http请求发送消息给client端,client端接收server端发来的RemotingBillCommand消息,放入线程池异步处理,处理完成调用server端的回调接口反馈处理成功失败结果,整个过程并不复杂。

三、排查过程

本地模拟线上发送消息的过程:在本地启动bill-data-selsyn-client,设置堆内存为1G,bill-data-selsyn-server用while循环每隔10ms向bill-data-selsyn-client发送一条RemotingBillCommand消息。

1、通过jps获取bill-data-selsyn-client的pid为12884
img

2、jconsole连接pid为12884的java进程观察堆内存的变化情况情况

堆内存整体变化情况如下,可以看出占用的堆内存在不断增加:
img

Eden区内存变化情况如下,有频繁的Young GC发生:
img

Old区内存变化情况如下,可以看出老年代内存占用呈阶梯上升趋势,有年轻代的对象不断晋升到老年代:
img

3、通过命令 jmap -histo 12884观察堆中对象数量、占用空间的变化情况
img

运行几分钟后再次运行jmap命令结果如下,发现 com.gooagoo.bill.data.selsy.bean.RemotingBillCommand对象数目明显增多:
img

4、在堆内存即将用尽的时候通过命令jstat -gc 12884 1000 30连续打印30秒GC情况如下,明显此时发生了频繁的Full GC,达到每秒数次的频率:
img

5、继续运行程序,最终控制台输出了OOM的异常:
img

6、由上述通过各种工具的分析可以得出结论,发生OOM的原因是大量com.gooagoo.bill.data.selsy.bean.RemotingBillCommand对象堆积在内存中,查看代码可知该对象是client端接收到server端发来的待处理的消息,所以被处理完成之前不能被垃圾回收,最终频繁发生Full GC之后仍然回收不到足够内存导致java.lang.OutOfMemoryError: GC overhead limit exceeded错误的发生,知道是由于RemotingBillCommand对象的堆积造成内存溢出之后于是查看代码分析原因:(1)、处理RemotingBillCommand消息的过程中有不少网络IO和磁盘IO的发生,特别是出现了从网络下载图片写入磁盘,后面再从磁盘读取下载的图片上传的逻辑,这过程要发生两次耗时的磁盘IO操作;(2)、创建线程池使用的是Executors.newFixedThreadPool创建的线程池,这种方式创建的线程池队列无限长,所有未处理的消息都会堆积在内存中;(3)、异步处理RemotingBillCommand的线程池的线程数量为Runtime.getRuntime().availableProcessors() * 10,然而处理RemotingBillCommand消息的过程属于IO密集型的操作,线程数偏少。因此可以大致得出结论,RemotingBillCommand对象堆积的原因是由于处理消息过程是耗时操作,处理消息速度赶不上接收消息的速度,处理不完的消息就堆积在了内存中,最终造成OOM异常。针对这些原因修改代码逻辑:(1)、将从网络下载图片到磁盘再读取上传的逻辑改成,把下载的图片内容转成byte数组作为变量存在内存中,然后上传图片的时候直接用内存中保存的byte数组上传,这样去掉了两次磁盘IO操作。(2)、手动创建线程池,并将线程池线程数量加大,改成Runtime.getRuntime().availableProcessors() * 40。通过这种方式修改后的代码上线后就没有再出现OOM。

四、总结与启示

1、创建线程池要用手动创建方式,通过Executors的静态方法创建的线程池都有发生OOM的可能。
2、程序中尽量减少各种耗时的IO操作,避免影响程序性能。
3、要考虑限流方面的问题,考虑系统的处理能力,避免请求堆积对系统造成压力。

上一篇