最近我们线上有同学反馈,java 服务在接入了支持预发的 javaagent 以后会出现缓存的内存增长,去掉 agent 启动以后内存增长正常。于是分析了一下这个问题,写了这篇文章。
备注:JVM 堆内存最大 1000M
主要会涉及下面这些内容:
-
JVM native 内存分析的通用方法
-
JVM Bootstrap ClassLoader 源码分析
-
gdb 的一些调试技巧
-
bytebuddy 打破双亲委派的类加载器
-
不好好干好日志的本分,处处恶心第一名的 log4j2 是如何处理错误堆栈的
背景介绍
线上全链路预发支持不能只支持 http 接口,还得支持 dubbo rpc、rocketmq、httpclient 等。
-
http:针对 http 调用,都会添加一个流量标识的 header:x-ccloud-pre (1-预发流量 2-正式流量),可以支持okhttp、okhttp3、httpclient 4.x、Spring RestTemplate
-
dubbo:预发实例都会增加一个 dubbo.provider.group=gray 的参数,通过 group 来区分正式/预发的 provider,agent 内部实现根据流量标识来过滤 provider 的逻辑,并且通过 attachment 把流量标识往下游服务透传。
-
RocketMQ:生产者只向本环境的 MQ 投递消息,消费者只消费本环境 MQ,原理就是根据是否是预发流量,如果是预发请求则将要投递的 topic 修改为预发的 topic。
以上实现都是通过一个 javaagent 来实现的,以实现业务方零代码修改接入。
分析过程
首先确认过不是堆内存的问题,因此需要用上 NativeMemoryTracking 来分析 native 内存,于是加上 -XX:NativeMemoryTracking=detail。但是只有这一个工具还不够,还需要结合 pmap、tcmalloc、jemalloc 来分析。因为内存增长缓慢,这里开启了一个后台定期执行 pmap 的脚本,用来分析内存增长在那一块内存区域内,然后放在哪里跑一晚上。
while true
do
sleep 900
name=`date +"%Y_%m_%d_%H_%M_%S"`
echo $name
pmap -x 72 > "pmap_$name.out"
done
通过 diff 对比分析,找到了内存缓存增长的区域,随后 dump 出这一块内存,dump 的方式可以通过 gdb,也可以通过读取 /proc/$pid/maps 的方式来实现。
通过 dump 出来的内存,首先通过 strings 命令看看里面有没有认识的字符串。
很快发现里面有很多我们熟悉的类,比如: com.cvte.psd.pr.agent.rocketmq.consumer.push.RocketMqListenerOrderlyWrapperCreator$RocketListenerOrderlyWrapper,这个内部类实现了org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly接口,请记住这个接口,后面会频繁出现。
RocketListenerOrderlyWrapper 做的事情也很简单,就是对 mq 消息处理进行了代理处理。RocketListenerOrderlyWrapper 实现如下:
import org.apache.rocketmq.client.consumer.listener.MessageListenerOrderly;
public class RocketMqListenerOrderlyWrapperCreator implements PreReleaseWrapperCreator<MessageListenerOrderly> {
public static class RocketListenerOrderlyWrapper implements MessageListenerOrderly {
private MessageListenerOrderly originListener;
private PreReleaseManager preReleaseManager;
public RocketListenerOrderlyWrapper(MessageListenerOrderly originListener, PreReleaseManager preReleaseManager) {
this.originListener = originListener;
}
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
// ...
}
}
}
这个类就是用来包装 RocketMQ 的消息,来实现预发特性的。
上面 dump 出来的内容到底都是字符串,还是 class 文件的常量池的一部分呢?通过 16 进制分析工具可以进一步分析。把上面的 dump 文件导入到 010 Editor(www.sweetscape.com/010editor/ )中,搜索 java 字节码的魔数(0xCAFEBABE),可以看这个这段内存中有 2.6W 个 class 文件。
可以删掉第一个 0xCAFEBABE 前面的字节,把剩下的文件当做 class 文件解析。
为什么会有这么多类文件出现在 native 内存中呢?通过 nmt 可以进一步辅助分析。这里可以看到类加载相关的内存 malloc 有 597M 左右,虽然 malloc 不代表真实的使用量(可能 malloc 以后不写,或者用完 free),这个值这么大还是不太正常。
接下来用 arthas 注