SpringBoot内嵌JAR免解压加载原理

SpringBoot内嵌JAR免解压加载原理

起因

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

一个包含内嵌JAR的OSGi构件大概长这个样子:
SpringBoot内嵌JAR免解压加载原理_第1张图片

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

这其中,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资源的读取方案也就有了。

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();
}

参考资料

你可能感兴趣的:(javaspringboot)