PAYATHON 2026

Java 8 fat-JAR 打包后无法读取 JPEG2000 图像

排障赵师傅

结论

问题通常出在 fat-JAR 的合并过程:ImageIO 的 SPI 注册文件被覆盖或丢失了。

META-INF/services/javax.imageio.spi.ImageReaderSpi

jai-imageio-jpeg2000 通过 Java SPI 机制注册 JPEG2000 reader。在 IntelliJ 中运行时,各个依赖 JAR 独立存在于 classpath,JVM 可以读取每个 JAR 里的 SPI 文件。maven-assembly-plugin 把依赖展开并合并到同一个 JAR 后,同名资源可能互相覆盖。此时,即使 reader 的 .class 文件仍在,ImageIO 也发现不了它,PDFBox 随后会抛出 MissingImageReaderException。

.m2 下的目录是按 Maven 坐标组织的仓库存储结构,不会原样写入 fat-JAR。它和 fat-JAR 中的路径不同是正常的。

使用 Maven Shade Plugin 合并 SPI 文件

可以用 maven-shade-plugin 替换当前的 maven-assembly-plugin,并配置 ServicesResourceTransformer。该 transformer 会合并各依赖中的 META-INF/services/* 文件,避免同名文件只留下一个。

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.5.3</version>

            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>shade</goal>
                    </goals>

                    <configuration>
                        <createDependencyReducedPom>false</createDependencyReducedPom>

                        <filters>
                            <filter>
                                <artifact>*:*</artifact>
                                <excludes>
                                    <exclude>META-INF/*.SF</exclude>
                                    <exclude>META-INF/*.DSA</exclude>
                                    <exclude>META-INF/*.RSA</exclude>
                                </excludes>
                            </filter>
                        </filters>

                        <transformers>
                            <transformer implementation=
                                "org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>

                            <transformer implementation=
                                "org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>CoverSheetCreatorFX</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

如果主类位于某个 package 中,mainClass 需要填写完整类名,例如:

<mainClass>com.example.CoverSheetCreatorFX</mainClass>

重新构建并运行:

mvn clean package
java -jar target/your-application.jar

项目中应固定插件版本。上面的版本可以作为 Java 8 项目的配置示例,实际使用前还要结合 Maven 版本和构建 JDK 完整测试一次。

继续使用 Maven Assembly Plugin

如果暂时不更换插件,可以在现有 assembly 配置中加入 metaInf-services container descriptor handler:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-assembly-plugin</artifactId>

            <executions>
                <execution>
                    <phase>package</phase>
                    <goals>
                        <goal>single</goal>
                    </goals>

                    <configuration>
                        <archive>
                            <manifest>
                                <mainClass>CoverSheetCreatorFX</mainClass>
                            </manifest>
                        </archive>

                        <descriptorRefs>
                            <descriptorRef>jar-with-dependencies</descriptorRef>
                        </descriptorRefs>

                        <containerDescriptorHandlers>
                            <containerDescriptorHandler>
                                <handlerName>metaInf-services</handlerName>
                            </containerDescriptorHandler>
                        </containerDescriptorHandlers>

                        <appendAssemblyId>false</appendAssemblyId>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

这项配置会让 assembly plugin 合并 META-INF/services 的内容。它是否可用还与项目采用的插件版本有关,因此构建完成后要检查生成物,不能只看构建是否成功。

检查 SPI 文件是否被覆盖

先查看依赖 JAR 中的服务注册文件:

unzip -p "$HOME/.m2/repository/com/github/jai-imageio/jai-imageio-jpeg2000/1.3.0/jai-imageio-jpeg2000-1.3.0.jar" \
  META-INF/services/javax.imageio.spi.ImageReaderSpi

再查看 fat-JAR 中的同一个文件:

unzip -p target/your-application.jar \
  META-INF/services/javax.imageio.spi.ImageReaderSpi

也可以先检查该文件是否存在:

jar tf target/your-application.jar |
  grep 'META-INF/services/javax.imageio.spi.ImageReaderSpi'

如果 fat-JAR 中没有这个文件,或者文件内容只包含其他 ImageIO 依赖的注册信息,没有 JPEG2000 provider,就可以确定问题出在 SPI 文件的打包过程。

还要检查 JPEG2000 reader 的 class 是否已经打进 JAR:

jar tf target/your-application.jar |
  grep -i 'jpeg2000\|j2k\|ImageReaderSpi'

找到 class 只能证明代码已经打包,无法证明 SPI 注册完整。ImageIO 能否发现 provider,还取决于注册文件和运行时的服务扫描。

在运行时检查 JPEG2000 reader

可以在程序启动阶段临时加入以下诊断代码:

import javax.imageio.ImageIO;
import javax.imageio.spi.IIORegistry;
import javax.imageio.spi.ImageReaderSpi;
import java.util.Iterator;

public class ImageIODiagnostics {

    public static void printReaders() {
        ImageIO.scanForPlugins();

        IIORegistry registry = IIORegistry.getDefaultInstance();
        Iterator<ImageReaderSpi> providers =
                registry.getServiceProviders(ImageReaderSpi.class, true);

        while (providers.hasNext()) {
            ImageReaderSpi provider = providers.next();
            System.out.println(
                    provider.getClass().getName()
                            + " -> "
                            + String.join(", ", provider.getFormatNames())
            );
        }
    }
}

分别在 IntelliJ 和 fat-JAR 环境中运行这段代码,然后对比输出的 provider。

ImageIO.scanForPlugins() 会重新扫描 classpath 上的 SPI 服务,却无法恢复打包时已经丢失的 META-INF/services 文件。它适合排查初始化或 classloader 问题,无法取代正确的打包配置。

IntelliJ 正常而 fat-JAR 失败的原因

IntelliJ 通常通过类似下面的 classpath 运行程序:

target/classes
jai-imageio-core-1.4.0.jar
jai-imageio-jpeg2000-1.3.0.jar
jbig2-imageio-3.0.0.jar
pdfbox-....jar
...

这些依赖仍是独立的 JAR,各自都能保留自己的注册文件:

META-INF/services/javax.imageio.spi.ImageReaderSpi

传统 fat-JAR 会先解压这些 JAR,再把文件写入同一个归档。多个依赖中存在相同路径时,如果构建插件没有合并资源,后写入的文件就会覆盖先写入的文件。最终,class 仍然存在,服务注册信息却不完整。

.m2 中可能有这样的仓库目录:

com/github/jai-imageio/jai-imageio-jpeg2000/1.3.0/

它表示:

groupId / artifactId / version

这不是 Java package。依赖展开后,class 会按照自身声明的 package 存放,不需要保留 Maven 仓库的外层目录。

其他排查项

合并 SPI 文件后如果问题仍未解决,可以继续检查以下内容。

1. 确认实际依赖版本

检查是否有版本冲突、依赖排除,或者多个 PDFBox 版本同时进入 classpath:

mvn dependency:tree

也可以缩小输出范围:

mvn dependency:tree \
  -Dincludes=com.github.jai-imageio,org.apache.pdfbox

重点检查:

  • jai-imageio-jpeg2000 是否确实进入 runtime classpath;
  • 是否被某个 <exclusion> 排除;
  • 是否存在多个 PDFBox 或 jai-imageio 版本;
  • 打包插件处理的是否为 Maven 实际解析出的依赖。

2. 检查是否启用了类裁剪

使用 Shade Plugin 的 <minimizeJar>true</minimizeJar> 或其他压缩、混淆工具时,SPI 加载的类可能被误判为“未使用”并删除。JPEG2000 reader 通过配置文件动态加载,裁剪工具不一定能从静态调用关系中识别它。

排查期间不要启用 minimizeJar。如果必须缩减体积,需要为 SPI provider 添加保留规则。

3. 核对运行 fat-JAR 的 Java 环境

IntelliJ 使用的 JDK 可能与命令行 java 不同,可以分别检查:

java -version
mvn -version

还要核对 IntelliJ 的 Project SDK、Maven Runner JDK,以及最终用户运行 JAR 时使用的 Java 版本。不同 Java 8 发行版对 JavaFX 的打包方式可能不同,但这通常不会直接造成 JPEG2000 reader 消失。

4. 检查 classloader 和初始化时机

如果应用使用了自定义 classloader、插件框架或特殊的 JavaFX 启动方式,ImageIO 首次初始化时可能拿到了不包含 provider 的线程上下文 classloader。可以在应用启动早期执行:

Thread.currentThread().setContextClassLoader(
        ImageIODiagnostics.class.getClassLoader()
);
ImageIO.scanForPlugins();

这段代码会改变线程上下文 classloader,不适合作为未经验证的通用修复。只有确认 SPI 文件已经正确合并、provider class 也确实存在,但注册仍然失败时,才需要从这个方向排查。

5. 区分 JPEG2000 与 JBIG2

jbig2-imageio 负责 JBIG2 图像,不处理 JPEG2000。它也可能带有自己的 ImageIO SPI 文件,并覆盖 JPEG2000 provider 的服务文件。项目中有多个 ImageIO 扩展时,fat-JAR 应合并 META-INF/services,不能任意保留其中一个依赖的版本。

处理顺序

先让 assembly 配置支持 metaInf-services 合并,或者改用配置了 ServicesResourceTransformer 的 Shade Plugin。重新打包后,检查 fat-JAR 中的 META-INF/services/javax.imageio.spi.ImageReaderSpi,确认其中包含 JPEG2000 provider。随后运行诊断代码,确认 ImageIO 已经完成注册。

如果服务文件和 provider class 都完整,问题却仍然存在,再检查依赖冲突、JDK 差异、类裁剪和 classloader。调整 fat-JAR 内的 class 路径通常无法解决这个异常。

备注:内容仅供参考。