SpringBoot内嵌JAR免解压加载原理

起因

因为我的项目环境基于Felix(一个apache的开源OSGi实现框架),Felix框架在解决蕴含内嵌JAR包的构件时,须要将内嵌JAR解压缩到本地cache目录,能力拜访这些内嵌JAR资源。因为内嵌JAR数量宏大,会额定占用约500M左右的反复磁盘空间。

一个蕴含内嵌JAR的OSGi构件大略长这个样子:

回想到Springboot在打包时,能够同样将我的项目所有依赖打包到独自的一个JAR中,应用java -jar test.jar这种模式间接运行。这为我的项目的散发带来了极大的不便。
一个SpringBoot繁多JAR文件的目录构造和OSGi内嵌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中。

<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-loader</artifactId>    <version>2.6.6</version></dependency>

SpringBoot繁多JAR包MANIFEST内容:

Manifest-Version: 1.0Implementation-Title: SpringBootDemoSpring-Boot-Version: 2.0.6.RELEASEMain-Class: org.springframework.boot.loader.JarLauncherStart-Class: springboot2.DemoAppSpring-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.classentry time: 1490962798000class 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资源的读取计划也就有了。

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格局详析