可执行jar包

可执行jar包
代长亚我们在平时的工作中,不管是对于一个普通的Java工程还是一个SpringBoot工程,都会将这些工程打包成一个可执行的jar包,然后就可以利用java -jar xxx.jar命令来运行可执行jar包。
本文是对如何生成一个可执行jar包、以及可执行jar包背后原理的一个学习探索和整理。
java -jar xxx.jar命令的原理
当我们在命令行执行java -jar xxx.jar命令时,Java虚拟机会加载我们的jar包,虚拟机中的app类加载器会获取jar包中的META-INF/MANIFEST.MF文件,该文件中列出了该可执行jar包的入口程序、依赖的jar包、版本号等。
手动创建一个可执行jar包
知道了这个原理,我们可以手动创建一个可执行jar包。
首先创建一个简单的类:
1 | package guava.list.test; |
为了测试可执行jar包对第三方jar包的依赖,我们这个类依赖guava库。使用javac命令编译该文件:
1 | javac -cp libs/* guava/list/test/ListTest.java |
其中libs目录中存放guava的jar包。
接下来创建一个MANIFEST.MF文件,内容如下:
1 | Manifest-Version: 1.0 |
各字段的含义如下:
Manifest-Version:清单文件的版本,默认为1.0Main-Class:程序的入口,即main函数所在的类Class-Path:可执行jar包依赖的包的路径
此时目录结构如下:
最后使用jar命令来生成可执行jar包:
1 | jar -cvfm jartest.jar MANIFEST.MF guava libs |
jar是jdk自带的命令,各个参数的含义如下:
c:创建新档案v:在标准输出中输出详细信息f:指定档案文件名m:指定清单文件,也就是指定我们的MANIFEST.MF。如果没有这个参数,会生成一个默认的MANIFEST.MF文件。jartest.jar:指定生成jar包的名称MANIFEST.MF:指定清单文件guava libs:指定要打包的文件
最后输出一个可执行jar包:jartest.jar。
这样就可以运行这个可执行jar包:
1 | > java -jar jartest.jar |
解压jartest.jar来查看目录结构:
发现jar命令根据MANIFEST.MF文件的描述,将我们自己的类、第三方依赖类都打包进了这个可执行jar包,并且新建了一个META-INF目录来存放MANIFEST.MF文件。
maven生成普通的可执行jar包
日常开发中,我们不会手动维护依赖包更不会手动编写MANIFEST.MF描述文件,而是会使用maven这样的工具来维护我们的整个工程。
使用maven来生成可以执行的jar包原理上和上文所说的手动创建一个可执行jar包是一样的:
- 需要一个
META-INF/MANIFEST.MF文件来指定入口函数,以及依赖包的位置 - 能够在指定依赖包的位置找到依赖包
如果直接使用mvn package命令来对项目进行打包,是无法生成一个可执行jar包的。原因就是这样生成的jar包,META-INF/MANIFEST.MF文件中没有指定Main-Class以及Class-Path,因此java虚拟机在加载jar包后找不到入口函数以及依赖的包。
我们需要使用maven插件来生成可执行的jar包。有以下几种方法:
使用maven-jar-plugin和maven-dependency-plugin插件
在pom.xml中配置:
1 | <build> |
maven-jar-plugin插件用于生成META-INF/MANIFEST.MF文件的部分内容。<mainClass>love.wangqi.ListTest</mainClass>指定MANIFEST.MF中的Main-Class,<addClasspath>true</addClasspath>会在MANIFEST.MF加上Class-Path项并配置依赖包,<classpathPrefix>lib/</classpathPrefix>指定依赖包所在的目录。
下面就是我的工程生成的MANIFEST.MF文件内容:
1 | Manifest-Version: 1.0 |
只生成MANIFEST.MF文件还不够,maven-dependency-plugin插件用于将依赖包拷贝到<outputDirectory>${project.build.directory}/lib</outputDirectory>指定的位置,即lib目录下。
配置完成后,使用mvn package命令会在target目录下生成可执行的jar包,并将依赖包拷贝到target/lib目录下,目录结构如下:
可以看到,所以依赖的包(包括guava以及guava本身依赖的包)都被拷贝到了lib目录下。有了入口函数以及依赖包,我们就可以通过java -jar xxx.jar命令来运行jar包了。
这种方式生成的jar包有个缺点,就是依赖包是单独存放的,这样不便于管理。
使用maven-assembly-plugin插件
在pom.xml中配置:
1 | <build> |
配置后可以使用mvn package assembly:single命令来生成可执行jar包。
命令执行后,会在target目录下生成两个jar包:一个是普通的xxx.jar,另一个是xxx-jar-with-dependencies.jar文件,这个文件不但包含了自己项目中的代码和资源,还包含了所有依赖包的内容。所以可以直接通过java -jar命令来运行。
它的MANIFEST.MF文件内容如下:
1 | Manifest-Version: 1.0 |
可以看到,MANIFEST.MF文件指定了Main-Class,并没有指定Class-Path。原因我们查看jar包的目录结构可知:
可以看到,maven-assembly-plugin插件并不是打包依赖的第三方jar包,而是将所有依赖的class文件打包进这个单一的jar包。
加上以下配置,就可以直接使用mvn package来打包,无需使用assembly:single。
1 | <build> |
其中<phase>package</phase>、<goal>single</goal>表示在执行package打包时,执行assembly:single。
使用maven-shade-plugin插件
在pom.xml中配置:
1 | <build> |
配置后可以使用mvn package命令来生成可执行jar包。
命令执行后,会在target目录下生成两个jar包:一个是普通的original-xxx.jar,另一个是xxx.jar文件。其中original-xxx.jar是不包含依赖的原始文件,xxx.jar是包含了依赖以及程序入口的可执行jar包。
maven-shade-plugin插件与maven-assembly-plugin插件一样,也是将第三方依赖的class文件打包进这个独立的jar包中,所以这个jar包可以独立运行。
maven生成SpringBoot项目的可执行jar包
SpringBoot项目与普通的java项目是不一样的,它无法使用前面说的这几种maven插件来生成可执行jar包,而是需要引入spring-boot-maven-plugin插件来打包:
1 | <build> |
打包之后可执行jar包的目录结构如下:
其中MANIFEST.MF内容如下:
1 | Manifest-Version: 1.0 |
我们发现与普通Java工程不一样的是,SpringBoot生成的可执行jar包指定的Main-Class并不是我们在程序中main函数所在类,而是名为org.springframework.boot.loader.JarLauncher的类。当我们使用java -jar执行jar包时,调用的是JarLauncher类的main方法。
JarLauncher类位于org.springframework.boot.loader的package中,这个package在打包时由spring-boot-maven-plugin插件追加进jar包。Spring Boot Loader是SpringBoot提供的一个工具,用于执行SpringBoot打包出来的jar包。
JarLauncher的执行原理
Springboot设计了多个Launcher用于启动不同类型的程序,分别是:
- JarLauncher:用于加载并执行jar包
- WarLauncher:用于加载并执行war包
- PropertiesLauncher:PropertiesLauncher可以通过配置
loader.path去加载外部的jar包
以当前程序使用的JarLauncher的为例:
1 | protected void launch(String[] args) throws Exception { |
其执行的主流程如下:
- 注册一个处理自定义URL的jar协议
- 为执行指定
archive的类加载器 - 调用
Start-Classs中指定类中的main方法
处理自定义URL的jar协议
1 | public static void registerUrlProtocolHandler() { |
为什么Springboot需要自定义jar包的处理协议?
在JDK里面,jar资源的分隔符是!/,但是JDK中只支持一个!/,这无法满足Springboot的需求。因此Springboot扩展了java.util.jar.JarFile即org.springframework.boot.loader.jar.JarFile,它支持多个!/,表示jar文件嵌套jar文件、jar文件嵌套目录。文件的url如下所示:
1 | jar:file:/Users/wangqi/IdeaProjects/springboot-jar-test/target/springboot-jar-test-0.0.1-SNAPSHOT.jar!/BOOT-INF/classes!/ |
这是因为Springboot重新扩展了jar文件的协议,因此需要自定义一个jar文件协议的处理器。
通过将包名org.springframework.boot.loader追加到系统属性java.protocol.handler.pkgs中,来定义jar文件的处理器:org.springframework.boot.loader.jar.Handler。
Springboot还定义了一个org.springframework.boot.loader.archive.Archive类来定义资源统一访问的接口。比如getUrl方法获取资源的url,getManifest获取资源的MANIFEST文件,getNestedArchives方法获取内部的嵌套资源。还在Archive内部定义了一个Entry接口,用于表示Archive内部的子资源。
Archive有两个实现:JarFileArchive、ExplodedArchive,分别表示jar文件,文件目录。
类加载器
Springboot自定义了一个类加载器:LaunchedURLClassLoader,这是为什么?
我们知道,java中的类加载器遵循双亲委派机制,从上到下依次为Bootstrap ClassLoader(启动类加载器)、Extension ClassLoader(扩展类加载器)、Application ClassLoader(应用程序类加载器)以及自定义的类加载器。如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
但是因为Springboot打出的jar包依赖的各个第三方jar文件,并不在自己的classpath下,它们存放在jar包的BOOT-INF/lib目录下,如果采用双亲委派机制的话,获取不到这些依赖。因此需要破坏双亲委派机制,使用自定义的类加载器。
在LaunchedURLClassLoader创建之前,会先调用getClassPathArchives方法获得所有依赖资源来组成classpath。资源过滤遵循以下规则:
1 | protected boolean isNestedArchive(Archive.Entry entry) { |
即位于BOOT-INF/classes/和BOOT-INF/lib/的class文件以及jar包。
资源的加载按照Spring-Boot-Classpath-Index中指定的BOOT-INF/classpath.idx文件提供的顺序,比如:
1 | - "spring-boot-starter-web-2.3.3.RELEASE.jar" |
依赖的加载顺序由maven根据短路优先原则和先声明优先原则来确定,确保不会有相互冲突的依赖。
接着将这个资源列表传递给LaunchedURLClassLoader。
Springboot使用这个自定义的LaunchedURLClassLoader类加载器来执行后续的程序。当需要加载新的类时,LaunchedURLClassLoader就能找到指定的位置去加载类。
LaunchedURLClassLoader重写了loadClass方法,因此jvm会调用该方法来加载类。loadClass的执行主要分成两步:
第一步,调用definePackageIfNecessary(String className)方法来定义指定类所在的package。definePackageIfNecessary方法的关键代码如下:
1 | String packageEntryName = packageName.replace('.', '/') + "/"; |
遍历LaunchedURLClassLoader中保存的所有目录以及jar包,这些资源是前面调用getClassPathArchives方法获取得来的,如果发现这些资源中有存在指定的类,则调用definePackage方法保存packageName与资源url的对应关系,确保jar包与相应的package相关联。
第二步,调用super.loadClass()方法来加载指定的类。
这一步是正常的双亲委派机制的流程。类加载器首先调用上层类加载器的loadClass方法来尝试加载类,按LaunchedURLClassLoader->AppClassLoader->ExtClassLoader->BootstrapClassLoader的顺序依次调用。
上层的类加载器无法加载到jar包中的资源,于是从上往下按BootstrapClassLoader->ExtClassLoader->AppClassLoader->LaunchedURLClassLoader的顺序调用下层findClass方法。
直到调用LaunchedURLClassLoader父类URLClassLoader的findClass方法,主要代码如下:
1 | String path = name.replace('.', '/').concat(".class"); |
第一步,将类名解析成路径并加上.class后缀。
第二步,根据之前注册的资源列表找到指定类所在的资源。关键代码是Resource res = ucp.getResource(path, false);。ucp是URLClassPath实例,里面保存了之前注册的资源列表,遍历该列表并找到指定类所对应的资源url。比如类org/springframework/boot/SpringApplication.class对应的资源url为jar:file:/Users/wangqi/IdeaProjects/springboot-jar-test/target/springboot-jar-test-0.0.1-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.3.3.RELEASE.jar!/org/springframework/boot/SpringApplication.class。
第三步,最终根据类名以及对应的资源url调用defineClass方法完成类的加载并返回。
执行main方法
1 | protected String getMainClass() throws Exception { |
执行main的方法就相对简单,流程如下:
- 根据
MANIFEST文件中的Start-Class找到指定的启动类。 - 利用反射加载并启动
Start-Class指定类中的main方法,这里用到了前面自定义的LaunchedURLClassLoader类加载器。
https://my.oschina.net/thinwonton/blog/877493
https://www.jianshu.com/p/e32e4c1595a4
https://blog.csdn.net/xiao__gui/article/details/47341385
https://juejin.im/post/6844904181304672270
https://fangjian0423.github.io/2017/05/31/springboot-executable-jar/
https://xie.infoq.cn/article/765f324659d44a5e1eae1ee0c
https://www.codenong.com/cs106676979/
https://blog.csdn.net/BryantLmm/article/details/86305047
https://juejin.im/post/6844904194319745037







