一波三折!记一次非堆内存泄漏(CXF+Jackson)的排查

起因

表面现象:客户生产环境,运行一段时间(10~20天)后,无法连接kafka服务,这个现象反复出现。

通过pinpoint监控查看故障前后的jvm状态,意外发现一个以前从未留意过的问题:那就是非堆内存满了。
一波三折!记一次非堆内存泄漏(CXF+Jackson)的排查_第1张图片

从上图可以看出来,非堆内存满了之后,系统进行了频繁的FullGC,但是内存并没有得到回收。
借助pinpoint,我们往前回溯从上次jvm启动后,非堆内存的变化,发现:

  • 9月2日,重启后, 非堆内存占用:300M
  • 9月9日 1.4G
  • 9月16日 2.1G
  • 9月23日 3G
  • 9月29日 4G

一般情况下,对于内存,我们会比较关注 堆内存,一般的内存泄漏,也都发生在堆内存中。非堆内存一般出问题的很少,所以我们关注也比较少。

初步怀疑

印象中,非堆内存(或者metaspace)存储的内容包括:class对象、字符串常量池、java栈内存、本地native库分配的内存、DirectByteBuffer分配的内存;
(上面描述可能不准确)

由于系统中很少使用DirectByteBuffer,所以首先怀疑嵌入jvm进程的本地native库rocksdb,作为小文件读写缓存,rocksdb嫌疑最大。
于是我们写了一个小程序对rocksdb的内存占用进行分析,试验场景:10G数据写入rocksdb和随机读写,非堆内存一直稳定在 300M,并没有增长。于是排除了rocksdb的嫌疑。
一波三折!记一次非堆内存泄漏(CXF+Jackson)的排查_第2张图片

发现问题

在排除rocksdb的嫌疑后,再回想到最初查看系统日志时的内存溢出提示:CallWebService:java.lang.OutOfMemoryError: Compressed class space,网上搜索一番,找到一篇知乎小短文:
JVM调优中,压缩类空间(Compressed Class space)如何理解
看到下面说明:

一般来说,平均一个 Klass 大小可以当成 1K 来算,默认的 1G 大小可以存储 100 万的 Klass。
如果遇到了 `java.lang.OutOfMemoryError: Compressed class space`,就是类太多了,需要结合具体情况去选择 JVM 调优还是 bug 排查。

由于业务系统正常启动情况下 class 大约是 3万个,这个溢出说明系统的class 接近 100万了?

系统出现问题时,由于各种错误较多,所以居然忽略了这个重要的错误信息,这也算是走了一段弯路。

结合出错位置:CallWebService,由于系统中使用了CXF来动态调用webservice,动态调用webservice的过程包含了:java代码生成、class编译和加载的过程,
难道是这个过程存在class泄漏吗?

立即开始行动,找到系统使用的CXF版本:2.7.3,编写一个小程序来验证:

public static void main(String[] args) throws Exception {
    String wsdlUrl = "http://10.1.28.143:8094/services/test?wsdl";
    String method = "AAAA";
    Object[] params = { "1" };

    for (int i = 0; i < 1000; i++) {
        Object[] result = invokeWebService(wsdlUrl, method, params);
        System.out.println(Arrays.asList(result));
    }

    new CountDownLatch(1).await();
}

public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
    Client client = null;
    try {
        DynamicClientFactory factory = DynamicClientFactory.newInstance();
        client = factory.createClient(wsdlUrl);
        return client.invoke(method, params);
    }
    finally {
        if (client != null) {
            client.destroy();
        }
    }
}

使用jconsole观察调用过程,果然class数量在不断增长,FullGC也不能回收。

第一次解决

就CXF2.7.3动态调用webservice可能存在class泄漏问题,在网上检索一番,好像没看到有相关的话题。
去maven中心仓库search.maven.org查找CXF的最新版本,如下:


    org.apache.cxf
    cxf-bundle-compatible
    3.5.3
    bundle

换上新版本再次试验,结果调用正常了。区别是 3.5.3版本Client对象提供一个close方法(2.7.3没有close方法)

public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
    Client client = null;
    try {
        DynamicClientFactory factory = DynamicClientFactory.newInstance();
        client = factory.createClient(wsdlUrl);
        return client.invoke(method, params);
    }
    finally {
        if (client != null) {
            // client.destroy(); 2.7.3版本使用destroy方法
            client.close(); // 3.5.3版本使用close方法
        }
    }
}

查看 CXF3.5.3版本close方法的内容:

static class DynamicClientImpl extends ClientImpl implements AutoCloseable {
    final ClassLoader cl;
    final ClassLoader orig;
    DynamicClientImpl(Bus bus, Service svc, QName port,
                        EndpointImplFactory endpointImplFactory,
                        ClassLoader l) {
        super(bus, svc, port, endpointImplFactory);
        cl = l;
        orig = Thread.currentThread().getContextClassLoader(); //保存原始的classloader
    }
    @Override
    public void close() throws Exception {
        destroy();
        if (Thread.currentThread().getContextClassLoader() == cl) {
            Thread.currentThread().setContextClassLoader(orig); //还原原始的classloader
        }
    }
}

原来,在创建DynamicClientImpl实例时保存了当前的上下文classloader,同时在close()时,对上下文classloader进行了还原。
我们再一步跟踪下org.apache.cxf.endpoint.dynamic.DynamicClientFactory.createClient()的逻辑:

public Client createClient(String wsdlUrl, QName service, ClassLoader classLoader, QName port,
    List bindingFiles) {

    //为了演示方便,下面只摘抄了关键代码

    //0、实例化clientimpl对象 
    ClientImpl client = new ClientImpl(bus, svc, port, getEndpointImplFactory());

    //1、根据wsdl生成java代码
    JCodeModel codeModel = intermediateModel.generateCode(null, elForRun);
    File src = new File(tmpdir, stem + "-src");
    Object writer = JAXBUtils.createFileCodeWriter(src);
    codeModel.build(writer);

    //2、编译java代码
    File classes = new File(tmpdir, stem + "-classes");
    setupClasspath(classPath, classLoader);
    List srcFiles = FileUtils.getFilesRecurse(src, ".+\\.java$");
    compileJavaSrc(classPath.toString(), srcFiles, classes.toString()));

    //3、创建classloader
    URL[] urls = new URL[] { classes.toURI().toURL() };
    ClassLoader cl = ClassLoaderUtils.getURLClassLoader(urls, classLoader);

    //4、加载class
    JAXBContext context = JAXBContext.newInstance(packageList, cl, contextProperties);
    JAXBDataBinding databinding = new JAXBDataBinding();
    databinding.setContext(context);
    svc.setDataBinding(databinding);

    //5、将新的classloader设置到当前线程上下文中,这一步的目的,是在后续使用invoke方法调用webservice时,能从当前线程上下文classloader中找到webservice动态生成的类
    ClassLoaderUtils.setThreadContextClassloader(cl);

    //6、TypeClass初始化 (这一步含义还不清楚)
    ServiceInfo svcfo = client.getEndpoint().getEndpointInfo().getService();
    TypeClassInitializer visitor = new TypeClassInitializer(svcfo, intermediateModel, allowWrapperOps());
    visitor.walk();
    return client;
}

至此,class泄漏的原因应该比较清楚了:

原因是 CXF2.7.3在client.destroy()后, 缺少上下文ClassLoader的还原,导致当前的ClassLoader变成了一个链,
每动态调用一次,ClassLoader链就变长一次,导致所有加载的class都无法卸载。

由于升级CXF涉及工作量较大,我们只需在CXF2.7.3之上,在调用client前后加上一段小小的逻辑,来手工还原classloader就行了。改造如下:

public static Object[] invokeWebService(String wsdlUrl, String method, Object... params) throws Exception {
    Client client = null;
    ClassLoader orig = Thread.currentThread().getContextClassLoader();
    try {
        DynamicClientFactory factory = DynamicClientFactory.newInstance();
        client = factory.createClient(wsdlUrl);
        return client.invoke(method, params);
    }
    finally {
        if (orig != Thread.currentThread().getContextClassLoader()) {
            Thread.currentThread().setContextClassLoader(orig);  //为2.7.3版本手工还原classloader
        }
        if (client != null) {
            client.destroy(); //2.7.3版本的用法
        }
    }
}

再次实测效果如下:
一波三折!记一次非堆内存泄漏(CXF+Jackson)的排查_第3张图片

问题至此,看上去似乎完美解决了。但殊不知,后面还有一个巨大的坑在等着我们。

文章太长了,后续接着写。(给一点小提示,看文章标题)

你可能感兴趣的:(一波三折!记一次非堆内存泄漏(CXF+Jackson)的排查)