Spring Boot jar可执行原理的彻底分析
前言
文章篇幅较长,但是包含了SpringBoot可执行jar包从头到尾的原理,请读者耐心观看。同时文章是基于SpringBoot-2.1.3进行分析。涉及的知识点主要包括Maven的生命周期以及自定义插件,JDK提供关于jar包的工具类以及Springboot如何扩展,最后是自定义类加载器。
spring-boot-maven-plugin
SpringBoot的可执行jar包又称fatjar,是包含所有第三方依赖的jar包,jar包中嵌入了除java虚拟机以外的所有依赖,是一个all-in-onejar包。普通插件maven-jar-plugin生成的包和spring-boot-maven-plugin生成的包之间的直接区别,是fatjar中主要增加了两部分,第一部分是lib目录,存放的是Maven依赖的jar包文件,第二部分是springbootloader相关的类。
fatjar目录结构
├─BOOT-INF
│ ├─classes
│ └─lib
├─META-INF
│ ├─maven
│ ├─app.properties
│ ├─MANIFEST.MF
└─org
└─springframework
└─boot
└─loader
├─archive
├─data
├─jar
└─util
也就是说想要知道fatjar是如何生成的,就必须知道spring-boot-maven-plugin工作机制,而spring-boot-maven-plugin属于自定义插件,因此我们又必须知道,Maven的自定义插件是如何工作的
Maven的自定义插件
Maven拥有三套相互独立的生命周期:clean、default和site,而每个生命周期包含一些phase阶段,阶段是有顺序的,并且后面的阶段依赖于前面的阶段。生命周期的阶段phase与插件的目标goal相互绑定,用以完成实际的构建任务。
org.springframework.boot spring-boot-maven-plugin repackage
repackage目标对应的将执行到org.springframework.boot.maven.RepackageMojo#execute,该方法的主要逻辑是调用了org.springframework.boot.maven.RepackageMojo#repackage
privatevoidrepackage()throwsMojoExecutionException{ //获取使用maven-jar-plugin生成的jar,最终的命名将加上.orignal后缀 Artifactsource=getSourceArtifact(); //最终文件,即Fatjar Filetarget=getTargetFile(); //获取重新打包器,将重新打包成可执行jar文件 Repackagerrepackager=getRepackager(source.getFile()); //查找并过滤项目运行时依赖的jar Setartifacts=filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters())); //将artifacts转换成libraries Librarieslibraries=newArtifactsLibraries(artifacts,this.requiresUnpack, getLog()); try{ //提供SpringBoot启动脚本 LaunchScriptlaunchScript=getLaunchScript(); //执行重新打包逻辑,生成最后fatjar repackager.repackage(target,libraries,launchScript); } catch(IOExceptionex){ thrownewMojoExecutionException(ex.getMessage(),ex); } //将source更新成xxx.jar.orignal文件 updateArtifact(source,target,repackager.getBackupFile()); }
我们关心一下org.springframework.boot.maven.RepackageMojo#getRepackager这个方法,知道Repackager是如何生成的,也就大致能够推测出内在的打包逻辑。
privateRepackagergetRepackager(Filesource){ Repackagerrepackager=newRepackager(source,this.layoutFactory); repackager.addMainClassTimeoutWarningListener( newLoggingMainClassTimeoutWarningListener()); //设置mainclass的名称,如果不指定的话则会查找第一个包含main方法的类,repacke最后将会设置org.springframework.boot.loader.JarLauncher repackager.setMainClass(this.mainClass); if(this.layout!=null){ getLog().info("Layout:"+this.layout); //重点关心下layout最终返回了org.springframework.boot.loader.tools.Layouts.Jar repackager.setLayout(this.layout.layout()); } returnrepackager; }
/** *ExecutableJARlayout. */ publicstaticclassJarimplementsRepackagingLayout{ @Override publicStringgetLauncherClassName(){ return"org.springframework.boot.loader.JarLauncher"; } @Override publicStringgetLibraryDestination(StringlibraryName,LibraryScopescope){ return"BOOT-INF/lib/"; } @Override publicStringgetClassesLocation(){ return""; } @Override publicStringgetRepackagedClassesLocation(){ return"BOOT-INF/classes/"; } @Override publicbooleanisExecutable(){ returntrue; } }
layout我们可以将之翻译为文件布局,或者目录布局,代码一看清晰明了,同时我们需要关注,也是下一个重点关注对象org.springframework.boot.loader.JarLauncher,从名字推断,这很可能是返回可执行jar文件的启动类。
MANIFEST.MF文件内容
Manifest-Version:1.0 Implementation-Title:oneday-auth-server Implementation-Version:1.0.0-SNAPSHOT Archiver-Version:PlexusArchiver Built-By:oneday Implementation-Vendor-Id:com.oneday Spring-Boot-Version:2.1.3.RELEASE Main-Class:org.springframework.boot.loader.JarLauncher Start-Class:com.oneday.auth.Application Spring-Boot-Classes:BOOT-INF/classes/ Spring-Boot-Lib:BOOT-INF/lib/ Created-By:ApacheMaven3.3.9 Build-Jdk:1.8.0_171
repackager生成的MANIFEST.MF文件为以上信息,可以看到两个关键信息Main-Class和Start-Class。我们可以进一步,程序的启动入口并不是我们SpringBoot中定义的main,而是JarLauncher#main,而再在其中利用反射调用定义好的Start-Class的main方法
JarLauncher
重点类介绍
- java.util.jar.JarFileJDK工具类提供的读取jar文件
- org.springframework.boot.loader.jar.JarFileSpringboot-loader继承JDK提供JarFile类
- java.util.jar.JarEntryDK工具类提供的``jar```文件条目
- org.springframework.boot.loader.jar.JarEntrySpringboot-loader继承JDK提供JarEntry类
- org.springframework.boot.loader.archive.ArchiveSpringboot抽象出来的统一访问资源的层
- JarFileArchivejar包文件的抽象
- ExplodedArchive文件目录
这里重点描述一下JarFile的作用,每个JarFileArchive都会对应一个JarFile。在构造的时候会解析内部结构,去获取jar包里的各个文件或文件夹类。我们可以看一下该类的注释。
/*Extendedvariantof{@linkjava.util.jar.JarFile}thatbehavesinthesamewaybut *offersthefollowingadditionalfunctionality. *
-
*
- Anested{@linkJarFile}canbe{@link#getNestedJarFile(ZipEntry)obtained}based *onanydirectoryentry. *
- Anested{@linkJarFile}canbe{@link#getNestedJarFile(ZipEntry)obtained}for *embeddedJARfiles(aslongastheirentryisnotcompressed). **/
jar里的资源分隔符是!/,在JDK提供的JarFileURL只支持一个'!/‘,而Springboot扩展了这个协议,让它支持多个'!/‘,就可以表示jarinjar、jarindirectory、fatjar的资源了。
自定义类加载机制
- 最基础:BootstrapClassLoader(加载JDK的/lib目录下的类)
- 次基础:ExtensionClassLoader(加载JDK的/lib/ext目录下的类)
- 普通:ApplicationClassLoader(程序自己classpath下的类)
首先需要关注双亲委派机制很重要的一点是,如果一个类可以被委派最基础的ClassLoader加载,就不能让高层的ClassLoader加载,这样是为了范围错误的引入了非JDK下但是类名一样的类。其二,如果在这个机制下,由于fatjar中依赖的各个第三方jar文件,并不在程序自己classpath下,也就是说,如果我们采用双亲委派机制的话,根本获取不到我们所依赖的jar包,因此我们需要修改双亲委派机制的查找class的方法,自定义类加载机制。
先简单的介绍Springboot2中LaunchedURLClassLoader,该类继承了java.net.URLClassLoader,重写了java.lang.ClassLoader#loadClass(java.lang.String,boolean),然后我们再探讨他是如何修改双亲委派机制。
在上面我们讲到Springboot支持多个'!/‘以表示多个jar,而我们的问题在于,如何解决查找到这多个jar包。我们看一下LaunchedURLClassLoader的构造方法。
publicLaunchedURLClassLoader(URL[]urls,ClassLoaderparent){ super(urls,parent); }
urls注释解释道theURLsfromwhichtoloadclassesandresources,即fatjar包依赖的所有类和资源,将该urls参数传递给父类java.net.URLClassLoader,由父类的java.net.URLClassLoader#findClass执行查找类方法,该类的查找来源即构造方法传递进来的urls参数
//LaunchedURLClassLoader的实现 protectedClass>loadClass(Stringname,booleanresolve) throwsClassNotFoundException{ Handler.setUseFastConnectionExceptions(true); try{ try{ //尝试根据类名去定义类所在的包,即java.lang.Package,确保jarinjar里匹配的manifest能够和关联 //的package关联起来 definePackageIfNecessary(name); } catch(IllegalArgumentExceptionex){ //Tolerateraceconditionduetobeingparallelcapable if(getPackage(name)==null){ //ThisshouldneverhappenastheIllegalArgumentExceptionindicates //thatthepackagehasalreadybeendefinedand,therefore, //getPackage(name)shouldnotreturnnull. //这里异常表明,definePackageIfNecessary方法的作用实际上是预先过滤掉查找不到的包 thrownewAssertionError("Package"+name+"hasalreadybeen" +"definedbutitcouldnotbefound"); } } returnsuper.loadClass(name,resolve); } finally{ Handler.setUseFastConnectionExceptions(false); } }
方法super.loadClass(name,resolve)实际上会回到了java.lang.ClassLoader#loadClass(java.lang.String,boolean),遵循双亲委派机制进行查找类,而BootstrapClassLoader和ExtensionClassLoader将会查找不到fatjar依赖的类,最终会来到ApplicationClassLoader,调用java.net.URLClassLoader#findClass
如何真正的启动
Springboot2和Springboot1的最大区别在于,Springboo1会新起一个线程,来执行相应的反射调用逻辑,而SpringBoot2则去掉了构建新的线程这一步。方法是org.springframework.boot.loader.Launcher#launch(java.lang.String[],java.lang.String,java.lang.ClassLoader)反射调用逻辑比较简单,这里就不再分析,比较关键的一点是,在调用main方法之前,将当前线程的上下文类加载器设置成LaunchedURLClassLoader
protectedvoidlaunch(String[]args,StringmainClass,ClassLoaderclassLoader) throwsException{ Thread.currentThread().setContextClassLoader(classLoader); createMainMethodRunner(mainClass,args,classLoader).run(); }
Demo
publicstaticvoidmain(String[]args)throwsClassNotFoundException,MalformedURLException{ JarFile.registerUrlProtocolHandler(); //构造LaunchedURLClassLoader类加载器,这里使用了2个URL,分别对应jar包中依赖包spring-boot-loader和spring-boot,使用"!/"分开,需要org.springframework.boot.loader.jar.Handler处理器处理 LaunchedURLClassLoaderclassLoader=newLaunchedURLClassLoader( newURL[]{ newURL("jar:file:/E:/IdeaProjects/oneday-auth/oneday-auth-server/target/oneday-auth-server-1.0.0-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-loader-1.2.3.RELEASE.jar!/") ,newURL("jar:file:/E:/IdeaProjects/oneday-auth/oneday-auth-server/target/oneday-auth-server-1.0.0-SNAPSHOT.jar!/BOOT-INF/lib/spring-boot-2.1.3.RELEASE.jar!/") }, Application.class.getClassLoader()); //加载类 //这2个类都会在第二步本地查找中被找出(URLClassLoader的findClass方法) classLoader.loadClass("org.springframework.boot.loader.JarLauncher"); classLoader.loadClass("org.springframework.boot.SpringApplication"); //在第三步使用默认的加载顺序在ApplicationClassLoader中被找出 classLoader.loadClass("org.springframework.boot.autoconfigure.web.DispatcherServletAutoConfiguration"); //SpringApplication.run(Application.class,args); }
org.springframework.boot spring-boot-loader 2.1.3.RELEASE org.springframework.boot spring-boot-maven-plugin 2.1.3.RELEASE
总结
对于源码分析,这次的较大收获则是不能一下子去追求弄懂源码中的每一步代码的逻辑,即便我知道该方法的作用。我们需要搞懂的是关键代码,以及涉及到的知识点。我从Maven的自定义插件开始进行追踪,巩固了对Maven的知识点,在这个过程中甚至了解到JDK对jar的读取是有提供对应的工具类。最后最重要的知识点则是自定义类加载器。整个代码下来并不是说代码究竟有多优秀,而是要学习他因何而优秀。