背景
最近有一个需求要往公司的生产数据库中导入一批数据,因为python用的不熟练,所以最终还是决定用老本行java来导数据到db中。使用maven来构建管理项目,同时为了方便操作JDBC、处理数据库表和JAVA对象的映射、SQL语句的生成,使用了Mybatis。但是,在将项目代码以及依赖的jar包打包成一个可执行的jar包,并部署到服务器上之后,执行该jar包却报属于依赖包中的某个class文件找不到。于是我就围绕“如何构建一个可执行的jar包”这个问题,展开了对一下知识点的学习:
- java -jar命令执行一个jar包的执行过程。
- ClassLoader的双亲委派机制与自定义ClassLoader。
- Springboot是如何构建一个包含了依赖包的可执行jar包。
项目结构
演示说明用的项目,就三个文件
pom文件👇
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>jar-load-dependency</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.72</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 使用maven-assembly-plugin将依赖包打入到项目的jar包内-->
<plugin>
<groupId>org.apache.maven.pl根据指定的配置文件来进行打包ugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<finalName>jar-with-dependency</finalName>
<appendAssemblyId>false</appendAssemblyId>
<descriptors>
<!-- maven-assembly-plugin根据指定的配置文件来进行打包-->
<descriptor>assembly.xml</descriptor>
</descriptors>
<archive>
<manifest>
<mainClass>Test</mainClass>
</manifest>
</archive>
<archiverConfig>
<compress>false</compress>
</archiverConfig>
</configuration>
<executions>
<execution>
<id>package-jar-with-dependencies</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
assembly.xml文件👇
<assembly>
<id>package</id>
<formats>
<format>jar</format><!--打包的文件格式,也可以有:war zip-->
</formats>
<!--压缩包下是否生成和项目名相同的根目录-->
<includeBaseDirectory>false</includeBaseDirectory>
<dependencySets>
<dependencySet>
<!--是否把本项目添加到依赖文件夹下-->
<useProjectArtifact>false</useProjectArtifact>
<!-- 存放依赖包的目录名-->
<outputDirectory>lib</outputDirectory>
</dependencySet>
</dependencySets>
<fileSets>
<fileSet>
<directory>${project.build.directory}/classes/</directory>
<outputDirectory>/</outputDirectory>
<filtered>false</filtered>
</fileSet>
</fileSets>
</assembly>
Test.java类
import com.alibaba.fastjson.JSONObject;
public class Test {
public static void main(String[] args) throws Exception {
// Test类依赖了fastjson包中的JSONObject类
JSONObject jsonObject = new JSONObject();
jsonObject.put("hello", "world");
System.out.println( jsonObject.toJSONString());
}
}
用 mvn package将上述项目打包成jar包,查看jar包内的结构:

META-INF下的MANIFEST文件的配置如下👇(jar文件中MANIFEST文件的作用以及配置,请查看oracle的文档[JAR File Specification](docs.oracle.com/javase/8/do…

jar包的入口是Test类的main方法,我们使用java -jar 命令来启动我们项目:

woops,提示找不到com.alibaba.fastjson.JSONObject。奇怪了,命名已经将fastjson的依赖jar包,放入到我们项目的jar包中了,为什么执行的时候,还是找不到fastjson中的类呢?java -jar 命令的执行机制是如何的呢?
"java -jar ${文件名}"执行过程
众所周知,我们的java程序被编译打包成class文件的形式,运行在JVM上,JVM通过ClassLoader来读取加载程序运行过程中需要用到的class。通常如果我们在项目中没有额外设置ClassLoader的话,那么运行的时候默认会有这三个ClassLoader:BootstrapClassLoader、ExtClassLoader、AppClassLoader。
以我们的项目为例子,执行java -jar的过程大致如下(简单的针对classloader来描述下java -jar的执行过程,实际过程肯定没这么简单的,jvm是做了很多工作的,而且用户也可以通过java命令的各种参数来影响这个启动的过程):
- JVM会设置好上述三个ClassLoader加载class文件的路径,BootstrapClassLoader负责加载
${JAVA_HOME}/jre/lib内的包,ExtClassLoader 负责加载${JAVA_HOME}/jre/lib/ext下面的 jar 包,AppClassLoader负载加载jar包内的所有class文件,以及用户自定义的classpath(可以通过java -classpath或者MANIFEST.MF文件中的Class-Path配置classpath)下的第三方jar包。 - JVM读取MANIFEST.MF中的Class-Path和java -classpath,查看用户有无设置项目的classpath,如果有,则将自定义的classpath的路径交给AppClassLoader,后续加载这些目录下的jar文件内的class文件,就交给AppClassLoader来查找。
- JVM读取MANIFEST.MF中的Main-Class配置,知道jar包的执行入口是Test.class的main方法,遂执行之。
OK,这下我们知道了,java -jar命令并不会去让JVM的ClassLoader加载被执行的jar包内的jar文件。那我们将项目的依赖jar包都解压到项目内,再将项目打包成可执行jar包,不就行了,而且maven-assembly-plugin插件也是支持这个操作的,我们修改下pom文件:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>jar-load-dependency</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.72</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- 使用maven-assembly-plugin将依赖包打入到项目的jar包内-->
<plugin>
<groupId>org.apache.maven.pl根据指定的配置文件来进行打包ugins</groupId>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<finalName>jar-with-dependency</finalName>
<appendAssemblyId>false</appendAssemblyId>
<!-- <descriptors>
<!– maven-assembly-plugin根据指定的配置文件来进行打包–>
<descriptor>assembly.xml</descriptor>
</descriptors>-->
<!-- 将👆的descriptors注释掉,添加descriptorRefs直接引用内置的描述符"jar-with-dependencies"
它会帮助我们将项目的依赖jar包都解压到项目内-->
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
<archive>
<manifest>
<mainClass>Test</mainClass>
</manifest>
</archive>
<archiverConfig>
<compress>false</compress>
</archiverConfig>
</configuration>
<executions>
<execution>
<id>package-jar-with-dependencies</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
执行mvn clean package命令打包我们的项目,打包后的项目如下,你看,我们项目唯一依赖的fastjson jar包,已经被解压,所有的class文件都在我们项目的jar包内:

使用java -jar 命令来启动我们的项目,bingo,成功执行,不会报依赖jar包的类找不到了,JVM已经将我们jar包内的所有class文件都交给AppClassLoader来加载了:

不过,将第三方依赖包的所有class文件都解压到项目的jar包内,会有3个问题:
- jar包内结构不清晰。如果项目依赖了很多jar包,都被解压到项目的jar内,就会找到我们项目的class文件和依赖包的class文件放在一起,对于我们查看jar包里的内容造成了干扰。
- 如果两个不同的依赖jar包,它们之中有相同路径相同名字但是文件内容不同的class文件,就会导致冲突,其中一个class文件将会被覆盖。
- 项目打包过程慢。一个jar包内有很多class文件,如果项目依赖了很多jar包,将会有很多class文件需要被解压到项目内,这么多小文件的读写,速度上肯定会比只是拷贝几个jar包慢,所以会导致我们项目的打包过程变慢(虽然每次打包的时间不会很夸张的慢,不过现在都讲Devops,很多公司都有在开展使用持续集成,自动化构建测试,来提高效率。如果一次打包的时间慢了10s,一天里面自动化构建了100次,就总共慢了1000s≈16m,嗯还是挺慢的)。
所以将依赖jar包的class文件都解压到项目内,也不是一个很优雅的做法。那有没有什么方式能够让项目jar包内的依赖jar包被添加到classloader中呢?答案当然是有的,我们可以自定义自己的ClassLoader(集成自ClassLoader类或者它的子类),来实现这一过程,具体怎么自定义ClassLoader在这里就不多说了。
大名鼎鼎的Springboot,搞java的大家肯定都知道,springboot项目可以被打包成一个可执行的jar包,项目的依赖都以jar文件的形式存在在项目的jar包内,启动一个Springboot项目,直接java -jar就完事了。其实Springboot也是通过自定义ClassLoader,来加载项目jar包内的依赖jar包。下面我们就来看看Springboot是如何实现的。
Springboot实现可执行jar包
官方文档说明
可以先查看下Springboot官网中的这篇文档Creating an Executable Jar:

大致意思如下:
Springboot项目会被构建成一个独立的可执行的jar文件,仅仅只需要这个jar文件就可以运行我们的项目。可执行jar文件中,包含了这个项目已经被编译过的class文件,以及项目代码所需要的所有依赖jar包。
Java的JDK没有提供一个标准的方式去加载嵌套的jar文件(嵌套jar文件指定的是包含在某个jar文件中的jar文件),比如说A.jar文件里面包含了B.jar文件,B.jar就是嵌套jar文件,JDK提供的"java.util.jar.JarFile",只提供了获取A.jar里面的文件的api,只能获取B.jar文件,但是没有提供api来获取B.jar中的文件。这将会成为你去发布一个独立(这里所谓的独立,指的是发布一个应用只需要一个文件就行了,不用在发布应用的时候,还要额外准备其他应用所依赖的文件(JDK不算,哈哈哈))应用时的问题。
为了解决这个问题,很多开发者使用 “uber” jar文件。uber jar文件指定就是将项目所依赖的所有jar包内的class文件全部打包进项目的jar包内。这种方式将会带来两个问题,1:通过项目的jar包很难知道项目依赖了那些文件,2:如果两个不同的依赖jar包,它们之中有相同路径相同名字但是文件内容不同的class文件,就会导致冲突,其中一个class文件将会被覆盖。
针对上述问题,Springboot提供了另一种方式,能够让你在项目的jar包内直接嵌套jar包。如果你是maven项目,只需要使用Springboot提供的spring-boot-maven-plugin插件来打包构建你的项目即可。
使用spring-boot-maven-plugin
wow,只要使用spring-boot-maven-plugin的这个插件就能解决我们的问题,实在是太秀了吧,赶紧在项目中配置上来搞一把,修改后的pom文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>jar-load-dependency</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.72</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>2.3.2.RELEASE</version>
<executions>
<execution>
<goals>
<!-- 如果项目的pom文件没有设置“org.springframework.boot:spring-boot-starter-parent”为parent
需要自己配置将spring-boot-maven-plugin和maven构建深生命周期内的repackage绑定,
这样子,在repackage的时候才会使用spring-boot-maven-plugin插件-->
<goal>repackage</goal>
</goals>
</execution>
</executions>
<!-- 如果项目中有多个包含了main方法的类,需要在这里配置指定应用程序的入口 -->
<configuration>
<mainClass>Test</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
将我们的项目打包构建并使用java -jar执行,Test的main方法成功执行,并没有报找不到fastjson。Springboot也太棒了吧~
spring-boot-maven-plugin原理
查看上一小节中用spring-boot-maven-plugin插件打包出来的项目jar的结构:

spring-boot-maven-plugin将jar包内的文件总共分为三部分:
- BOOT-INF下放着项目的class文件以及依赖jar包
- META-INF下放着和jar包执行相关的的MANIFEST.MF,还放着maven的pom文件。
- 最后一个部分,也是最重要的一个部分,org/spring/boot文件夹内放着Springboot用来加载项目第三方依赖jar的代码(编译后的class文件),这些代码来自于Springboot的这个jar包:org.springframework.boot:spring-boot-loader
查看spring-boot-maven-plugin为我们项目生成的META-INF/MANIFEST.MF文件:
Manifest-Version: 1.0
Spring-Boot-Classpath-Index: BOOT-INF/classpath.idx
Archiver-Version: Plexus Archiver
Built-By: XPS
Start-Class: Test
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 2.3.2.RELEASE
Created-By: Apache Maven 3.5.0
Build-Jdk: 1.8.0_92
Main-Class: org.springframework.boot.loader.JarLauncher
几个关键配置说明下:
-
Spring-Boot-Classpath-Index:一个索引文件,记录了项目所有的依赖jar包:

-
Spring-Boot-Classes:项目代码编译后的class文件所在的目录
-
Spring-Boot-Lib:项目第三方依赖包所在的目录
-
Main-Class:jar包的入口类
-
Start-Class:项目的入口类
想知道Springboot的jar包是如何加载项目jar内的依赖jar包到ClassLoader中,我们只要从入口,也就是“Main-Class”配置项中配置的org.springframework.boot.loader.JarLauncher的main方法入手即可。
首先将spring-boot-loader添加到pom文件的依赖中
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
<version>2.3.2.RELEASE</version>
</dependency>
然后在IDE中打开spring-boot-loader中的JarLauncher类源码:

接下来我们就可以从JarLauncher的main方法开始debug,来一探究竟了,看看Springboot是如何加载第三方依赖jar包的。
具体分析源码的过程,本文就不写了,可以查看这两篇博客,写的还是挺挺细致的:
除此之外debug源码的时候,结合我自己的经验,给大家伙儿提几个建议:
-
debug一定要有明确的目标,明确要查看哪个功能点。一开始对项目不熟悉的时候,这个目标的粒度最好比较小,在debug的过程中与目标无关的代码直接F6跳过就完事了。不然每一行代码都看过去,最后debug完,脑子🧠还是一片浆糊。
-
debug的时候要关注各个类的继承和接口实现,否则,debug的过程中,从 “实现类/子类”和“接口/父类”之间跳来跳去,脑子要被跳晕掉。
-
第一次debug某个功能的时候,先粗略的debug一遍,只关注整个功能的框架流程,脑子里先搭好这个功能的框架。有了框架之后,debug的时候再关注具体的代码实现。就比如说这里debug JarLuancher的过程,第一遍debug的时候,不要深究每一行代码,只要知道大概的过程就行:
- 创建JarFileArchive读取当前项目的jar包。
- 创建自定义ClassLoader,LaunchedURLClassLoader的实例(继承自URLClassLoader),将项目内的依赖jar包的URL传入进去LaunchedURLClassLoader
- 将LaunchedURLClassLoader设置为当前线程的ClassLoader(Thread.currentThread().setContextClassLoader(LaunchedURLClassLoader)),并将AppClassLoader设置为LaunchedURLClassLoader的parent classloader
- 通过MINIFEST.MF中的Start-Class配置以及java的反射机制,运行项目的真正入口类的main方法,来启动项目。
- 项目中有用到依赖jar包内的class文件的时候,通过LaunchedURLClassLoader来查找。
知道了JarLauncher加载依赖jar的大概过程之后,接下来debug的时候,我们再深入细节去debug相关的代码,比如LaunchedURLClassLoader是如何读取dao 依赖包的jar文件中的class文件:
- 因为依赖jar包包含在项目的jar包中,是嵌套jar,java的JDK的java.util.JarFile不能读取嵌套jar中的文件,所以Spring这里自己基于java.util.JarFile实现了org.springframework.boot.loader.jar.JarFile,可以通过文件流的方式读取嵌套jar包内的class文件。
- 当程序需要一个依赖包内的class文件的时候,先询问LaunchedURLClassLoader中是否已经加载了这个文件,如果没有,则询问它的parent classloader,即AppClassLoader,如果也没有加载,则一层层询问上去,直到最底层的BootStrapClassLoader。如果这些classloader都没有已经加载这个class文件的话,则从最底层的BootStrapClassLoader开始,向上一层层的尝试去查找读取这个class文件,最终,LaunchedURLClassLoader从嵌套的依赖jar包内找到了这个class文件,并将它加载到LaunchedURLClassLoader中。(这个就是类加载的双亲委派机制,对于ClassLoader是十分主要的,不问就不详细描述了,可以参考这篇文章学习下 “面试官:java双亲委派机制及作用”,我们自己在自定义ClassLoader的时候,也要注意尽量不要override父类的一些方法导致违反双亲委派机制。)
-
debug知名的开源项目的时候,最好储备些“设计模式”的知识。因为这些知名开源库的代码,肯定是符合“6大这几原则”,各种设计模式用的飞起。比如说mybatis的一级缓存机制(BaseExecutor(三个实现类SimpleExecutor,ReuseExecutor,BatchExecutor)),就用了装饰者模式,它的二级缓存机制CachingExecutor用了责任链模式:

所以了解些设计模式,对于我们debug开源项目是很有帮助的,能让我们快速明白要debug的代码的逻辑结构。
参考
Java类加载器--默认的三种类加载器(AppClassLoader/ExtClassLoader/BootstrapClassLoader)