起因
表面现象:客户生产环境,运行一段时间(10~20天)后,无法连接kafka服务,这个现象反复出现。
通过pinpoint监控查看故障前后的jvm状态,意外发现一个以前从未留意过的问题:那就是非堆内存满了。
从上图可以看出来,非堆内存满了之后,系统进行了频繁的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的嫌疑。
发现问题
在排除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版本的用法
}
}
}
再次实测效果如下:
问题至此,看上去似乎完美解决了。但殊不知,后面还有一个巨大的坑在等着我们。
文章太长了,后续接着写。(给一点小提示,看文章标题)