程序员社区

Jar 包那些事 2


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());
Jar 包那些事 2插图
Jar 包那些事 2插图1

先看一下 handler 是在什么时候赋值的吧:

Jar 包那些事 2插图2
Jar 包那些事 2插图3

好,我们继续看下 handler.openConnection() 做了什么吧

Jar 包那些事 2插图4
Jar 包那些事 2插图5
Jar 包那些事 2插图6

接下来看下它的 getInputStream() 方法:

Jar 包那些事 2插图7

tag1

Jar 包那些事 2插图8

tag2

想看的自己看吧,我逃了v

科普1 —— URLStreamHandler

URLStreamHandler 的主要作用就是打开各种各样的资源(http、ftp、file 等)的链接。

Java 中使用 URL 来描述资源,而 URL 有一个方法 URL#openConnection() 用于打开链接。由于URL用于表达各种各样的资源,打开资源的具体动作由 java.net.URLStreamHandler 这个类的子类来完成。根据不同的协议,会有不同的 handler 实现。而 JDK 内置了相当多的handler实现用于应对不同的协议,比如 jar、file、http 等。

Jar 包那些事 2插图9

URL 内部有一个静态 HashTable 属性,用于保存已经被发现的协议和 handler 实例的映射。

Jar 包那些事 2插图10

使用自定义的 URLStreamHandler 有以下两种方法:

  1. 实现 URLStreamHandlerFactory 接口,通过方法 URL.setURLStreamHandlerFactory() 将其设置进去。该属性是一个静态属性,且只能被设置一次。JDK 有一个 URLStreamHandlerFactory 实现(但是不知道在哪用到)如下:
Jar 包那些事 2插图11
  1. 直接提供 URLStreamHandler 的子类,作为URL的构造方法的入参之一

注:在 JVM 中对 URLStreamHandler 的子类有固定的规范要求:子类的类名必须是Handler,同时最后一级的包名必须是协议的名称。比如自定义了Http的协议实现,则类名必然为xx.http.Handler;而且在 JVM 启动的时候,需要设置java.protocol.handler.pkgs系统属性,如果有多个实现类,那么中间用|隔开。因为JVM在尝试寻找Handler时,会从这个属性中获取包名前缀,最终使用包名前缀.协议名.Handler,使用Class.forName方法尝试初始化类,如果初始化成功,则会使用该类的实现作为协议实现。

示例:

Jar 包那些事 2插图12
Jar 包那些事 2插图13

科普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 包启动流程

Jar 包那些事 2插图14

上篇文章我们说了为啥 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)]

Jar 包那些事 2插图15

先看下它父类的构造

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 协议呢?

  1. 因为我们引入的 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 中资源(包括字节码和资源文件)的加载。

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

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要想启动加载,就需要自定义实现自己的类加载器去加载。

源码解析:

Jar 包那些事 2插图17

tag1 getClassPathArchives()

Jar 包那些事 2插图18
Jar 包那些事 2插图19

注:此处生成的 Archive 集合包含:一个 BOOT-INF/classes 目录的 Archive(classes 目录是一个 Archive) 和 BOOT-INF/lib 目录下 jar 包的 Archive(每个 jar 包是一个 Archive)。

Jar 包那些事 2插图20

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)

Jar 包那些事 2插图21
tag2.1 archive.getUrl()
Jar 包那些事 2插图22

当前类的类加载器为 AppClassLoader。

tag2.2 new LaunchedURLClassLoader(urls, classLoader)

参见 LaunchedURLClassLoader

3)将新的 ClassLoader 放入线程上下文中,并执行 MANIFEST.MF 文件里面 Statr-Class 属性指定类的 main 方法

Jar 包那些事 2插图23
Jar 包那些事 2插图24

LaunchedURLClassLoader

LaunchedURLClassLoader 继承了 URLClassLoader 主要重写了他的 loaderClass 方法,我们先看下它的构造:

Jar 包那些事 2插图25

再看下它的 loaderClass 方法:

Jar 包那些事 2插图26

tag1 definePackageIfNecessary(String className)

Jar 包那些事 2插图27

tag2 loaderClass()

loaderClass() 方法之前已经分析过了,主要做了双亲委派,其中与当前类加载器相关的方法时 findClass() 方法:

Jar 包那些事 2插图28

由于源码比较深,直接给大家看结果:

Jar 包那些事 2插图29

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

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

Jar 包那些事 2插图30

当新建了 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包启动核心原理及流程详解

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Jar 包那些事 2

一个分享Java & Python知识的社区