做Java应用分发的朋友应该都有印象,JDK 8那个时代,装好JDK之后目录里自带一个jre文件夹,直接拷贝打包就能给用户跑。但JDK 17是完全不一样的世界——解压开装好的JDK,你翻遍目录也找不到jre这俩字。原因很简单:从JDK 9开始Java彻底搞模块化,官方把原来的jre从JDK安装目录里拆了出去,转而推jlink这个工具,让开发者按需生成自定义运行时。JDK 17的安装目录里,跟运行时强相关的只有jmods和lib/modules,真正能独立分发的JRE不再默认提供。
这意味着什么?意味着你在生产环境、交付环境、容器镜像里用的Java运行环境,以后都得自己想办法搞。再像以前那样指望官方安装包直接带一个jre目录,已经不可能了。下面我就从两条实际路线出发,把JDK 17制作jre包的完整思路、命令和坑位都写清楚。
1. 为什么现在要自己动手做JRE包
1.1 JDK 9之后的目录变化
很多从JDK 8直接跳到JDK 17的开发者,第一个不适应的就是目录结构。过去安装JDK后,顶层目录下面直接躺着一个jre子目录,把整个JDK目录压缩分发出去就完事。JDK 9引入Project Jigsaw模块化系统后,运行时被拆成了可插拔的模块集合,安装目录里的jmods放的是模块描述文件,lib/modules是已经融合好的模块镜像,而原来的jre目录已经不在标准发布里了。
这背后的设计逻辑是:既然模块化了,就没必要让所有人扛着完整JDK到处跑,完全可以jlink出只包含所需模块的自定义运行时。但这也带来一个问题——以前"官方给现成的"变成了"你自己判断需要什么"。很多老项目没有模块化改造,本身也没有module-info.java,面对jlink时第一反应就是懵。
1.2 自制JRE的典型应用场景
我不止一次在群里看到有人问:"JDK 17装好了,怎么没有jre?"这问题背后,通常是下面几种刚需:
- 桌面工具分发给非技术用户,总不能叫人家先装个JDK再双击你的jar。
- 内网离线环境部署,机器不能外网,必须本地拷一个能跑的运行时。
- 容器镜像瘦身。谁都知道一个基础镜像若带完整JDK,体积轻松上300MB,发布和拉取都难受。
- 版本管理。线上环境装了多个JDK,应用对应的运行时版本被误改,出事查起来想骂人。固定一个自制JRE包随应用分发,环境一致性问题能被压下去。
所以,虽然官方不再给现成JRE,但"做个JRE包"这个需求一直存在,只是换了个形式:要么用jlink定制,要么手工拼装。下面两条路线我都实际跑过,各自的坑也基本踩过,挑重点写出来。
2. 两条主流制作路线对比
2.1 官方推荐的jlink方案
jlink是JDK自带的一个命令行工具,它根据模块描述,把JDK jmods目录下需要的模块挑出来,组装成一个包含bin、conf、lib的独立运行时目录。输出物就是一个你能直接拷走分发的"迷你JDK",里面只有java启动器和运行时模块,没有javac、jar这些开发工具。
它最大的优势是:生成的运行时只包含应用真正用到的模块,体积能做到很小;同时模块依赖是显式的,运行期不会出现"明明全量JDK跑得好好的,换运行时突然丢模块"这种玄学问题(当然,前提是你的依赖别搞错)。官方推荐的定位就是做定制运行时镜像,和打包工具配合使用非常搭。
2.2 手工拼装目录方案
手工拼装就是我从老Java工程师那学来的做法:拿一份完整JDK,按目录结构把运行相关的部分抠出来——bin(保留java启动器)、conf(各种配置文件)、lib(模块镜像和native库),开发相关的jmods、include、src.zip都删掉。
这个方案的优点是什么?快,不用分析模块依赖,不要求你的应用是模块化应用,拷出来的运行时基本等同于以前的老式JRE。缺点是粗,体积大,而且"能不能删"全凭经验,删错了启动报错时一头雾水。我的看法是:它适合救急、适合给那种跑了几年的老项目做最小改造,但如果是从零开始的新项目,还是优先jlink。
2.3 怎么选:先看场景再动手
| 维度 | jlink定制 | 手工拼装 |
|---|---|---|
| 分析复杂度 | 需要梳理模块依赖 | 不需要 |
| 产物体积 | 可控制在几十MB | 通常200MB以上 |
| 对老项目兼容 | 非模块化应用需额外处理 | 基本无缝 |
| 排错难度 | 依赖问题较明确 | native库/目录缺失较隐蔽 |
| 适合场景 | 新项目、容器化、分发 | 快速救急、老系统 |
一句话结论:能走jlink就走jlink,实在没条件就手工拼装。下面两章的实操,我会把每个命令和产物目录都写清楚。
3. 实操:用jlink从JDK 17生成最小运行时
3.1 用jdeps摸清模块依赖
很多人一上来就敲jlink,结果不停报缺模块。正确做法是先分析应用到底依赖哪些模块。jdeps是JDK自带的依赖分析工具,它对模块化应用和非模块化应用都适用。
先拿一个模拟的非模块化Spring Boot项目jar包举例:
jdeps --print-module-deps --ignore-missing-deps app.jar执行完会输出类似这样的一行:
java.base,java.sql,java.naming,java.management,jdk.unsupported这意味着你的应用运行时至少需要这些模块。其中--ignore-missing-deps很关键,因为fat jar里有些依赖不在模块路径里,不忽略它,jdeps会直接报一堆找不到的错误,没法乖乖干活。
如果项目本身写了module-info.java,是模块化应用,那就把模块路径指到编译输出目录,让jdeps连应用自身的模块依赖一起分析。模块化应用的依赖结果更精确,这是JDK 9以后最好的状态。多数存量项目是非模块化的,所以下面的命令我都以非模块化应用为默认场景。
3.2 生成运行时镜像的核心命令
拿到依赖列表后,jlink命令就很简单了:
# 假设JAVA_HOME指向JDK 17 jlink --module-path $JAVA_HOME/jmods \ --add-modules java.se \ --output myjre为什么不直接写java.base,java.sql,...而用java.se?java.se是一个聚合模块,它把Java SE平台通常会用到的基础模块都包含进来(java.desktop、java.sql、java.naming、java.management、java.xml这些都在里面),比手动一个个列省心,也比ALL-MODULE-PATH小很多。我自己的经验是:大多数Web服务、命令行工具,用java.se基本够;如果框架里用了Unsafe,还得把jdk.unsupported补上:
jlink --module-path $JAVA_HOME/jmods \ --add-modules java.se,jdk.unsupported \ --output myjre生成完先别急着分发,用产物自检:
myjre/bin/java --list-modules myjre/bin/java -version myjre/bin/java -jar app.jar &看到版本号和控制台日志跟你本机跑的效果一致,这个运行时才算成立。
3.3 瘦身参数与实测体积
上面生成的运行时还是偏肥,因为默认情况下jlink不会帮你剥掉调试信息、帮助文档这些边角料。瘦身参数是这么组合的:
jlink --module-path $JAVA_HOME/jmods \ --add-modules java.se,jdk.unsupported \ --strip-debug \ --compress=2 \ --no-header-files \ --no-man-pages \ --include-locales=en,zh_CN \ --output myjre-slim各参数的用途我实际用过一遍:
--strip-debug:去掉模块里的调试符号和源代码行号信息,体积能小一截。代价是排障时JDK自身源码栈看不到行号,业务代码不受影响,因为你的类不在jlink产物里。正常发布可以用,遇到疑难杂症再开一个带调试的运行时辅助定位。--compress=2:给模块镜像做Zip压缩。0是不压缩,2压缩率最高,我一般都上2。代价是类加载时有解压开销,实测对启动时间影响很小,可以忽略。--no-header-files、--no-man-pages:不要C语言头文件和man手册,这俩对纯Java运行没影响,纯属体积优化。--include-locales=en,zh_CN:只保留英文和简体中文的locale数据。如果应用要面向多国用户,这里要按需加地区,不然日期、数字格式会退回英文。这个参数能砍掉不少体积,但千万别乱省。
体积极简估算(Linux x64,实际因发行版不同有浮动):
| 产物配置 | 体积大约 | 备注 |
|---|---|---|
| 完整JDK 17目录 | 280-320MB | 基线 |
| jlink java.se 默认 | 110-130MB | 可用 |
| jlink java.se + 全套瘦身 | 60-80MB | 推荐 |
| jlink java.base 裸模块 | 40-50MB | 特殊场景 |
| 手工拼装 | 220-260MB | 备用方案 |
这个差距在容器场景里非常直观。一个带完整JDK的镜像,和只带瘦身运行时的镜像,发布、拉取、磁盘占用的体感完全不同。
3.4 验证一个Spring Boot项目的完整流程
很多人的项目是Spring Boot fat jar。fat jar本身不是模块化应用,jdeps分析时对第三方jar会有大量missing依赖,所以用--ignore-missing-deps绕开。我的推荐步骤是:
- 先用
jdeps --print-module-deps --ignore-missing-deps拿到真实的内建模块依赖。 - 如果输出里包含
jdk.unsupported,别奇怪,Spring Boot内部确实会碰Unsafe,老老实实加上。 - 用3.3的瘦身命令生成运行时。
- 用
myjre/bin/java -jar app.jar跑一遍应用,把健康检查接口、数据库连接、日志输出都验证一遍,确认没有运行时缺失才打包分发。
实际跑下来的结论:一个普通的Spring Boot服务,用java.se,jdk.unsupported的瘦身运行时,几十MB体积极其舒服。你要是把JDK整包丢进容器基础镜像,两三倍体积差距一下就感觉到了。
4. 实操:手工拼装JRE目录(备用方案)
4.1 该拷贝哪些内容
老项目的改造里,我经常被问到两小时能不能搞定一个JRE。可以,方案就是手工拼。动手前先建一个临时工作目录,把整个JDK 17文件夹拷进来,然后按下面的清单处理:
- 直接删除不参与运行的内容:
jmods目录、include目录、src.zip、demo和sample(如果有)。 bin目录建议全留,或者只留java启动器和需要的动态库。这句重点说:Linux下只留java这个命令就够了,但Windows下不行,bin里一堆dll是运行时需要的,只留java.exe一定崩。conf目录全部保留。里面的security/java.security、logging.properties、net.properties虽然不是天天用到,但Web容器、加密组件初始化时缺它们会报各种莫名错误。lib目录全部保留,千万别按网上某些旧教程去挑.so/.dll删。JDK 17的native库交织得很细,少了一个libjvm.so就够你查半天的。legal目录有许可证声明,分发合规性考虑建议保留,体积又不大。
最终目录结构长这样,和官方老式JRE几乎一致:
myjre-manual/ ├── bin/ │ └── java ├── conf/ │ ├── logging.properties │ ├── net.properties │ └── security/ │ ├── java.policy │ └── java.security ├── lib/ │ ├── modules │ ├── jvm.cfg │ ├── server/ │ │ └── libjvm.so │ ├── libjava.so │ ├── libjimage.so │ └── ... 其他native库 └── legal/4.2 Windows与Linux的细节差异
这个方案在两个平台上的坑不太一样。我自己在Windows上拼过一次,印象很深:
- Windows的
bin目录里,除java.exe外还有jli.dll、zip.dll、java.dll、net.dll、awt.dll等一串动态库,少一个都起不来。保险做法是把bin整个拷过去,然后只删javac.exe、javap.exe、jar.exe、jdeps.exe、jshell.exe这些工具。 - Linux的
lib/server/libjvm.so路径是写死的,bin/java通过rpath去找../lib和../lib/server,所以目录层级不能动。同理lib/jvm.cfg决定了加载哪个虚拟机实现,也不能乱删。 - macOS的情况比较特殊,JDK 17的macOS交付路径一般不是手拼,而是保留标准目录结构直接打包,这里就不展开讲了。
4.3 配置环境变量并跑通
拼装完之后,给这个目录设成JAVA_HOME:
export JAVA_HOME=/path/to/myjre-manual export PATH=$JAVA_HOME/bin:$PATH java -version java --list-modules如果-version输出正常,再把应用跑一遍。注意手工拼装的运行时包含的模块是完整的,所以对老项目的兼容性最好,代价就是体积肥大。对于线上快速恢复、临时解决问题,这个方案确实够快。
5. 常见问题与排错实录
5.1 启动即报模块找不到
表现是启动时抛:
java.lang.module.ResolutionException: Module java.sql not found这基本可以断定jlink的--add-modules漏了模块。解决步骤:把应用重新用jdeps分析一次,把缺失模块补到命令行,重新生成。为什么会出现这种情况?多半是第一次做依赖梳理时用的jar不完整,或者应用里不同代码路径用的模块差异很大,比如控制台路径不用数据库,定时任务路径却用了。我的建议是:分析依赖时尽量用最终分发的那个完整jar,别用中间产物。
还有一种隐蔽情况:第三方依赖通过ServiceLoader动态发现实现类,你按jdeps结果加的模块里没有对应provider,运行期同样找不到。这时候需要--bind-services,下面细说。
5.2 反射和动态加载拿不到类
JDK 17里sun.misc.Unsafe、jdk.internal.*这些内部API都在jdk.unsupported或特定模块里,如果你的应用依赖了这类内部类,运行时没加对应模块,启动或运行中就会冒出NoClassDefFoundError或IllegalAccessError。解决办法是把分析结果里缺失的内部模块补上,最省事的组合是java.se,jdk.unsupported。
还有一类是反射扫描场景。像Spring、MyBatis这类框架,会对你的包做扫描,它们用的类是业务jar里的,这个不影响;但如果它们内部用到了java.desktop、java.xml等模块,你还按最小集去配,运行时会异常。所以反射多的应用,我宁可多配几个模块,也别抠到刚好能启动。
5.3 ServiceLoader与SPI失效
最典型的场景是JDBC:
java.sql.SQLException: No suitable driver found for jdbc:mysql://...明明你的Classpath里放了MySQL驱动jar,数据库驱动就是"没加载"。原因在于驱动是通过ServiceLoader注册的,而jlink默认只链接静态模块依赖,不会自动带上service provider模块。修复方法是在jlink时加上--bind-services:
jlink --module-path $JAVA_HOME/jmods \ --add-modules java.se,jdk.unsupported \ --bind-services \ --output myjre--bind-services会把所有包含provides声明的相关模块一并链接进来,这是处理SPI类框架的关键开关。如果你的驱动jar是外部jar而不是模块,jlink也无能为力,它只能处理模块层面的事情,外部jar的SPI扫描靠的是类路径机制,一般跑得通。
5.4 时区、安全、本地化配置缺失
缺conf或lib/tzdb.dat时,可能出现时区错乱、安全策略加载失败、日志输出乱码。手工拼装时我建议conf和lib整目录复制,斩断这类问题的根。用jlink定制时,注意三点:
- 做多语言应用,
--include-locales里必须把你需要的语言加全,比如只写en忘了zh_CN,中文系统上日期格式、数字分组会自动回退英文。 - 应用要操作安全算法、证书时,别把
conf/security/java.security弄丢,它记录着Provider列表和算法限制。 - 如果运行时是从Windows上制作的、部署到Linux,JVM本身的产物没有平台限制,但要注意你换平台后native库路径、系统时区数据依赖的是目标机器,不是产物目录。这个是环境差异,不是jlink问题。
5.5 手工拼装后启动崩了
手工拼完后启动直接崩,多数是下面几个原因:lib/server/libjvm.so被误删或路径不对、lib/modules缺失、bin只留了java而丢掉Windows dll、或者目录层级被改动过。排查思路很简单:先保证目录结构和官方完整JDK一致,再逐个删,删一次跑一次,别一口气删到底。我自己刚开始手拼时就吃过这个亏,图省事把lib删了一大堆,启动报错根本不知道是谁的锅,后来学乖了才明白"能跑再删"才是正道。
6. 实测数据与实操心得
6.1 体积、内存、启动速度实测
我在一台Linux x64测试机上,分别对完整JDK 17和几种jlink产物做了简单对比,结果如上文的体积表,再补充几个运行时表现的观察:
- 内存占用:JVM堆参数
-Xmx不变的前提下,运行时本身的内存占用量差异很小,影响微乎其微。瘦身省的是磁盘和分发带宽,不是内存。 - 启动速度:
--compress=2理论上加一点类加载开销,实测一个简单的Spring Boot服务,启动时间差异在百毫秒这个量级,基本感知不到。 - 可维护性:这是最容易被忽略的。瘦身后的运行时里没有javac、没有jcmd、没有jstack,出问题时远程排障手段会少很多。追求体积的同时,建议根据团队能力留好排查工具,别把路堵死。
6.2 容器部署场景的注意事项
用自制JRE做容器镜像,实践中我推荐多阶段构建,拿官方JDK镜像做构建阶段,最终运行镜像只拷贝jlink产物和一个精简基础层:
# 构建阶段:生成运行时 FROM <基础JDK镜像> AS builder RUN jlink --module-path $JAVA_HOME/jmods \ --add-modules java.se,jdk.unsupported \ --strip-debug --compress=2 \ --no-header-files --no-man-pages \ --output /tmp/myjre # 运行阶段:只带产物 FROM <精简基础镜像> COPY --from=builder /tmp/myjre /opt/myjre COPY app.jar /app/app.jar ENTRYPOINT ["/opt/myjre/bin/java", "-jar", "/app/app.jar"]这样最终镜像体积能比带完整JDK的基础镜像小一半以上。有一点要注意,如果线上要用jcmd、jstack做线程诊断,早期就把jdk.jcmd模块加进去,镜像都发布了再想加模块,只能重新生成重新发布。
6.3 我踩过的几个坑
最后把我的血泪教训集中列一遍,能帮你省不少时间:
- 第一个坑是模块依赖漏了。当时用jdeps分析一个工具jar,结果漏了运行时才扫描的接口实现,线上跑了一天开始报错。从那以后我坚持"发布前完整回归"原则,换了运行时一定要把核心流程全部跑一遍。
- 第二个坑是手动拼装时删native库图快,把
lib一顿整理之后,AWT相关代码一启动就崩。教训就是:native库别乱删,能跑就先不要动,等确定哪个组件没用到再删。 - 第三个坑是
--include-locales把日期格式坑成英文。测试环境都是中文,没暴露,一到客户那边全变英文月份。现在做多语言处理,我都会在验收清单里专门列一项"日期数字格式验证"。 - 第四个坑是过度追求体积,把conf目录当冗余删掉,结果Web容器初始化时读取安全配置文件失败。从那以后我就记住了:conf目录是运行时的一部分,不是文档。
做JRE包这件事本身不难,难的是把依赖边界搞清楚、把细节兜住。先用jdeps把依赖摸清,再决定用jlink还是手工拼装,做完了务必在真实业务场景下完整跑一遍。按这个节奏走,不管你的应用是桌面工具还是容器化服务,交付一套干净、最小的Java 17运行时都是可以稳定复现的。
最后再分享一个小技巧:把每条jlink命令固化成一个构建脚本,连同产物一起放到项目仓库里,下次换人接手、升级JDK版本时,直接改参数就能复现,比每个人凭记忆敲命令可靠得多。这是我在多次"咦,我当时怎么生成来着"之后总结出来的习惯。