咱們有一個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.java
大體意思就是,PF4J能夠動態地加載Class文件。同時,它還能夠實現動態地卸載Class文件。git
有個新需求,熱更新Plugin的版本。也就是說,將已經被load進JVM的舊Plugin版本ubload掉,而後load新版本的Plugin。PF4J工做得很好。爲了防止過時的Plugin太多,每次更新都會刪除舊版本。然而,奇怪的事發生了: > - 調用File.delete()方法返回true,可是舊文件卻還在 > - 手動去刪除文件,報進程佔用的錯誤 > - 當程序結束JVM退出以後,文件就跟着沒了github
如下是簡單的測試代碼,目前基於PF4j版本3.0.1:windows
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了。api
看來只能本身解決了。 從上面的代碼能夠看出,PF4J的Plugin管理是經過PluginManager這個類來操做的。該類定義了一系列的操做:getPlugin(), loadPlugin(), stopPlugin(), unloadPlugin()...緩存
核心代碼以下:oracle
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接口,能夠釋放資源,若有疑惑能夠參考這篇文章。 類卸載部分,暫時沒看出什麼問題。app
加載Plugin的部分稍複雜,核心邏輯以下ide
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; }
有四個比較重要的類 > 1. PluginDescriptor:用來描述Plugin的類。一個PF4J的Plugin,必須在Jar的Manifest(pom的"manifestEntries"或者"MANIFEST.MF"文件)裏標識Plugin的信息,如入口Class,PluginId,Plugin Version等等。 > 2. PluginDescriptorFinder:用來尋找PluginDescriptor的工具類,默認有兩個實現:ManifestPluginDescriptorFinder和PropertiesPluginDescriptorFinder,顧名思義,對應兩種Plugin信息的尋找方式。 > 3. PluginWrapper:Plugin的包裝類,持有Plugin實例的引用,並提供了相對應信息(如PluginDescriptor,ClassLoader)的訪問方法。 > 4. PluginClassLoader: 自定義類加載器,繼承自URLClassLoader並重寫了**loadClass()**方法,實現目標Plugin的加載。工具
回顧開頭所說的問題,文件刪不掉通常是別的進程佔用致使的,文件流打開以後沒有及時Close掉。可是咱們查了一遍上述過程當中出現的文件流操做都有Close。至此彷佛陷入了僵局。
換一個思路,既然文件刪不掉,那就看看賴在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); } } } } ~~~</path,></string,></string,></string,>