美文网首页
PF4J中FileSystem的Bug导致无法删除文件

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

作者: night_field | 来源:发表于2019-12-12 10:26 被阅读0次

    背景

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

    相关文章

      网友评论

          本文标题:PF4J中FileSystem的Bug导致无法删除文件

          本文链接:https://www.haomeiwen.com/subject/etangctx.html