最近在看jvm-sandbox
的一些功能,参考着实现了动态加载Jar包插件的功能,但是实现的这个功能有一个比较严重的问题,就是类加载完毕之后,当你需要覆盖或者卸载时候,该类加载器的引用是无法被回收的。也就是说由这个类加载器加载之后,无法卸载,这个加载器一直存在。
如果一旦新增或者覆盖的jar包过多,会导致类加载器一直堆积。严重点会发生泄漏的风险。
基于以上场景开始了漫漫排查路。
代码回顾
1. 自定义的类加载器
这个加载器的主要功能是负责路由,也是参考的jvm-sandbox
。
主要目的是将加载器隔离:比如主加载器A,插件加载器为B
同样一个接口A加载器肯定是有的,B加载器也有,如果各自加载那么同一个类也会出现不一致。所以为了保证全局唯一,有一些特定的类B中即便有的话也需要从A中去加载。这就是这个路由的意义。
/**
* 可路由的URLClassLoader
*
* @author luanjia@taobao.com
*/
public class ManagerClassLoader extends URLClassLoader {
private final Logger logger = LoggerFactory.getLogger(ManagerClassLoader.class);
private final Routing[] routingArray;
public ManagerClassLoader(final URL[] urls,
final Routing... routingArray) {
super(urls);
this.routingArray = routingArray;
}
public ManagerClassLoader(final URL[] urls,
final ClassLoader parent,
final Routing... routingArray) {
super(urls, parent);
this.routingArray = routingArray;
}
@Override
public URL getResource(String name) {
URL url = findResource(name);
if (null != url) {
return url;
}
url = super.getResource(name);
return url;
}
@Override
public Enumeration<URL> getResources(String name) throws IOException {
Enumeration<URL> urls = findResources(name);
if (null != urls) {
return urls;
}
urls = super.getResources(name);
return urls;
}
@Override
protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
// 优先查询类加载路由表,如果命中路由规则,则优先从路由表中的ClassLoader完成类加载
if (ArrayUtils.isNotEmpty(routingArray)) {
for (final Routing routing : routingArray) {
if (!routing.isHit(javaClassName)) {
continue;
}
final ClassLoader routingClassLoader = routing.classLoader;
try {
System.out.println("被转发的类名称:" + javaClassName);
return routingClassLoader.loadClass(javaClassName);
} catch (Exception cause) {
// 如果在当前routingClassLoader中找不到应该优先加载的类(应该不可能,但不排除有就是故意命名成同名类)
// 此时应该忽略异常,继续往下加载
// ignore...
}
}
}
// 先走一次已加载类的缓存,如果没有命中,则继续往下加载
final Class<?> loadedClass = findLoadedClass(javaClassName);
if (loadedClass != null) {
return loadedClass;
}
try {
Class<?> aClass = findClass(javaClassName);
if (resolve) {
resolveClass(aClass);
}
return aClass;
} catch (Exception cause) {
System.out.println("================================" + javaClassName);
return super.loadClass(javaClassName, resolve);
}
}
/**
* 类加载路由匹配器
*/
public static class Routing {
private final Collection<String/*REGEX*/> regexExpresses = new ArrayList<String>();
private ClassLoader classLoader;
/**
* 构造类加载路由匹配器
*
* @param classLoader 目标ClassLoader
* @param regexExpressArray 匹配规则表达式数组
*/
public Routing(final ClassLoader classLoader, final String... regexExpressArray) {
if (ArrayUtils.isNotEmpty(regexExpressArray)) {
regexExpresses.addAll(Arrays.asList(regexExpressArray));
}
this.classLoader = classLoader;
}
/**
* 当前参与匹配的Java类名是否命中路由匹配规则
* 命中匹配规则的类加载,将会从此ClassLoader中完成对应的加载行为
*
* @param javaClassName 参与匹配的Java类名
* @return true:命中;false:不命中;
*/
private boolean isHit(final String javaClassName) {
for (final String regexExpress : regexExpresses) {
try {
if (javaClassName.matches(regexExpress)) {
return true;
}
} catch (Throwable cause) {
cause.printStackTrace();
// logger.warn("routing {} failed, regex-express={}.", javaClassName, regexExpress, cause);
}
}
return false;
}
}
@Override
protected void finalize() throws Throwable {
// 一旦这个类被回收的话,会被回调。
System.out.println("ManagerClassLoader 终于被回收了!");
super.finalize();
}
}
2. 构建测试
这个测试比较简单:
- 构建一个Map来管理加载的类
- 每次加载一个ClassLoader的时候,先清空上一个。
为了简单方便,管理器永远只有一个加载器。但是为了查看效果,你可以重复一直加载。
- 控制台输入
1
的时候会手动加载一个jar包中的类。2
卸载jar包中的类和加载器.3
触发GC看是否会被回收掉。
/**
* @author liukaixiong
* @Email liukx@elab-plus.com
* @date 2021/12/27 - 17:27
*/
public class ClassLoaderTest {
public static void main(String[] args) throws Exception {
File file = new File("E:\\study\\sandbox\\sandbox-module\\manager-plugins\\cat-plugin-1.3.3-jar-with-dependencies.jar");
// URL urls = new URL("file:C:/Users/liukx/AppData/Local/Temp/manager_plugin124980413499729388.jar");
Map<String, AnnotationConfigApplicationContext> cacheMap = new HashMap<>();
Scanner input = new Scanner(System.in);
while (true) {
System.out.println("请输入执行 [1 : 加载 , 3 : 卸载]");
int next = input.nextInt();
System.out.println("接收到的指令:" + next);
if (1 == next) {
// 先清除上一个加载器
clearClassLoader(cacheMap);
// 加载一个新的类加载器
AnnotationConfigApplicationContext applicationContext = newManager(file);
cacheMap.put("A", applicationContext);
} else if (2 == next) {
clearClassLoader(cacheMap);
} else if (3 == next) {
System.gc();
System.out.println("触发了一次GC操作!");
}
}
}
// 先清空上一个加载器。
private static void clearClassLoader(Map<String, AnnotationConfigApplicationContext> cacheMap) throws IOException {
AnnotationConfigApplicationContext context = cacheMap.remove("A");
Optional.ofNullable(context).ifPresent((c) -> {
ManagerClassLoader classLoader = (ManagerClassLoader) c.getClassLoader();
try {
Objects.requireNonNull(classLoader).close();
} catch (IOException e) {
e.printStackTrace();
}
System.out.println("清除缓存");
});
}
// 实际中的自定义管理器
private static AnnotationConfigApplicationContext newManager(File file) {
List<String> includeClass = new ArrayList<>();
includeClass.add("^com\\.sandbox\\.manager\\.api\\..*");
includeClass.add("^com\\.alibaba\\.jvm\\.sandbox\\.api\\..*");
// includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
// // includeClass.add("^org\\.apache\\.commons\\.lang3\\..*");
includeClass.add("^org\\.springframework\\..*");
// includeClass.add("^java\\..*");
ManagerClassLoader urlClassLoader = new ManagerClassLoader(new URL[]{builderUrl(file)}, new ManagerClassLoader.Routing(
ClassLoaderTest.class.getClassLoader(),
includeClass.toArray(includeClass.toArray(new String[0]))));
AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
pluginApplicationContext.setClassLoader(urlClassLoader);
pluginApplicationContext.scan("com.sandbox.application.plugin");
pluginApplicationContext.refresh();
Trace bean = pluginApplicationContext.getBean(Trace.class);
String id = bean.getId();
System.out.println(">>>>> 执行 :: " + id);
return pluginApplicationContext;
}
// 简单的自定义加载方式
private static AnnotationConfigApplicationContext newMyClassLoader(File file) {
MyClassLoader urlClassLoader = new MyClassLoader(new URL[]{builderUrl(file)});
AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
pluginApplicationContext.setClassLoader(urlClassLoader);
pluginApplicationContext.scan("com.sandbox.application.plugin");
pluginApplicationContext.refresh();
return pluginApplicationContext;
}
// 最简单的加载方式
private static AnnotationConfigApplicationContext newURLClassloader(File file) {
URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{builderUrl(file)}, ClassLoaderTest.class.getClassLoader());
AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
pluginApplicationContext.setClassLoader(urlClassLoader);
pluginApplicationContext.scan("com.sandbox.application.plugin");
pluginApplicationContext.refresh();
return pluginApplicationContext;
}
private static URL builderUrl(File file) {
try {
// 每次都是构建一个新的临时的jar
File tempFile = File.createTempFile("manager_plugin", ".jar");
tempFile.deleteOnExit();
FileUtils.copyFile(file, tempFile);
return new URL("file:" + tempFile.getPath());
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
}
执行右键,运行main
方法
- 反复输入
1
不断重复加载。
这个时候我用的是JProfile、其实还可以查看java自带的jvisualvm.exe
工具查看。
这里还是稍微记录一下jvisualvm.exe
的使用方式:
- 位置是在
C:\Program Files\Java\jdk1.8.0_261\bin\jvisualvm.exe
。可以根据自己的java安装环境去查找。
- 你运行了程序,直接点击
jvisualvm.exe
打开。
这个时候你会看到虚拟机的运行环境,但是这个时候我们需要看某个实例的运行个数时。最好是在运行java程序中加入
-Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=4444 -Dcom.sun.management.jmxremote.ssl=false -Dcom.sun.management.jmxremote.authenticate=false
开启一个可远程观测的端口。
这个时候,你基本上可以看到实例的加载情况,但是无法追查到引用数据。
3. 使用jprofile去追查
Jprofile 11的下载
纯干货:内存溢出通过Jprofile排查思路以及实践总结
有需要的先了解一下上面的排查文章。
1. 定位java应用程序
点击OK。这个时候虚拟机的信息基本上都展现出来了。
2. 查看存活的类
定位你需要关注的类
3. 选择你关注的类,并生成快照
这个时候基本上中和类的总数和大小引入眼帘。
4. 追踪这个类的引用类
右键你选择的类
这个时候,有多少个实例就会有多少条记录。
其实我们目前按照正常情况来讲,触发GC之后应该只剩一个。但是现在显然不是。
这种情况一定是该实例引用被外部持有,没有被释放掉,导致GC无法回收这个实例。
随便打开一个看看:
关键引用图
说实话,一开始真看不出啥,确实没啥经验,只能慢慢摸索呗~
没有思路,这时我们可以换种方式: 排除法
遇到不会的,先搭一个简单的demo,一步一步朝着我们实际的实现出发。
越简单的案例越能快速反应问题,复杂的东西导致的因素会很多。
- 先写了一个
newURLClassloader
方法,从URLClassLoder出发,发现没问题,能被回收。 - 然后在手写了一个简单自定义的方法
newMyClassLoader
,发现也没问题。
public class MyClassLoader extends URLClassLoader {
public MyClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}
public MyClassLoader(URL[] urls) {
super(urls);
}
public MyClassLoader(URL[] urls, ClassLoader parent, URLStreamHandlerFactory factory) {
super(urls, parent, factory);
}
}
嗯,那一定就是实现的方式出了毛病。
- 然后从实现的
ManagerClassLoader
类中把实现方法loadClass
给注释掉了,发现居然是OK的。
嗯,越来越近了。
细看了一下loadClass
方法:
发现也没啥,就是特定的路径使用特定的类加载器加载。
protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
// 优先查询类加载路由表,如果命中路由规则,则优先从路由表中的ClassLoader完成类加载
if (ArrayUtils.isNotEmpty(routingArray)) {
for (final Routing routing : routingArray) {
if (!routing.isHit(javaClassName)) {
continue;
}
final ClassLoader routingClassLoader = routing.classLoader;
try {
System.out.println("被转发的类名称:" + javaClassName);
return routingClassLoader.loadClass(javaClassName);
} catch (Exception cause) {
// 如果在当前routingClassLoader中找不到应该优先加载的类(应该不可能,但不排除有就是故意命名成同名类)
// 此时应该忽略异常,继续往下加载
// ignore...
}
}
}
// 先走一次已加载类的缓存,如果没有命中,则继续往下加载
final Class<?> loadedClass = findLoadedClass(javaClassName);
if (loadedClass != null) {
return loadedClass;
}
try {
Class<?> aClass = findClass(javaClassName);
if (resolve) {
resolveClass(aClass);
}
return aClass;
} catch (Exception cause) {
System.out.println("================================" + javaClassName);
return super.loadClass(javaClassName, resolve);
}
}
应该就是使用方式的问题。
private static AnnotationConfigApplicationContext newManager(File file) {
List<String> includeClass = new ArrayList<>();
includeClass.add("^com\\.sandbox\\.manager\\.api\\..*");
includeClass.add("^com\\.alibaba\\.jvm\\.sandbox\\.api\\..*");
// includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
// // includeClass.add("^org\\.apache\\.commons\\.lang3\\..*");
includeClass.add("^org\\.springframework\\..*");
// includeClass.add("^java\\..*");
ManagerClassLoader urlClassLoader = new ManagerClassLoader(new URL[]{builderUrl(file)}, new ManagerClassLoader.Routing(
ClassLoaderTest.class.getClassLoader(),
includeClass.toArray(includeClass.toArray(new String[0]))));
AnnotationConfigApplicationContext pluginApplicationContext = new AnnotationConfigApplicationContext();
pluginApplicationContext.setClassLoader(urlClassLoader);
pluginApplicationContext.scan("com.sandbox.application.plugin");
pluginApplicationContext.refresh();
Trace bean = pluginApplicationContext.getBean(Trace.class);
String id = bean.getId();
System.out.println(">>>>> 执行 :: " + id);
return pluginApplicationContext;
}
这里的话就是遇到这些类的话使用主加载器去加载,否则使用自己的加载器。
然后联想到关键引用图中有一个,这里有点运气的因素。
这个属于主加载器也有的,但是没在转发中声明路径,然后加入了这个路径。
//加上这个
includeClass.add("^com\\.lkx\\..*"); //todo 原来如此
然后按照上述步骤重新测试,发现com.lkx.jvm.sandbox.core.classloader.ManagerClassLoader#finalize
的方法被回调了,类也被回收了。
此时,脑瓜子依然嗡嗡作响~。。。
给个解释吧?我也不知道啊!睡服不了自己啊?
强装镇定...
按照正常来讲,A和B是两个不同的加载器,B负责加载插件范围内的实例,比如lang3的工具类,这个是不会和A的工具类起冲突的,因为是各自独立的。那么InterfaceProxyUtils
这个工具类为什么不同呢?即便A和B都依赖这个工具类,也是各自独立的。为什么会有引用关系呢?
知道了结果,这个时候我们开始反推过程。
然后开始捣鼓JProfile,发现有个功能可以从实例一直往上查找直到GC ROOT
! 绝了~
- 选中一个应该被回收的类
从这个路径中可以发现挺多问题的,原来这个类是被Spring持有的。从之前的图也能看出端倪..
4. 胡说八道
为什么Spring会持有呢?首先我们加载插件包的时候是用的Spring的scan方式扫描的包,但是我们先看一下入口类 AttributeMethods
// 省略大部分源码
final class AttributeMethods {
// 静态缓存类,而且还是全局的
private static final Map<Class<? extends Annotation>, AttributeMethods> cache =
new ConcurrentReferenceHashMap<>();
// 重点看是哪里调用了这个静态方法
static AttributeMethods forAnnotationType(@Nullable Class<? extends Annotation> annotationType) {
if (annotationType == null) {
return NONE;
}
return cache.computeIfAbsent(annotationType, AttributeMethods::compute);
}
}
原来这里面是有一个保存属性结构的全局缓存工具类,一旦加载插件包中发现属性注解的时候都会先缓存起来。
调用入口在
org.springframework.core.annotation.AnnotationTypeMapping#AnnotationTypeMapping
中调用了AttributeMethods._forAnnotationType_(annotationType);
我们插件包中确实有一个类注解缓存比如:
interface IHttpServletRequest {
@InterfaceProxyUtils.ProxyMethod(name = "getRemoteAddr")
String getRemoteAddress();
}
Spring在解析的时候会把一些结构性的东西保存下来。
这个时候相当于B加载器的实例对象引用被A加载器的实例应用持有了,所以一直回收不了。但是如果在ManagerClassLoader
声明这个类的路径就是由A加载,B去A里面找的话,就能够被回收。
以上兜兜转转终于定位到了,也是对JProfile有了更深一步的了解。
很多时候当你知识面不够广的时候,可以换一种思路去验证:
- 比如排除法,先把复杂的东西简单化,一步一步验证。
- 在无意中得到解决方法的时候,你不知道为什么会这样?
- 此时再通过结果反推过程,得到最终的原因。
如果此时你正在观看这篇文章,不要纠结能不能解决你目前的问题,排查思路和工具的使用能够让你让你多一种解决方案。
不太喜欢贴大量代码,影响阅读,所以不要纠结代码。