URL
date
AI summary
slug
status
tags
summary
type

前言

在前面的文章我们罗列了创建可执行jar包的N种方式。
对于暴力型和半优雅型,没有什么特别之处。而对于优雅型,我们有必要探究一下它的原理。
本篇文章就以spring-boot的依赖包处理机制为例来探究优雅型的jar包究竟是如何实现的。

SpringBoot fatJar的包内结构

首先,我们看看spring-boot-maven-plugin创建出来的jar包的包内结构
example.jar | +-META-INF | +-MANIFEST.MF #1 - MANIFEST.MF +-org | +-springframework | +-boot | +-loader #2 - spring boot loader classes | +-<spring boot loader classes> +-BOOT-INF +-classes #3 - project classes | +-mycompany | +-project | +-YourClasses.class +-lib #4 - nested jar libraries +-dependency1.jar +-dependency2.jar
我们把jar包里的文件分成4类:
  1. META-INF/MANIFEST.MF
  1. spring-boot-loader 启动相关类文件 org.springframework.boot.loader
  1. 应用类文件 BOOT-INF/classes
  1. 应用依赖包 BOOT-INF/lib
我们先来看看MANIFEST.MF文件,关于它的文件规范你可以参考官方文档:https://docs.oracle.com/javase/8/docs/technotes/guides/jar/jar.htm
notion image
可以看到,引导jar包启动的Main-Class并不是我们的主类,而是spring-boot-loader体系里的启动类。而我们的启动类被放在Start-Class属性里。这个属性并不是标准MANIFEST.MF文件规范里的属性,而是SpringBoot自定义的,猜测肯定会在JarLaunchermain方法里被使用。那我们就从这个Main-Class——org.springframework.boot.loader.JarLauncher入手来看看SpringBoot究竟做了什么

启动类——org.springframework.boot.loader.JarLauncher

notion image
JarLauncher.main主要做了3件事情:
  1. 注册自定义jar协议解析器——org.springframework.boot.loader.jar.Handler
  1. 创建自定义类加载器——org.springframework.boot.loader.LaunchedURLClassLoader
  1. 用自定义的类加载器加载我们真正的启动类,并反射调用它的静态启动方法main方法

LaunchedURLClassLoader和URLClassLoader

第一步注册的jar协议解析器其实是在第二步创建的LaunchedURLClassLoader的体系里发挥作用的。所以我们先来看看LaunchedURLClassLoader以及它的父类URLClassLoaderURLClassLoader扩展了ClassLoader的功能,允许从URL指定的位置加载类。这些URL可以指向本地文件系统上的目录或JAR文件,也可以是远程服务器上的资源。对于不同协议的处理,这里就要引出URLStreamHandler这个接口了。它就是URLClassLoader里负责解析url协议的解析器。
URLClassLoader遵循双亲委派机制,所以我们重点关注findClass的实现(如未做特殊说明,本篇文章涉及到的jdk源码都基于版本Azul Zulu Version 11.0.15
notion image
可以看到这里的实现是先把全限定类名转换成了文件路径,然后委托给了ucp(URLClassPath)去查找对应的类文件。查找到类文件之后,会调用defineClass方法读取类文件的内容并转换成Class对象。我们先看通过ucp查找类文件的过程:
notion image

Loader

ucp查找类文件是通过Loader进行的,什么是Loader呢?其实Loader对应classpath下的每个jar包或者目录(我们创建UrlClassLoader时候会传入一组URL,这个就是classpath)
notion image
有一点需要注意的是,如果这个URL对应的是一个jar包,它还会根据jar包里的MANIFEST.MF文件取到Class-Path属性,并把其值也加入到这个URL列表里,后续在遍历过程中也会转换成Loader并进行资源查找。也就是说,MANIFEST.MFClass-Path属性具有传递性。
notion image
Loader总共有3种:FileLoaderJarLoaderLoaderLoaderFileLoaderJarLoader的基类,可以认为:Loader可以处理各种协议,而FileLoader是专门针对文件协议的Loader (但不包含jar文件),JarLoader则是专门针对jar包的Loader。但是Java自带的JarLoader并不支持jar in jar这种解析。而SpringBoot则是利用了基类Loader的通用流程,实现了自定义的jar协议解析器,支持了jar in jar的解析。下面我们重点来看一下基类Loader的通用流程(Loader.getResource):
notion image
逻辑很简单,就是直接调用url.openConnection方法获取到inputStream读取文件。我们再看看url.openConnection
notion image
直接调用的是handler.openConnection,这个handler其实就是这个URL对应的处理器。

URLStreamHandler

那么一个URL怎么找到它对应的协议处理器呢?答案是有两种方案

1. 创建URL的时候手动指定

notion image

2. 未手动指定时,根据协议寻找其解析器

如果没有手动指定,URLClassLoader会根据对应的协议从预先定义好的一系列包路径下去找对应的解析器,当然这个包路径我们可以自定义,并且自定义的优先级更高
notion image

串起来了

还记得JarLauncher.main启动的第一步吗?就是注册自定义jar协议的解析器:
notion image
其中PROTOCOL_HANDLER的常量值为java.protocol.hander.pkgs,而HANDLERS_PACKAGE的常量值为org.springframework.boot.loader。由于我们处理的是jar协议,所以org.springframework.boot.loader拼上对应的jar协议,就找到了对应的jar协议处理器——org.springframework.boot.loader.jar.Handler
URL和Handler通过openConnection方法联系到一起,在这个方法里,SpringBoot通过自定义的org.springframework.boot.loader.jar.JarURLConnection扩展了java.net.JarURLConnection,实现了jar in jar的解析。
另外,我们发现SpringBoot同样也使用了第一种方式(在JarFileArchive这种场景下,也就是fatJar的场景),也就是在创建URL的时候手动指定了URLStreamHandler
notion image
不过这两种方式调用的构造函数不一样,其中,手动指定Handler的方式调用的是有参数的构造函数,并传入了一个jarFile,这个其实就是SpringBoot打好包的fat jar
而对于隐式创建的Handler,调用的是无参构造器,也就是没有一个jarFile,而是要通过传入的url先去得到root jar

从一个URL最终读取到内容的过程

我们简单总结一下:
  1. SpringBoot注册了一个Handler来处理”jar:”这种协议的URL
  1. SpringBoot扩展了JarFileJarURLConnection,内部处理jar in jar的情况
  1. 在处理多重jar in jar的URL时,SpringBoot会循环处理,并缓存已经加载到的JarFile
  1. 对于多重jar in jar,实际上是解压到了临时目录来处理,可以参考JarFileArchive里的代码(这里的多重jar in jar应该指的是至少两重才会解压到临时目录)
  1. 在获取URL的InputStream时,最终获取到的是JarFile里的JarEntryData

jarMode

在阅读源码的过程中,我们发现了一个叫做jarMode的系统变量,它看起来会决定最终的启动类
notion image
正常模式下的启动类应该是Application(@SpringBootApplication注解的类),而对于jarMode模式,启动类则会变成org.springframework.boot.loader.jarmode.JarModeLauncher
notion image
它利用了SpringBoot的spring-factoriesSPI来加载JarMode实现类,根据jarmode系统属性值来匹配具体的实现类,并调用它的run方法。这相当于变相地改变了应用的启动行为。官方开发jarmode的一个用途是用来制作镜像的分层工具。具体可以看:https://juejin.cn/post/6844904182743302151
notion image
而对于org.springframework.boot.loader.jarmode.路径下的类,由于加载都是发生在JarModeLauncher里,而这个类是已经切换成LaunchedUrlClassLoader来加载了。所以需要在LaunchedUrlClassLoader加载类的时候加上特殊逻辑——要使用SystemClassLoader加载,因为这个类是在打好的jar包的根目录下的。这里也隐含了我们无法在应用的依赖jar包里自定义实现JarMode类,而是必须利用打包工具打到jar包的根目录下。

definePackageIfNecessary

仔细的你肯定也发现了,LaunchedUrlClassLoader还有这么一个方法,它是干嘛的呢?我们先看看URLClassLoader,前面分析到资源查找,找到资源之后,就会调用defineClass方法:
notion image
可以看到,这里也有definePackage的动作,但是会根据资源的manifest是否为空,走两条不同的分支,后者的各种数据都为null,而前者用的是manifest里取出来的属性值。而这个manifest只有JarLoader加载的资源才会设值,而SpringBoot是用通用的Loader加载的,所以这里走的是后一个分支。如果真这么走的话,可能会存在问题。所以SpringBoot需要前置解决这个问题,也就是在走UrlClassLoader加载类之前,先通过自己的方式人为的把package定义好,这样走到这里的getAndVerifyPackage就不为空了。
不过这里definePackageIfNecessary看起来是比较低效的,因为每一次都要遍历整个urls,直到匹配到为止:
notion image

三种运行模式

SpringBoot应用支持三种运行模式

在IDE里直接运行

这种运行方式并没有用到上文提到的技术,没有用到LaunchedURLClassLoader,并且启动方法直接就是我们自己的Application类里的main方法。在这种模式下,IDE负责把所有的依赖包添加到SystemClassLoaderclasspath

以fat jar方式运行

fat jar的模式就使用了上文说到的技术,存在2个类加载器:
  1. SystemClassLoaderclasspath只包含fat jar本身
  1. LaunchedURLClassLoaderclasspath包含了fat jar里BOOT-INF/lib下的所有jar包,它的parent是SystemClassLoader

解压fat jar运行

这是解压(exploded)运行的场景,这种模式和fat jar模式类似。不过SystemClassLoaderclasspath变成了目录,并且LaunchedURLClassLoaderclasspath也没有jar in jar的情况了。兼容性可能会更好

参考

  1. https://iqeq00.gitbook.io/microservices-book/springboot/loader
  1. 这篇文章分享了1个不同类加载器导致的问题 https://blog.andycen.com/2020/06/14/Spring-Boot原理解析之类加载机制/
  1. springboot应用启动原理(一) 将启动脚本嵌入jar https://segmentfault.com/a/1190000013489340
  1. springboot应用启动原理(二) 扩展URLClassLoader实现嵌套jar加载 https://segmentfault.com/a/1190000013532009
  1. https://juejin.cn/post/7084532763625259038
  1. https://docs.spring.io/spring-boot/docs/2.1.14.RELEASE/reference/html/executable-jar.html
  1. https://hengyun.tech/spring-boot-application-start-analysis/
  1. https://hengyun.tech/spring-boot-classloader/
  1. https://dzone.com/articles/spring-boot-classloader-and-class-override
spring-cloud-gateway内存泄漏?spring-cloud-gateway跨域配置两三事