SpringBoot的main函数运行之前都发生了什么

前言

SpringBoot项目通常都是由主类的main函数开始启动,好奇心驱使我想搞明白通常项目所有的内容都被打成了一个fat jar,按理说jar包中再包含的jar是没有办法被jdk加载的,所以这个过程SpringBoot又是如何让单个jar直接运行起来的?

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);

spring-boot-loader

通过解压SpringBoot的maven插件二次打包的jar,可以看到目录如下:

BOOT-INF/classes 下是spring-boot项目中编写的java源码编译后的class BOOT-INF/lib 下是spring-boot项目依赖的所有jar包 META-INF 是jar的信息,包含主类和sring-boot添加的额外的信息记录
-org.springgramework 下则是maven插件装载进去的class文件,也就是fat jar可以运行起来的源码 ├── BOOT-INF │ ├── classes │ └── lib ├── META-INF │ ├── MANIFEST.MF │ └── maven └── org └── springframework

当然,直接看org.springframework下反编译的源码有点晦涩,毕竟是反编译而来的。查看spring boot的源码可以在spring-boot-tools项目下看到spring-boot-loader子项目,这个其实就是maven插件装载到fat jar中的class文件的源码,所以阅读这个子项目的源码基本就可以搞清楚,spring boot的fat jar是如何把自己跑起来的。

SpringBoot项目的启动方式

1. idea中的启动

通常在IDEA中默认的启动方式是直接通过主类启动,所有依赖的jar都通过jdk的参数添加进来。很明显,这种启动方式没有借助于spring-boot-loader,是正常的java程序运行方式启动。

这种启动方式经常用于开发,毕竟直接启动更快一些。但是也有弊端,那就是通过command line的形式启动时,如果依赖的jar过多,会导致拼接的命令过长而报错,所以此中方式通常没有办法用于中大型项目

除了在idea中借助于开发工具拼接运行命令之外,spring boot支持三种常见的启动方式。

  • properties
  • 2. jar

    jar方式就是借助于spring boot的maven插件二次打包后的fat jar的形式启动。对应spring-boot-loader项目中的JarLauncher类,源码如下(源码中的注释,部分翻译,部分为我自己添加,帮助阅读):

    * 用于基于JAR形式的启动,该jar依赖的所有的其他jar包在/BOOT-INF/lib路径下 * 该jar对应的spring boot的项目的java类全部位于/BOOT-INF/classes下 public class JarLauncher extends ExecutableArchiveLauncher { // 依赖的class文件的路径 static final String BOOT_INF_CLASSES = "BOOT-INF/classes/"; // 依赖的其他jar文件的路径 static final String BOOT_INF_LIB = "BOOT-INF/lib/"; public JarLauncher() { protected JarLauncher(Archive archive) { super(archive); // 判断entry是否为内嵌依赖jar的,判断的依据是名称 @Override protected boolean isNestedArchive(Archive.Entry entry) { if (entry.isDirectory()) { return entry.getName().equals(BOOT_INF_CLASSES); return entry.getName().startsWith(BOOT_INF_LIB); // jar形式的main-class public static void main(String[] args) throws Exception { new JarLauncher().launch(args);

    由于jar形式的启动是最常见的方式,所以本文会着重jar形式启动的分析。

    3. war包形式

    在spring boot之前,大多数的spring mvc项目都是打成war包,置于tomcat的webapp目录下来运行,所以springboot也是支持这种形式的启动,只要在spring boot的maven插件中将打包的目前格式改为WAR即可。在loader项目中对应的启动类为:WarLauncher

    * 注释翻译:用于war包形式的启动,只支持标准的WAR归档文件。 * 三方依赖的jar位于 WEB-INF/lib, 也可以为WEB-INF/lib-provided, * 项目的class文件位于WEB-INF/classes路径下。 public class WarLauncher extends ExecutableArchiveLauncher { private static final String WEB_INF = "WEB-INF/"; private static final String WEB_INF_CLASSES = WEB_INF + "classes/"; private static final String WEB_INF_LIB = WEB_INF + "lib/"; private static final String WEB_INF_LIB_PROVIDED = WEB_INF + "lib-provided/"; // 部分代码删除。。。 // 启动主类 public static void main(String[] args) throws Exception { new WarLauncher().launch(args);

    4. 基于配置属性的形式启动

    基于自定义配置的启动方式,兼容Fat JAR。这种启动方式就比较灵活,可以通过三方插件将项目打成多种格式,或者不二次打包等等,最后通过配置解析来启动spring boot项目。

    比如,可以将依赖,配置文件,启动类打包到指定目录,然后按照如下方式启动:

    java -Dloader.main=xxx.xxx.Application \ # 主类
    -Dloader.path=lib,config,resource,xxx.jar \  依赖和配置资源
    -Dspring.profiles.active=dev \  // profiles
    org.springframework.boot.loader.PropertiesLauncher // 启动类
    

    所以Spring Boot的loader项目,就是提供spring boot应用可以在不同场景和需求下都可以正常启动的能力,完成了从打包和实际项目运行的桥接过程。

    接下来,我们以JAR启动的方式,来分析分析,Spring boot到底是如何完成启动过程的:

    可执行Jar启动

    jar形式的启动,主类为JarLauncher,其继承自ExecutableArchiveLauncher,最上层的父类为Launcher,同时也是所有其他启动形式的顶层父类。

  • 第一是注册扩展protocol handler
  • 第二是获取fat jar中所有的归档(三方jar,class,资源文件等等)来创建自定义的类加载器(ClassLoader)
  • 最后使用创建好的类加载器,携带启动参数,创建主类启动对象,启动主类(主类在loader中为Start Class,其实就是spring boot应用的启动类,在spring的maven插件中被打包定义为Start class)
  • protected void launch(String[] args) throws Exception {
        // 注册URL protocol handler
        JarFile.registerUrlProtocolHandler();
        // 获取fat jar中的archives(也就是三方jar,class,以及其他资源文件),创建类加载器
        ClassLoader classLoader = createClassLoader(getClassPathArchives());
        // 获取sub class(也就是spring boot 应用的主类),使用启动参数和创建好的class loader启动
        launch(args, getMainClass(), classLoader);
    

    接下来,我们针对这三个步骤展开来讲。

    1注册扩展UrlProtocolHandler

    其实这个方法相当于在启动java应用时添加参数:-Djava.protocol.handler.pkgs=xxx.xxx.xxx,其作用就是对Url类支持的协议进行扩展。多个指定的包的地址使用|来连接。

    * 翻译:注册一个handler,以便定位URLStreamHandler来处理jar urls * Register a {@literal 'java.protocol.handler.pkgs'} property so that a * {@link URLStreamHandler} will be located to deal with jar URLs. public static void registerUrlProtocolHandler() { // 获取当前jvm中的handler参数 String handlers = System.getProperty(PROTOCOL_HANDLER, ""); // 如果已有参数为空,则直接指定springboot的handler,否则|拼接进行扩展 System.setProperty(PROTOCOL_HANDLER, ("".equals(handlers) ? HANDLERS_PACKAGE : handlers + "|" + HANDLERS_PACKAGE)); // 最后重置缓存的handler resetCachedUrlHandlers();

    那Spring boot扩展这个来干嘛呢,方法注释上说时为了定位URLStreamHandler来处理jar文件,后续我们分析的过程中再继续看。

  • 获取ClassPath下的文件资源
  • createClassLoader(getClassPathArchives());虽然第二个步骤只有一句话,但这其实就是SpringBoot可以直接启动jar文件的核心逻辑,所以展开来讲,首先是获取jar中的资源文件。

    getClassPathArchives在Launcher中是abstract的,其具体实现在ExecutableArchiveLauncher中。getClassPathArchives的实现其实代码不多,核心方法是getNestedArchives(获取嵌套的jar等文件)。看到这里其实我们就能稍微理解为什么Spring Boot能够直接启动并直接嵌套自身jar中的其他jar了,其逻辑就是通过某种方式解析并获取jar(猜测是作为普通资源文件获取,然后读内存或者写到其他目录,再加载进来,不过因为我已经读过了,所以猜测其实是对的,哈哈哈)然后传递给自定义的classLoader加载,从而完成了依赖的jar的加载。

    * 获取class path下的文件,jar启动方式其实主要是获取嵌套在fat jar中的其他三方jar @Override protected List<Archive> getClassPathArchives() throws Exception { // 获取嵌套的文档文件, List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive)); // 后置处理归档文件 postProcessClassPathArchives(archives); // 返回结果 return archives;

    看着这里其实有点疑惑,this.archive是啥,之前没有提到过。JarLauncher刚刚是在main方法中无参new的,所以就是隐含的执行ExecutableArchiveLauncher的无参的构造方法,这个archive就是在那个时候实例化的。

    public ExecutableArchiveLauncher() {
        try {
            this.archive = createArchive();
        catch (Exception ex) {
            throw new IllegalStateException(ex);
    

    ok,所以这个时候需要搁置刚才的逻辑,先看看这个archive是什么东西,才能接着看它是如何获取jar中的archives的。

    createArchive方法主要有两个逻辑,首先是获取当前类对应的绝对路径。接着判断,如果绝对路径对应的是目录,则archive就是ExplodedArchive,当前我们假设是用jar启动的,那绝对路径对应的就是jar文件本省,此时this.archive就会被实例化成JarFileArchive

    看到这里就清楚了,this.archiveJarFileArchive的实例。所以,获取jar中的archive逻辑就是在这个类中实现的。

    ExplodedArchive的实现会用于warproperties的启动形式的archives的获取。

    * 创建Archive protected final Archive createArchive() throws Exception { // 反射获取当前jar的 protectionDomain,可以理解为一个jar会对应一个ProtectionDomain,主要是jar中资源的权限检查和控制 ProtectionDomain protectionDomain = getClass().getProtectionDomain(); // 然后获取当前类的codeSource CodeSource codeSource = protectionDomain.getCodeSource(); // 最后获取当前类的路径URL,再拿到path 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"); // 有了path,就可以将其包装为java的抽象文件类 File root = new File(path); if (!root.exists()) { throw new IllegalStateException("Unable to determine code source archive from " + root); // 最后如果是目录,archive就会被实例化成ExplodedArchive, 如果是Jar形式启动,那就是非目录,所以实例化成JarFileArchive return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));

    到这里,就到最重要的逻辑:解析fat jar中的资源文件,包括三方jar,class文件,资源文件等等。

    首先看入口方法,方法的逻辑很简单。首先是迭代自身,获取entry,第二是包装entry为Archive,最后返回。所以对应的搞清楚这两个逻辑,就能理解jar中的资源文件是如何解析的。

    * 获取嵌套的archives * @param filter the filter used to limit entries @Override public List<Archive> getNestedArchives(EntryFilter filter) throws IOException { List<Archive> nestedArchives = new ArrayList<>(); // 迭代自己本身,通过外部传递的isNestedArchive,JarLauncher中实现了,通过class的前缀判断 for (Entry entry : this) { if (filter.matches(entry)) { // 通过entry,获取并包装为Archive nestedArchives.add(getNestedArchive(entry)); // 包装为不可变集合返回 return Collections.unmodifiableList(nestedArchives);

    EntryIterator

    首先是自身的迭代器,通过内部类EntryIterator来实现,这里的逻辑很简单不赘述。核心就一句话,entries都是通过this.jarFile获取的。所以核心逻辑就在JarFile类中 。

    JarFile

    基础jdk的JarFile进行扩展的子类,类注释上解释说扩展的功能有两点。

  • 获取嵌套的jar中的任一目录下的文件
  • 获取嵌套的jar中的jar文件
  • finally,到了解析自身jar最核心的逻辑了。看完JarFile类就能明白~!

    JarFileEntry

    JarFile中有一个很重要的类: JarFileEntry,其类图如下 :

    image.png

    首先是其实现了迭代接口,用于jar中entry的迭代遍历。第二个比较重要的就是实现了中央目录的Visitor,这个是核心。借助于CentralDirectoryParser类,在RandomAccessDataloader.dat下的类,辅助数据读取)的帮助下,解析并遍历了整个JarFile中的文件,然后JarFileEntry作为visitor被setCentralDirectoryParser中,也完成了整个JarFile中的文件的遍历,并将其缓存在entriesCache中。entriesCache是一个被同步的synchronizedMap包裹的LinkedHashMap。上文提到的EntryIterator迭代数据其实就来自于这里的map缓存的数据。