基本解决思路是:开发时怎么运行程序,在部署的地方就配置同样的环境来运行程序
- 方案一:仅发布 Jar 包
- 方案二:使用 jpackage 打包发布
- 方案三:使用 jlink 构建 runtime,制作 bat(或 exe)启动脚本
- 方案四:使用 jlink 构建 runtime,利用 exe4j 制作 exe
- 方案五:手工精简 JRE 构建 runtime
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 工具
打包完成的产品结构:
| 文件/文件夹 | 来源 | 作用 |
|---|---|---|
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
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 包:
在生成的 target 目录下面可以看到 Jar 包 helloworld-1.0-jar-with-dependencies.jar:
步骤2:分析用到的模块
使用 jdeps 命令可以查看 helloworld-1.0-jar-with-dependencies.jar 用到的模块
jdeps .\target\helloworld-1.0-jar-with-dependencies.jar
可以看到,helloworld-1.0-jar-with-dependencies.jar用到了 java.base、java.desktop、java.logging 、java.sql 这 4 个模块
步骤3:jpackage 打包
为了方便进一步处理,这里创建一个名为 input 的文件夹,将 helloworld-1.0-jar-with-dependencies.jar 放进这个文件夹中:
在 input 文件夹下面,icon.ico 是为可执行文件准备的图标,在打包过程中需要用到这个 .ico 文件:
最后执行 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
| 参数 | 值 | 说明 |
|---|---|---|
--type | app-image | 打包的方式,在 Windows 平台可选择 app-image 、exe 或 msi |
--name | hello | 可执行文件名称 |
--vendor | world | 可执行文件的提供者 |
--app-version | 0.0.1.0 | 可执行文件的版本号 |
--input | input | 输入文件夹 |
--main-jar | helloworld-1.0-jar-with-dependencies.jar | 输入文件夹里面的核心 Jar 包 |
--icon | .\input\icon.ico | 图标的路径 |
--dest | dist | 输出文件夹 |
--add-modules | java.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 | 显示控制台(黑窗口) | |
... | ... | ... |
打包完成后:
在生成的 dist 目录下面,整个 hello 文件夹就是打包的结果,可以作为产品发布给用户:
双击 hello.exe 即可运行程序,在任务管理器中能看到这个应用的运行情况:
在 Windows 平台,使用 jpacakage 的 app-image 方式打包生成的文件夹结构为 app + runtime + 可执行文件名称.exe + 可执行文件名称.ico:
这种方式打包的 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.base 的 runtime 经过 7z 压缩的大小为 13.09MB ,基本能够满足客户端程序的发布需求
| 模块 | --compress=0 | --compress=1 | --compress=2 | zip | 7z |
|---|---|---|---|---|---|
java.base | 36.13MB | 29.93MB | 26.21MB | 15.05MB | 13.09MB |
java.base,java.desktop | 63.49MB | 49.88MB | 40.81MB | 26.98MB | 24.47MB |
java.base,java.logging | 36.31MB | 30.04MB | 26.30MB | 15.13MB | 13.17MB |
java.base,java.sql | 45.40MB | 36.21MB | 29.99MB | 18.66MB | 16.66MB |
java.base,java.xml | 45.10MB | 36.02MB | 29.85MB | 18.53MB | 16.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 工具
打包完成的产品结构:
创建运行时(用到 jdeps 和 jlink 命令):
jdeps .\target\helloworld-1.0-jar-with-dependencies.jar
jlink --output runtime --add-modules java.base,java.desktop,java.logging,java.sql --strip-debug --no-man-pages --no-header-files --compress=2
为了模仿 jpackage 的 app-image 方式打包生成的文件夹结构,在 runtime 的同级目录下面创建一个 app 文件夹,并将 helloworld-1.0-jar-with-dependencies.jar 放进去,然后创建一个名为 app.bat 的文件:
制作 bat 启动脚本:
.\runtime\bin\java.exe -jar "./app/helloworld-1.0-jar-with-dependencies.jar"
双击 app.bat 就可以启动程序
如果依赖和业务代码没有被打包在一起,则可以用另一种方式启动程序:
制作 exe 启动脚本:
bat 脚本虽然简单,但是看上去有点简陋,制作一个带图标的 exe 启动脚本可能会更好一些:
先准备 3 个文件:
其中,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:
如果需要隐藏控制台的黑窗口, 将 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 即可运行程序,在任务管理器中能看到这个应用的运行情况:
注意到,脚本启动的问题是不能修改程序在任务管理器中的应用名(OpenJDK Platform binary)和图标!
方案四:使用 jlink 构建 runtime,利用 exe4j 制作 exe
适用场景:Java 9 及更高版本
优点1:可以修改程序在任务管理器中的应用名和图标
缺点1:需要用到额外的工具 exe4j
缺点2:打包出来的 GUI 分辨率有问题,字体比较模糊,影响使用体验
缺点3:只有 Java 9 及更高版本提供 jlink,被广泛使用的 Java 8 不提供 jlink 工具
打包完成的产品结构:
创建运行时(用到 jdeps 和 jlink 命令):
jdeps .\target\helloworld-1.0-jar-with-dependencies.jar
jlink --output runtime --add-modules java.base,java.desktop,java.logging,java.sql --strip-debug --no-man-pages --no-header-files --compress=2
为了模仿 jpackage 的 app-image 方式打包生成的文件夹结构,在 runtime 的同级目录下面创建一个 app 目录,并将 helloworld-1.0-jar-with-dependencies.jar 和图标文件 icon.ico 放进去:
步骤1:下载、安装 exe4j 软件
下载 exe4j 软件,配置环境变量 EXE4J_JAVA_HOME,启动 exe4j:
点击 Enter License,输入 Name、Company 和 License key:
注意:如果这里没有填写有效的
License Key,那么打包出来的可执行文件会报错"this executable was created with an evaluation version exe4j"
步骤2:选择工程类型
如果没有打包成单文件 exe 的需求,选择 Regular mode 即可:
步骤3:输入应用程序名称、路径
步骤4:设置 exe 的类型、名称、图标路径
本项目用的是 64 位的 Java 环境:
步骤5:将 Jar 包添加为 Class Path
这里必须使用相对路径
.\app\helloworld-1.0-jar-with-dependencies.jar
填写 com.UI.LoginJFrame 作为程序的主入口类:
步骤6:将 runtime 文件夹设置为 JRE
helloworld 项目用到的语法不超过 Java 7,因此最低版本选择 1.7,不设置最高版本
点击 Advanced Options 按钮, 将 runtime 文件夹以相对路径的形式设置为 JRE,并放在搜索路径的第一个位置:
对于客户端应用程序,选择默认的虚拟机也没有问题:
步骤7:一直点击 Next,生成 exe 可执行文件
可以得到带图标的可执行文件 hello.exe:
将 hello.exe 移动到上一级目录,确保相对路径是正确的:
此时,双击 hello.exe 就可以运行程序,在任务管理器中能看到这个应用的运行情况:
注意到,用 exe4j 制作的 exe 在任务管理器中有自己的应用名和图标,这是方案三做不到的效果
方案五:手工精简 JRE 构建 runtime
考虑到 Java 的良好兼容性,基于低版本 Java 编写的代码一般可以在高版本 Java 环境中正常运行
因此,在 2024 年,已经没有什么理由必须基于 Java 8 来运行客户端软件
如果不得不使用 Java 8,则可以通过移除 JRE 中用不到的组件来达到压缩软件体积的目的
优点1:不需要 jlink 工具,支持 Java 8
优点2:打包体积小
缺点1:操作过于繁琐,非常容易出错
缺点2:一旦遗漏了某些必要的组件,软件在运行过程中就会出问题
打包完成的产品结构:
创建可执行文件:
制作 hello.exe 的过程可以参考 方案三 或 方案四
创建运行时(以 Java 8 为例):
本文使用的是 OpenJDK ,不同版本的 JDK 在目录结构上可能会有区别
打开 JDK 的安装位置,找到 jre 这个文件夹:
将 jre 文件夹复制到项目的目录下,并改名为 runtime :
此时的
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.jar、charsets.jar、jfr.jar、localedata.jar、jsse.jar,其中,用到了rt.jar里面的 2559 个 class 文件。
除了统计 class 文件的个数以外,ExtractClassName 脚本还将涉及到的 class 文件名按照所属的 Jar 包进行分类,写入 rt.txt、charsets.txt、jfr.jar、localedata.txt 和 jsse.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:
解压 rt.jar,重命名为 rt_jar,将 rt_jar 文件夹复制到项目的目录下:
执行 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.*:
这里新添加的 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:
然后第 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.jar、jfr.jar、localedata.jar和jsse.jar的精简也是同样的步骤,即反复执行PackageJar、根据报错信息不断向*.txt添加缺失的依赖虽然
PackageJar本身能够从报错信息中提取类名,但是这个功能太脆弱,只能实现一定程度的自动化在对
localedata.jar进行处理时需要注意,它在JRE中的路径和其它的 Jar 包不一样
步骤4:移除 lib 目录不必要的依赖
理论上,.\runtime\lib 只需要保留被用到的 Jar 包,即 rt.jar、charsets.jar、jfr.jar、ext/localedata.jar 和 jsse.jar ,因此,可以尝试删除 .\runtime\lib 目录下的其它 Jar 包
具体的操作是:删除某个 Jar 包,然后用 .\runtime\bin\java.exe 启动并完整运行一遍程序,如果不报错,就说明可以删除;如果报错,就把这个 Jar 包加回来
对于除 Jar 包以外的文件或者文件夹,也是执行同样的操作,进而移除 lib 目录下所有不必要的依赖
在本项目中,
.\runtime\lib需要保留rt.jar、charsets.jar、jfr.jar、ext/localedata.jar、jsse.jar以及resources.jar,一些重要的文件夹例如amd64和security也是不能移除的除此之外,
.\runtime\lib目录下还有许多配置文件和数据文件,考虑到它们占用的空间非常小,因此全部保留
本项目的 lib 目录(精简后):
步骤5:移除 bin 目录不必要的组件
不同于 .\runtime\lib 目录,.\runtime\bin 目录下主要是一些可执行文件(例如 java.exe 这个命令行工具)和动态链接库(各种 .dll)
为了能够执行 Jar 包,java.exe 这个命令行工具必须保留,作为 Java 核心的 server/jvm.dll 也必须保留,其它的组件可以试着删除
具体的操作是:用.\runtime\bin\java.exe 启动程序保持运行,试着删除某个动态链接库,如果显示被占用无法删除,就说明这个组件是必要的;如果直接就删掉了,则说明这个组件在当前的机器上是不必要的
注意: bin 目录的精简需要在不同的机器以及不同版本的 Windows 系统上做测试,因为很有可能会出现特定的
.dll文件在一台机器上可以删除,而在另一台机器上被占用不能删除的情况
本项目的 bin 目录(精简后):
步骤6:测试 runtime
将 runtime 文件夹移动到app 文件夹的同级目录,确保相对路径是正确的:
这里的 app.exe 是按照 方案三 的步骤制作的可执行文件,双击 app.exe 程序顺利执行,脚本启动方式确实简单有效,不容易出问题
然而, hello.exe 是按照 方案四 中的步骤利用 exe4j 制作的可执行文件,双击 hello.exe 启动程序报错:
这表明 exe4j 需要的依赖并没有完全被包含在精简后的 runtime (JRE) 中
为了解决这个问题,需要在
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.exe 和 app 文件夹的同级目录,双击 hello.exe 启动程序,如果依然有缺失的依赖,则反复执行此操作补全缺失的依赖
最终得到的 runtime 的体积为 22.73 MB,经过 zip 压缩为 12 MB,经过 7z 压缩为 10.5 MB 。相比之下,Java 9 之后基于模块构建的 runtime 体积则要大得多:
| 模块 | --compress=0 | --compress=1 | --compress=2 | zip | 7z |
|---|---|---|---|---|---|
java.base | 36.13MB | 29.93MB | 26.21MB | 15.05MB | 13.09MB |
java.base,java.desktop | 63.49MB | 49.88MB | 40.81MB | 26.98MB | 24.47MB |
java.base,java.logging | 36.31MB | 30.04MB | 26.30MB | 15.13MB | 13.17MB |
java.base,java.sql | 45.40MB | 36.21MB | 29.99MB | 18.66MB | 16.66MB |
java.base,java.xml | 45.10MB | 36.02MB | 29.85MB | 18.53MB | 16.53MB |
手工精简 JRE 虽然能够获得比较小的打包体积,但是此方案风险太大,一旦遗漏了某些必要的组件,软件在运行过程中就会出问题。所以,此方案仅供参考,不推荐使用