PF4J中FileSystem的Bug导致无法删除文件

背景

我们有一个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;
}

有四个比较重要的类

  1. PluginDescriptor:用来描述Plugin的类。一个PF4J的Plugin,必须在Jar的Manifest(pom的"manifestEntries"或者"MANIFEST.MF"文件)里标识Plugin的信息,如入口Class,PluginId,Plugin Version等等。
  2. PluginDescriptorFinder:用来寻找PluginDescriptor的工具类,默认有两个实现:ManifestPluginDescriptorFinderPropertiesPluginDescriptorFinder,顾名思义,对应两种Plugin信息的寻找方式。
  3. PluginWrapper:Plugin的包装类,持有Plugin实例的引用,并提供了相对应信息(如PluginDescriptor,ClassLoader)的访问方法。
  4. 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文件,结果如下图:

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下的实现如下:

file system的windows实现

FileSystemProvider被创建之后会被缓存起来,作为工具类FIleSystems的一个static成员变量,所以FileSystemProvider是不会被GC的。每当FileSystemProvider创建一个FileSystem,它会把该FileSystem放到自己的一个Map里面做缓存,所以正常情况FileSystem也是不会被GC的,正和上面MAT的分析结果一样。而FileSystemclose()方法,其中一步就是释放引用,所以在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);
            }

        }
    }
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350