# 引言

在做项目的时候写了以下代码,直接在 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);
1
2
3
4
5

其实仔细想一想就知道是什么原因了,是因为 FileInputStream 无法解析 !/ 从而无法读取到 jar 包中的文件,所以之后采用了下面这种写法,代码就可以正常执行了:

URL url = SpringTestApplication.class.getResource("/application.yml");
InputStream inputStream = url.openStream();
1
2

那么为什么这种方式就可以正常获取到流呢,它内部的实现和第一种有啥不同吗?我们来探索一下,测试代码如下:

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());
1
2

先看一下 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 有以下两种方法:

  1. 实现 URLStreamHandlerFactory 接口,通过方法 URL.setURLStreamHandlerFactory() 将其设置进去。该属性是一个静态属性,且只能被设置一次。JDK 有一个 URLStreamHandlerFactory 实现(但是不知道在哪用到)如下:

  1. 直接提供 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

代码:

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());
    }
}
1
2
3
4
5
6
7
8
9

输出:

java -jar fat-jar-example.one-jar.jar
哒哒是只小傻狗
com.simontuffs.onejar.JarClassLoader@610455d6
com.simontuffs.onejar.JarClassLoader@610455d6
sun.misc.Launcher$AppClassLoader@42a57993       # 他的父类加载器为加载
1
2
3
4
5

通过输出结果我们发现,我们的类加载器好像并不是使用的默认的 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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

感兴趣的可以自行看一下源码,因为没有办法运行,我也不知道我说的对不对,反正就是启动的时候,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>
1
2
3
4

# 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);

	}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61

该接口有两个实现,分别是 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
...
1
2
3
4
5
6
7
8
9

其他(我表示没看懂):附录 D.2. Spring Boot 的"JarFile"类

# JarLauncher 源码

类图

先看下它父类的构造

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));
	}

    ...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

再看下 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 1)扩展 Jar 协议

为什么需要扩展 jar 协议呢?

  1. 因为我们引入的 jar 包中可能用到了自己类路径下的文件,此时他的 path 就可能是这样的:
jar:file:/Users/x5456/IdeaProjects/spring-test/target/ars.jar!/BOOT-INF/lib/canRunJar-1.0-SNAPSHOT.jar!/aaa.txt
1

其中有两个!/,而默认的 jar 协议对应的 Handler 无法通过这样的路径打开链接,所以需要我们对其进行扩展,从而实现对 jar in jar 中**资源(包括字节码和资源文件)**的加载。

  1. 【非主要,其它实现也有不用 Handler 来做到的】Spring 通过自定义的 Handler 来复用 ClassLoader 原有的代码,使其支持获取 jar in jar 中的字节码文件。

# 2)创建一个新的 ClassLoader

为什么要创建新的 ClassLoader 呢?

  1. 因为 AppClassLoader 无法加载 jar 包中的 jar 包(可能是没有办法把带!/的路径当做类加载路径吧)
  2. 因为他将编译出的字节码放在了 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 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
1
2

但是如果在这里保存了文件,重启的时候就找不到其中的文件了,因为重启后生成的缓存文件夹不一样。

当新建了 webapp 目录,jar 包启动的时候,他会指向 webapp 在主机上的路径,但是换了台主机,他依然会指向缓存文件夹

# 本文参考

使用 Maven 生成 Fat Jar

Spring-Boot 启动之前做了哪些事?

SpringBoot(二) 启动分析 JarLauncher 图不错

SpringBoot 源码分析之 SpringBoot 可执行文件解析 版本是 1.x 的

终于搞懂了 SpringBoot 的 jar 包启动原理

***SpringBoot 基于 jar 包启动核心原理及流程详解