从字节数组创建一个ClassLoader来加载JAR文件

11

我想编写一个自定义类加载器,从自定义网络跨越加载一个JAR文件。最终,我只需使用一个JAR文件的字节数组。

我无法将字节数组转储到文件系统并使用URLClassLoader
我的第一个计划是从流或字节数组创建一个JarFile对象,但它只支持File对象。

我已经编写了一个使用JarInputStream的东西:

public class RemoteClassLoader extends ClassLoader {

    private final byte[] jarBytes;

    public RemoteClassLoader(byte[] jarBytes) {
        this.jarBytes = jarBytes;
    }

    @Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        Class<?> clazz = findLoadedClass(name);
        if (clazz == null) {
            try {
                InputStream in = getResourceAsStream(name.replace('.', '/') + ".class");
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                StreamUtils.writeTo(in, out);
                byte[] bytes = out.toByteArray();
                clazz = defineClass(name, bytes, 0, bytes.length);
                if (resolve) {
                    resolveClass(clazz);
                }
            } catch (Exception e) {
                clazz = super.loadClass(name, resolve);
            }
        }
        return clazz;
    }

    @Override
    public URL getResource(String name) {
        return null;
    }

    @Override
    public InputStream getResourceAsStream(String name) {
        try (JarInputStream jis = new JarInputStream(new ByteArrayInputStream(jarBytes))) {
            JarEntry entry;
            while ((entry = jis.getNextJarEntry()) != null) {
                if (entry.getName().equals(name)) {
                    return jis;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

对于小的JAR文件,这可能很好用,但我尝试加载一个大小为2.7MB、几乎包含2000个类的jar文件时,仅遍历所有条目就需要160毫秒,更别说加载它找到的类了。如果有人知道比每次加载类时都遍历JarInputStream条目更快的解决方案,请分享!


我曾在一个项目中继承了一个类似的类加载器。那里它也非常慢。除此之外,它还给我带来了各种头疼的问题。最终得出的教训是,除非你真的需要,否则不要使用自定义类加载器。 - cogsmos
1
我必须问一下...将它存储在文件中有什么问题吗?我的意思是,Jars的大小可能是任意的,将所有已加载的Jars永久保存在内存中听起来像是一个非常糟糕的主意。这也意味着你将保留Jar字节、缓存条目以及已加载的类...你要付出3倍的代价。 - kaqqao
@wyr0,您是否也期望得到一个答案,即使它更快、更可扩展,也不会将您的jar内容存储到文件系统中? - Nicolas Filotto
2个回答

8

你能做的最好的事情

首先,您无需使用JarInputStream,因为它只向类ZipInputStream添加了对清单的支持,而我们在这里并不真正关心。您不能将条目放入缓存中(除非直接存储每个条目的内容,这会导致内存消耗极大),因为ZipInputStream不适用于共享,因此无法同时读取。您能做的最好的事情是将条目的名称存储到缓存中,以便在我们知道条目存在时仅迭代条目。

代码可能如下所示:

public class RemoteClassLoader extends ClassLoader {

    private final byte[] jarBytes;
    private final Set<String> names;

    public RemoteClassLoader(byte[] jarBytes) throws IOException {
        this.jarBytes = jarBytes;
        this.names = RemoteClassLoader.loadNames(jarBytes);
    }

    /**
     * This will put all the entries into a thread-safe Set
     */
    private static Set<String> loadNames(byte[] jarBytes) throws IOException {
        Set<String> set = new HashSet<>();
        try (ZipInputStream jis = 
             new ZipInputStream(new ByteArrayInputStream(jarBytes))) {
            ZipEntry entry;
            while ((entry = jis.getNextEntry()) != null) {
                set.add(entry.getName());
            }
        }
        return Collections.unmodifiableSet(set);
    }

    ...

    @Override
    public InputStream getResourceAsStream(String name) {
        // Check first if the entry name is known
        if (!names.contains(name)) {
            return null;
        }
        // I moved the JarInputStream declaration outside the
        // try-with-resources statement as it must not be closed otherwise
        // the returned InputStream won't be readable as already closed
        boolean found = false;
        ZipInputStream jis = null;
        try {
            jis = new ZipInputStream(new ByteArrayInputStream(jarBytes));
            ZipEntry entry;
            while ((entry = jis.getNextEntry()) != null) {
                if (entry.getName().equals(name)) {
                    found = true;
                    return jis;
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // Only close the stream if the entry could not be found
            if (jis != null && !found) {
                try {
                    jis.close();
                } catch (IOException e) {
                    // ignore me
                }
            }
        }
        return null;
    }
}

理想的解决方案

使用 JarInputStream 访问 zip 条目显然不是正确的方法,因为您需要迭代条目以找到它,这是一种不可扩展的方法,因为性能将取决于 jar 文件中条目的总数。

为了获得最佳性能,您需要使用 ZipFile 以便通过方法getEntry(name) 直接访问条目,无论档案大小如何。不幸的是,类 ZipFile 没有提供任何接受归档内容的 byte 数组作为构造函数(这也不是一个好习惯,如果文件太大,您可能会面临 OOME),而只接受 File ,因此您需要更改类的逻辑,以便将 zip 的内容存储到临时文件中,然后将此临时文件提供给您的 ZipFile 以便直接访问条目。

代码可能如下所示:

public class RemoteClassLoader extends ClassLoader {

    private final ZipFile zipFile;

    public RemoteClassLoader(byte[] jarBytes) throws IOException {
        this.zipFile = RemoteClassLoader.load(jarBytes);
    }

    private static ZipFile load(byte[] jarBytes) throws IOException {
        // Create my temporary file
        Path path = Files.createTempFile("RemoteClassLoader", "jar");
        // Delete the file on exit
        path.toFile().deleteOnExit();
        // Copy the content of my jar into the temporary file
        try (InputStream is = new ByteArrayInputStream(jarBytes)) {
            Files.copy(is, path, StandardCopyOption.REPLACE_EXISTING);
        }
        return new ZipFile(path.toFile());
    }

    ...

    @Override
    public InputStream getResourceAsStream(String name) {
        // Get the entry by its name
        ZipEntry entry = zipFile.getEntry(name);
        if (entry != null) {
            // The entry could be found
            try {
                // Gives the content of the entry as InputStream
                return zipFile.getInputStream(entry);
            } catch (IOException e) {
                // Could not get the content of the entry
                // you could log the error if needed
                return null;
            }
        }
        // The entry could not be found
        return null;
    }
}

如果您将文件保存为临时文件,那么您就可以使用标准的URLClassloader,不是吗? - Jan Cetkovsky
@JanCetkovsky 这是正确的,但这不是 OP 想要的,我引用 "我无法将字节数组转储到文件系统并使用 URLClassLoader。" - Nicolas Filotto
我的意思是,你的“理想解决方案”可以通过扩展UrlClassLoader(因为你无论如何都要将文件转存到那里)来简化。 - Jan Cetkovsky
@JanCetkovsky 可能是的,但这里的想法是更详细地展示两种方法之间的主要差异,第一种方法的时间复杂度为 O(n),而另一种方法的时间复杂度为 O(1) - Nicolas Filotto

3

我会遍历该类一次并缓存条目。同时,我还会查看URLClassLoader的源代码以了解它是如何实现的。如果这种方法失败了,就将数据写入临时文件,然后通过普通的类加载器来加载。


我正在考虑像你建议的那样缓存这些条目。我猜它不会占用比JAR字节数组已经使用的更多的空间。我查看了URLClassLoader,但它使用了一个在Sun包中实现的URLClassPath代理。我想我会采用缓存的想法。谢谢。 - Bradley Odell
它应该比原始文件使用更多的空间,因为它是压缩过的。但不应该太多。 - Peter Lawrey

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