使用URLClassLoader重新加载jar时出现问题

19

我需要在现有应用程序的某些部分添加插件功能。我希望能够在运行时添加一个jar文件,而应用程序应该能够加载来自jar文件的类,而无需重新启动应用程序。目前为止一切顺利。我在网上找到了一些使用URLClassLoader的示例,并且它运行良好。

我还希望能够在更新版本的jar文件可用时重载同一个类。我再次找到了一些示例,并且我理解实现这一点的关键是我需要为每个新加载使用一个新的类加载器实例。

我编写了一些示例代码,但出现了NullPointerException。首先让我向大家展示代码:

package test.misc;

import java.io.File;
import java.net.URL;
import java.net.URLClassLoader;

import plugin.misc.IPlugin;

public class TestJarLoading {

    public static void main(String[] args) {

        IPlugin plugin = null;

        while(true) {
            try {
                File file = new File("C:\\plugins\\test.jar");
                String classToLoad = "jartest.TestPlugin";
                URL jarUrl = new URL("jar", "","file:" + file.getAbsolutePath()+"!/");
                URLClassLoader cl = new URLClassLoader(new URL[] {jarUrl}, TestJarLoading.class.getClassLoader());
                Class loadedClass = cl.loadClass(classToLoad);
                plugin = (IPlugin) loadedClass.newInstance();
                plugin.doProc();
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    Thread.sleep(30000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

IPlugin是一个简单的接口,只有一个方法doProc:

public interface IPlugin {
    void doProc();
}

jartest.TestPlugin是实现该接口的一个类,其中doProc方法只会打印一些语句。

现在,我将jartest.TestPlugin类打包成名为test.jar的jar文件,并将其放置在C:\plugins下运行此代码。第一次迭代顺利进行,该类无错误地加载。

当程序执行sleep语句时,我用包含同一类的更新版本的新jar文件替换了C:\plugins\test.jar,并等待下一个while迭代。现在这里有件事我不理解。有时会成功重新加载更新后的类,即下一次迭代正常运行。但有时候,我会看到抛出异常:

java.lang.NullPointerException
at java.io.FilterInputStream.close(FilterInputStream.java:155)
at sun.net.www.protocol.jar.JarURLConnection$JarURLInputStream.close(JarURLConnection.java:90)
at sun.misc.Resource.getBytes(Resource.java:137)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:256)
at java.net.URLClassLoader.access$000(URLClassLoader.java:56)
at java.net.URLClassLoader$1.run(URLClassLoader.java:195)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:188)
at java.lang.ClassLoader.loadClass(ClassLoader.java:307)
at java.lang.ClassLoader.loadClass(ClassLoader.java:252)
at test.misc.TestJarLoading.main(TestJarLoading.java:22)

我在网上搜索并仔细思考,但无法得出任何结论,为什么会抛出这个异常,而且只是有时候,而不是总是。

我需要您的经验和专业知识来理解这个问题。这段代码有什么问题吗?请帮忙!!

如果您需要更多信息,请让我知道。感谢您的关注!


你有没有可能在它仍然从旧的JAR文件中读取时替换了它? - Adam Crume
@Adam - 当类被加载并且doProc()方法已执行后,我会替换jar文件。但是让我尝试一下你的建议,增加睡眠时间并稍后替换jar文件。我会告诉你结果的。谢谢。 - samitgaur
目前还没有成功。现在看来,重新加载时它似乎完全是随机的,无论是成功还是失败。不确定我的环境是否有问题。如果有人能在他们的机器上尝试上面的代码,那将会很有帮助。我正在使用Windows XP上的Eclipse运行它。 - samitgaur
我有一些线索。基本上问题似乎是当我放置新的jar文件时,旧的jar文件仍在使用,导致生成的jar文件损坏。然而,没有任何有用的异常被抛出。我尝试了另一种实现方式,在那里我定义了自己的类加载器版本,这样我就能获得更准确的异常。所以问题是,上面代码中的jar文件是否在整个程序运行期间都被锁定(似乎是这种情况)?我该如何释放它? - samitgaur
6个回答

18
为了大家的利益,让我总结一下真正的问题和对我有效的解决方案。
正如Ryan指出的那样,JVM中存在一个影响Windows平台的bug。在加载类时,URLClassLoader不会关闭打开的jar文件,从而有效地锁定了这些jar文件。这些jar文件无法被删除或替换。
解决方案很简单:在读取完jar文件后关闭它们。然而,为了获取打开的jar文件的句柄,我们需要使用反射,因为我们需要遍历的属性不是公开的。所以我们沿着这条路径遍历下去。
URLClassLoader -> URLClassPath ucp -> ArrayList<Loader> loaders
JarLoader -> JarFile jar -> jar.close()

可以将关闭打开的JAR文件的代码添加到扩展URLClassLoader的close()方法中。
public class MyURLClassLoader extends URLClassLoader {

public PluginClassLoader(URL[] urls, ClassLoader parent) {
    super(urls, parent);
}

    /**
     * Closes all open jar files
     */
    public void close() {
        try {
            Class clazz = java.net.URLClassLoader.class;
            Field ucp = clazz.getDeclaredField("ucp");
            ucp.setAccessible(true);
            Object sunMiscURLClassPath = ucp.get(this);
            Field loaders = sunMiscURLClassPath.getClass().getDeclaredField("loaders");
            loaders.setAccessible(true);
            Object collection = loaders.get(sunMiscURLClassPath);
            for (Object sunMiscURLClassPathJarLoader : ((Collection) collection).toArray()) {
                try {
                    Field loader = sunMiscURLClassPathJarLoader.getClass().getDeclaredField("jar");
                    loader.setAccessible(true);
                    Object jarFile = loader.get(sunMiscURLClassPathJarLoader);
                    ((JarFile) jarFile).close();
                } catch (Throwable t) {
                    // if we got this far, this is probably not a JAR loader so skip it
                }
            }
        } catch (Throwable t) {
            // probably not a SUN VM
        }
        return;
    }
}

(这段代码是从Ryan发布的第二个链接中获取的。这段代码也发布在错误报告页面上。)
然而,有一个限制:为了使这段代码能够正常工作并能够获取到打开的JAR文件的句柄以关闭它们,用于通过URLClassLoader实现从文件加载类的加载器必须是一个JarLoader。查看URLClassPath的源代码(方法getLoader(URL url)),我注意到它只在用于创建URL的文件字符串不以“/”结尾时才使用JARLoader。因此,URL必须定义为这样:
URL jarUrl = new URL("file:" + file.getAbsolutePath());

整体的类加载代码应该类似于这样:
void loadAndInstantiate() {
    MyURLClassLoader cl = null;
    try {
        File file = new File("C:\\jars\\sample.jar");
        String classToLoad = "com.abc.ClassToLoad";
        URL jarUrl = new URL("file:" + file.getAbsolutePath());
        cl = new MyURLClassLoader(new URL[] {jarUrl}, getClass().getClassLoader());
        Class loadedClass = cl.loadClass(classToLoad);
        Object o = loadedClass.getConstructor().newInstance();
    } finally {
        if(cl != null)
            cl.close();
    } 
}

更新:JRE 7在URLClassLoader类中引入了一个close()方法,可能已经解决了这个问题。我还没有验证过。

多年之后,我仍然在JDK / JRE 7上重现了它,这次是在Mac上。我不确定文件上是否有锁定,但只要任何被引用的JAR文件被打开,ClassLoader就不会立即被完成。我猜需要自定义JAR加载...唉。 - Thomas Jung
@Samit G:实例被加载后,应用程序会想要调用一个带有某些参数的方法来进行进一步处理,那么如果我没记错的话,我不能关闭我的类加载器?你有什么想法吗? - chaosguru

11
这种行为与jvm中的一个bug有关
这里记录了2个解决方法here

3
自Java 7开始,确实在URLClassLoader中有一个close()方法,但是如果您直接或间接调用ClassLoader#getResource(String)、ClassLoader#getResourceAsStream(String)或ClassLoader#getResources(String)类型的方法,则仍然不足以完全释放jar文件。事实上,默认情况下,如果我们直接或间接调用上述方法之一,则JarFile实例会自动存储到JarFileFactory的缓存中,并且即使我们调用java.net.URLClassLoader#close(),这些实例也不会被释放。
在这种特殊情况下,即使使用Java 1.8.0_74,仍然需要进行黑客攻击。这是我的黑客攻击https://github.com/essobedo/application-manager/blob/master/src/main/java/com/github/essobedo/appma/core/util/Classpath.java#L83,我在这里使用https://github.com/essobedo/application-manager/blob/master/src/main/java/com/github/essobedo/appma/core/DefaultApplicationManager.java#L388。即使有了这个黑客攻击,我仍然不得不显式调用GC来完全释放jar文件,如您所见https://github.com/essobedo/application-manager/blob/master/src/main/java/com/github/essobedo/appma/core/DefaultApplicationManager.java#L419

@dalvarezmartinez1 是的,我知道这是一个老问题,这里的大多数答案都已经过时了。很高兴知道我的回答能帮到你 :-) - Nicolas Filotto
你的代码是唯一能为我工作的。在我调用release方法之后,我需要调用loader.close(),然后再调用System.gc,这样就可以完成了。 - dalvarezmartinez1

1

这是一个关于编程的更新,已经在Java 7上测试成功。现在URLClassLoader对我来说正常工作。请注意保留HTML标签。

MyReloader

class MyReloaderMain {

...

//assuming ___BASE_DIRECTORY__/lib for jar and ___BASE_DIRECTORY__/conf for configuration
String dirBase = ___BASE_DIRECTORY__;

File file = new File(dirBase, "lib");
String[] jars = file.list();
URL[] jarUrls = new URL[jars.length + 1];
int i = 0;
for (String jar : jars) {
    File fileJar = new File(file, jar);
    jarUrls[i++] = fileJar.toURI().toURL();
    System.out.println(fileJar);
}
jarUrls[i] = new File(dirBase, "conf").toURI().toURL();

URLClassLoader classLoader = new URLClassLoader(jarUrls, MyReloaderMain.class.getClassLoader());

// this is required to load file (such as spring/context.xml) into the jar
Thread.currentThread().setContextClassLoader(classLoader);

Class classToLoad = Class.forName("my.app.Main", true, classLoader);

instance = classToLoad.newInstance();

Method method = classToLoad.getDeclaredMethod("start", args.getClass());
Object result = method.invoke(instance, args);

...
}

关闭并重启类加载器

然后更新你的jar包并调用

classLoader.close();

然后,您可以使用新版本重新启动应用程序。

不要将您的jar文件包含在基础类加载器中

不要将您的jar文件包含在 "MyReloaderMain.class.getClassLoader()" 的基础类加载器 "MyReloaderMain" 中,换句话说,开发两个项目,其中一个用于 "MyReloaderMain",另一个用于您的实际应用程序,两者之间没有依赖关系,否则您将无法理解谁正在加载什么。


1
从操作者:「无需重新启动应用程序」。 - dalvarezmartinez1

0

Windows上的jdk1.8.0_25中仍存在错误。尽管@Nicolas的答案有所帮助,但在WildFly上运行时,我遇到了sun.net.www.protocol.jar.JarFileFactoryClassNotFound,并且在调试一些盒子测试时发生了几次vm崩溃...

因此,我最终将处理加载和卸载的代码部分提取到外部jar中。从主代码中,我只需使用java -jar....调用它,现在看起来一切都很好。

注意:当jvm退出时,Windows会释放已加载的jar文件上的锁定,这就是为什么这个方法可行的原因。


0
原则上,已加载的类不能使用相同的类加载器重新加载。 对于新的加载,需要创建一个新的类加载器,然后加载该类。 使用URLClassLoader存在一个问题,即JAR文件仍然保持打开状态。 如果您使用不同的URLClassLoader实例从一个JAR文件加载了多个类,并在运行时更改了该JAR文件,则通常会出现此错误:java.util.zip.ZipException: ZipFile invalid LOC header (bad signature)。 错误可能是不同的。 为了避免以上错误,需要在使用给定JAR文件的所有URLClassLoader上使用close方法。但这实际上是导致整个应用程序重启的解决方案。
更好的解决方案是修改URLClassLoader,以便将jar文件的内容加载到RAM缓存中。这不再影响从同一jar文件读取数据的其他URLClassloader。然后可以在应用程序运行时自由更改jar文件。例如,您可以使用此URLClassLoader的修改来实现此目的:内存中的URLClassLoader

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接