JVM调优实战——解决内存占用高问题
JVM启动后一段时间内内存占用飙升
如下,是我们一服务重启后运行快2天的内存占用情况,可以发现内存一直从45%涨到了62%,8G的容器,上涨内存大小为1.36G!

但我们这个服务其实没有内存泄露问题,因为JVM为堆申请的内存是虚拟内存,如4.8G,但在启动后JVM一开始可能实际只使用了3G内存,导致Linux实际只分配了3G。
然后在gc时,由于会复制存活对象到堆的空闲部分,如果正好复制到了以前未使用过的区域,就又会触发Linux进行内存分配,故一段时间内内存占用会越来越多,直到堆的所有区域都被touch到。

而通过添加JVM参数-XX:+AlwaysPreTouch,可以让JVM为堆申请虚拟内存后,立即把堆全部touch一遍,使得堆区域全都被分配物理内存,而由于Java进程主要活动在堆内,故后续内存就不会有很大变化了,我们另一服务添加了此参数,内存表现如下:

可以看到,内存上涨幅度不到2%,无此参数可以提高内存利用度,加此参数则会使应用运行得更稳定。
如我们之前一服务一周内会有1到2次GC耗时超过2s,当我添加此参数后,再未出现过此情况。这是因为当无此参数时,若GC访问到了未读写区域,会触发Linux分配内存,大多数情况下此过程很快,但有极少数情况下会较慢,在GC日志中则表现为sys耗时较高。

CPU占用过高定位分析
Linux 方式
利用top命令查看当前java进程号
1 | top |

根据进程号利用top -Hp命令查看占用CPU最高的线程pid,并将其转换为16进制
1 | top -Hp <pid> |
如果java服务有多个可以通过 ps -aux|grep java 命令来找到对应服务的进程号(PID)
1 | ps -aux|grep java |
查询当前java进程所有线程堆栈信息,输出至1.txt
1 | jstack <pid> > 1.txt |
可以使用vim命令查询 log 文件
1 | vim 1.txt |
直接使用 /
1 | /<hex_pid> |
也可查找对应线程堆栈信息
1 | jstack <pid> | grep -A 10 <hex_pid> |
Arthas 方式
进入控制台,我们直接键入thread命令可以看到正在运行的线程,且排第一的是CPU占用率最高的线程
1 | thread |
由控制台可知,它的pid,所以我们直接键入:
1 | thread <pid> |
通过控制台打印,可以直接定位到了问题代码段,在TestController的42行。

知道了代码的位置之后,我们根据类的包路径com.example.arthasExample.TestController直接通过Arthas反编译查看源码,命令如下:
1 | jad --source-only com.example.arthasExample.TestController |
最终我们定位到了问题代码,即时修复即可。
内存100%排查手段
top命令查看cpu和内存情况
1 | top |
我们 JVM 设置的最大堆内存是800M,虚拟机内存是3G,这里看到内存已经爆了,随之带来的则是频繁 FULL GC导致的CPU 100%

查看 JVM 内存使用情况
1 | jmap -heap PID |
可以看到老年代的使用率已经100%了

查看GC的情况
1 | jstat -gc <pid> |
这里连续的几次查看,可以看到FULL GC的次数在疯狂的增长,而且FULL GC的平均时间也在增长,这就是CPU 100%的原因,因为FULL GC会导致stop-the-world的发生

使用jmap命令生成分析所需要用的dump文件
1 | jmap -dump:format=b,file=./jmap_dump.hprof <pid> |
查看占用最大的类

还可以换一种方式,就是如果是外部请求导致的,可以直接查看线程信息



调优手段
1.尽量避免产生大的对象
2.调大堆内存,减少 FULLGC(对于简单场景,这是最简单直接有效的方法)
3.调大年轻代,减少 MinorGC 频率,但同时也需要考虑 MinorGC 时长问题,内存调大,意味着回收不掉的存活对象会更多,此时虽然频率低了,但是持续时长会增加,所以 MinorGC 时间更多的取决于GC后存活的对象的数量( MinorGC Time = MinorGC的时间间隔+复制存活对象的时间)
4.选择合适的GC回收器,主要有两种类型:一种是响应速度快(GMS、G1),一种是吞吐量高(Parallel Scavenge)
5.设置Eden、Survivor的比例以及设置各种阈值参数(三思而后行,可能带来负提升)
JVM内存相关配置参数
-XX:MaxTenuringThreshold:对象年龄计数器 升代的年龄阈值
-XX:PetenureSizeThreshold:直接分配到老年代的对象大小阈值
-XX:+UseAdaptiveSizePolicy:是否动态调整JVM各区域大小以及进入老年代的年龄(JDK8默认开启,建议不要随便关闭,除非你对JVM内存有了非常明确的规划)
-XX:MaxRAMPercentage : 以容器的大小为准,按比例分配内存给JVM
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/ #{path}: 打印GC LOG文件到#{path}路径
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/#{path} :服务第一次内存溢出的时候打印Dump文件到#{path}路径
-Xms:堆初始大小
-Xmx:堆最大值
-Xmn:年轻代大小
-XX:SurvivorRatio :设置Eden、Survivor的比例
常用内存异常
- java heap space:堆内存溢出,FGC后依旧内存不够分配,会引起频繁FGC
- PermGen space:方法区溢出
- StackOverflowError: 栈溢出,每个请求的线程都有一个分配的栈大小,一般是死循环或者深度递归导致
- GC overhead limit exceeded:超出GC开销限制,意思是GC占用大量时间释放了很小的空间,一种内存警告,多半要发生堆内存溢出
长连接Netty服务内存泄漏
为了本地复现Netty泄漏,定位详细的内存泄漏代码,我们需要做这几步:
1、配置足够小的本地JVM内存,以便快速模拟堆外内存泄漏。
如图,我们设置PermSize=30M, MaxPermSize=43M

2、模拟足够多的长连接请求,我们使用Postman定时批量发请求,以达到服务的堆外内存泄漏。
启动项目,通过JProfiler JVM监控工具,我们观察到内存缓慢的增长,最终触发了本地Netty的堆外内存泄漏,本地复现成功:

3、开启Netty的高级内存泄漏检测级别,JVM参数如下:
-Dio.netty.leakDetectionLevel=advanced

再启动项目,模拟请求,达到本地应用JVM内存泄漏,Netty输出如下具体日志信息,可以看到,具体的日志信息比之前的信息更加完善:
1 | 2020-09-24 20:11:59.078 [nioEventLoopGroup-3-1] INFO io.netty.handler.logging.LoggingHandler [101] - [id: 0x2a5e5026, L:/0:0:0:0:0:0:0:0:8883] READ: [id: 0x926e140c, L:/127.0.0.1:8883 - R:/127.0.0.1:58920] |
开启高级的泄漏检测级别后,通过上面异常日志,我们可以看到内存泄漏的具体地方:com.jd.jr.keeplive.front.service.nettyServer.handler.LongRotationServerHandler.getClientMassageInfo(LongRotationServerHandler.java:169)

如何回收泄漏的ByteBuf
其实Netty官方也针对这个问题做了专门的讨论,一般的经验法则是,最后访问引用计数对象的一方负责销毁该引用计数对象,具体来说:
- 如果一个[发送]组件将一个引用计数的对象传递给另一个[接收]组件,则发送组件通常不需要销毁它,而是由接收组件进行销毁。
- 如果一个组件使用了一个引用计数的对象,并且知道没有其他对象将再访问它(即,不会将引用传递给另一个组件),则该组件应该销毁它。
总结起来主要三个方式:
方式一:
手动释放,哪里使用了,使用完就手动释放。
方式二:
升级ChannelHandler为SimpleChannelHandler,在SimpleChannelHandler中,Netty对收到的所有消息都调用了ReferenceCountUtil.release(msg)。
方式三:
如果处理过程中不确定ByteBuf是否应该被释放,那交给Netty的ReferenceCountUtil.release(msg)来释放,这个方法会判断上下文是否可以释放。
考虑到长连接前置应用使用的是ChannelHandler,如果升级SimpleChannelHandler对现有API接口变动比较大,同时如果手动释放,不确定是否应该释放风险也大,因此使用方式三,如下:
****
问题修复后,线上服务正常,内存使用率也没有再出现因泄漏而增长,从线上我们增加的日志中看出,FullHttpRequest中ByteBuf内存释放成功。 从此长连接前置内存泄漏的问题彻底解决。

参考资料
美团的这篇技术文章共 2w+ 字,详细介绍了 GC 基础,总结了 CMS GC 的一些常见问题分析与解决办法,出现线上问题时可以参考下思路:https://tech.meituan.com/2020/11/12/java-9-cms-gc.html
阿里也有一篇关于JVM内存问题排查的3W字长文,可以结合一起参考:https://mp.weixin.qq.com/s/zshcVuQreAB8YHwjBL0EmA




