SpringBoot内嵌JAR免解压加载原理

SpringBoot内嵌JAR免解压加载原理 起因 由于项目环境基于Felix(一个apache的开源OSGi实现框架),Felix框架在处理包含内嵌JAR包的构件时,需要将内嵌JAR解压缩到本地cache目录,才能访问这些内嵌JAR资源。由于内嵌JAR数量庞大,会额外占用约500M左右的重复磁盘空间。
一个包含内嵌JAR的OSGi构件大概长这个样子:
SpringBoot内嵌JAR免解压加载原理
文章图片

回想到Springboot在打包时,可以同样将项目所有依赖打包到单独的一个JAR中,使用java -jar test.jar这种形式直接运行。这为项目的分发带来了极大的方便。
一个SpringBoot单一JAR文件的目录结构和OSGi内嵌JAR结构相仿:
SpringBoot内嵌JAR免解压加载原理
文章图片

这其中,BOOT-INF/classes、BOOT-INF/lib/*.jar 是我们应用的classs-path的内容。我们观察到SpringBoot在运行期间,并不需要将lib下的jar包解压缩到本地磁盘目录,就可以直接访问内嵌JAR中的classs,这是怎么做到的呢?
探究 由于Java对class的加载是随需加载的,即:应用程序启动期间,不会也没有必要把JAR包内的所有class字节内容读取到内存中;而是随着程序的运行,当需要用到某一个class的功能时,才从class-path上搜寻该class的字节码,再加载到内存中。
我们知道,从文件存储格式上看,JAR文件本质上就是Zip文件格式(只是JAR约定META-INF/MANIFEST.MF文件必须是压缩包的第一个entry)。
那么,Zip文件为什么可以实现对Entry的随机读取呢?这取决于Zip文件的存储结构。Zip文件是由一个个Entry数据顺序堆叠起来的,在Zip文件最后,存储了所有Entry的目录信息,其中标识了每一个Entry在Zip文件中的偏移位置。由于Zip文件是对每一个Entry单独压缩(每一个Entry可以选择是否压缩、以及压缩模式),而不是对所有Entry一起压缩--这很关键!所以,通过Entry目录可以定位到任何一个Entry,从而实现Entry数据的随机读取。
我们再看JDK的Zip相关API。JDK是通过自身java.util.zip.ZipFile这个类来实现对JAR中的class或资源的随机读取的。这个类需要一个本地File做构造函数参数,同时不支持内嵌JAR包中class资源的读取。
SpringBoot的实现 spring-boot-loader是SpringBoot的引导程序,当你使用Maven-Install打包一个SpringBoot单一JAR包时,SpringBootLoader的代码被拷贝到JAR中。

org.springframework.boot spring-boot-loader 2.6.6

SpringBoot单一JAR包MANIFEST内容:
Manifest-Version: 1.0 Implementation-Title: SpringBootDemo Spring-Boot-Version: 2.0.6.RELEASE Main-Class: org.springframework.boot.loader.JarLauncher Start-Class: springboot2.DemoApp Spring-Boot-Classes: BOOT-INF/classes/ Spring-Boot-Lib: BOOT-INF/lib/

我们看到,SpringBootLoader:
  • 接管了SpringBoot程序的启动入口(Main-Class)
  • 对JDK中java.util.zip.ZipFile实现了扩展,以便支持内嵌JAR中class的随机访问
  • 提供了SpringBoot-ClassLoader负责BOOT-INF下classes、lib的资源加载(Spring-Boot-Classes、Spring-Boot-Lib内容构成了应用真正的class-path)
使用SpringBootLoader的Zip扩展来随机读取内嵌JAR的资源 我们打包一个SpringBootJar,假如名字叫:SpringBootDemo-1.0-boot.jar,其中,在BOOT-INF/lib目录下存在一个内嵌JAR:logback-core-1.2.3.jar,我们来尝试读取这个内嵌JAR中的class文件:ch.qos.logback.core.Appender.class
示例代码如下:
import org.springframework.boot.loader.jar.JarFile; public class Demo {public static void main(String[] args) throws Exception { JarFile rootJar = new JarFile(new File("SpringBootDemo-1.0-boot.jar")); String innerName = "BOOT-INF/lib/logback-core-1.2.3.jar"; String className = "ch.qos.logback.core.Appender"; ZipEntry entry = rootJar.getEntry(innerName); JarFile innerJar = rootJar.getNestedJarFile(entry); String classEntryName = className.replace('.', '/') + ".class"; entry = innerJar.getEntry(classEntryName); InputStream in = innerJar.getInputStream(entry); byte[] bs = new byte[(int) entry.getSize()]; for (int i = 0; i < bs.length; ) { i += in.read(bs, i, bs.length - i); } in.close(); System.out.println("entry name: " + entry.getName()); System.out.println("entry time: " + entry.getTime()); System.out.println("class File first byte: 0x" + Integer.toHexString(bs[0] & 0xFF)); rootJar.close(); } }

执行结果如下:我们看到已成功读取Appender.class文件内容。
entry name: ch/qos/logback/core/Appender.class entry time: 1490962798000 class File first byte: ca

OSGi构件内嵌JAR的读取尝试 然而,当我们尝试用同样的方法,去读取一个OSGi构件内嵌JAR资源时候,出现了错误:
Unable to open nested entry 'lib/cdi-api-1.0.jar'. It has been compressed and nested jar files must be stored without compression. Please check the mechanism used to create your executable jar file at org.springframework.boot.loader.jar.JarFile.createJarFileFromFileEntry(JarFile.java:332)

至此,SpringBootLoader在不解压内嵌JAR包时就可以读取其内容的关键就在于:内嵌JAR包必须以STORED方式存储!
其实,想一下也容易明白,只有内嵌JAR使用STORED模式存储时(即:非压缩,另一个模式是:DEFLATED),其内嵌JAR的子资源才能通过地址偏移在不使用压缩算法的情况下进行定位。一旦使用了压缩算法,就必须完整的将内嵌JAR包进行解压缩才能获取内嵌子资源的位置,这需要在内存中持有整个解压缩的数据,显然是不可接受的。
解决方案 明白了SpringBootLoader读取内嵌JAR资源的关键(内嵌JAR以STORED模式存储),那么对应的OSGi构件内嵌JAR资源的读取方案也就有了。
【SpringBoot内嵌JAR免解压加载原理】OSGi构件是通过maven-bundle-plugin来进行打包的,需要对其进行修改、或者在其后再增加一个新的插件,实现对内嵌JAR包的存储模式的修改。关键代码示例如下;
public static void main(String[] args) throws Exception {File innerjar = new File("test.jar"); byte[] bs = Files.readAllBytes(innerjar.toPath()); ZipOutputStream jarout = new ZipOutputStream(new FileOutputStream("fat.jar")); Manifest manifest = new Manifest(); manifest.getMainAttributes().putValue("Manifest-Version", "1.0"); manifest.getMainAttributes().putValue("Bundle-ClassPath", ".,lib/test.jar"); ZipEntry entry = new ZipEntry(JarFile.MANIFEST_NAME); jarout.putNextEntry(entry); manifest.write(jarout); jarout.closeEntry(); entry = new ZipEntry("lib/test.jar"); entry.setMethod(ZipEntry.STORED); entry.setSize(bs.length); entry.setCrc(0x6baaa6bL); jarout.putNextEntry(entry); jarout.write(bs); jarout.closeEntry(); jarout.close(); }

参考资料
  • 压缩包Zip格式详析

    推荐阅读