背景
我們有一個Plugin的管理系統,可以實現Jar包的熱裝載,內部是基於一個Plugin管理類庫 PF4J,類似於OSGI,現在是GitHub上一個千星項目。
以下是該類庫的官網介紹
A plugin is a way for a third party to extend the functionality of an application. A plugin implements extension points declared by application or other plugins. Also a plugin can define extension points. With PF4J you can easily transform a monolithic java application in a modular application.
大致意思就是,PF4J可以動態地加載Class文件。同時,它還可以實現動態地卸載Class文件。
問題描述
有個新需求,熱更新Plugin的版本。也就是說,將已經被load進JVM的舊Plugin版本ubload掉,然後load新版本的Plugin。PF4J工作得很好。爲了防止過期的Plugin太多,每次更新都會刪除舊版本。然而,奇怪的事發生了:
- 調用File.delete()方法返回true,但是舊文件卻還在
- 手動去刪除文件,報進程佔用的錯誤
- 當程序結束JVM退出之後,文件就跟着沒了
以下是簡單的測試代碼,目前基於PF4j版本3.0.1:
public static void main(String[] args) throws InterruptedException {
// create the plugin manager
PluginManager pluginManager = new DefaultPluginManager();
// start and load all plugins of application
Path path = Paths.get("test.jar");
pluginManager.loadPlugin(path);
pluginManager.startPlugins();
// do something with the plugin
// stop and unload all plugins
pluginManager.stopPlugins();
pluginManager.unloadPlugin("test-plugin-id");
try {
// 這裏並沒有報錯
Files.delete(path);
} catch (IOException e) {
e.printStackTrace();
}
// 文件一直存在,直到5s鍾程序退出之後,文件自動被刪除
Thread.sleep(5000);
}
去google了一圈,沒什麼收穫,反而在PF4J工程的Issues裏面,有人報過相同的Bug,但是後面不了了之被Close了。
問題定位
看來只能自己解決了。
從上面的代碼可以看出,PF4J的Plugin管理是通過PluginManager這個類來操作的。該類定義了一系列的操作:getPlugin(), loadPlugin(), stopPlugin(), unloadPlugin()…
unloadPlugin
核心代碼如下:
private boolean unloadPlugin(String pluginId) {
try {
// 將Plugin置爲Stop狀態
PluginState pluginState = this.stopPlugin(pluginId, false);
if (PluginState.STARTED == pluginState) {
return false;
} else {
// 得到Plugin的包裝類(代理類),可以認爲這就是Plugin類
PluginWrapper pluginWrapper = this.getPlugin(pluginId);
// 刪除PluginManager中對該Plugin各種引用,方便GC
this.plugins.remove(pluginId);
this.getResolvedPlugins().remove(pluginWrapper);
// 觸發unload的事件
this.firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState));
// 熱部署的一貫作風,一個Jar一個ClassLoader:Map的Key是PluginId,Value是對應的ClassLoader
// ClassLoader是自定義的,叫PluginClassLoader
Map<String, ClassLoader> pluginClassLoaders = this.getPluginClassLoaders();
if (pluginClassLoaders.containsKey(pluginId)) {
// 將ClassLoader的引用也刪除,方便GC
ClassLoader classLoader = (ClassLoader)pluginClassLoaders.remove(pluginId);
if (classLoader instanceof Closeable) {
try {
// 將ClassLoader給close掉,釋放掉所有資源
((Closeable)classLoader).close();
} catch (IOException var8) {
throw new PluginRuntimeException(var8, "Cannot close classloader", new Object[0]);
}
}
}
return true;
}
} catch (IllegalArgumentException var9) {
return false;
}
}
public class PluginClassLoader extends URLClassLoader {
}
代碼邏輯比較簡單,是標準的卸載Class的流程:將Plugin的引用置空,然後將對應的ClassLoader close掉以釋放資源。這裏特別要注意,這個ClassLoader是URLClassLoader的子類,而URLClassLoader實現了Closeable接口,可以釋放資源,如有疑惑可以參考這篇文章。
類卸載部分,暫時沒看出什麼問題。
loadPlugin
加載Plugin的部分稍複雜,核心邏輯如下
protected PluginWrapper loadPluginFromPath(Path pluginPath) {
// 得到PluginDescriptorFinder,用來查找PluginDescriptor
// 有兩種Finder,一種是通過Manifest來找,一種是通過properties文件來找
// 可想而知,這裏會有IO讀取操作
PluginDescriptorFinder pluginDescriptorFinder = getPluginDescriptorFinder();
// 通過PluginDescriptorFinder找到PluginDescriptor
// PluginDescriptor記錄了Plugin Id,Plugin name, PluginClass等等一系列信息
// 其實就是加載配置在Java Manifest中,或者plugin.properties文件中關於plugin的信息
PluginDescriptor pluginDescriptor = pluginDescriptorFinder.find(pluginPath);
pluginId = pluginDescriptor.getPluginId();
String pluginClassName = pluginDescriptor.getPluginClass();
// 加載Plugin
ClassLoader pluginClassLoader = getPluginLoader().loadPlugin(pluginPath, pluginDescriptor);
// 創建Plugin的包裝類(代理),這個包裝類包含Plugin相關的所有信息
PluginWrapper pluginWrapper = new PluginWrapper(this, pluginDescriptor, pluginPath, pluginClassLoader);
// 設置Plugin的創建工廠,後續Plugin的實例是通過工廠模式創建的
pluginWrapper.setPluginFactory(getPluginFactory());
// 一些驗證
......
// 將已加載的Plugin做緩存
// 可以跟上述unloadPlugin的操作可以對應上
plugins.put(pluginId, pluginWrapper);
getUnresolvedPlugins().add(pluginWrapper);
getPluginClassLoaders().put(pluginId, pluginClassLoader);
return pluginWrapper;
}
有四個比較重要的類
- PluginDescriptor:用來描述Plugin的類。一個PF4J的Plugin,必須在Jar的Manifest(pom的"manifestEntries"或者"MANIFEST.MF"文件)裏標識Plugin的信息,如入口Class,PluginId,Plugin Version等等。
- PluginDescriptorFinder:用來尋找PluginDescriptor的工具類,默認有兩個實現:ManifestPluginDescriptorFinder和PropertiesPluginDescriptorFinder,顧名思義,對應兩種Plugin信息的尋找方式。
- PluginWrapper:Plugin的包裝類,持有Plugin實例的引用,並提供了相對應信息(如PluginDescriptor,ClassLoader)的訪問方法。
- PluginClassLoader: 自定義類加載器,繼承自URLClassLoader並重寫了**loadClass()**方法,實現目標Plugin的加載。
回顧開頭所說的問題,文件刪不掉一般是別的進程佔用導致的,文件流打開之後沒有及時Close掉。但是我們查了一遍上述過程中出現的文件流操作都有Close。至此似乎陷入了僵局。
MAT
換一個思路,既然文件刪不掉,那就看看賴在JVM裏面到底是什麼東西。
跑測試代碼,然後通過命令jps查找Java進程id(這裏是11210),然後用以下命令dump出JVM中alive的對象到一個文件tmp.bin:
jmap -dump:live,format=b,file=tmp.bin 11210
接着在內存分析工具MAT中打開dump文件,結果如下圖:
發現有一個類com.sun.nio.zipfs.ZipFileSystem佔了大半的比例(68.8%),該類被sun.nio.fs.WindowsFileSystemProvider持有着引用。根據這個線索,我們去代碼裏面看哪裏有調用FileSystem相關的api,果然,在PropertiesPluginDescriptorFinder中找到了幕後黑手(只保留核心代碼):
/**
* Find a plugin descriptor in a properties file (in plugin repository).
*/
public class PropertiesPluginDescriptorFinder implements PluginDescriptorFinder {
// 調用此方法去尋找plugin.properties,並加載Plugin相關的信息
public PluginDescriptor find(Path pluginPath) {
// 關注getPropertiesPath這個方法
Path propertiesPath = getPropertiesPath(pluginPath, propertiesFileName);
// 讀取properties文件內容
......
return createPluginDescriptor(properties);
}
protected Properties readProperties(Path pluginPath) {
Path propertiesPath;
try {
// 文件最終是通過工具類FileUtils去得到Path變量
propertiesPath = FileUtils.getPath(pluginPath, propertiesFileName);
} catch (IOException e) {
throw new PluginRuntimeException(e);
}
// 加載properties文件
......
return properties;
}
}
public class FileUtils {
public static Path getPath(Path path, String first, String... more) throws IOException {
URI uri = path.toUri();
// 其他變量的初始化,跳過
......
// 通過FileSystem去加載Path,出現了元兇FileSystem!!!
// 這裏拿到FileSystem之後,沒有關閉資源!!!
// 隱藏得太深了
return getFileSystem(uri).getPath(first, more);
}
// 這個方法返回一個FileSystem實例,注意方法簽名,是會有IO操作的
private static FileSystem getFileSystem(URI uri) throws IOException {
try {
return FileSystems.getFileSystem(uri);
} catch (FileSystemNotFoundException e) {
// 如果uri不存在,也返回一個跟此uri綁定的空的FileSystem
return FileSystems.newFileSystem(uri, Collections.<String, String>emptyMap());
}
}
}
刨根問底,終於跟MAT的分析結果對應上了。原來PropertiesPluginDescriptorFinder去加載Plugin描述的時候是通過FileSystem去做的,但是加載好之後,沒有調用FileSystem.close()方法釋放資源。我們工程裏面使用的DefaultPluginManager默認包含兩個DescriptorFinder:
protected PluginDescriptorFinder createPluginDescriptorFinder() {
// DefaultPluginManager的PluginDescriptorFinder是一個List
// 使用了組合模式,按添加的順序依次加載PluginDescriptor
return new CompoundPluginDescriptorFinder()
// 添加PropertiesPluginDescriptorFinder到List中
.add(new PropertiesPluginDescriptorFinder())
// 添加ManifestPluginDescriptorFinder到List中
.add(new ManifestPluginDescriptorFinder());
}
最終我們用到的其實是ManifestPluginDescriptorFinder,但是代碼裏先會用PropertiesPluginDescriptorFinder加載一遍(無論加載是否成功持都會持了文件的引用),發現加載不到,然後再用ManifestPluginDescriptorFinder。所以也就解釋了,當JVM退出之後,文件自動就刪除了,因爲資源被強制釋放了。
問題解決
自己寫一個類繼承PropertiesPluginDescriptorFinder,重寫其中的readProperties方法調用自己寫的MyFileUtil.getPath方法,當使用完FileSystem.getPath之後,把FileSystem close掉,核心代碼如下:
public class FileUtils {
public static Path getPath(Path path, String first, String... more) throws IOException {
URI uri = path.toUri();
......
// 使用完畢,調用FileSystem.close()
try (FileSystem fs = getFileSystem(uri)) {
return fs.getPath(first, more);
}
}
private static FileSystem getFileSystem(URI uri) throws IOException {
try {
return FileSystems.getFileSystem(uri);
} catch (FileSystemNotFoundException e) {
return FileSystems.newFileSystem(uri, Collections.<String, String>emptyMap());
}
}
}
後續
隱藏得如此深的一個bug…雖然這並不是個大問題,但確實困擾了我們一段時間,而且確實有同仁也碰到過類似的問題。給PF4J上發了PR解決這個頑疾,也算是對開源社區盡了一點綿薄之力,以防後續同學再遇到類似情況。
總結
文件無法刪除,95%的情況都是因爲資源未釋放乾淨。
PF4J去加載Plugin的描述信息有兩種方式,一種是根據配置文件plugin.progerties,一種是根據Manifest配置。默認的行爲是先通過plugin.progerties加載,如果加載不到,再通過Manifest加載。
而通過plugin.progerties加載的方法,內部是通過nio的FileSystem實現的。而當通過FileSystem加載之後,直至Plugin unload之前,都沒有去調用**FileSystem.close()**方法釋放資源,導致文件無法刪除的bug。
FileSystem的創建是通過FileSystemProvider來完成的,不通的系統下有不同的實現。如Windows下的實現如下:
FileSystemProvider被創建之後會被緩存起來,作爲工具類FIleSystems的一個static成員變量,所以FileSystemProvider是不會被GC的。每當FileSystemProvider創建一個FileSystem,它會把該FileSystem放到自己的一個Map裏面做緩存,所以正常情況FileSystem也是不會被GC的,正和上面MAT的分析結果一樣。而FileSystem的close()方法,其中一步就是釋放引用,所以在close之後,類就可以被內存回收,資源得以釋放,文件就可以被正常刪除了
public class ZipFileSystem extends FileSystem {
// FileSystem自己所對應的provider
private final ZipFileSystemProvider provider;
public void close() throws IOException {
......
// 從provider中,刪除自己的引用
this.provider.removeFileSystem(this.zfpath, this);
......
}
}
public class ZipFileSystemProvider extends FileSystemProvider {
// 此Map保存了所有被這個Provider創建出來的FileSystem
private final Map<Path, ZipFileSystem> filesystems = new HashMap();
void removeFileSystem(Path zfpath, ZipFileSystem zfs) throws IOException {
// 真正刪除引用的地方
synchronized(this.filesystems) {
zfpath = zfpath.toRealPath();
if (this.filesystems.get(zfpath) == zfs) {
this.filesystems.remove(zfpath);
}
}
}
}