PF4J中FileSystem的Bug導(dǎo)致無法刪除文件

背景

我們有一個(gè)Plugin的管理系統(tǒng),可以實(shí)現(xiàn)Jar包的熱裝載,內(nèi)部是基于一個(gè)Plugin管理類庫PF4J,類似于OSGI,現(xiàn)在是GitHub上一個(gè)千星項(xiàng)目。
以下是該類庫的官網(wǎng)介紹

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可以動(dòng)態(tài)地加載Class文件。同時(shí),它還可以實(shí)現(xiàn)動(dòng)態(tài)地卸載Class文件。

問題描述

有個(gè)新需求,熱更新Plugin的版本。也就是說,將已經(jīng)被load進(jìn)JVM的舊Plugin版本ubload掉,然后load新版本的Plugin。PF4J工作得很好。為了防止過期的Plugin太多,每次更新都會(huì)刪除舊版本。然而,奇怪的事發(fā)生了:

  • 調(diào)用File.delete()方法返回true,但是舊文件卻還在
  • 手動(dòng)去刪除文件,報(bào)進(jìn)程占用的錯(cuò)誤
  • 當(dāng)程序結(jié)束JVM退出之后,文件就跟著沒了

以下是簡(jiǎn)單的測(cè)試代碼,目前基于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 {
        // 這里并沒有報(bào)錯(cuò)
        Files.delete(path);
    } catch (IOException e) {
        e.printStackTrace();
    }

    // 文件一直存在,直到5s鐘程序退出之后,文件自動(dòng)被刪除
    Thread.sleep(5000);
}

去google了一圈,沒什么收獲,反而在PF4J工程的Issues里面,有人報(bào)過相同的Bug,但是后面不了了之被Close了。

問題定位

看來只能自己解決了。
從上面的代碼可以看出,PF4J的Plugin管理是通過PluginManager這個(gè)類來操作的。該類定義了一系列的操作:getPlugin(), loadPlugin(), stopPlugin(), unloadPlugin()...

unloadPlugin

核心代碼如下:

private boolean unloadPlugin(String pluginId) {
    try {
        // 將Plugin置為Stop狀態(tài)
        PluginState pluginState = this.stopPlugin(pluginId, false);
        if (PluginState.STARTED == pluginState) {
            return false;
        } else {
            // 得到Plugin的包裝類(代理類),可以認(rèn)為這就是Plugin類
            PluginWrapper pluginWrapper = this.getPlugin(pluginId);
            // 刪除PluginManager中對(duì)該P(yáng)lugin各種引用,方便GC
            this.plugins.remove(pluginId);
            this.getResolvedPlugins().remove(pluginWrapper);
            // 觸發(fā)unload的事件
            this.firePluginStateEvent(new PluginStateEvent(this, pluginWrapper, pluginState));
            // 熱部署的一貫作風(fēng),一個(gè)Jar一個(gè)ClassLoader:Map的Key是PluginId,Value是對(duì)應(yīng)的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 {
}

代碼邏輯比較簡(jiǎn)單,是標(biāo)準(zhǔn)的卸載Class的流程:將Plugin的引用置空,然后將對(duì)應(yīng)的ClassLoader close掉以釋放資源。這里特別要注意,這個(gè)ClassLoader是URLClassLoader的子類,而URLClassLoader實(shí)現(xiàn)了Closeable接口,可以釋放資源,如有疑惑可以參考這篇文章。
類卸載部分,暫時(shí)沒看出什么問題。

loadPlugin

加載Plugin的部分稍復(fù)雜,核心邏輯如下

protected PluginWrapper loadPluginFromPath(Path pluginPath) {
    // 得到PluginDescriptorFinder,用來查找PluginDescriptor
    // 有兩種Finder,一種是通過Manifest來找,一種是通過properties文件來找
    // 可想而知,這里會(huì)有IO讀取操作
    PluginDescriptorFinder pluginDescriptorFinder = getPluginDescriptorFinder();
    // 通過PluginDescriptorFinder找到PluginDescriptor
    // PluginDescriptor記錄了Plugin Id,Plugin name, PluginClass等等一系列信息
    // 其實(shí)就是加載配置在Java Manifest中,或者plugin.properties文件中關(guān)于plugin的信息
    PluginDescriptor pluginDescriptor = pluginDescriptorFinder.find(pluginPath);

    pluginId = pluginDescriptor.getPluginId();
    String pluginClassName = pluginDescriptor.getPluginClass();

    // 加載Plugin
    ClassLoader pluginClassLoader = getPluginLoader().loadPlugin(pluginPath, pluginDescriptor);
    // 創(chuàng)建Plugin的包裝類(代理),這個(gè)包裝類包含Plugin相關(guān)的所有信息
    PluginWrapper pluginWrapper = new PluginWrapper(this, pluginDescriptor, pluginPath, pluginClassLoader);
    // 設(shè)置Plugin的創(chuàng)建工廠,后續(xù)Plugin的實(shí)例是通過工廠模式創(chuàng)建的
    pluginWrapper.setPluginFactory(getPluginFactory());

    // 一些驗(yàn)證
    ......

    // 將已加載的Plugin做緩存
    // 可以跟上述unloadPlugin的操作可以對(duì)應(yīng)上
    plugins.put(pluginId, pluginWrapper);
    getUnresolvedPlugins().add(pluginWrapper);
    getPluginClassLoaders().put(pluginId, pluginClassLoader);

    return pluginWrapper;
}

有四個(gè)比較重要的類

  1. PluginDescriptor:用來描述Plugin的類。一個(gè)PF4J的Plugin,必須在Jar的Manifest(pom的"manifestEntries"或者"MANIFEST.MF"文件)里標(biāo)識(shí)Plugin的信息,如入口Class,PluginId,Plugin Version等等。
  2. PluginDescriptorFinder:用來尋找PluginDescriptor的工具類,默認(rèn)有兩個(gè)實(shí)現(xiàn):ManifestPluginDescriptorFinderPropertiesPluginDescriptorFinder,顧名思義,對(duì)應(yīng)兩種Plugin信息的尋找方式。
  3. PluginWrapper:Plugin的包裝類,持有Plugin實(shí)例的引用,并提供了相對(duì)應(yīng)信息(如PluginDescriptor,ClassLoader)的訪問方法。
  4. PluginClassLoader: 自定義類加載器,繼承自URLClassLoader并重寫了loadClass()方法,實(shí)現(xiàn)目標(biāo)Plugin的加載。

回顧開頭所說的問題,文件刪不掉一般是別的進(jìn)程占用導(dǎo)致的,文件流打開之后沒有及時(shí)Close掉。但是我們查了一遍上述過程中出現(xiàn)的文件流操作都有Close。至此似乎陷入了僵局。

MAT

換一個(gè)思路,既然文件刪不掉,那就看看賴在JVM里面到底是什么東西。
跑測(cè)試代碼,然后通過命令jps查找Java進(jìn)程id(這里是11210),然后用以下命令dump出JVM中alive的對(duì)象到一個(gè)文件tmp.bin:

jmap -dump:live,format=b,file=tmp.bin 11210

接著在內(nèi)存分析工具MAT中打開dump文件,結(jié)果如下圖:

dump

發(fā)現(xiàn)有一個(gè)類com.sun.nio.zipfs.ZipFileSystem占了大半的比例(68.8%),該類被sun.nio.fs.WindowsFileSystemProvider持有著引用。根據(jù)這個(gè)線索,我們?nèi)ゴa里面看哪里有調(diào)用FileSystem相關(guān)的api,果然,在PropertiesPluginDescriptorFinder中找到了幕后黑手(只保留核心代碼):

/**
 * Find a plugin descriptor in a properties file (in plugin repository).
 */
public class PropertiesPluginDescriptorFinder implements PluginDescriptorFinder {
    // 調(diào)用此方法去尋找plugin.properties,并加載Plugin相關(guān)的信息
    public PluginDescriptor find(Path pluginPath) {
        // 關(guān)注getPropertiesPath這個(gè)方法
        Path propertiesPath = getPropertiesPath(pluginPath, propertiesFileName);

        // 讀取properties文件內(nèi)容
        ......

        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,出現(xiàn)了元兇FileSystem!?。?        // 這里拿到FileSystem之后,沒有關(guān)閉資源?。?!
        // 隱藏得太深了
        return getFileSystem(uri).getPath(first, more);
    }
    
    // 這個(gè)方法返回一個(gè)FileSystem實(shí)例,注意方法簽名,是會(huì)有IO操作的
    private static FileSystem getFileSystem(URI uri) throws IOException {
        try {
            return FileSystems.getFileSystem(uri);
        } catch (FileSystemNotFoundException e) {
            // 如果uri不存在,也返回一個(gè)跟此uri綁定的空的FileSystem
            return FileSystems.newFileSystem(uri, Collections.<String, String>emptyMap());
        }
    }
}

刨根問底,終于跟MAT的分析結(jié)果對(duì)應(yīng)上了。原來PropertiesPluginDescriptorFinder去加載Plugin描述的時(shí)候是通過FileSystem去做的,但是加載好之后,沒有調(diào)用FileSystem.close()方法釋放資源。我們工程里面使用的DefaultPluginManager默認(rèn)包含兩個(gè)DescriptorFinder:

    protected PluginDescriptorFinder createPluginDescriptorFinder() {
        // DefaultPluginManager的PluginDescriptorFinder是一個(gè)List
        // 使用了組合模式,按添加的順序依次加載PluginDescriptor
        return new CompoundPluginDescriptorFinder()
            // 添加PropertiesPluginDescriptorFinder到List中
            .add(new PropertiesPluginDescriptorFinder())
            // 添加ManifestPluginDescriptorFinder到List中
            .add(new ManifestPluginDescriptorFinder());
    }

最終我們用到的其實(shí)是ManifestPluginDescriptorFinder,但是代碼里先會(huì)用PropertiesPluginDescriptorFinder加載一遍(無論加載是否成功持都會(huì)持了文件的引用),發(fā)現(xiàn)加載不到,然后再用ManifestPluginDescriptorFinder。所以也就解釋了,當(dāng)JVM退出之后,文件自動(dòng)就刪除了,因?yàn)橘Y源被強(qiáng)制釋放了。

問題解決

自己寫一個(gè)類繼承PropertiesPluginDescriptorFinder,重寫其中的readProperties()方法調(diào)用自己寫的MyFileUtil.getPath()方法,當(dāng)使用完FileSystem.getPath之后,把FileSystem close掉,核心代碼如下:

public class FileUtils {
    public static Path getPath(Path path, String first, String... more) throws IOException {
        URI uri = path.toUri();
        ......
        // 使用完畢,調(diào)用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());
        }
    }
}

后續(xù)

隱藏得如此深的一個(gè)bug...雖然這并不是個(gè)大問題,但確實(shí)困擾了我們一段時(shí)間,而且確實(shí)有同仁也碰到過類似的問題。給PF4J上發(fā)了PR解決這個(gè)頑疾,也算是對(duì)開源社區(qū)盡了一點(diǎn)綿薄之力,以防后續(xù)同學(xué)再遇到類似情況。

總結(jié)

文件無法刪除,95%的情況都是因?yàn)橘Y源未釋放干凈。
PF4J去加載Plugin的描述信息有兩種方式,一種是根據(jù)配置文件plugin.progerties,一種是根據(jù)Manifest配置。默認(rèn)的行為是先通過plugin.progerties加載,如果加載不到,再通過Manifest加載。
而通過plugin.progerties加載的方法,內(nèi)部是通過nio的FileSystem實(shí)現(xiàn)的。而當(dāng)通過FileSystem加載之后,直至Plugin unload之前,都沒有去調(diào)用FileSystem.close()方法釋放資源,導(dǎo)致文件無法刪除的bug。

FileSystem的創(chuàng)建是通過FileSystemProvider來完成的,不通的系統(tǒng)下有不同的實(shí)現(xiàn)。如Windows下的實(shí)現(xiàn)如下:

file system的windows實(shí)現(xiàn)

FileSystemProvider被創(chuàng)建之后會(huì)被緩存起來,作為工具類FIleSystems的一個(gè)static成員變量,所以FileSystemProvider是不會(huì)被GC的。每當(dāng)FileSystemProvider創(chuàng)建一個(gè)FileSystem,它會(huì)把該FileSystem放到自己的一個(gè)Map里面做緩存,所以正常情況FileSystem也是不會(huì)被GC的,正和上面MAT的分析結(jié)果一樣。而FileSystemclose()方法,其中一步就是釋放引用,所以在close之后,類就可以被內(nèi)存回收,資源得以釋放,文件就可以被正常刪除了

public class ZipFileSystem extends FileSystem {
    // FileSystem自己所對(duì)應(yīng)的provider
    private final ZipFileSystemProvider provider;
    public void close() throws IOException {
        ......
        // 從provider中,刪除自己的引用
        this.provider.removeFileSystem(this.zfpath, this);
        ......
    }
}

public class ZipFileSystemProvider extends FileSystemProvider {
    // 此Map保存了所有被這個(gè)Provider創(chuàng)建出來的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);
            }

        }
    }
}
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容