嵌套Jar类加载实现

635 阅读13分钟

背景

在Java中,一个类用全名(包名+类名)作为其标识。在JVM中,一个类用其全名和类加载器作为唯一标识,不同类加载器加载的类置于不同的命名空间中,基于此可以实现类的隔离。在使用自定义类加载器加载类时,被加载的类一般都是在外部的Jar中,那嵌套Jar(Jar内部的Jar)中的类该如何使用类加载器加载呢?比如我们开发一个工具包,该该工具包会引用第三方包,因为某些原因需要使用自定义类加载器加载该第三方包,为了实现工具包开箱即用,就需要将第三方包放在工具包的jar中,此时就需要实现嵌套Jar的加载,现有的类加载器并不支持。

Jar文件中类加载原理

Jar文件是基于Zip格式的一种包文件格式,用于将许多Java类文件和相关的元数据和资源(如文本、图像等)聚合到一个文件中进行分发。因此,实现Jar包中的类文件加载,其实就是实现Zip包中的类文件加载。众所周知,要访问Zip包中的文件,一般都要对Zip包进行解压,但实际上,JVM在加载Jar中的资源时,并没有解压,那这个是怎么实现的?

Jar文件遍历及读取

Jar文件是一种Zip文件,我们可以像读取Zip文件一样来读取Jar文件,比如spring-core-5.3.33.jar文件,可以得到如下输出:

JarFile jarFile = new JarFile("spring-core-5.3.33.jar");
Enumeration<JarEntry> entries = jarFile.entries();
while (entries.hasMoreElements()) {
  JarEntry entry = entries.nextElement();
  String entryName = entry.getName();
  System.out.printf("is directory: %s, entry name: %s%n", entry.isDirectory(), entryName);
}
jarFile.close();
is directory: true, entry name: META-INF/
is directory: false, entry name: META-INF/MANIFEST.MF
is directory: true, entry name: org/
is directory: true, entry name: org/springframework/
is directory: true, entry name: org/springframework/asm/
is directory: false, entry name: org/springframework/asm/AnnotationVisitor.class
is directory: false, entry name: org/springframework/asm/AnnotationWriter.class
...
is directory: false, entry name: org/springframework/objenesis/strategy/StdInstantiatorStrategy.class

如果要读取读取Jar包的指定文件,比如META-INF/MANIFEST.MF,可以直接构造JarEntry对象,通过JarFile获取对应文件的输入流,如下:

JarFile jarFile = new JarFile(path);
JarEntry entry = new JarEntry("META-INF/MANIFEST.MF");
InputStream inputStream = jarFile.getInputStream(entry);
BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream));
String line;
while ((line = reader.readLine()) != null) {
  System.out.println(line);
}
reader.close();
jarFile.close();
Manifest-Version: 1.0
Implementation-Title: spring-core
Automatic-Module-Name: spring.core
Implementation-Version: 5.3.33
Created-By: 1.8.0_402 (Oracle Corporation)
Dependencies: jdk.unsupported

URLClassLoader类加载器实现

我们知道,JVM是通过双亲委派机制来实现类加载,关于双亲委派机制,不是本文要讨论的内容,这里不做介绍,感兴趣的可以自行搜索相关资料。为了实现自定义类加载器,在实践过程中,一般都是继承URLClassLoader实现,下面我们来看下URLClassLoader是如何实现Jar中的class文件加载的。

1.jpg 从上面的继承关系可以看出,URLClassLoader继承自ClassLoader类,重写了其中的findClass方法。在实现上,会将类全路径名中的.转换为/,并拼接上.class,这与上一节介绍的Zip文件中的文件路径相一致。接着会调用URLClassLoader调用URLClassPath#getResource(java.lang.String, boolean)方法,获取对应的Class文件资源,而后创建出Class对象。

protected Class<?> findClass(final String name)
    throws ClassNotFoundException
{
    final Class<?> result;
    try {
        result = AccessController.doPrivileged(
            new PrivilegedExceptionAction<Class<?>>() {
                public Class<?> run() throws ClassNotFoundException {
                    String path = name.replace('.', '/').concat(".class");
                    Resource res = ucp.getResource(path, false);
                    if (res != null) {
                        try {
                            return defineClass(name, res);
                        } catch (IOException e) {
                            throw new ClassNotFoundException(name, e);
                        } catch (ClassFormatError e2) {
                            if (res.getDataError() != null) {
                                e2.addSuppressed(res.getDataError());
                            }
                            throw e2;
                        }
                    } else {
                        return null;
                    }
                }
            }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (ClassNotFoundException) pae.getException();
    }
    if (result == null) {
        throw new ClassNotFoundException(name);
    }
    return result;
}

URLClassPath类用于维护一个 URL 的搜索路径,以便从Jar文件和目录中加载类和资源。我们直接看下URLClassPath#getResource(java.lang.String, boolean)方法,在该方法中,会不断遍历Loader,检查要加载的类全路径名在在该Loader中是否存在,每个Loader就对应了一个URL。

public Resource getResource(String name, boolean check) {
    if (DEBUG) {
        System.err.println("URLClassPath.getResource(\"" + name + "\")");
    }

    Loader loader;
    int[] cache = getLookupCache(name);
    for (int i = 0; (loader = getNextLoader(cache, i)) != null; i++) {
        Resource res = loader.getResource(name, check);
        if (res != null) {
            return res;
        }
    }
    return null;
}

源码一直跟下去,可以看到URLClassPath是通过指定的URL创建Loader,不同类型的URL会返回不同具体实现的Loader。

  • 如果URL不是以/结尾,认为是Jar文件,则返回JarLoader类型,显然是用于Jar的加载。
  • 如果URL以/结尾,且协议为file,则返回FileLoader类型,显然是直接用于Class文件的加载。
  • 如果URL以/结尾,且协议不是file,则返回Loader类型,这里埋个伏笔,在介绍URL协议扩展时再做介绍。
/*
 * Returns the Loader for the specified base URL.
 */
private Loader getLoader(final URL url) throws IOException {
    try {
        return java.security.AccessController.doPrivileged(
            new java.security.PrivilegedExceptionAction<Loader>() {
            public Loader run() throws IOException {
                String file = url.getFile();
                if (file != null && file.endsWith("/")) {
                    if ("file".equals(url.getProtocol())) {
                        return new FileLoader(url);
                    } else {
                        return new Loader(url);
                    }
                } else {
                    return new JarLoader(url, jarHandler, lmap, acc);
                }
            }
        }, acc);
    } catch (java.security.PrivilegedActionException pae) {
        throw (IOException)pae.getException();
    }
}

本文探讨的是Jar文件的加载,这里我们只分析JarLoader的实现,其他类型的Loader不是本文要分析的内容。看下JarLoader#getResource(java.lang.String, boolean)方法。在该方法中,会根据类的全路径名获取JarEntry对象,而后就会调用JarLoader#checkResource(java.lang.String, boolean, java.util.jar.JarEntry)方法,在方法最后会返回一个匿名内部类,继承自抽象类Resource,在其成员变量中会定义InputStream成员变量,它的值也正是我们上一节介绍的通过jar.getInputStream(entry)方法来获取。至此,介绍完了JVM是如果加载Jar包中的Class文件。

/*
 * Returns the JAR Resource for the specified name.
 */
Resource getResource(final String name, boolean check) {
    if (metaIndex != null) {
        if (!metaIndex.mayContain(name)) {
            return null;
        }
    }

    try {
        ensureOpen();
    } catch (IOException e) {
        throw new InternalError(e);
    }
    final JarEntry entry = jar.getJarEntry(name);
    if (entry != null)
        return checkResource(name, check, entry);

    if (index == null)
        return null;

    HashSet<String> visited = new HashSet<String>();
    return getResource(name, check, visited);
}

Resource checkResource(final String name, boolean check,
    final JarEntry entry) {
...
    return new Resource() {
...
        public InputStream getInputStream() throws IOException
            { return jar.getInputStream(entry); }
 ...
    };
}

嵌套Jar加载实现

在上一节,我们介绍了Jar中的Class文件JVM是如何搜索和加载的,下面我们来看看嵌套Jar中的Class文件该如何加载。

Jar URL协议

URLClassLoader类加载器是将Jar文件封装成URL对象来实现后续的类加载逻辑。Jar的URL格式如下: jar:<url>!/<entry>。 从其格式定义来看,URL协议中只允许有一层jar,对于嵌套jar并不支持。此外,我们在前几节也分析到,Jar文件其实就是一个Zip包,嵌套Jar其实就是Zip包中的Zip包。如果要实现嵌套Jar的类文件加载,就需要实现Zip包下Zip包里的Class文件加载,JarFile#getInputStream(ZipEntry)放在只能读取嵌套Jar的文件作为输入流,这还是一个压缩文件,无法直接使用。为了实现嵌套Jar的类加载,下面给出2种实现思路。

Jar解压拷贝加载

既然嵌套Jar是Jar包中带有Jar,要加载内部Jar中的Class文件,我们可以将内部Jar拷贝出来,当做普通Jar来实现类加载,下面提供了一种该思路的实现。实现上,parseUrl方法会分离出嵌套Jar和非嵌套Jar,利用copyJarFile方法,拷贝出嵌套Jar写入到外部文件中,而后重新组装URL对象,使用URLClassLoader来实现类加载。该方案实现上比较简单,也容易可行,但缺点也很明显,需要解压拷贝文件,不使用时还需要销毁,不够优雅。

/**
 * 嵌套Jar类加载器
 *
 * <p>支持嵌套Jar和普通Jar的加载,嵌套Jar会被拷贝到临时目录,非嵌套Jar直接加载。
 * 1. jar:file:/path/sql-parser.jar!/abc/driver-8.37.3.jar。
 * 2. file:/path/driver-8.37.3.jar。
 */
@Slf4j
public class NestedJarClassLoader extends SecureClassLoader {

  private final File tmpDir;

  private final Map<String, URL> nestedDestMap = new HashMap<>();

  private final Map<String, URL> unnestedDestMap = new HashMap<>();

  private final Map<String, URL> nestedSrcMap = new HashMap<>();

  private final URLClassLoader classLoader;

  static {
    ClassLoader.registerAsParallelCapable();
  }

  public NestedJarClassLoader(URL[] urls, ClassLoader parent) {
    this.tmpDir = new File(System.getProperty("java.io.tmpdir") + File.separator + "classloader");
    this.parseUrl(urls);
    if (parent != null) {
      this.classLoader = new URLClassLoader(getURLs(), parent);
    } else {
      this.classLoader = new URLClassLoader(getURLs());
    }
    // 对拷贝的Jar进行清理
    Runtime.getRuntime().addShutdownHook(new Thread(() -> {
      Collection<URL> destUrls = nestedDestMap.values();
      for (URL destUrl : destUrls) {
        File file = new File(destUrl.getFile());
        if (file.exists() && file.delete()) {
          log.info("jar is {} deleted", file.getAbsolutePath());
        }
      }
    }));
  }

  @Override
  protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    checkJarFile();
    return this.classLoader.loadClass(name, resolve);
  }

  private void parseUrl(URL[] urls) {
    for (URL url : urls) {
      checkJarUrl(url);
      if ("jar".equals(url.getProtocol())) {
        String jarName = UUID.randomUUID().toString().replace("-", "") + ".jar";
        File destFile = new File(tmpDir, jarName);
        try {
          nestedDestMap.put(url.toString(), destFile.toURI().toURL());
          nestedSrcMap.put(url.toString(), url);
        } catch (Exception e) {
          throw new ServerException("Failed to convert file to URL", e);
        }
      } else if ("file".equals(url.getProtocol())) {
        unnestedDestMap.put(url.toString(), url);
      } else {
        throw new RuntimeException("Unsupported URL protocol: " + url.getProtocol());
      }
    }
  }

  private void checkJarFile() {
    if (nestedDestMap.isEmpty()) {
      return;
    }
    for (String src : nestedDestMap.keySet()) {
      URL destUrl = nestedDestMap.get(src);
      File file = new File(destUrl.getFile());
      if (!file.exists()) {
        copyJarFile(nestedSrcMap.get(src), file);
      }
    }
  }

  private void copyJarFile(URL srcUrl, File destFile) {
    destFile.getParentFile().mkdirs();
    try (InputStream is = srcUrl.openStream(); OutputStream os = Files.newOutputStream(destFile.toPath())) {
      byte[] buffer = new byte[1024];
      int bytesRead;
      while ((bytesRead = is.read(buffer)) != -1) {
        os.write(buffer, 0, bytesRead);
      }
      log.info("Jar file {} copied to {}", srcUrl, destFile.getAbsolutePath());
    } catch (Exception e) {
      throw new RuntimeException("Failed to copy jar file", e);
    }
  }

  private URL[] getURLs() {
    return Stream.concat(
        Arrays.stream(unnestedDestMap.values().toArray(new URL[0])),
        Arrays.stream(nestedDestMap.values().toArray(new URL[0]))
    ).toArray(URL[]::new);
  }

  private void checkJarUrl(URL url) {
    if (!url.getPath().endsWith(".jar")) {
      throw new RuntimeException("URL must be a JAR URL");
    }
  }

}

扩展URL协议实现嵌套Jar加载

在SpringBoot中,我们打的包一般都是FatJar,典型的Jar中带有Jar,那SpringBoot是如何实现FatJar的类加载的?此外,前面我也介绍原生的Jar URL协议不支持嵌套Jar格式,如果我们扩充Jar URL协议,让其支持嵌套Jar格式,那是不是也就支持了嵌套Jar的类加载?SpringBoot是不是也采用的这套方案?带着这些疑惑,我们来看看Jar URL协议是如何扩充的?FatJar又是如何加载的。

Jar URL协议扩充

对于Jar URL协议的扩充,必须在扩充完后URLClassLoader类加载器还可以继续使用,那该如何实现?前面介绍URLClassPath时,有提到,在URL路径以/结尾且不是file协议时,会创建Loader对象,而Loader对象则是实现URL协议的关键。我们来看下Loader的关键代码,输入流是是调用URLConnection#getInputStream()方法获取。

private static class Loader implements Closeable {
    ...
    Resource getResource(final String name, boolean check) {
        ...
        return new Resource() {
            final URLConnection uc;
            try {
                ...
                uc = url.openConnection();
                ...
            } catch (Exception e) {
                return null;
            }
            ...
            public InputStream getInputStream() throws IOException {
                return uc.getInputStream();
            }
            ...
        };
    }
    ...
}

URLConnection是一个抽象类,URLConnection#openConnection()返回的是它的实现类实例,实例是由通过URL构造参数中protocol选出来的一个URLStreamHandler负责创建,一类URLStreamHandler负责创建一类URLConnection实例。因此,要实现URL协议扩展,就需要自定义URLConnection实现类,并实现一个URLStreamHandler类,用于自定义URLConnection实现类对象的创建。

/**
 * The URLStreamHandler for this URL.
 */
transient URLStreamHandler handler;

public URLConnection openConnection() throws java.io.IOException {
    return handler.openConnection(this);
}

摸清了URL协议的扩展原理,我们看看自定义URLStreamHandler类该如何使用,这里就需要从URL构造方法开始分析。URL构造方法非常多,我们选择其中一个来分析。如果未传入指定的URLStreamHandler,则通过URL#getURLStreamHandler(Srting)方法来获取JDK自带的URLStreamHandler,而JDK自带的URLStreamHandler都在sun.net.www.protocol包下。在这段代码中,对于jar协议有一段特殊的处理逻辑,如果Handler是JDK自带的URLStreamHandler,并且URL协议是嵌套jar格式,如jar:jar:file/filepath格式,…

public URL(String protocol, String host, int port, String file,
           URLStreamHandler handler) throws MalformedURLException {
    this.protocol = protocol;
    // ...
    // Note: we don't do full validation of the URL here. Too risky to change
    // right now, but worth considering for future reference. -br
    if (handler == null &&
        (handler = getURLStreamHandler(protocol)) == null) {
        throw new MalformedURLException("unknown protocol: " + protocol);
    }
    this.handler = handler;
    if (host != null && isBuiltinStreamHandler(handler)) {
        String s = IPAddressUtil.checkExternalForm(this);
        if (s != null) {
            throw new MalformedURLException(s);
        }
    }
    if ("jar".equalsIgnoreCase(protocol)) {
        if (handler instanceof sun.net.www.protocol.jar.Handler) {
            // URL.openConnection() would throw a confusing exception
            // so generate a better exception here instead.
            String s = ((sun.net.www.protocol.jar.Handler) handler).checkNestedProtocol(file);
            if (s != null) {
                throw new MalformedURLException(s);
            }
        }
    }
}

public String checkNestedProtocol(String spec) {
    if (spec.regionMatches(true, 0, "jar:", 0, 4)) {
        return "Nested JAR URLs are not supported";
    } else {
        return null;
    }
}

2.jpg

总结一下,URLClassLoader类加载器是通过URLConnection来读取Jar中的文件,而URLConnection对象则是由URLStreamHandler来创建。要实现嵌套Jar的类加载,自定义URLStreamHandler和URLConnection类,并在URL中指定自定义的URLStreamHandler类型,从而扩充URL协议,支持嵌套Jar的类加载。

SpringBoot加载原理

上一节介绍了扩充URL协议实现嵌套Jar的加载原理,我们知道,SpringBoot一般都是打成FatJar,FatJar的就是嵌套Jar,因此,SpringBoot的类加载器就典型的URL协议扩展,下面介绍下其是如何实现的。 SpringBoot提供了spring-boot-maven-plugin插件,可以非常方便的将SpringBoot项目打成FatJar包。我们对FatJar包解压,看看其中的结构,如下,我们发现在原有的工程中多了一部分包,org.springframework.boot.loader,该包就是spring-boot-maven-plugin插件拷贝进去的,用于FatJar的类加载以及服务的启动。

.
├── META-INF
│   └── MANIFEST.MF
├── BOOT-INF
│   ├── classes
│   │   └── 应用程序Class文件
│   └── lib
│       └── 第三方依赖Jar
└── org
    └── springframework
        └── boot
            └── loader
                └── springboot启动程序

SpringBoot使用Launcher代理启动,抽象了Archive的概念,作为统一访问资源的逻辑层。Archive有2种实现,一个是用于Jar的JarFileArchive,一个是用于文件目录ExplodedArchive),可以抽象为统一访问资源的逻辑层。 3.jpg 对于FatJar,会封装成JarFileArchive,内部使用JarFile代表对应的FatJar。JarFile并不是JDK自带的类,而是JDK自带类的子类,从下面的继承关系可以看到org.springframework.boot.loader.jar.JarFile继承了java.util.jar.JarFile,同时实现了Iterable迭代器。

public JarFileArchive(File file, URL url) throws IOException {
  this(new JarFile(file));
  this.url = url;
}

4.jpg JarFile的迭代器用于遍历JarFile压缩包中的JarEntry,比如BOOT-INF/lib/a.jar,BOOT-INF/lib/b.jar,下面的entries就是嵌套Jar内包含的所有Jar文件。对于内部每个JarEntry又会创建成JarFile,同时会生成RandomAccessData,实现Jar in Jar文件的随机访问。

public Iterator<java.util.jar.JarEntry> iterator() {
    return (Iterator) this.entries.iterator(this::ensureOpen);
}
  
private JarFile createJarFileFromFileEntry(JarEntry entry) throws IOException {
    if (entry.getMethod() != ZipEntry.STORED) {
     throw new IllegalStateException(
       "Unable to open nested entry '" + entry.getName() + "'. It has been compressed and nested "
         + "jar files must be stored without compression. Please check the "
         + "mechanism used to create your executable jar file");
    }
    RandomAccessData entryData = this.entries.getEntryData(entry.getName());
    return new JarFile(this.rootFile, this.pathFromRoot + "!/" + entry.getName(), entryData,
      JarFileType.NESTED_JAR);
}

下面再来看看JarFileArchive#getUrl()方法,该方法用来获取Archive的URL,用于URLClassLoader。在JarFileArchive#getUrl()方法中,还是调用JarFile#getUrl()来获取URL,URL是通过调用new URL("jar", "", -1, file, new Handler(this))来创建,在构造方法中传入了Handler,这就是我们上面介绍的自定义URLStreamHandler。

public URL getUrl() throws MalformedURLException {
    if (this.url == null) {
     String file = this.rootFile.getFile().toURI() + this.pathFromRoot + "!/";
     file = file.replace("file:////", "file://"); // Fix UNC paths
     this.url = new URL("jar", "", -1, file, new Handler(this));
    }
    return this.url;
}

再来看下Handler的实现,Handler继承了URLStreamHandler,在Handler#openConnection(URL)方法,会调用JarURLConnection#get(URL, JarFile)来获取嵌套Jar的URLConnection。 5.jpg

protected URLConnection openConnection(URL url) throws IOException {
  if (this.jarFile != null && isUrlInJarFile(url, this.jarFile)) {
   return JarURLConnection.get(url, this.jarFile);
  }
  try {
   return JarURLConnection.get(url, getRootJarFileFromUrl(url));
  }
  catch (Exception ex) {
   return openFallbackConnection(url, ex);
  }
}

再来看下JarURLConnection的实现,JarURLConnection同样也是自定义的URLConnection,继承自JDK自带的JarURLConnection。在该类中,JarURLConnection#get(URL, JarFile)方法中,扩展了URL协议,通过JarURLConnection#getInputStream(FileHeader)获取到压缩包内Class文件的输入流,实现最终的嵌套Jar内类文件的读取。

static JarURLConnection get(URL url, JarFile jarFile) throws IOException {
    StringSequence spec = new StringSequence(url.getFile());
    int index = indexOfRootSpec(spec, jarFile.getPathFromRoot());
    if (index == -1) {
     return (Boolean.TRUE.equals(useFastExceptions.get()) ? NOT_FOUND_CONNECTION
       : new JarURLConnection(url, null, EMPTY_JAR_ENTRY_NAME));
    }
    int separator;
    while ((separator = spec.indexOf(SEPARATOR, index)) > 0) {
     JarEntryName entryName = JarEntryName.get(spec.subSequence(index, separator));
     JarEntry jarEntry = jarFile.getJarEntry(entryName.toCharSequence());
     if (jarEntry == null) {
      return JarURLConnection.notFound(jarFile, entryName);
     }
     jarFile = jarFile.getNestedJarFile(jarEntry);
     index = separator + SEPARATOR.length();
    }
    JarEntryName jarEntryName = JarEntryName.get(spec, index);
    if (Boolean.TRUE.equals(useFastExceptions.get()) && !jarEntryName.isEmpty()
      && !jarFile.containsEntry(jarEntryName.toString())) {
     return NOT_FOUND_CONNECTION;
    }
    return new JarURLConnection(url, new JarFileWrapper(jarFile), jarEntryName);
  }
  
InputStream getInputStream(FileHeader entry) throws IOException {
    if (entry == null) {
        return null;
    }
    InputStream inputStream = getEntryData(entry).getInputStream();
    if (entry.getMethod() == ZipEntry.DEFLATED) {
        inputStream = new ZipInflaterInputStream(inputStream, (int) entry.getSize());
    }
    return inputStream;
}

基于URL协议扩充的嵌套Jar加载

前面2节介绍了Java URL协议的扩展原理以及SpringBoot针对FatJar文件的类加载原理,如果期望不解压Jar文件实现嵌套Jar的类加载,可以对于SpringBoot的类加载代码进行改造裁剪,来实现嵌套Jar的加载。这里改造相对较多,比如嵌套Jar文件的过滤,SpringBoot只会识别BOOT-INF/lib目录下的jar,改造的复杂度也不会太高,这里就不再说介绍,后续会把改造后的内容发出来。

总结

在Java中,使用Jar来对一类Class文件封装,便于Class文件的管理。原生的Java类加载器只支持单层Jar内Class的加载,并不支持嵌套Jar(Jar in Jar)内Class的加载。本文介绍了2种实现思路,第1这种通过解压Jar文件方式实现。第2种URL协议扩展原理和SpringBoot针对FatJar的类加载原理介绍基于URL协议扩展的类加载方案,实现嵌套Jar的加载。2种思路各有优劣,在实际项目中可以按需使用。

参考资料