title: Jar 包那些事 2
date: 2021/01/08 15:12
remark: 本文基于 SpringBoot 2.2.5
引言
在做项目的时候写了以下代码,直接在 IDE 中运行发现是没有问题的,但是打成 jar 包运行就出现了文件没有找到的错误。
// jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/classes!/application.yml
URL url = SpringTestApplication.class.getResource("/application.yml");
String path = url.getPath();
// jar包执行时抛出空指针异常。
FileInputStream fileInputStream = new FileInputStream(path);
其实仔细想一想就知道是什么原因了,是因为 FileInputStream 无法解析 !/
从而无法读取到 jar 包中的文件,所以之后采用了下面这种写法,代码就可以正常执行了:
URL url = SpringTestApplication.class.getResource("/application.yml");
InputStream inputStream = url.openStream();
那么为什么这种方式就可以正常获取到流呢,它内部的实现和第一种有啥不同吗?我们来探索一下,测试代码如下:
URL url = new URL("jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/classes/application.yml");
System.out.println(url.openStream().read());
先看一下 handler 是在什么时候赋值的吧:
好,我们继续看下 handler.openConnection() 做了什么吧
接下来看下它的 getInputStream() 方法:
tag1
tag2
想看的自己看吧,我逃了v。
科普1 —— URLStreamHandler
URLStreamHandler 的主要作用就是打开各种各样的资源(http、ftp、file 等)的链接。
Java 中使用 URL 来描述资源,而 URL 有一个方法 URL#openConnection() 用于打开链接。由于URL用于表达各种各样的资源,打开资源的具体动作由 java.net.URLStreamHandler 这个类的子类来完成。根据不同的协议,会有不同的 handler 实现。而 JDK 内置了相当多的handler实现用于应对不同的协议,比如 jar、file、http 等。
URL 内部有一个静态 HashTable 属性,用于保存已经被发现的协议和 handler 实例的映射。
使用自定义的 URLStreamHandler 有以下两种方法:
- 实现 URLStreamHandlerFactory 接口,通过方法 URL.setURLStreamHandlerFactory() 将其设置进去。该属性是一个静态属性,且只能被设置一次。JDK 有一个 URLStreamHandlerFactory 实现(但是不知道在哪用到)如下:
- 直接提供 URLStreamHandler 的子类,作为URL的构造方法的入参之一
注:在 JVM 中对 URLStreamHandler 的子类有固定的规范要求:子类的类名必须是Handler,同时最后一级的包名必须是协议的名称。比如自定义了Http的协议实现,则类名必然为xx.http.Handler;而且在 JVM 启动的时候,需要设置java.protocol.handler.pkgs系统属性,如果有多个实现类,那么中间用|隔开。因为JVM在尝试寻找Handler时,会从这个属性中获取包名前缀,最终使用包名前缀.协议名.Handler,使用Class.forName方法尝试初始化类,如果初始化成功,则会使用该类的实现作为协议实现。
示例:
科普2 —— FatJar
什么是 Fat Jar?
简单地说就是胖 Jar 呗!哈哈!就是说这个 Jar 所装的东西比一般的 Jar 要多嘛!一般地,我们通过 maven 的插件 maven-jar-plugin 所打包生成的 jar 都是只包含我们项目的源码的,它不包含我们所依赖的第三方库,这样就会导致一个问题,如果我们的第三方库不在 CLASSPATH 下面会怎样?当然是会出现 java.lang.NoClassDefFoundError 的问题咯!因此,我们需要使用一种方式,能使得项目所依赖的第三方库能够随着我们的项目源码一起被打包,这样我们就完全不用担心类找不到的问题啦!这就是 Fat Jar 的来历!
如何打出 Fat Jar?
生成Fat Jar我们使用的是One-Jar这个Maven插件。具体配置代码如下:
<build>
<finalName>fat-jar-example</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<mainClass>cn.x5456.TestCanRunJar</mainClass>
</manifest>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>com.jolira</groupId>
<artifactId>onejar-maven-plugin</artifactId>
<version>1.4.4</version>
<executions>
<execution>
<goals>
<goal>one-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
代码:
public class TestCanRunJar {
public static void main(String[] args) throws ClassNotFoundException {
System.out.println(StrUtil.format("{}是只小傻狗", "哒哒"));
System.out.println(Thread.currentThread().getContextClassLoader());
System.out.println(TestCanRunJar.class.getClassLoader());
System.out.println(TestCanRunJar.class.getClassLoader().getParent());
}
}
输出:
java -jar fat-jar-example.one-jar.jar
哒哒是只小傻狗
com.simontuffs.onejar.JarClassLoader@610455d6
com.simontuffs.onejar.JarClassLoader@610455d6
sun.misc.Launcher$AppClassLoader@42a57993 # 他的父类加载器为加载
通过输出结果我们发现,我们的类加载器好像并不是使用的默认的 AppClassLoader,而是使用的 com.simontuffs.onejar.JarClassLoader
,我们代码中并没有这个类啊,这个类是哪来的呢,我们解压下打包出来的 fat jar,看下它的文件结构:
tree fat-jar-example.one-jar
fat-jar-example.one-jar
├── META-INF
│ └── MANIFEST.MF
├── OneJar.class
├── com
│ └── simontuffs
│ └── onejar
│ ├── Boot$1.class
│ ├── Boot$2.class
│ ├── Boot$3.class
│ ├── Boot.class # 由 MANIFEST.MF 得知启动类是 Boot
│ ├── Handler$1.class
│ ├── Handler.class # 自定义了一个 onejar 协议,实现了 URLStreamHandler#openConnection() 方法,通过这个方法我们可以在获取到 URL 对象的时候拿到他的 inputStream
│ ├── IProperties.class
│ ├── JarClassLoader$1.class
│ ├── JarClassLoader$2.class
│ ├── JarClassLoader$ByteCode.class
│ ├── JarClassLoader$FileURLFactory$1.class
│ ├── JarClassLoader$FileURLFactory.class
│ ├── JarClassLoader$IURLFactory.class
│ ├── JarClassLoader$OneJarURLFactory.class
│ ├── JarClassLoader.class # 自定义的类加载器,用于加载 lib 和 main 目录下的 jar 包
│ ├── OneJarFile$1.class
│ ├── OneJarFile$2.class
│ ├── OneJarFile.class
│ └── OneJarURLConnection.class
├── lib
│ └── hutool-all-4.6.1.jar # 项目依赖的 jar 包
└── main
└── fat-jar-example.jar # 打出的可执行 jar
感兴趣的可以自行看一下源码,因为没有办法运行,我也不知道我说的对不对,反正就是启动的时候,Boot 调用 JarClassLoader 的 load 方法,将需要加载的类和 jar 包加载到内存中(byte[]),当用到的时候从中查找并 defineClass。
SpringBoot 基于 jar 包启动流程
上篇文章我们说了为啥 MANIFEST.MF 文件中的 Main-Class 是 XXXLauncher,我们今天就介绍一下 JarLauncher 吧。为了看这部分代码,我们需要引入spring-boot-loader
的依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
</dependency>
Archive 的概念
SpringBoot 抽象了 Archive 的概念,一个 Archive 可以是 jar(JarFileArchive),可以是一个文件目录(ExplodedArchive),可以抽象为统一访问资源的逻辑层。
Spring Boot 中 Archive 的源码如下:
public interface Archive extends Iterable<Archive.Entry>, AutoCloseable {
/**
* 获取该归档的url
*/
URL getUrl() throws MalformedURLException;
/**
* 获取jar!/META-INF/MANIFEST.MF或[ArchiveDir]/META-INF/MANIFEST.MF
*/
Manifest getManifest() throws IOException;
/**
* 获取嵌套档案,即 jar!/BOOT-INF/lib/*.jar或[ArchiveDir]/BOOT-INF/lib/*.jar
*/
List<Archive> getNestedArchives(EntryFilter filter) throws IOException;
/**
* Closes the {@code Archive}, releasing any open resources.
* @throws Exception if an error occurs during close processing
* @since 2.2.0
*/
@Override
default void close() throws Exception {
}
/**
* 代表档案(jar包或文件夹)中的资源文件
*/
interface Entry {
/**
* Returns {@code true} if the entry represents a directory.
* @return if the entry is a directory
*/
boolean isDirectory();
/**
* Returns the name of the entry.
* @return the name of the entry
*/
String getName();
}
/**
* Strategy interface to filter {@link Entry Entries}.
*/
interface EntryFilter {
/**
* Apply the jar entry filter.
* @param entry the entry to filter
* @return {@code true} if the filter matches
*/
boolean matches(Entry entry);
}
}
该接口有两个实现,分别是 org.springframework.boot.loader.archive.ExplodedArchive 和 org.springframework.boot.loader.archive.JarFileArchive。前者用于在文件夹目录下寻找资源,后者用于在 jar 包环境下寻找资源。
JarFile
对jar包的封装,每个 JarFileArchive 都会对应一个 JarFile。JarFile 被构造的时候会解析内部结构,去获取 jar 包里的各个文件或文件夹,这些文件或文件夹会被封装到 Entry 中。
这个JarFile有很多Entry,比如:
META-INF/
META-INF/MANIFEST.MF
spring/
spring/study/
....
spring/study/executablejar/ExecutableJarApplication.class
lib/spring-boot-starter-1.3.5.RELEASE.jar
lib/spring-boot-1.3.5.RELEASE.jar
...
其他(我表示没看懂):附录D.2. Spring Boot的"JarFile"类
JarLauncher 源码
类图
[图片上传失败...(image-10474b-1610415059589)]
先看下它父类的构造
public abstract class ExecutableArchiveLauncher extends Launcher {
private final Archive archive;
public ExecutableArchiveLauncher() {
try {
// 主要就是将当前类所在的 jar 或者路径,创建一个 Archive 对象
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}
...
}
public abstract class Launcher {
...
protected final Archive createArchive() throws Exception {
ProtectionDomain protectionDomain = getClass().getProtectionDomain();
CodeSource codeSource = protectionDomain.getCodeSource();
URI location = (codeSource != null) ? codeSource.getLocation().toURI() : null;
String path = (location != null) ? location.getSchemeSpecificPart() : null;
if (path == null) {
throw new IllegalStateException("Unable to determine code source archive");
}
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
// 根据代码所在路径封装成不同的档案类型,毕竟有两种运行方式嘛:
// java org/springframework/boot/loader/JarLauncher path -> /Users/x5456/IdeaProjects/canRunJar/target/classes/
// java -jar ars.jar path -> /Users/x5456/IdeaProjects/canRunJar/target/jarstarter.jar
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}
...
}
再看下 Launcher#launch() 方法
public abstract class Launcher {
/**
* 启动应用程序。 此方法是子类public static void main(String[] args)方法应调用的初始入口点。
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
// 1)扩展 Jar 协议
JarFile.registerUrlProtocolHandler();
// 2)创建一个新的 ClassLoader
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 3)将新的 ClassLoader 放入线程上下文中,并执行 MANIFEST.MF 文件里面 Statr-Class 属性指定类的 main 方法
launch(args, getMainClass(), classLoader);
}
...
1)扩展 Jar 协议
为什么需要扩展 jar 协议呢?
- 因为我们引入的 jar 包中可能用到了自己类路径下的文件,此时他的 path 就可能是这样的:
jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/lib/canRunJar-1.0-SNAPSHOT.jar!/aaa.txt
其中有两个!/
,而默认的 jar 协议对应的 Handler 无法通过这样的路径打开链接,所以需要我们对其进行扩展,从而实现对 jar in jar 中资源(包括字节码和资源文件)的加载。
- 【非主要,其它实现也有不用 Handler 来做到的】Spring 通过自定义的 Handler 来复用 ClassLoader 原有的代码,使其支持获取 jar in jar 中的字节码文件。
2)创建一个新的 ClassLoader
为什么要创建新的 ClassLoader 呢?
- 因为 AppClassLoader 无法加载 jar 包中的 jar 包(可能是没有办法把带
!/
的路径当做类加载路径吧) - 因为他将编译出的字节码放在了 BOOT-INF/classes 目录下。
对于 Java 标准的 jar 文件来说,规定在一个 jar 文件中,我们必须要将指定 main.class 的类直接放置在文件的顶层目录中(也就是说,它不予许被嵌套),否则将无法加载,对于 BOOT-INF/class/路径下的 class 因为不在顶层目录,因此也是无法直接进行加载, 而对于BOOT-INF/lib/ 路径的 jar 属于嵌套的(Fatjar),也是不能直接加载,因此Spring要想启动加载,就需要自定义实现自己的类加载器去加载。
源码解析:
tag1 getClassPathArchives()
注:此处生成的 Archive 集合包含:一个 BOOT-INF/classes 目录的 Archive(classes 目录是一个 Archive) 和 BOOT-INF/lib 目录下 jar 包的 Archive(每个 jar 包是一个 Archive)。
System.getproperty("java.io.tmpdir") 是获取操作系统缓存的临时目录,不同操作系统的缓存临时目录不一样:
Windows的缓存目录为:C:\Users\登录用户~1\AppData\Local\Temp\
Linux:/tmp
Mac:/var/folders/28/1tyh6prj3xg6xcdx_3qlkwr80000gn/T/
但我为啥觉得他只能处理 3 层嵌套,再多了就不行了,不往下看了,不知道多层嵌套 jar 的这种情况是怎么出现的。
tag2 createClassLoader(List<Archive> archives)
tag2.1 archive.getUrl()
当前类的类加载器为 AppClassLoader。
tag2.2 new LaunchedURLClassLoader(urls, classLoader)
参见 LaunchedURLClassLoader
3)将新的 ClassLoader 放入线程上下文中,并执行 MANIFEST.MF 文件里面 Statr-Class 属性指定类的 main 方法
LaunchedURLClassLoader
LaunchedURLClassLoader 继承了 URLClassLoader 主要重写了他的 loaderClass 方法,我们先看下它的构造:
再看下它的 loaderClass 方法:
tag1 definePackageIfNecessary(String className)
tag2 loaderClass()
loaderClass() 方法之前已经分析过了,主要做了双亲委派,其中与当前类加载器相关的方法时 findClass() 方法:
由于源码比较深,直接给大家看结果:
org.springframework.boot.loader.jar.Handler
如果有jar包中包含jar,或者jar包中包含jar包里面的class文件,那么会使用 !/
分隔开,这种方式只有 org.springframework.boot.loader.jar.Handler 能处理,它是 SpringBoot 内部扩展出来的一种 URL 协议。
总结
各个组件的作用:
-
Jar/WarLaucher 定义扫描 jar 包和字节码的类路径
-
扩展了 java.util.jar.JarFile、URLStreamHandler、java.net.JarURLConnection 实现了 jar in jar 中资源的加载。
-
通过自定义类加载器 LauncherURLClassLoader,实现了jar in jar中class文件的加载。
-
JarFileArchive 相当于 JarFile 的适配器,适配了 Archive 接口,当然他内部逻辑也有很多。
- 对于多重 jar in jar,实际上是解压到了临时目录来处理,可以参考 JarFileArchive 里的代码。
扩展
本文是采用this.getClass().getResource("/")
获取的类路径地址,得到的是带!/
的路径,那么采用request.getServletContext().getRealPath("/");
获取到的地址是什么样的呢,经过测试发现,他会在使用 java -jar 命令的主机上新建一个缓存路径,在我的 mac 上路径就是这样:
# tomcat-docbase 前面的路径是 System.getProperty(java.io.tempdir)
/private/var/folders/28/1tyh6prj3xg6xcdx_3qlkwr80000gn/T/tomcat-docbase.2007207725651733988.8080
但是如果在这里保存了文件,重启的时候就找不到其中的文件了,因为重启后生成的缓存文件夹不一样。
当新建了 webapp 目录,jar 包启动的时候,他会指向 webapp 在主机上的路径,但是换了台主机,他依然会指向缓存文件夹
强烈建议看一下
springboot应用启动原理(一) 将启动脚本嵌入jar
springboot应用启动原理(二) 扩展URLClassLoader实现嵌套jar加载
SpringBoot重构Jar包源码分析
本文参考
使用Maven生成Fat Jar
Spring-Boot启动之前做了哪些事?
SpringBoot(二) 启动分析JarLauncher 图不错
SpringBoot源码分析之SpringBoot可执行文件解析 版本是 1.x 的
终于搞懂了SpringBoot的jar包启动原理
***SpringBoot基于jar包启动核心原理及流程详解