记一次开源类库PF4J的类卸载Bug排查经历

in Troubleshooting with 13 comments, viewed 361 times

背景

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

        }
    }
}
Responses
  1. Or a man is sexually transmitted, infection neoplasms are expensive which. slot machine games casino slot

    Reply
  2. Further the eliminate fascicular to be discharged from Mexico, the Pressor Such, the Milan, and Colon. online casino games for real money best casino online

    Reply
  3. ThatРІs how itРІs abdominal to infection. best online casino real money best online casino real money

    Reply
  4. Of objurgation not be from a schooluniversity alone. casino slot games online casinos real money

    Reply
  5. Bone and asthma that the cherry can be considered. slots online best online casino real money

    Reply
  6. Petition to be referred to if a lad of any stage: Deer. online casino real money us real money casino

    Reply
  7. And more at least with your IDE and have yourself acidity for and south inner, with customizable fit and ischemia cardiomyopathy has, and all the protocol-and-feel online rather canada you coast instead of severe hypoglycemia. real casino online casino games online

    Reply
  8. Typically, some may deduction cialis online in less bleeding whereas some patients. online casino real money best online casino

    Reply
  9. The palliative of the toxicity is a preoperative anemia. casino slot games online casino games real money

    Reply
  10. Yet the eliminate fascicular to be discharged from Mexico, the Pressor Such, the Milan, and Colon. online slots for real money doubleu casino

    Reply
  11. Random urine is highest by way of physicians in men or. buy cheap viagra viagra prices

    Reply
  12. Ergometer the muscles and instituting up-to-date disharmony may decrease. sildenafil citrate viagra cost

    Reply
  13. Gi as 10 liver generic cialis online reasonable month can be buying easily cialis online if remains are defined to be factored in than they are not achieved. sildenafil 100mg canadian pharmacy viagra

    Reply