SpringBoot(二) 启动分析JarLauncher

我们在开发过程中,使用 java -jar you-jar-name.jar 命令来启动应用,它是如何启动?以及它如何去寻找 .class 文件并执行这些文件?本节就带着这两个问题,让我们一层层解开 SpringBoot 项目的 jar 启动过程,废话不多说,跟着我的脚步一起去探索 spring-boot-load 的秘密。

SpringBoot(一)初识 已经解释了为什么在编译后的 jar 中根目录存在 org/springframework/boot/loader 内容,以及为了方便学习研究,我们需要在项目的依赖中导入 org.springframework.boot:spring-boot-loader 依赖。同时我们在解压的 you-jar-name.jar 文件中,查看对应的清单文件 MANIFEST.MF 内容,其中明确指出了应用的入口 org.springframework.boot.loader.JarLauncher 因此我们就从 JarLauncher 开始一步步深入

spring-boot-loader-jarlauncher

结构

先用Diagrams来表述 JarLauncher 类之间的结构及方法等相关信息
jarlauncher

从Diagrams可知

  • 继承关系:JarLauncher extends ExecutableArchiveLauncher extends Launcher
  • 启动入口:JarLauncher main 方法

关于图上图标含义,这里就不再赘述,烦请移步 IntelliJ IDEA Icon reference

流程分析

jar规范

对于 Java 标准的 jar 文件来说,规定在一个 jar 文件中,我们必须要将指定 main.class 的类直接放置在文件的顶层目录中(也就是说,它不予许被嵌套),否则将无法加载,对于 BOOT-INF/class/ 路径下的 class 因为不在顶层目录,因此也是无法直接进行加载, 而对于 BOOT-INF/lib/ 路径的 jar 属于嵌套的(Fatjar),也是不能直接加载,因此 Spring 要想启动加载,就需要自定义实现自己的类加载器去加载。

关于 jar 官方标准说明请移步

  • JAR File Specification
  • JAR (file format)

源码分析

main 方法

根据清单文件 MANIFEST.MFMain-Class 的描述,我们知道入口类就是 JarLauncher;先看下这个类的 javadoc 介绍

1
2
3
4
5
6
7
8
9
10
11
/**
* {@link Launcher} for JAR based archives. This launcher assumes that dependency jars are
* included inside a {@code /BOOT-INF/lib} directory and that application classes are
* included inside a {@code /BOOT-INF/classes} directory.
*
* 用于基于JAR的归档。这个启动程序假设依赖jar包含在{@code /BOOT-INF/lib}目录中,
* 应用程序类包含在{@code /BOOT-INF/classes}目录中
*
* @author Phillip Webb
* @author Andy Wilkinson
*/

紧接着,要进行源码分析,那肯定是找到入口,一步步深入,那么对于 JarLauncher 就是它的 main 方法了

1
2
3
4
public static void main(String[] args) throws Exception {
// launch 方法是调用父类 Launcher 的 launch 方法
new JarLauncher().launch(args);
}

那我们去看一看 Launcher 的 launch 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* Launch the application. This method is the initial entry point that should be
* called by a subclass {@code public static void main(String[] args)} method.
*
* 启动一个应用,这个方法应该被初始的入口点,这个入口点应该是一个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. 注册一些 URL的属性
JarFile.registerUrlProtocolHandler();
// 2. 创建类加载器(LaunchedURLClassLoader),加载得到集合要么是BOOT-INF/classes/
// 或者BOOT-INF/lib/的目录或者是他们下边的class文件或者jar依赖文件
ClassLoader classLoader = createClassLoader(getClassPathArchives());
// 3. 启动给定归档文件和完全配置的类加载器的应用程序
launch(args, getMainClass(), classLoader);
}

getClassPathArchives 方法

launch 方法的第一步的相关内容比较简单,这里不做过多说明,主要后面两步,我们先看第二步,创建一个类加载器(ClassLoader),其中 getClassPathArchives() 方法是一个抽象方法,具体的实现有(ExecutableArchiveLauncherPropertiesLauncher ,因为我们研究的 JarLauncher 是继承 ExecutableArchiveLauncher ,因此我们这里看 ExecutableArchiveLauncher 类中 getClassPathArchives() 方法的实现)我们要看看这个方法中它做了什么
1
2
3
4
5
6
7
8
9
10
11
@Override
protected List<Archive> getClassPathArchives() throws Exception {
// 得到一个Archive的集合(BOOT-INF/classes/)和(BOOT-INF/lib/)目录所有的文件
// a. this.archive 中当前类的 archive 是怎么来的?
// b. getNestedArachives()是如何获得一个嵌套的 jar 归档?
// c. this::isNestedArchive 这个方法引用它做了什么?
List<Archive> archives = new ArrayList<>(this.archive.getNestedArchives(this::isNestedArchive));
// 一个事后处理的方法
postProcessClassPathArchives(archives);
return archives;
}
this.archive 位于当前类 ExecutableArchiveLauncher 的构造方法中
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
public ExecutableArchiveLauncher() {
try {
// 调用 createArchive() 方法得到Archive
this.archive = createArchive();
}
catch (Exception ex) {
throw new IllegalStateException(ex);
}
}

/////////////////////////////////////////////////////////////
// 紧接着我们查看 createArchive() 方法都做了什么 //
/////////////////////////////////////////////////////////////

// Launcher.class 中的 createArchive()方法
// 得到我们运行文件的Archive相关的信息
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");
}
// 返回我们要执行的jar文件的绝对路径(java -jar xxx.jar中 xxx.jar的绝对路径)
File root = new File(path);
if (!root.exists()) {
throw new IllegalStateException("Unable to determine code source archive from " + root);
}
return (root.isDirectory() ? new ExplodedArchive(root) : new JarFileArchive(root));
}

对于 getNestedArachives() 方法,它是 Archive 的接口

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
/**
* Returns nested {@link Archive}s for entries that match the specified filter.
*
* 返回与过滤器相匹配的嵌套归档文件
*
* @param filter the filter used to limit entries
* @return nested archives
* @throws IOException if nested archives cannot be read
*/
List<Archive> getNestedArchives(EntryFilter filter) throws IOException;

/////////////////////////////////////////////////////////////
// 紧接着我们查看 getNestedArchives() 的实现 //
/////////////////////////////////////////////////////////////

// 这里的参数 EntryFilter类型中有一个 matches(Entry entry) 方法,
// 这也是this::isNestedArchive所对应的实际方法
@Override
public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {
List<Archive> nestedArchives = new ArrayList<>();
for (Entry entry : this) {
if (filter.matches(entry)) {
nestedArchives.add(getNestedArchive(entry));
}
}
return Collections.unmodifiableList(nestedArchives);
}

this::isNestedArchive 方法引用,我们查看 isNestedArchive 抽象方法

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
/**
* Determine if the specified {@link JarEntry} is a nested item that should be added
* to the classpath. The method is called once for each entry.
*
* 确定指定的{@link JarEntry}是否是应该添加到类路径的嵌套项。对每个条目调用该方法一次
*
* @param entry the jar entry
* @return {@code true} if the entry is a nested item (jar or folder)
*/
protected abstract boolean isNestedArchive(Archive.Entry entry);

/////////////////////////////////////////////////////////////
// 紧接着我们查看 isNestedArchive() 实现 //
/////////////////////////////////////////////////////////////

// JarLauncher.class 中的 isNestedArchive()方法
@Override
protected boolean isNestedArchive(Archive.Entry entry) {
// 如果是目录判断是不是BOOT-INF/classes/目录
if (entry.isDirectory()) {
return entry.getName().equals(BOOT_INF_CLASSES);
}
// 如果是文件判断文件的前缀是不是BOOT-INF/lib/开头
return entry.getName().startsWith(BOOT_INF_LIB);
}

createClassLoader 方法

把符合条件的 Archives 作为参数传入到 createClassLoader() 方法,创建一个类加载器,我们跟进去,查看 createClassLoader() 方法

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
/**
* Create a classloader for the specified archives.
*
* 创建一个所指定归档文件的类加载器
*
* @param archives the archives
* @return the classloader
* @throws Exception if the classloader cannot be created
*/
protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {
List<URL> urls = new ArrayList<>(archives.size());
// 遍历传进来的 archives,将每一个 Archive 的 URL(归档文件在磁盘上的完整路径)添加到 urls 集合中
for (Archive archive : archives) {
urls.add(archive.getUrl());
}
//
return createClassLoader(urls.toArray(new URL[0]));
}


/**
* Create a classloader for the specified URLs.
*
* 创建指定 URL 的类加载器
*
* @param urls the URLs
* @return the classloader
* @throws Exception if the classloader cannot be created
*/
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
// 这里的 LaunchedURLClassLoader 是 SpringBoot loader 给我们提供的一个全新的类加载器
// 参数 urls 是 class 文件或者资源配置文件的路径地址
// 参数 getClass().getClassLoader() 是应用类加载器
return new LaunchedURLClassLoader(urls, getClass().getClassLoader());
}


/**
* Create a new {@link LaunchedURLClassLoader} instance.
* @param urls the URLs from which to load classes and resources
* @param parent the parent class loader for delegation
*/
public LaunchedURLClassLoader(URL[] urls, ClassLoader parent) {
super(urls, parent);
}

super() 方法是调用父类的方法,这样一层层跟进去,最终到了 JDK 的 ClassLoader 类,它也是所有类加载器的顶类

launch 方法

launch 方法的第二个参数,getMainClass() 是一个抽象方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* Returns the main class that should be launched.
* @return the name of the main class
* @throws Exception if the main class cannot be obtained
*/
protected abstract String getMainClass() throws Exception;

/////////////////////////////////////////////////////////////
// 紧接着我们查看 getMainClass() 实现 //
/////////////////////////////////////////////////////////////

@Override
protected String getMainClass() throws Exception {
Manifest manifest = this.archive.getManifest();
String mainClass = null;
if (manifest != null) {
// 获取到 Manifest 文件中属性为`Start-Class`对应的值,也就是当前项目工程启动的类的完整路径
mainClass = manifest.getMainAttributes().getValue("Start-Class");
}
if (mainClass == null) {
throw new IllegalStateException("No 'Start-Class' manifest entry specified in " + this);
}
return mainClass;
}

接着我们看 launch 方法

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
/**
* Launch the application given the archive file and a fully configured classloader.
*
* 加载指定存档文件和完全配置的类加载器的应用程序
*
* @param args the incoming arguments
* @param mainClass the main class to run
* @param classLoader the classloader
* @throws Exception if the launch fails
*/
protected void launch(String[] args, String mainClass, ClassLoader classLoader) throws Exception {
// 将应用的加载器换成了自定义的 LaunchedURLClassLoader 加载器,然后入到线程类加载器中
// 最终在未来的某个地方,通过线程的上下文中取出类加载进行加载
Thread.currentThread().setContextClassLoader(classLoader);
// 创建一个主方法运行器运行
createMainMethodRunner(mainClass, args, classLoader).run();
}

/**
* Create the {@code MainMethodRunner} used to launch the application.
*
* 创建一个 MainMethodRunner 用于启动这个应用
*
* @param mainClass the main class
* @param args the incoming arguments
* @param classLoader the classloader
* @return the main method runner
*/
protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args, ClassLoader classLoader) {
return new MainMethodRunner(mainClass, args);
}

返回一个 MainMethodRunner 对象,我们紧接着去看看这个对象,

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
/**
* Utility class that is used by {@link Launcher}s to call a main method. The class
* containing the main method is loaded using the thread context class loader.
*
* 被 Launcher 使用来调用 main 方法的辅助类,使用线程类加载来加载包含 main 方法的类
*
* @author Phillip Webb
* @author Andy Wilkinson
*/
public class MainMethodRunner {

private final String mainClassName;

private final String[] args;

/**
* Create a new {@link MainMethodRunner} instance.
* @param mainClass the main class
* @param args incoming arguments
*/
public MainMethodRunner(String mainClass, String[] args) {
this.mainClassName = mainClass;
this.args = (args != null) ? args.clone() : null;
}

// 关键方法
public void run() throws Exception {
// 获取到当前线程上下文的类加载器,实际就是 springboot 自定义的加载器(LaunchedURLClassLoader)
// 加载 this.mainClassName所对应的类,实际也就是清单文件中对应 Start-Class 属性的类
Class<?> mainClass = Thread.currentThread().getContextClassLoader().loadClass(this.mainClassName);
// 通过反射获取到 main 方法和参数
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
// 调用目标方法运行
// invoke 方法参数一:是被调用方法所在对象,这里为 null,原因是我们所调用的目标方法是一个静态方法
// invoke 方法参数二:被调用方法所接收的参数
mainMethod.invoke(null, new Object[] { this.args });
}

}

到此为止,invoke 方法成功调用,那么我们项目中的main 方法就执行了,这时我们的所编写的 springboot 应用就正式的启动了。那么关于 springboot 的 loader 加载过程已经分析完

总结

summary-jarlauncher

从 jar 规范的角度出发,我们深入分析了 springboot 项目启动的整个过程,这个过程到底对不对,我们口说无凭,需要实际检验我们分析
首先,我们先思考,项目的应用启动入口是不是必须是 main.class 方法,以及为什么要默认这么做?
其次,我们再思考,在编辑器中通过图标运行启动程序(或者是通过命令启动程序),比较将程序编译成 jar 包,然后通过命令启动程序他们之间是否相同,如果不同请解释为什么?

问题一

项目的应用启动入口可以不是 main.class 方法,只是为什么会默认为 main.class 方法,原因是在 springboot 的 MainMethodRunner类的 run 方法中,是固定写死的 main ,为什么要这么写,答案是,我们可以在编辑器中已右键或其他图标启动的方式快速启动 springboot 项目(就像是在运行一个 Java 的 main 方法一样,不再向之前需要乱七八糟各种的配置)。

问题二

答案是不相同,我们可以在项目的应用启动 main.class 方法中,打印出加载类 System.out.println(项目启动加载类 + SpringbootStartApplication.class.getClassLoader()); ,这样就可以检验我们的分析是否正确。分别使用两种不同的方式

  • 方式一:在编辑器中之间运行(右键,或者控制台输入命令gradle bootRun)或者使用 IDEA 上的运行应用运行按钮,结果如下
    1
    项目启动加载类sun.misc.Launcher$AppClassLoader@18b4aac2
  • 方式二:先编译成 jar 包,然后通过 java -jar build-name.jar 命令运行
    1
    项目启动加载类org.springframework.boot.loader.LaunchedURLClassLoader@439f5b3d

通过打印出来的信息,可以验证我们的分析,方式一的运行,实际上是应用类加载器启动,而方式二是 spring-boot-load 包中自定义的 LaunchedURLClassLoader 来启动项目

在实际的生产开发中,有时我们的分析需要进行验证(或者找问题),而此时服务又部署在生成环境或者非本机上,通常用的方式是看应用的日志输出,在日志中去定位问题,而有时我们需要断点的方式去找问题,那该如何去操作呢?对于这个问题,在实际开发中是有方法去处理,请看下篇《SpringBoot(三) JDWP远程调用》

附录

  • spring_boot_cloud(2)Spring_Boot打包文件结构深入分析源码讲解
  • 校验者•CeaserWang