背景
在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文件加载的。
从上面的继承关系可以看出,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;
}
}
总结一下,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),可以抽象为统一访问资源的逻辑层。
对于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;
}
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。
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种思路各有优劣,在实际项目中可以按需使用。