最近在看jvm-sandbox的一些功能,參考著實(shí)現(xiàn)了動(dòng)態(tài)加載Jar包插件的功能,但是實(shí)現(xiàn)的這個(gè)功能有一個(gè)比較嚴(yán)重的問題,就是類加載完畢之后,當(dāng)你需要覆蓋或者卸載時(shí)候,該類加載器的引用是無法被回收的。也就是說由這個(gè)類加載器加載之后,無法卸載,這個(gè)加載器一直存在。
如果一旦新增或者覆蓋的jar包過多,會(huì)導(dǎo)致類加載器一直堆積。嚴(yán)重點(diǎn)會(huì)發(fā)生泄漏的風(fēng)險(xiǎn)。
基于以上場景開始了漫漫排查路。
代碼回顧
1. 自定義的類加載器
這個(gè)加載器的主要功能是負(fù)責(zé)路由,也是參考的jvm-sandbox。
主要目的是將加載器隔離:比如主加載器A,插件加載器為B
同樣一個(gè)接口A加載器肯定是有的,B加載器也有,如果各自加載那么同一個(gè)類也會(huì)出現(xiàn)不一致。所以為了保證全局唯一,有一些特定的類B中即便有的話也需要從A中去加載。這就是這個(gè)路由的意義。
/**
* 可路由的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 {
// 優(yōu)先查詢類加載路由表,如果命中路由規(guī)則,則優(yōu)先從路由表中的ClassLoader完成類加載
if (ArrayUtils.isNotEmpty(routingArray)) {
for (final Routing routing : routingArray) {
if (!routing.isHit(javaClassName)) {
continue;
}
final ClassLoader routingClassLoader = routing.classLoader;
try {
System.out.println("被轉(zhuǎn)發(fā)的類名稱:" + javaClassName);
return routingClassLoader.loadClass(javaClassName);
} catch (Exception cause) {
// 如果在當(dāng)前routingClassLoader中找不到應(yīng)該優(yōu)先加載的類(應(yīng)該不可能,但不排除有就是故意命名成同名類)
// 此時(shí)應(yīng)該忽略異常,繼續(xù)往下加載
// ignore...
}
}
}
// 先走一次已加載類的緩存,如果沒有命中,則繼續(xù)往下加載
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;
/**
* 構(gòu)造類加載路由匹配器
*
* @param classLoader 目標(biāo)ClassLoader
* @param regexExpressArray 匹配規(guī)則表達(dá)式數(shù)組
*/
public Routing(final ClassLoader classLoader, final String... regexExpressArray) {
if (ArrayUtils.isNotEmpty(regexExpressArray)) {
regexExpresses.addAll(Arrays.asList(regexExpressArray));
}
this.classLoader = classLoader;
}
/**
* 當(dāng)前參與匹配的Java類名是否命中路由匹配規(guī)則
* 命中匹配規(guī)則的類加載,將會(huì)從此ClassLoader中完成對應(yīng)的加載行為
*
* @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 {
// 一旦這個(gè)類被回收的話,會(huì)被回調(diào)。
System.out.println("ManagerClassLoader 終于被回收了!");
super.finalize();
}
}
2. 構(gòu)建測試
這個(gè)測試比較簡單:
- 構(gòu)建一個(gè)Map來管理加載的類
- 每次加載一個(gè)ClassLoader的時(shí)候,先清空上一個(gè)。
為了簡單方便,管理器永遠(yuǎn)只有一個(gè)加載器。但是為了查看效果,你可以重復(fù)一直加載。
- 控制臺輸入
1的時(shí)候會(huì)手動(dòng)加載一個(gè)jar包中的類。2卸載jar包中的類和加載器.3觸發(fā)GC看是否會(huì)被回收掉。
/**
* @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("請輸入執(zhí)行 [1 : 加載 , 3 : 卸載]");
int next = input.nextInt();
System.out.println("接收到的指令:" + next);
if (1 == next) {
// 先清除上一個(gè)加載器
clearClassLoader(cacheMap);
// 加載一個(gè)新的類加載器
AnnotationConfigApplicationContext applicationContext = newManager(file);
cacheMap.put("A", applicationContext);
} else if (2 == next) {
clearClassLoader(cacheMap);
} else if (3 == next) {
System.gc();
System.out.println("觸發(fā)了一次GC操作!");
}
}
}
// 先清空上一個(gè)加載器。
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("清除緩存");
});
}
// 實(shí)際中的自定義管理器
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(">>>>> 執(zhí)行 :: " + 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 {
// 每次都是構(gòu)建一個(gè)新的臨時(shí)的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;
}
}
執(zhí)行右鍵,運(yùn)行main方法
- 反復(fù)輸入
1不斷重復(fù)加載。
這個(gè)時(shí)候我用的是JProfile、其實(shí)還可以查看java自帶的jvisualvm.exe工具查看。
這里還是稍微記錄一下jvisualvm.exe的使用方式:
- 位置是在
C:\Program Files\Java\jdk1.8.0_261\bin\jvisualvm.exe??梢愿鶕?jù)自己的java安裝環(huán)境去查找。
- 你運(yùn)行了程序,直接點(diǎn)擊
jvisualvm.exe打開。
這個(gè)時(shí)候你會(huì)看到虛擬機(jī)的運(yùn)行環(huán)境,但是這個(gè)時(shí)候我們需要看某個(gè)實(shí)例的運(yùn)行個(gè)數(shù)時(shí)。最好是在運(yùn)行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
開啟一個(gè)可遠(yuǎn)程觀測的端口。

這個(gè)時(shí)候,你基本上可以看到實(shí)例的加載情況,但是無法追查到引用數(shù)據(jù)。
3. 使用jprofile去追查
Jprofile 11的下載
純干貨:內(nèi)存溢出通過Jprofile排查思路以及實(shí)踐總結(jié)
有需要的先了解一下上面的排查文章。
1. 定位java應(yīng)用程序

點(diǎn)擊OK。這個(gè)時(shí)候虛擬機(jī)的信息基本上都展現(xiàn)出來了。
2. 查看存活的類

定位你需要關(guān)注的類
3. 選擇你關(guān)注的類,并生成快照


這個(gè)時(shí)候基本上中和類的總數(shù)和大小引入眼簾。
4. 追蹤這個(gè)類的引用類
右鍵你選擇的類


這個(gè)時(shí)候,有多少個(gè)實(shí)例就會(huì)有多少條記錄。

其實(shí)我們目前按照正常情況來講,觸發(fā)GC之后應(yīng)該只剩一個(gè)。但是現(xiàn)在顯然不是。
這種情況一定是該實(shí)例引用被外部持有,沒有被釋放掉,導(dǎo)致GC無法回收這個(gè)實(shí)例。
隨便打開一個(gè)看看:
關(guān)鍵引用圖

說實(shí)話,一開始真看不出啥,確實(shí)沒啥經(jīng)驗(yàn),只能慢慢摸索唄~
沒有思路,這時(shí)我們可以換種方式: 排除法
遇到不會(huì)的,先搭一個(gè)簡單的demo,一步一步朝著我們實(shí)際的實(shí)現(xiàn)出發(fā)。
越簡單的案例越能快速反應(yīng)問題,復(fù)雜的東西導(dǎo)致的因素會(huì)很多。
- 先寫了一個(gè)
newURLClassloader方法,從URLClassLoder出發(fā),發(fā)現(xiàn)沒問題,能被回收。 - 然后在手寫了一個(gè)簡單自定義的方法
newMyClassLoader,發(fā)現(xiàn)也沒問題。
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);
}
}
嗯,那一定就是實(shí)現(xiàn)的方式出了毛病。
- 然后從實(shí)現(xiàn)的
ManagerClassLoader類中把實(shí)現(xiàn)方法loadClass給注釋掉了,發(fā)現(xiàn)居然是OK的。
嗯,越來越近了。
細(xì)看了一下loadClass方法:
發(fā)現(xiàn)也沒啥,就是特定的路徑使用特定的類加載器加載。
protected Class<?> loadClass(final String javaClassName, final boolean resolve) throws ClassNotFoundException {
// 優(yōu)先查詢類加載路由表,如果命中路由規(guī)則,則優(yōu)先從路由表中的ClassLoader完成類加載
if (ArrayUtils.isNotEmpty(routingArray)) {
for (final Routing routing : routingArray) {
if (!routing.isHit(javaClassName)) {
continue;
}
final ClassLoader routingClassLoader = routing.classLoader;
try {
System.out.println("被轉(zhuǎn)發(fā)的類名稱:" + javaClassName);
return routingClassLoader.loadClass(javaClassName);
} catch (Exception cause) {
// 如果在當(dāng)前routingClassLoader中找不到應(yīng)該優(yōu)先加載的類(應(yīng)該不可能,但不排除有就是故意命名成同名類)
// 此時(shí)應(yīng)該忽略異常,繼續(xù)往下加載
// ignore...
}
}
}
// 先走一次已加載類的緩存,如果沒有命中,則繼續(xù)往下加載
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);
}
}
應(yīng)該就是使用方式的問題。
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(">>>>> 執(zhí)行 :: " + id);
return pluginApplicationContext;
}
這里的話就是遇到這些類的話使用主加載器去加載,否則使用自己的加載器。
然后聯(lián)想到關(guān)鍵引用圖中有一個(gè),這里有點(diǎn)運(yùn)氣的因素。

這個(gè)屬于主加載器也有的,但是沒在轉(zhuǎn)發(fā)中聲明路徑,然后加入了這個(gè)路徑。
//加上這個(gè)
includeClass.add("^com\\.lkx\\..*"); //todo 原來如此
然后按照上述步驟重新測試,發(fā)現(xiàn)com.lkx.jvm.sandbox.core.classloader.ManagerClassLoader#finalize的方法被回調(diào)了,類也被回收了。
此時(shí),腦瓜子依然嗡嗡作響~。。。
給個(gè)解釋吧?我也不知道??!睡服不了自己?。?/strong>
強(qiáng)裝鎮(zhèn)定...
按照正常來講,A和B是兩個(gè)不同的加載器,B負(fù)責(zé)加載插件范圍內(nèi)的實(shí)例,比如lang3的工具類,這個(gè)是不會(huì)和A的工具類起沖突的,因?yàn)槭歉髯元?dú)立的。那么InterfaceProxyUtils這個(gè)工具類為什么不同呢?即便A和B都依賴這個(gè)工具類,也是各自獨(dú)立的。為什么會(huì)有引用關(guān)系呢?
知道了結(jié)果,這個(gè)時(shí)候我們開始反推過程。
然后開始搗鼓JProfile,發(fā)現(xiàn)有個(gè)功能可以從實(shí)例一直往上查找直到GC ROOT ! 絕了~
- 選中一個(gè)應(yīng)該被回收的類


從這個(gè)路徑中可以發(fā)現(xiàn)挺多問題的,原來這個(gè)類是被Spring持有的。從之前的圖也能看出端倪..

4. 胡說八道
為什么Spring會(huì)持有呢?首先我們加載插件包的時(shí)候是用的Spring的scan方式掃描的包,但是我們先看一下入口類 AttributeMethods
// 省略大部分源碼
final class AttributeMethods {
// 靜態(tài)緩存類,而且還是全局的
private static final Map<Class<? extends Annotation>, AttributeMethods> cache =
new ConcurrentReferenceHashMap<>();
// 重點(diǎn)看是哪里調(diào)用了這個(gè)靜態(tài)方法
static AttributeMethods forAnnotationType(@Nullable Class<? extends Annotation> annotationType) {
if (annotationType == null) {
return NONE;
}
return cache.computeIfAbsent(annotationType, AttributeMethods::compute);
}
}
原來這里面是有一個(gè)保存屬性結(jié)構(gòu)的全局緩存工具類,一旦加載插件包中發(fā)現(xiàn)屬性注解的時(shí)候都會(huì)先緩存起來。
調(diào)用入口在
org.springframework.core.annotation.AnnotationTypeMapping#AnnotationTypeMapping中調(diào)用了AttributeMethods._forAnnotationType_(annotationType);
我們插件包中確實(shí)有一個(gè)類注解緩存比如:
interface IHttpServletRequest {
@InterfaceProxyUtils.ProxyMethod(name = "getRemoteAddr")
String getRemoteAddress();
}
Spring在解析的時(shí)候會(huì)把一些結(jié)構(gòu)性的東西保存下來。
這個(gè)時(shí)候相當(dāng)于B加載器的實(shí)例對象引用被A加載器的實(shí)例應(yīng)用持有了,所以一直回收不了。但是如果在ManagerClassLoader聲明這個(gè)類的路徑就是由A加載,B去A里面找的話,就能夠被回收。

以上兜兜轉(zhuǎn)轉(zhuǎn)終于定位到了,也是對JProfile有了更深一步的了解。
很多時(shí)候當(dāng)你知識面不夠廣的時(shí)候,可以換一種思路去驗(yàn)證:
- 比如排除法,先把復(fù)雜的東西簡單化,一步一步驗(yàn)證。
- 在無意中得到解決方法的時(shí)候,你不知道為什么會(huì)這樣?
- 此時(shí)再通過結(jié)果反推過程,得到最終的原因。
如果此時(shí)你正在觀看這篇文章,不要糾結(jié)能不能解決你目前的問題,排查思路和工具的使用能夠讓你讓你多一種解決方案。
不太喜歡貼大量代碼,影響閱讀,所以不要糾結(jié)代碼。