眾所周知,Springboot的FAT JAR機(jī)制大大的簡化了應(yīng)用的打包和啟動,并且統(tǒng)一了不同stack(command, web, batch)的打包和啟動方法,使得一個應(yīng)該的開發(fā)和部署都變得簡單了,本文想在這里解析一下FAT JAR的方式下,Springboot的類加載機(jī)制。
Springboot FAT JAR的結(jié)構(gòu)
我們知道Springboot提供了spring-boot-maven-plugin這個maven plugin在build時生成FAT jar,如果我們build了一個springboot的應(yīng)用,去target folder下可以看見兩個名字非常類似的文件: abc-0.0.1-SNAPSHOT.jar 和abc-0.0.1-SNAPSHOT.jar.original,這兩個文件的大小相差非常明顯,我們解壓開.original文件可以發(fā)現(xiàn)如下結(jié)構(gòu),這個就是我們應(yīng)用中所有本地文件(代碼和資源文件),而不包含第三方的依賴等等。

如果我們解壓打開abc-0.0.1-SNAPSHOT.jar (FAT JAR),目錄結(jié)構(gòu)如下, 本地的文件都在BOOT-INF/classes下,但是還多了:
BOOT-INF/lib - 存放所有dependences的JAR
org/Springframework - 存放springboot相關(guān)的class

目錄結(jié)構(gòu)有不少的變化,可以認(rèn)為在執(zhí)行spring-boot-maven-plugin后,abc-0.0.1-SNAPSHOT.jar.original被重新打包成了abc-0.0.1-SNAPSHOT.jar這個FAT JAR。
在BOOT-INF/lib下有一個dependency需要花功夫研究: spring-boot-loader,這個JAR保證了為什么通過java -jar命令能夠執(zhí)行FAT JAR從而啟動Springboot應(yīng)用。
spring-boot-loader 做了什么
既然把所有的依賴,class和資源文件都包含在一個JAR內(nèi),那就必須要解決啟動時如何去load這些claas和資源文件,為了一探究竟,我們需要把spring-boot-loader的source code拉下來,一個簡單的方法就是把spring-boot-loader作為依賴加到應(yīng)用的pom.xml中,當(dāng)然scope可以設(shè)置成provided,因?yàn)镕AT JAR中一定會包含這個依賴。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-loader</artifactId>
<scope>Provided</scope>
</dependency>
這樣我們就能很方便的拿到spring-boot-loader的source code。通過FAT JAR中的MANIFEST.MF,我們能快速找到真正的Springboot bootstrap方法應(yīng)該是org.springframework.boot.loader.JarLauncher#main,真正的code在基類Launcher#launch中。
public abstract class Launcher {
private static final String JAR_MODE_LAUNCHER = "org.springframework.boot.loader.jarmode.JarModeLauncher";
/**
* 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.
* @param args the incoming arguments
* @throws Exception if the application fails to launch
*/
protected void launch(String[] args) throws Exception {
if (!isExploded()) {
JarFile.registerUrlProtocolHandler();
}
ClassLoader classLoader = createClassLoader(getClassPathArchivesIterator());
String jarMode = System.getProperty("jarmode");
String launchClass = (jarMode != null && !jarMode.isEmpty()) ? JAR_MODE_LAUNCHER : getMainClass();
launch(args, launchClass, classLoader);
}
可以發(fā)現(xiàn)這里創(chuàng)建了一個新的classloader - LaunchedURLClassLoader,具體是,
protected ClassLoader createClassLoader(URL[] urls) throws Exception {
return new LaunchedURLClassLoader(isExploded(), getArchive(), urls, getClass().getClassLoader());
}
并把classload 加入thread 的context中。
protected void launch(String[] args, String launchClass, ClassLoader classLoader) throws Exception {
Thread.currentThread().setContextClassLoader(classLoader);
createMainMethodRunner(launchClass, args, classLoader).run();
}
為什么需要LaunchedURLClassLoader呢?設(shè)想一下,現(xiàn)在FAT JAR中依賴的各個jar文件其實(shí)并不在運(yùn)行時應(yīng)用的classpath下,也就是根據(jù)類加載的雙親委派機(jī)制,這些依賴沒辦法被默認(rèn)的任何一個classloader加載,Springboot為了解決這個問題,自定義了類加載機(jī)制。
P.S 不同的內(nèi)置classloader的scope 如下:
Bootstrap ClassLoader(加載JDK的/lib目錄下的類)
Extension ClassLoader(加載JDK的/lib/ext目錄下的類)
Application ClassLoader(程序自己classpath下的類)
LaunchedURLClassLoader做了什么
LaunchedURLClassLoader繼承了java.net.URLClassLoader,自己實(shí)現(xiàn)了loadClass方法。
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
if (name.startsWith("org.springframework.boot.loader.jarmode.")) {
try {
Class<?> result = loadClassInLaunchedClassLoader(name);
if (resolve) {
resolveClass(result);
}
return result;
}
catch (ClassNotFoundException ex) {
}
}
if (this.exploded) {
return super.loadClass(name, resolve);
}
Handler.setUseFastConnectionExceptions(true);
try {
try {
definePackageIfNecessary(name);
}
catch (IllegalArgumentException ex) {
// Tolerate race condition due to being parallel capable
if (getPackage(name) == null) {
// This should never happen as the IllegalArgumentException indicates
// that the package has already been defined and, therefore,
// getPackage(name) should not return null.
throw new AssertionError("Package " + name + " has already been defined but it could not be found");
}
}
return super.loadClass(name, resolve);
}
finally {
Handler.setUseFastConnectionExceptions(false);
}
}
definePackageIfNecessary 確保了Jar in Jar里的class manifest能夠和package關(guān)聯(lián)起來。
private void definePackage(String className, String packageName) {
try {
AccessController.doPrivileged((PrivilegedExceptionAction<Object>) () -> {
String packageEntryName = packageName.replace('.', '/') + "/";
String classEntryName = className.replace('.', '/') + ".class";
for (URL url : getURLs()) {
try {
URLConnection connection = url.openConnection();
if (connection instanceof JarURLConnection) {
JarFile jarFile = ((JarURLConnection) connection).getJarFile();
if (jarFile.getEntry(classEntryName) != null && jarFile.getEntry(packageEntryName) != null
&& jarFile.getManifest() != null) {
definePackage(packageName, jarFile.getManifest(), url);
return null;
}
}
}
catch (IOException ex) {
// Ignore
}
}
return null;
}, AccessController.getContext());
}
catch (java.security.PrivilegedActionException ex) {
// Ignore
}
}
可以看到最終load class還是調(diào)了super.loadClass,也就是java.lang.ClassLoader#loadClass,這其實(shí)又回到了雙親委派機(jī)制,最后讓Application Classloader來load。
LaunchedURLClassLoader的作用是在FAT JAR(Jar in Jar)這樣的目錄結(jié)構(gòu)中,能夠找到要load的class(依賴中的類或者應(yīng)用自己的類),并且load他們。
我們看看這個class load是怎么load 我們在springboot應(yīng)用中定義的main class,也就是應(yīng)用的入口程序的。
在org.springframework.boot.loader.MainMethodRunner中,通過LaunchedURLClassLoader load并且通過反射調(diào)用了main 方法。
public void run() throws Exception {
Class<?> mainClass = Class.forName(this.mainClassName, false, Thread.currentThread().getContextClassLoader());
Method mainMethod = mainClass.getDeclaredMethod("main", String[].class);
mainMethod.setAccessible(true);
mainMethod.invoke(null, new Object[] { this.args });
}
Java -Jar 和IDE里啟動Sprintboot 有什么區(qū)別
Java -Jar是以FAT JAR的方式用LaunchedURLClassLoader來load class。而在IDE中則是直接以ApplicationClassLoader來load的。這種差別會導(dǎo)致調(diào)用classloader.getResourceAsStream()得到不一樣的結(jié)果,這是因?yàn)镕AT JAR啟動時,LaunchedURLClassLoader的load的urls并沒有FAT JAR本身,如abc-0.0.1-SNAPSHOT.jar, 但是應(yīng)用中的src/main/resources/META-INF/resources目錄被打包到了FAT JAR里,也就是abc-0.0.1-SNAPSHOT.jar!/META-INF/resources,這樣這些resource也就不會被訪問到了
這也就是為什么有時候在IDE里能讀到的resource在Run FAT JAR的情況下讀不到了,Springboot也給了多種方式來正確的load resource: https://www.baeldung.com/spring-load-resource-as-string