JAR文件句柄泄漏问题分析与解决方案
根据堆转储分析截图,我发现了JAR文件句柄泄漏的具体证据。以下是详细分析和解决方案:

堆转储分析结果
关键发现
从堆转储中可以看到:
-
com.gitee.starblues.loader.jar.JarFileWrapper存在大量实例 -
com.gitee.starblues.loader.jar.RandomAccessDataFile占用显著内存 -
java.util.concurrent.ConcurrentHashMap高亮显示,表明可能存在缓存管理问题
根据堆栈分析结果,我发现了一个严重的内存问题。主要内存占用来自插件框架的JAR文件处理和Agent事件系统。
1. 主要内存占用对象
| 类名 | 实例数 | 浅层大小 | 保留大小 | 问题描述 |
|---|---|---|---|---|
int[] | 136,434 | 270.58 MB | 270.58 MB | 大量整型数组 |
PipelineEvent | 1,048,576 | 50.33 MB | 50.33 MB | 事件积压严重 |
MetricEvent | 1,048,576 | 50.33 MB | 50.33 MB | 事件积压严重 |
byte[] | 294,908 | 35.77 MB | 33.4 MB | 字节数组缓存 |
JarFileWrapper | 36,850 | 4.13 MB | 310.83 MB | JAR文件泄漏 |
JarFileEntries | 39,269 | 2.2 MB | 277.68 MB | JAR文件泄漏 |
2. 核心问题
事件系统积压:PipelineEvent和MetricEvent各有100多万个实例,说明事件生产者速度远超消费者速度。
JAR文件泄漏:JarFileWrapper和JarFileEntries的保留内存远大于浅层大小,说明存在对象引用链导致无法GC。
问题根因分析
1. JarFileWrapper 实例泄漏
2. RandomAccessDataFile 未正确关闭
查找到jarFileWrapper创建地址
static JarURLConnection get(URL url, JarFile jarFile) throws IOException {// ... 前面的代码 ...JarFileWrapper jarFileWrapper = new JarFileWrapper(jarFile);PluginResourceStorage.addJarFile(jarFileWrapper);System.out.println("brick loader cached jar file "+jarFileWrapper.getUrl().toString());return new JarURLConnection(url, jarFileWrapper, jarEntryName);
}
创建过程:
-
将传入的
JarFile对象包装成JarFileWrapper -
通过
PluginResourceStorage.addJarFile(jarFileWrapper)将包装后的文件添加到资源存储中 -
输出日志信息
-
使用包装后的
jarFileWrapper创建新的JarURLConnection对象
这个包装过程主要用于在插件资源存储中管理JAR文件的生命周期,确保在插件卸载时能够正确关闭相关的JAR文件资源。
根据添加日志代码运行记录分析
System.out.println("brick loader cached jar file "+jarFileWrapper.getUrl().toString());

问题分析
1. 重复JAR文件缓存
从日志中可以看到,同一个JAR文件被重复缓存多次:
-
jjwt-impl-0.11.5.jar被缓存了至少15次 -
jjwt-api-0.11.5.jar被缓存了至少4次 -
classes目录被缓存了至少6次
jar缓存多次原因
-
每次 URL 连接被创建时,都会调用
PluginResourceStorage.addJarFile -
在
PluginResourceStorage.addJarFile中,会遍历所有已注册的插件 Storage 并添加 JAR 文件 -
没有去重机制,导致同一个 JAR 文件被重复添加
2. 根本原因
问题出现在 JarURLConnection.get()方法中
JarFileWrapper jarFileWrapper = new JarFileWrapper(jarFile);
PluginResourceStorage.addJarFile(jarFileWrapper);
System.out.println("brick loader cached jar file name="+jarFileWrapper.getName()+" url=" +jarFileWrapper.getUrl().toString());
return new JarURLConnection(url, jarFileWrapper, jarEntryName);
每次调用这个方法都会创建新的 JarFileWrapper实例,但没有检查是否已经存在相同的JAR文件
优化方案:
在创建JarFileWrapper对象时,先从PluginResourceStorage加载缓存,没有则创建包装类缓存,返回对应包装类
在当前的 PluginResourceStorage.Storage 类设计中,三个 Map 确实存在数据冗余和重复缓存的问题:
当前缓存结构分析
-
rootJarFileMap:存储文件对象到 JarFileWrapper 的映射 -
jarFileMap:存储文件名到 AbstractJarFile 列表的映射 -
jarFileCache:存储文件名到 JarFileWrapper 的映射(新添加的)
存在的问题
-
数据冗余:同一个
JarFileWrapper对象可能同时存在于jarFileMap和jarFileCache中 -
内存浪费:维护多个映射结构增加了内存开销
-
一致性风险:多个缓存之间可能存在数据不一致的问题
优化方案
方案1:统一缓存结构
将三个 Map 合并为一个统一的缓存结构:
主要改进:
-
统一缓存结构:使用单一的
jarFileCache映射来存储所有 JAR 文件,避免数据冗余 -
辅助映射:使用
fileToNameMap来支持通过 File 对象查找,保持原有功能 -
线程安全:使用
putIfAbsent确保并发环境下的安全性 -
去重机制:自动避免重复缓存相同的 JAR 文件
-
简化关闭逻辑:只需要清理一个主缓存和一个辅助映射
-
新增调试方法:添加了
getAllCachedJarFiles方法便于监控缓存状态
问题根因分析:
JarURLConnection和PluginResourceStorage 的修改大大减少了句柄消耗问题。那么问题来了,之前代码在JDK11与JDK17表现完全不一致。之前代码在JDK11时并未出现当前严重的句柄泄漏,但是在JDK17时出现了严重泄漏,具体是什么原因导致的呢?
JDK 11和JDK 17在JAR文件处理和资源管理方面确实有一些重要差异,这解释了为什么在JDK 17中会出现更严重的句柄泄漏问题。
JDK 11 vs JDK 17 的主要差异
1. 模块系统(Module System)的强化
-
JDK 9+引入了模块系统,对类加载和资源访问有更严格的管理
-
JDK 17进一步加强了模块隔离,可能导致资源无法被及时回收
2. 垃圾回收器的改进
-
JDK 17使用G1 GC作为默认回收器,与JDK 11的Parallel GC有不同的内存管理策略
-
G1 GC可能更晚触发Full GC,导致资源句柄保持更长时间
3. JAR文件处理的内部实现变化
-
JDK 17对
JarFile和JarURLConnection的内部实现可能有优化 -
可能使用了不同的缓存策略或资源管理机制
针对JDK 17的进一步优化
基于上述分析,我们需要对代码进行进一步优化以适应JDK 17的特性:
