诡异的JVM永久代溢出

弱龄寄事外,委怀在琴书。这篇文章主要讲述诡异的JVM永久代溢出相关的知识,希望能为你提供帮助。
内容简介生产上两个应用无缘无故的出现Perm区OOM,近期也没变动,用VisualVM点垃圾回收也能对Perm区回收,所以很奇怪。后来才发现,原来是别人通过instrument方法attach了一个agent到JVM进程上,扫描了所有的class对象并且没释放,导致perm区溢出。本文详细介绍perm区为何持续增长,以及通过简单示例介绍instrument如何使perm区溢出的。


问题描述很久没有变更的两个应用,生产上突然出现Perm区溢出了,使用的中间件是Weblogic 12.1.3,jdk是1.7.0.80,两个不同的应用最近都出了问题,最近的操作就是做了一次Weblogic漏洞的升级,但也是一个月之前了。
使用的jvm主要参数如下

-XX:+CMSParallelRemarkEnabled
-XX:CMSFullGCsBeforeCompaction=0
-XX:PermSize=1024m
-XX:MaxPermSize=1024m
-Xms5120m
-Xmx5120m
-XX:CMSInitiatingOccupancyFraction=65
-XX:+UseCMSInitiatingOccupancyOnly
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection
-XX:+CMSClassUnloadingEnabled
-XX:+ExplicitGCInvokesConcurrent

按理说上述配置是可以回收Perm区的,但是看GC日志发现FullGC也回收不了。奇怪的是,我在VisualVM上点一下”垃圾回收“,就回收了。

生产上点一下垃圾回收,就把Perm区回收了

初步分析使用jmap -permstat查看Perm区的东西,都是WSServiceDelegate$DelegatingLoader加载的类,而且都是dead的。

使用arthas进行跟踪是谁调用了WSServiceDelegate的类加载方法,发现是有两个WebService在调用的时候每次都new客户端实例,而每new一个,就新加载一个代理类。
我们的客户端是基于Sun的jax-ws的实现。
在 JAX-WS中,一个远程调用可以转换为一个基于XML的协议例如SOAP。在使用JAX-WS过程中,开发者不需要编写任何生成和处理SOAP消息的代码。JAX-WS的运行时实现会将这些API的调用转换成为对应的SOAP消息。
在服务器端,用户只需要通过java语言定义远程调用所需要实现的接口SEI (service endpoint interface),并提供相关的实现,通过调用JAX-WS的服务发布接口就可以将其发布为WebService接口。
在客户端,用户可以通过JAX-WS的API创建一个代理(用本地对象来替代远程的服务)来实现对于远程服务器端的调用。
在客户端,我们不需要自己写代码,使用wsimport工具自动根据wsdl生成客户端代码,比如下面这个就是一个生成的客户端类。
/**
* This class was generated by the JAX-WS RI.
* JAX-WS RI 2.2.4-b01
* Generated source version: 2.2
*
*/
@WebServiceClient(name = "HelloImplService", targetNamespace = "http://ws.test.com/", wsdlLocation = "http://localhost:8080/testjws/service/sayHi?wsdl")
public class HelloImplService
extends Service
....

出现问题的直接原因是WSServiceDelegate$DelegatingLoader加载了太多类没有被回收,最后perm区溢出。
问题还原下面是我写的一个示例,代码可在https://gitee.com/ifool123/webservice_demo上下载,使用jdk1.7, 同时,使用2.2.10的jaxws-rt,与生产环境一致。
< dependency>
< groupId> com.sun.xml.ws< /groupId>
< artifactId> jaxws-rt< /artifactId>
< version> 2.2.10< /version>
< /dependency>

Server类是启动服务端,Client类是用客户端调用服务端。
![image-20220313161149847](jdk动态代理导致perm区溢出问题分析/image-20220313161149847.png)
下面是调用服务端的代码,不是生产上出问题的代码,重点就是 HelloImpl service = new HelloImplService().getHelloImplPort();

package com.test.webservice.client;

public class Client
public static void main(String[] args) throws InterruptedException
for(int i = 0; i < 10000000; i++)
HelloImpl service = new HelloImplService().getHelloImplPort();
String a = service.sayHello1();
String b = service.sayHello("test");
Thread.sleep(1000);



调用栈如下,每次getPort都会最终走到创建类,这是因为webservice是基于spi实现的,我本地的代码跟weblogic中的调用栈不一样,weblogic中间有一些自己的实现,但是开始的部分和最后走到WSServiceDelegate的方法是一样的。
at java.lang.reflect.Proxy.newInstance()
at java.lang.reflect.Proxy.newProxyInstance(Proxy.java:755)
at com.sun.xml.ws.client.WSServiceDelegate$3.run(WSServiceDelegate.java:742)
at java.security.AccessController.doPrivileged(AccessController.java:-2)
at com.sun.xml.ws.client.WSServiceDelegate.createProxy(WSServiceDelegate.java:738)
at com.sun.xml.ws.client.WSServiceDelegate.createEndpointIFBaseProxy(WSServiceDelegate.java:820)
at com.sun.xml.ws.client.WSServiceDelegate.getPort(WSServiceDelegate.java:451)
at com.sun.xml.ws.client.WSServiceDelegate.getPort(WSServiceDelegate.java:419)
at com.sun.xml.ws.client.WSServiceDelegate.getPort(WSServiceDelegate.java:401)
at javax.xml.ws.Service.getPort(Service.java:119)
at com.test.webservice.client.HelloImplService.getHelloImplPort(HelloImplService.java:72)
at com.test.webservice.client.Client.main(Main.java:8)

循环的不停调用这个WebService,会不停的产生com.sun.proxy.$ProxyXXX类,XXX是一个递增的序列。
[Loaded com.sun.proxy.$Proxy721 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy722 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy723 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy724 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy725 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy726 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy727 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy728 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy729 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy730 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy731 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]
[Loaded com.sun.proxy.$Proxy732 from com.sun.xml.ws.client.WSServiceDelegate$DelegatingLoader]

这个就是java中的动态代理类。
同时,Perm区会一直增长,但是我在本地不管怎么试,都是能回收的,所以这个程序并没有复现问题。如何复现,在最后面再说。


我们先分析一下为什么产生这么多代理类。

为什么会产生这么多代理类首先,对于上面的客户端代码,如果getPort只调用一次,就不会创建多个类了,代码如下:
public static void main(String[] args) throws InterruptedException
HelloImpl service = new HelloImplService().getHelloImplPort();
for(int i = 0; i < 10000000; i++)
String a = service.sayHello1();
String b = service.sayHello("test");


但是,官方没有说明获取实例的方法是线程安全的,所以多线程的情况下有可能有问题,不过我们用ThreadLocal解决这个问题应该也可以。我们主要看一下,在每次都调用getPort的时候,为什么会产生多个代理类。
问题原因比较复杂,主要有两个

  1. jax-ws rt 2.2.6中引入了一个bug,导致在jdk1.6中,每次都会new一个instance,在2.2.7中做了修复
  2. jdk1.7中,升级了动态代理的缓存机制,导致2.2.7中又出现了这个问题。
在WSServiceDelegate中,会使用JDK动态代理为ServicePort提供一个动态类,代码如下,
private < T> T createProxy(final Class< T> portInterface, final InvocationHandler pis)

// When creating the proxy, use a ClassLoader that can load classes
// from both the interface class and also from this classes
// classloader. This is necessary when this code is used in systems
// such as OSGi where the class loader for the interface class may
// not be able to load internal JAX-WS classes like
// "WSBindingProvider", but the class loader for this class may not
// be able to load the interface class.
final ClassLoader loader = getDelegatingLoader(portInterface.getClassLoader(),
WSServiceDelegate.class.getClassLoader());

// accessClassInPackage privilege needs to be granted ...
RuntimePermission perm = new RuntimePermission("accessClassInPackage.com.sun." + "xml.internal.*");
PermissionCollection perms = perm.newPermissionCollection();
perms.add(perm);

return AccessController.doPrivileged(
new PrivilegedAction< T> ()
@Override
public T run()
Object proxy = Proxy.newProxyInstance(loader,
new Class[]portInterface, WSBindingProvider.class, Closeable.class, pis);
return portInterface.cast(proxy);

,
new AccessControlContext(
new ProtectionDomain[]
new ProtectionDomain(null, perms)
)
);

生成代理类的代码就是
Object proxy = Proxy.newProxyInstance(loader,
new Class[]portInterface, WSBindingProvider.class, Closeable.class, pis);

会调用java.lang.reflect.Proxy里的newProxyInstance,这里面有三个参数:
loader : 用来加载动态代理类的ClassLoader
interfaces: 这个动态代理类要实现的接口,可以有多个
invocationhandler : 这个是就是动态生产一个类的时候,对应的类里的函数的实现体

public static Object newProxyInstance(ClassLoader loader,Class< ?> [] interfaces,InvocationHandler h)

【诡异的JVM永久代溢出】这个方法并不是每次都会生成类,而是有缓存的。
对于动态代理类,缓存的原理大致如下,就相当于redis的hset,一级key为classloader,二级key为实现的接口拼成的字符串(排序过的),也就是说,你要是持续的用同一个类加载器生成同样接口的代理类,不会每次都创建的,而是有缓存。

//缓存是一个二级的map,其中第一级key是classloader,然后第二级key是实现的接口的组合
Map< ClassLoader, Map< Object, Class> > cache;

//获取缓存的过程
Object subKey = Arrays.asList(interfaceNames); //把接口的名字数组转换成一个list,作为次级key
Map< Object, Class> valueMap = cache.get(classloader);
if(valueMap == null)
valueMap = new HashMap< Object,Class> ();
cache.put(classloader, valueMap);
Class clazz = proxy.newInstance(); //生成类
valueMap.put(subKey, clazz);
return clazz;
else
Class clazz

    推荐阅读