如何将 Java 程序发布给 Windows 用户?

3,062 阅读17分钟

基本解决思路是:开发时怎么运行程序,在部署的地方就配置同样的环境来运行程序

1. 服务器应用程序

2. 客户端应用程序

1. 服务器应用程序

方案一:仅发布 Jar 包

编译 Java 源代码,将源代码以及所有的依赖一起打成 Jar 包,传到服务器

在服务器上配置 Java 运行环境(JRE),使用 java -jar 命令运行 Jar 包就能启动程序

优点1:产品的体积小,仅包含业务代码(以及依赖)

优点2:用户能够编辑 Jar 包来满足特定的需求,例如替换其中的部分依赖

技术人员一般都具有配置 JRE 的能力,所以这个方案虽然简单,但也足够有效

方案二:打包 Docker 镜像

// TODO

方案三:使用 GraalVM 构建原生可执行文件

// TODO

2. 客户端应用程序

方案一:仅发布 Jar 包(不推荐)

如果用户有技术背景,同样可以让用户在本地安装好 JRE 用命令行运行 Jar 包

缺点1:手动安装 Java 运行环境(JRE)的过程会给使用者造成麻烦

缺点2:用户可能会配错 JRE 的版本,导致程序不能正常运行

缺点3:使用 java -jar 命令行工具来启动程序的方式不适合有 GUI 的程序

方案二:使用 jpackage 打包发布(推荐)

适用场景:Java 14 及更高版本

优点1:打包操作比较简单,jpackage 是 Java 自带的工具,不需要借助其它的软件

优点2:打包的产物内置 Java 运行环境(仅包含必要的模块,体积较小),不需要安装 JRE

优点3:以平台可执行文件提供给用户,可双击启动,对 GUI 程序友好

缺点1:只有 Java 14 及更高版本提供 jpackage,被广泛使用的 Java 8 不提供 jpackage 工具

打包完成的产品结构:

1722322955579.png

文件/文件夹来源作用
app 文件夹jpackage 命令的 --input 参数所指定文件夹里面的内容,并增加 .jpackage.xml可执行文件名称.cfg存放 Jar 包、配置文件
runtime 文件夹根据 jpackage 命令的 --add-modules--jlink-options 等参数生成相当于 JRE,即 Java 运行时环境
可执行文件名称.exe根据 jpackage 命令的 --input--main-jar 等参数生成主程序的启动器,双击后调用 runtime 包含的 Java 环境来执行 app 中的 Jar 包
可执行文件名称.ico根据 jpackage 命令的 --icon 参数指定的文件生成应用程序的图标(不是窗口的图标)

主要步骤(用到 jdeps 和 jpackage 命令):

jdeps .\target\helloworld-1.0-jar-with-dependencies.jar

1722258034088.png

jpackage --type app-image --name hello --vendor world --app-version 0.0.1.0 --input input --main-jar helloworld-1.0-jar-with-dependencies.jar --icon .\input\icon.ico  --dest dist  --add-modules java.base,java.desktop,java.logging,java.sql --jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2" --win-console

示例 demo:

这是一个用来测试的 helloworld 项目,基于 maven 构建,Java 版本是 18,采用 Java 的 Swing 技术来实现客户端界面,使用 SQLite 来做数据存储,需要在 pom.xml 配置 SQLite 的依赖:

<dependency>
    <groupId>org.xerial</groupId>
    <artifactId>sqlite-jdbc</artifactId>
    <version>3.41.2.2</version>
    <scope>runtime</scope>
</dependency>

为了将依赖和业务代码一起打进 Jar 包,在 pom.xml 中对 maven-assembly-plugin 插件进行配置:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-assembly-plugin</artifactId>
    <version>3.7.1</version>
    <configuration>
        <descriptorRefs>
            <descriptorRef>jar-with-dependencies</descriptorRef>
        </descriptorRefs>
        <archive>
            <manifest>
                <mainClass>com.UI.LoginJFrame</mainClass>
            </manifest>
        </archive>
    </configuration>
    <executions>
        <execution>
            <id>make-assembly</id>
            <phase>package</phase>
            <goals>
                <goal>single</goal>
            </goals>
        </execution>
    </executions>
</plugin>

步骤1:制作 Jar 包

其中 com.UI.LoginJFrame 是程序的主入口类名。配置 pom.xml 之后,使用 mvn package 命令打成 Jar 包:

1722257451984.png

在生成的 target 目录下面可以看到 Jar 包 helloworld-1.0-jar-with-dependencies.jar

1722257653118.png

步骤2:分析用到的模块

使用 jdeps 命令可以查看 helloworld-1.0-jar-with-dependencies.jar 用到的模块

jdeps .\target\helloworld-1.0-jar-with-dependencies.jar

1722258034088.png

可以看到,helloworld-1.0-jar-with-dependencies.jar用到了 java.basejava.desktopjava.loggingjava.sql 这 4 个模块

步骤3:jpackage 打包

为了方便进一步处理,这里创建一个名为 input 的文件夹,将 helloworld-1.0-jar-with-dependencies.jar 放进这个文件夹中:

1722259637783.png

input 文件夹下面,icon.ico 是为可执行文件准备的图标,在打包过程中需要用到这个 .ico 文件:

1722259357811.png

最后执行 jpacakge 打包命令(不显示控制台):

jpackage --type app-image --name hello --vendor world --app-version 0.0.1.0 --input input --main-jar helloworld-1.0-jar-with-dependencies.jar --icon .\input\icon.ico  --dest dist  --add-modules java.base,java.desktop,java.logging,java.sql --jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2"

如果需要显示控制台,则加上 --win-console

jpackage --type app-image --name hello --vendor world --app-version 0.0.1.0 --input input --main-jar helloworld-1.0-jar-with-dependencies.jar --icon .\input\icon.ico  --dest dist  --add-modules java.base,java.desktop,java.logging,java.sql --jlink-options "--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2" --win-console
参数说明
--typeapp-image打包的方式,在 Windows 平台可选择 app-imageexemsi
--namehello可执行文件名称
--vendorworld可执行文件的提供者
--app-version0.0.1.0可执行文件的版本号
--inputinput输入文件夹
--main-jarhelloworld-1.0-jar-with-dependencies.jar输入文件夹里面的核心 Jar 包
--icon.\input\icon.ico图标的路径
--destdist输出文件夹
--add-modulesjava.base,java.desktop,java.logging,java.sql用到的模块(参考jdeps 命令)
--jlink-options"--strip-native-commands --strip-debug --no-man-pages --no-header-files --compress=2"参考jlink命令
--win-console显示控制台(黑窗口)
.........

打包完成后:

1722260189485.png

在生成的 dist 目录下面,整个 hello 文件夹就是打包的结果,可以作为产品发布给用户:

1722322955579.png

双击 hello.exe 即可运行程序,在任务管理器中能看到这个应用的运行情况:

1722260834006.png

在 Windows 平台,使用 jpacakageapp-image 方式打包生成的文件夹结构为 app + runtime + 可执行文件名称.exe + 可执行文件名称.ico

1722261672233.png

这种方式打包的 hello.exe (即 可执行文件名称.exe) 并不是应用程序主体,它只是一个启动器,在功能上可以理解为:

.\runtime\bin\java.exe  -jar .\app\helloworld-1.0-jar-with-dependencies.jar

注意:如果jpackage命令的--jlink-options 参数包含--strip-native-commands选项, 那么生成的 runtime 文件夹里面是没有 java.exe 等命令行工具的。

如果想要一份包含 java.exe 等命令行工具的 runtime ,可以直接用 jlink 创建:

jlink --output runtime --add-modules java.base,java.desktop,java.logging,java.sql  --strip-debug --no-man-pages --no-header-files --compress=2

使用 jlink 命令可以在Java 18 环境下创建包含不同模块的 runtime 。其中,仅包含 java.baseruntime 经过 7z 压缩的大小为 13.09MB ,基本能够满足客户端程序的发布需求

模块--compress=0--compress=1--compress=2zip7z
java.base36.13MB29.93MB26.21MB15.05MB13.09MB
java.base,java.desktop63.49MB49.88MB40.81MB26.98MB24.47MB
java.base,java.logging36.31MB30.04MB26.30MB15.13MB13.17MB
java.base,java.sql45.40MB36.21MB29.99MB18.66MB16.66MB
java.base,java.xml45.10MB36.02MB29.85MB18.53MB16.53MB

附录:pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>org.example</groupId>
    <artifactId>helloworld</artifactId>
    <version>1.0</version>
    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.xerial</groupId>
            <artifactId>sqlite-jdbc</artifactId>
            <version>3.41.2.2</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-assembly-plugin</artifactId>
                <version>3.7.1</version>
                <configuration>
                    <descriptorRefs>
                        <descriptorRef>jar-with-dependencies</descriptorRef>
                    </descriptorRefs>
                    <archive>
                        <manifest>
                            <mainClass>com.UI.LoginJFrame</mainClass>
                        </manifest>
                    </archive>
                </configuration>
                <executions>
                    <execution>
                        <id>make-assembly</id>
                        <phase>package</phase>
                        <goals>
                            <goal>single</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.13.0</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.4.2</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-clean-plugin</artifactId>
                <version>3.3.2</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-deploy-plugin</artifactId>
                <version>3.1.2</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-install-plugin</artifactId>
                <version>3.1.2</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-site-plugin</artifactId>
                <version>3.8.2</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-resources-plugin</artifactId>
                <version>3.3.1</version>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>3.3.1</version>
            </plugin>
        </plugins>
    </build>
</project>

方案三:使用 jlink 构建 runtime,制作 bat(或 exe)启动脚本

适用场景:Java 9 及更高版本

优点1:打包操作简单,jlink 是 Java 9 自带的工具

优点2:打包的产物内置 Java 运行环境(仅包含必要的模块,体积较小),不需要安装 JRE

优点3:支持 Java 9,不需要太高的 Java 版本

缺点1:不能修改程序在任务管理器中的应用名(OpenJDK Platform binary)和图标

缺点2:只有 Java 9 及更高版本提供 jlink,被广泛使用的 Java 8 不提供 jlink 工具

打包完成的产品结构:

1722326701605.png

创建运行时(用到 jdeps 和 jlink 命令):

jdeps .\target\helloworld-1.0-jar-with-dependencies.jar

1722258034088.png

jlink --output runtime --add-modules java.base,java.desktop,java.logging,java.sql  --strip-debug --no-man-pages --no-header-files --compress=2

为了模仿 jpackageapp-image 方式打包生成的文件夹结构,在 runtime 的同级目录下面创建一个 app 文件夹,并将 helloworld-1.0-jar-with-dependencies.jar 放进去,然后创建一个名为 app.bat 的文件:

1722325364847.png

制作 bat 启动脚本:

1722325405455.png

.\runtime\bin\java.exe -jar "./app/helloworld-1.0-jar-with-dependencies.jar"

双击 app.bat 就可以启动程序

如果依赖和业务代码没有被打包在一起,则可以用另一种方式启动程序:

1722325727780.png

制作 exe 启动脚本:

bat 脚本虽然简单,但是看上去有点简陋,制作一个带图标的 exe 启动脚本可能会更好一些:

先准备 3 个文件:

1722326077937.png

其中,app.rc 文件的内容是:

1 ICON "app.ico"

安装 MinGW 并配置环境变量,使用 windres 命令将 app.rc 转化成 app.o

windres app.rc app.o

app.c 中编写如下代码:

#include <windows.h>
int main()
{
    system(".\\runtime\\bin\\java.exe -jar \"./app/helloworld-1.0-jar-with-dependencies.jar\"");
    return 0;
}

使用 gcc 命令将图标资源打包进去:

gcc app.c app.o -o app.exe -static

可以得到带图标的启动脚本 app.exe

1722326701605.png

如果需要隐藏控制台的黑窗口, 将 app.c 做如下修改,再编译成 app.exe

#include <windows.h>
int main()
{
    HWND hWnd = GetConsoleWindow();
    if (hWnd)
    {
        ShowWindow(hWnd, SW_HIDE);
    }
    system(".\\runtime\\bin\\java.exe -jar \"./app/helloworld-1.0-jar-with-dependencies.jar\"");
    return 0;
}

双击 app.exe 即可运行程序,在任务管理器中能看到这个应用的运行情况:

1722326922871.png

注意到,脚本启动的问题是不能修改程序在任务管理器中的应用名(OpenJDK Platform binary)和图标!

方案四:使用 jlink 构建 runtime,利用 exe4j 制作 exe

适用场景:Java 9 及更高版本

优点1:可以修改程序在任务管理器中的应用名和图标

缺点1:需要用到额外的工具 exe4j

缺点2:打包出来的 GUI 分辨率有问题,字体比较模糊,影响使用体验

缺点3:只有 Java 9 及更高版本提供 jlink,被广泛使用的 Java 8 不提供 jlink 工具

打包完成的产品结构:

1722863120715.png

创建运行时(用到 jdeps 和 jlink 命令):

jdeps .\target\helloworld-1.0-jar-with-dependencies.jar

1722258034088.png

jlink --output runtime --add-modules java.base,java.desktop,java.logging,java.sql  --strip-debug --no-man-pages --no-header-files --compress=2

为了模仿 jpackageapp-image 方式打包生成的文件夹结构,在 runtime 的同级目录下面创建一个 app 目录,并将 helloworld-1.0-jar-with-dependencies.jar 和图标文件 icon.ico 放进去:

1722861484675.png

步骤1:下载、安装 exe4j 软件

下载 exe4j 软件,配置环境变量 EXE4J_JAVA_HOME,启动 exe4j:

1722333848198.png

点击 Enter License,输入 NameCompanyLicense key

1722333926845.png

注意:如果这里没有填写有效的 License Key,那么打包出来的可执行文件会报错 "this executable was created with an evaluation version exe4j"

步骤2:选择工程类型

如果没有打包成单文件 exe 的需求,选择 Regular mode 即可:

1722329181492.png

步骤3:输入应用程序名称、路径

1722861881882.png

步骤4:设置 exe 的类型、名称、图标路径

1722861929472.png

本项目用的是 64 位的 Java 环境:

1722335281547.png

步骤5:将 Jar 包添加为 Class Path

1722862333900.png

这里必须使用相对路径 .\app\helloworld-1.0-jar-with-dependencies.jar

1722862042380.png

填写 com.UI.LoginJFrame 作为程序的主入口类:

1722862145769.png

步骤6:将 runtime 文件夹设置为 JRE

helloworld 项目用到的语法不超过 Java 7,因此最低版本选择 1.7,不设置最高版本

1722329595804.png

点击 Advanced Options 按钮, 将 runtime 文件夹以相对路径的形式设置为 JRE,并放在搜索路径的第一个位置:

1722862409352.png 1722329794713.png 1722862960534.png

对于客户端应用程序,选择默认的虚拟机也没有问题:

1722329826485.png

步骤7:一直点击 Next,生成 exe 可执行文件

1722335144051.png 1722329868229.png

可以得到带图标的可执行文件 hello.exe

1722862557414.png

hello.exe 移动到上一级目录,确保相对路径是正确的:

1722862677895.png

此时,双击 hello.exe 就可以运行程序,在任务管理器中能看到这个应用的运行情况:

1722335611556.png

注意到,用 exe4j 制作的 exe 在任务管理器中有自己的应用名和图标,这是方案三做不到的效果

方案五:手工精简 JRE 构建 runtime

考虑到 Java 的良好兼容性,基于低版本 Java 编写的代码一般可以在高版本 Java 环境中正常运行

因此,在 2024 年,已经没有什么理由必须基于 Java 8 来运行客户端软件

如果不得不使用 Java 8,则可以通过移除 JRE 中用不到的组件来达到压缩软件体积的目的

优点1:不需要 jlink 工具,支持 Java 8

优点2:打包体积小

缺点1:操作过于繁琐,非常容易出错

缺点2:一旦遗漏了某些必要的组件,软件在运行过程中就会出问题

打包完成的产品结构:

1722863620080.png

创建可执行文件:

制作 hello.exe 的过程可以参考 方案三方案四

创建运行时(以 Java 8 为例):

本文使用的是 OpenJDK ,不同版本的 JDK 在目录结构上可能会有区别

打开 JDK 的安装位置,找到 jre 这个文件夹:

1723196423656.png

jre 文件夹复制到项目的目录下,并改名为 runtime

1723196488331.png

1723196599230.png

此时的 runtime 是一个全量的 JRE,文件夹的体积为 105.86 MB

步骤1:收集依赖

使用 java -jar 运行程序,尽可能让程序走遍所有的逻辑分支。收集运行过程中涉及的依赖,并记录到 class.txt 文件中:

java -jar -verbose:class .\target\helloworld-1.0-jar-with-dependencies.jar > class.txt

这一步是精简 JRE 的基础,非常重要。如果是 GUI 程序,需要把可能的交互动作都做一遍!

class.txt 文件的内容如下(节选):

[Opened D:\JDK\path\jre\lib\rt.jar]
[Loaded java.lang.Object from D:\JDK\path\jre\lib\rt.jar]
[Loaded java.io.Serializable from D:\JDK\path\jre\lib\rt.jar]
[Loaded java.lang.Comparable from D:\JDK\path\jre\lib\rt.jar]
[Loaded java.lang.CharSequence from D:\JDK\path\jre\lib\rt.jar]
[Loaded java.lang.String from D:\JDK\path\jre\lib\rt.jar]
...

步骤2:分析依赖

容易观察到 class.txt 文件里面记录的信息有固定的格式,因此可以从中提取出涉及到的 Jar 包名称。更进一步,这些 Jar 包中被用到的 class 文件也可以被统计出来。

使用 ExtractClassName 脚本来分析 class.txt 文件(要注意文件编码问题):

public class ExtractClassName {

    public static void main(String[] args) {
        Path userDir = Paths.get(System.getProperty("user.dir"));
        File classTxtFile = userDir.resolve("class.txt").toFile();
        Map<String, Set<String>> map = new HashMap<>();
        try (BufferedReader reader = new BufferedReader(new InputStreamReader(
                Files.newInputStream(classTxtFile.toPath()), StandardCharsets.UTF_16LE))
        ) {
            String line;
            while ((line = reader.readLine()) != null) {
                if (line.startsWith("[Opened")) {
                    map.put(line.substring(8, line.length() - 1), new HashSet<>());
                } else if (line.startsWith("[Loaded")) {
                    String[] strings = line.substring(8, line.length() - 1).split(" from ");
                    if (map.containsKey(strings[1])) {
                        map.get(strings[1]).add(strings[0]);
                    } else {
                        Set<String> set = new HashSet<>();
                        set.add(strings[0]);
                        map.put(strings[1], set);
                    }
                }
            }
            Set<String> others = new TreeSet<>();
            for (Map.Entry<String, Set<String>> entry : map.entrySet()) {
                String key = entry.getKey().replace("\", "/");
                if (key.endsWith(".jar")) {
                    System.out.println(key + " " + entry.getValue().size());
                    String fileName = key.substring(key.lastIndexOf("/") + 1, key.lastIndexOf(".")) + ".txt";
                    Set<String> itemSet = new TreeSet<>(entry.getValue());
                    itemSet.add("META-INF/*");
                    Files.write(userDir.resolve(fileName), itemSet, StandardCharsets.UTF_8);
                } else {
                    others.add(key);
                }
            }
            for (String other : others) {
                System.err.println(other);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

输出如下:

file:/D:/Desktop/helloworld/target/helloworld-1.0-jar-with-dependencies.jar 129
D:/JDK/path/jre/lib/rt.jar 2559
D:/JDK/path/jre/lib/charsets.jar 7
D:/JDK/path/jre/lib/jfr.jar 1
file:/D:/JDK/path/jre/lib/ext/localedata.jar 4
D:/JDK/path/jre/lib/jsse.jar 1
__JVM_DefineClass__
java.awt.GraphicsEnvironment
java.awt.SystemColor
java.lang.invoke.LambdaForm
java.nio.file.Files
javax.swing.JTable
org.sqlite.SQLiteJDBCLoader
org.sqlite.core.CorePreparedStatement
org.sqlite.core.CoreResultSet
org.sqlite.core.CoreStatement
org.sqlite.core.DB
org.sqlite.jdbc3.JDBC3PreparedStatement
org.sqlite.jdbc3.JDBC3ResultSet
org.sqlite.jdbc3.JDBC3Statement
sun.awt.windows.WToolkit
sun.java2d.Disposer
sun.java2d.d3d.D3DScreenUpdateManager

从分析的结果可以看出,程序在运行过程中加载了 JRE 的 5 个 Jar 包:rt.jarcharsets.jarjfr.jarlocaledata.jarjsse.jar ,其中,用到了 rt.jar 里面的 2559 个 class 文件。

除了统计 class 文件的个数以外,ExtractClassName 脚本还将涉及到的 class 文件名按照所属的 Jar 包进行分类,写入 rt.txtcharsets.txtjfr.jarlocaledata.txtjsse.txt 这 5 个文件

例如,rt.txt 文件的内容如下(节选):

META-INF/*
com.sun.beans.util.Cache
com.sun.beans.util.Cache$CacheEntry
com.sun.beans.util.Cache$Kind
com.sun.beans.util.Cache$Kind$1
com.sun.beans.util.Cache$Kind$2
com.sun.beans.util.Cache$Kind$3
com.sun.beans.util.Cache$Kind$Strong
com.sun.beans.util.Cache$Kind$Weak
com.sun.beans.util.Cache$Ref
com.sun.imageio.plugins.bmp.BMPImageReaderSpi
com.sun.imageio.plugins.bmp.BMPImageWriterSpi
...

META-INF/* 虽然不是 class 文件的名字,但是打包过程需要用到这个,因此 ExtractClassName.main 在执行时会自动添加 META-INF/*

步骤3:重新打包 rt.jar

从全量 JRE 中的 lib 目录下找到 rt.jar

1723196682902.png

解压 rt.jar,重命名为 rt_jar,将 rt_jar 文件夹复制到项目的目录下:

1722867540211.png

执行 PackageJar 脚本,其功能是:

(1)读取 rt.txt 中的 class 文件名(其实是全类名)

(2)从 rt_jar 文件夹中复制涉及到的 class 文件并放进同目录下的 rt 文件夹(会自动创建)

(3)将 rt 文件夹打包成精简版的 rt.jar 覆盖掉 .\runtime\lib 目录里面原本的 rt.jar

(4)执行 .\runtime\bin\java.exe -jar .\target\helloworld-1.0-jar-with-dependencies.jar 命令测试新的 rt.jar 有没有缺失依赖

(5)从报错信息中提取缺失的 class 文件名,并将其追加到 rt.txt

public class PackageJar {
    public static void main(String[] args) {
        int repeat = 1;
        for (int i = 0; i < repeat; i++) {
            try {
                String userDir = System.getProperty("user.dir");
                packageJar("rt", new File(userDir, "runtime/lib/rt.jar").toPath());
                // packageJar("jfr", new File(userDir, "runtime/lib/jfr.jar").toPath());
                // packageJar("jsse", new File(userDir, "runtime/lib/jsse.jar").toPath());
                // packageJar("charsets", new File(userDir, "runtime/lib/charsets.jar").toPath());
                // packageJar("localedata", new File(userDir, "runtime/lib/ext/localedata.jar").toPath());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    private static void copyFileIfNotExist(File src, File dest) {
        if (dest.exists()) {
            return;
        }
        if (!src.exists()) {
            System.err.println(src.getPath() + "不存在");
            return;
        }
        if (dest.getParentFile().mkdirs()) {
            System.out.println(dest.getParentFile().getPath() + "创建成功");
        }
        try {
            Files.copy(src.toPath(), dest.toPath());
            System.out.println("增加 " + dest.getPath());
        } catch (IOException e) {
            System.err.println(dest.getPath() + " 创建过程发生异常 " + e.getMessage());
        }
    }

    private static void copyDirIfNotExist(File srcDir, File destDir) {
        if (!srcDir.exists()) {
            System.err.println(srcDir.getPath() + "不存在");
            return;
        }
        File[] files = srcDir.listFiles();
        if (files == null) {
            return;
        }
        for (File file : files) {
            if (file.isDirectory()) {
                copyDirIfNotExist(file, new File(destDir, file.getName()));
            } else {
                copyFileIfNotExist(file, new File(destDir, file.getName()));
            }
        }
    }

    public static boolean deleteDir(File dir) {
        if (dir == null) {
            return true;
        }
        File[] files = dir.listFiles();
        if (files == null) {
            return dir.delete();
        }
        for (File file : files) {
            if (file.isDirectory()) {
                if (!deleteDir(file)) {
                    return false;
                }
            } else {
                if (!file.delete()) {
                    return false;
                }
            }
        }
        return dir.delete();
    }

    private static void collectClassName(BufferedReader reader, Set<String> classNameSet) {
        String[] tags = {
                "java/lang/NoClassDefFoundError",
                "java.lang.NoClassDefFoundError",
                "java.lang.ClassNotFoundException",
                "Class not found",
                "Could not initialize class",
                "java.lang.Error: Could not find class",
                "Toolkit not found",
                "java.util.ServiceConfigurationError: sun.java2d.pipe.RenderingEngine: Provider"
        };
        int[] beginOffset = {2, 2, 2, 2, 1, 2, 2, 1};
        try {
            String line;
            while ((line = reader.readLine()) != null) {
                System.err.println(line);
                if (line.contains("thrown from the UncaughtExceptionHandler")) {
                    continue;
                }
                for (int i = 0; i < tags.length; i++) {
                    String tag = tags[i];
                    int index = line.indexOf(tag);
                    if (index == -1) {
                        continue;
                    }
                    int beginIndex = index + tag.length() + beginOffset[i];
                    String s = line.substring(beginIndex);
                    if (s.startsWith("Could not initialize class ")) {
                        s = s.substring("Could not initialize class ".length());
                    }
                    int endIndex = s.indexOf(" ");
                    if (endIndex == -1) {
                        endIndex = s.length();
                    }
                    String className = s.substring(0, endIndex);
                    if (className.startsWith("L") && className.endsWith(";")) {
                        className = className.substring(1, className.length() - 1);
                    }
                    classNameSet.add(className.replace("/", "."));
                }
            }
        } catch (IOException e) {
            System.err.println(reader + " 异常 " + e.getMessage());
        }
    }

    public static void packageJar(String jarName, Path jarFilePath) throws Exception {
        Path userDir = Paths.get(System.getProperty("user.dir"));
        File txtFile = userDir.resolve(jarName + ".txt").toFile();
        Set<String> itemSet = new TreeSet<>();
        if (txtFile.exists()) {
            List<String> lines = Files.readAllLines(txtFile.toPath());
            for (String line : lines) {
                if (!line.isEmpty()) {
                    itemSet.add(line);
                }
            }
        }
        for (String item : itemSet) {
            item = item.replace(".", "/");
            if (item.startsWith("-")) {
                if (item.endsWith("/*")) {
                    File dir = userDir.resolve(jarName).resolve(item.substring(1, item.length() - 2)).toFile();
                    if (dir.exists() && dir.isDirectory()) {
                        if (deleteDir(dir)) {
                            System.out.println(dir.getPath() + "已删除");
                        }
                    }
                } else {
                    File file = userDir.resolve(jarName).resolve(item.substring(1) + ".class").toFile();
                    if (file.exists() && file.isFile()) {
                        if (file.delete()) {
                            System.out.println(file.getPath() + "已删除");
                        }
                    }
                }
                continue;
            }
            if (item.endsWith("/*")) {
                String path = item.substring(0, item.length() - 2);
                File srcClassDir = userDir.resolve(jarName + "_jar").resolve(path).toFile();
                File destClassDir = userDir.resolve(jarName).resolve(path).toFile();
                if (srcClassDir.exists() && srcClassDir.isDirectory()) {
                    copyDirIfNotExist(srcClassDir, destClassDir);
                }
                continue;
            }
            File srcClassFile = userDir.resolve(jarName + "_jar").resolve(item + ".class").toFile();
            File destClassFile = userDir.resolve(jarName).resolve(item + ".class").toFile();
            if (srcClassFile.exists() && srcClassFile.isFile()) {
                copyFileIfNotExist(srcClassFile, destClassFile);
            }
            // 多版本 class 的情况
            String prefix = userDir.resolve(jarName + "_jar").resolve(item).toString();
            File dir = srcClassFile.getParentFile();
            if (dir.exists() && dir.isDirectory()) {
                File[] files = dir.listFiles();
                if (files == null) {
                    continue;
                }
                for (File file : files) {
                    String path = file.getPath();
                    if (path.startsWith(prefix) &&
                            path.substring(prefix.length()).matches("(\$\d+)+\.class")) {
                        copyFileIfNotExist(file, new File(destClassFile.getParentFile(), file.getName()));
                    }
                }
            }
        }
        Path jarDirPath = userDir.resolve(jarName);
        ProcessBuilder processBuilder1 = new ProcessBuilder(
                "jar", "-cfM", jarFilePath.toString(), "-C", jarDirPath.toString(), "./");
        System.out.println("执行 " + String.join(" ", processBuilder1.command()));
        // jar -cfM rt.jar -C rt ./
        processBuilder1.start().waitFor();
        File jarFile = jarFilePath.toFile();
        if (jarFile.exists()) {
            String jarFileSize = String.format("%.2f", jarFile.length() / 1024.0 / 1024.0);
            System.out.println(jarFile.getName() + " 的大小是 " + jarFileSize + "MB");
        }
        Path javaPath = userDir.resolve("runtime").resolve("bin").resolve("java.exe");
        String appJarName = "helloworld-1.0-jar-with-dependencies.jar";
        Path targetJarPath = userDir.resolve("target").resolve(appJarName);
        ProcessBuilder processBuilder2 = new ProcessBuilder(
                javaPath.toString(), "-jar", targetJarPath.toString());
        System.out.println("执行 " + String.join(" ", processBuilder2.command()));
        // java -jar helloworld-1.0-jar-with-dependencies.jar
        Process process = processBuilder2.start();
        process.waitFor(3L, TimeUnit.SECONDS);
        // ErrorStream
        BufferedReader reader1 = new BufferedReader(new InputStreamReader(process.getErrorStream()));
        Set<String> classNameSet1 = new HashSet<>();
        Runnable task1 = () -> collectClassName(reader1, classNameSet1);
        // InputStream
        BufferedReader reader2 = new BufferedReader(new InputStreamReader(process.getInputStream()));
        Set<String> classNameSet2 = new HashSet<>();
        Runnable task2 = () -> collectClassName(reader2, classNameSet2);
        try {
            CompletableFuture.allOf(
                    CompletableFuture.runAsync(task1),
                    CompletableFuture.runAsync(task2)
            ).get(5L, TimeUnit.SECONDS);
        } finally {
            process.destroy();
            reader1.close();
            reader2.close();
            if (!classNameSet1.isEmpty()) {
                System.err.println("缺少 " + String.join(",", classNameSet1));
                itemSet.addAll(classNameSet1);
            }
            if (!classNameSet2.isEmpty()) {
                System.err.println("缺少 " + String.join(",", classNameSet2));
                itemSet.addAll(classNameSet2);
            }
            Files.write(txtFile.toPath(), itemSet);
        }
    }
}

第 1 次执行 PackageJar 的输出是:

执行 jar -cfM D:\Desktop\helloworld\runtime\lib\rt.jar -C D:\Desktop\helloworld\rt ./
rt.jar 的大小是 4.96MB
执行 D:\Desktop\helloworld\runtime\bin\java.exe -jar D:\Desktop\helloworld\target\helloworld-1.0-jar-with-dependencies.jar
Exception in thread "AWT-EventQueue-0" Exception in thread "AWT-EventQueue-0"
Exception: java.lang.NoClassDefFoundError thrown from the UncaughtExceptionHandler in thread "AWT-EventQueue-0"
Exception in thread "AWT-Shutdown" 
Exception: java.lang.NoClassDefFoundError thrown from the UncaughtExceptionHandler in thread "AWT-Shutdown"

看到错误信息 java.lang.NoClassDefFoundError thrown from the UncaughtExceptionHandler,这说明新打包的 rt.jar 里面缺少一些非常基础的类文件

为了解决这个问题,试着在 rt.txt 里面手动添加一行 java.lang.*

1723202751176.png

这里新添加的 java.lang.* 表示 java.lang 这个包下面所有的 class 文件。这些 class 文件是 Java 程序的基础,精简版的 rt.jar 也应该包含全量的 java.lang

实际上,如果报错信息无法准确提供缺失项的名字,那么很有可能是缺少 java.lang.*java.util.* 这些基础的依赖

然后第 2 次执行 PackageJar ,输出是:

执行 jar -cfM D:\Desktop\helloworld\runtime\lib\rt.jar -C D:\Desktop\helloworld\rt ./
rt.jar 的大小是 5.14MB
执行 D:\Desktop\helloworld\runtime\bin\java.exe -jar D:\Desktop\helloworld\target\helloworld-1.0-jar-with-dependencies.jar
Exception in thread "AWT-EventQueue-0" java.lang.InternalError: bouncer cannot be found
    at sun.reflect.misc.MethodUtil.getTrampoline(MethodUtil.java:311)
    at sun.reflect.misc.MethodUtil.<clinit>(MethodUtil.java:81)
    at javax.swing.UIDefaults.getUI(UIDefaults.java:770)

根据报错信息,打开 sun.reflect.misc.MethodUtil.getTrampoline 的代码,找到了:

/*
 * Create a trampoline class.
 */
public final class MethodUtil extends SecureClassLoader {
    private static final String MISC_PKG = "sun.reflect.misc.";
    private static final String TRAMPOLINE = MISC_PKG + "Trampoline";
    private static final Method bounce = getTrampoline();
...

非常明显,sun.reflect.misc.Trampoline 是动态加载的,并且没有被 java -verbose:class 观测到

为了解决这个问题,这里将 sun.reflect.misc.Trampoline 手动添加到 rt.txt

1723203301999.png

然后第 3 次执行 PackageJar ,输出是:

执行 jar -cfM D:\Desktop\helloworld\runtime\lib\rt.jar -C D:\Desktop\helloworld\rt ./
rt.jar 的大小是 5.14MB
执行 D:\Desktop\helloworld\runtime\bin\java.exe -jar D:\Desktop\helloworld\target\helloworld-1.0-jar-with-dependencies.jar

程序顺利运行,这说明对 rt.jar 的精简是成功的,既减小了体积(从 60.26 MB 精简为 5.14 MB),又不影响程序正常工作

charsets.jarjfr.jarlocaledata.jarjsse.jar 的精简也是同样的步骤,即反复执行 PackageJar 、根据报错信息不断向 *.txt 添加缺失的依赖

虽然 PackageJar 本身能够从报错信息中提取类名,但是这个功能太脆弱,只能实现一定程度的自动化

在对 localedata.jar 进行处理时需要注意,它在 JRE 中的路径和其它的 Jar 包不一样

步骤4:移除 lib 目录不必要的依赖

理论上,.\runtime\lib 只需要保留被用到的 Jar 包,即 rt.jarcharsets.jarjfr.jarext/localedata.jarjsse.jar ,因此,可以尝试删除 .\runtime\lib 目录下的其它 Jar 包

具体的操作是:删除某个 Jar 包,然后用 .\runtime\bin\java.exe 启动并完整运行一遍程序,如果不报错,就说明可以删除;如果报错,就把这个 Jar 包加回来

对于除 Jar 包以外的文件或者文件夹,也是执行同样的操作,进而移除 lib 目录下所有不必要的依赖

在本项目中,.\runtime\lib 需要保留 rt.jarcharsets.jarjfr.jarext/localedata.jarjsse.jar 以及 resources.jar,一些重要的文件夹例如 amd64security 也是不能移除的

除此之外,.\runtime\lib 目录下还有许多配置文件和数据文件,考虑到它们占用的空间非常小,因此全部保留

本项目的 lib 目录(精简后):

1723204846548.png

步骤5:移除 bin 目录不必要的组件

不同于 .\runtime\lib 目录,.\runtime\bin 目录下主要是一些可执行文件(例如 java.exe 这个命令行工具)和动态链接库(各种 .dll

为了能够执行 Jar 包,java.exe 这个命令行工具必须保留,作为 Java 核心的 server/jvm.dll 也必须保留,其它的组件可以试着删除

具体的操作是:用.\runtime\bin\java.exe 启动程序保持运行,试着删除某个动态链接库,如果显示被占用无法删除,就说明这个组件是必要的;如果直接就删掉了,则说明这个组件在当前的机器上是不必要的

注意: bin 目录的精简需要在不同的机器以及不同版本的 Windows 系统上做测试,因为很有可能会出现特定的.dll 文件在一台机器上可以删除,而在另一台机器上被占用不能删除的情况

本项目的 bin 目录(精简后):

1723204922272.png

步骤6:测试 runtime

runtime 文件夹移动到app 文件夹的同级目录,确保相对路径是正确的:

1722876125340.png

这里的 app.exe 是按照 方案三 的步骤制作的可执行文件,双击 app.exe 程序顺利执行,脚本启动方式确实简单有效,不容易出问题

然而, hello.exe 是按照 方案四 中的步骤利用 exe4j 制作的可执行文件,双击 hello.exe 启动程序报错:

1722875136925.png

这表明 exe4j 需要的依赖并没有完全被包含在精简后的 runtimeJRE) 中

为了解决这个问题,需要在 rt.txt 里面手动添加 java.lang.*java.util.*java.io.*sun.reflect.*,然后重新打包 rt.jar

再次执行 PackageJar ,输出是:

执行 jar -cfM D:\Desktop\helloworld\runtime\lib\rt.jar -C D:\Desktop\helloworld\rt ./
rt.jar 的大小是 6.51MB
执行 D:\Desktop\helloworld\runtime\bin\java.exe -jar D:\Desktop\helloworld\target\helloworld-1.0-jar-with-dependencies.jar

runtime 文件夹移动到 hello.exeapp 文件夹的同级目录,双击 hello.exe 启动程序,如果依然有缺失的依赖,则反复执行此操作补全缺失的依赖

最终得到的 runtime 的体积为 22.73 MB,经过 zip 压缩为 12 MB,经过 7z 压缩为 10.5 MB 。相比之下,Java 9 之后基于模块构建的 runtime 体积则要大得多:

模块--compress=0--compress=1--compress=2zip7z
java.base36.13MB29.93MB26.21MB15.05MB13.09MB
java.base,java.desktop63.49MB49.88MB40.81MB26.98MB24.47MB
java.base,java.logging36.31MB30.04MB26.30MB15.13MB13.17MB
java.base,java.sql45.40MB36.21MB29.99MB18.66MB16.66MB
java.base,java.xml45.10MB36.02MB29.85MB18.53MB16.53MB

手工精简 JRE 虽然能够获得比较小的打包体积,但是此方案风险太大,一旦遗漏了某些必要的组件,软件在运行过程中就会出问题。所以,此方案仅供参考,不推荐使用