本节目标:把「共享归档」从概念变成可构建、可验证、可回滚的发布步骤:先确认默认 CDS 已经做了什么,再决定 AppCDS 值不值得加。
适用版本:JDK 25(Temurin 25.0.5+8)。本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125),默认 G1,压缩 oops,默认 MaxHeapSize 8 GB,G1 Region 4 MB。
承接的问题
9.1 的类加载统计里有一组容易被忽略的数字:
Hello -> total=415 shared=412 jar=0 appfile=1
MiniHttpd -> total=873 shared=834 jar=0 appfile=1
95% 以上的类来自 source: shared objects file。也就是说,JDK 自带的 CDS 归档已经在工作,不需要任何参数。团队的问题是:
- 默认 CDS 到底归档了什么,能不能看到?
- 应用自己的类能不能也归档,能省多少启动时间?
- 归档在什么条件下失效,失效后是启动失败还是自动降级?
jcmd VM.cds能查到什么,为什么网上的命令在本机报错?
这一节按「默认 CDS → 静态归档 → 动态 AppCDS → 失效与验证」的顺序走一遍。所有启动数字都是同一台机器、同一 JDK、交替运行的重复测量。
一、默认 CDS 已经在工作
VM.info 会打印当前进程映射的归档:
CDS archive(s) mapped at: [0x000003f000000000-0x000003f000d98000-0x000003f000d98000), size 14254080, SharedBaseAddress: 0x000003f000000000, ArchiveRelocationMode: 1.
14,254,080 字节,约 13.6 MB,来自 JDK 安装目录的 lib/server/classes.jsa。这个文件随 JDK 发布,包含 java.lang、集合、模块系统等核心类。
用 -Xshare:off 关掉它,再和默认模式对比。12 次交替测量最小 HTTP 服务的就绪时间:
default CDS: median=137.8 mean=139.5 min=87.9 max=197.1 ms
-Xshare:off: median=179.7 mean=180.2 min=141.2 max=226.8 ms
默认 CDS 把中位数从 179.7 ms 降到 137.8 ms,约 23%。这是「不写任何参数就有的收益」,也是后面评估 AppCDS 的基线。如果拿 -Xshare:off 当基线,AppCDS 的收益会被高估。
-Xlog:cds 可以看到映射过程:
java "-Xlog:cds" -cp hello.jar Hello
本机输出的前几行:
[0.009s][info][cds] trying to map /Users/.../lib/server/classes.jsa
[0.009s][info][cds] Opened shared archive file /Users/.../lib/server/classes.jsa.
[0.009s][info][cds] The shared archive file was created with UseCompressedOops = 1, UseCompressedClassPointers = 1, UseCompactObjectHeaders = 0
[0.009s][info][cds] Core region alignment: 16384
UseCompressedOops、UseCompressedClassPointers、UseCompactObjectHeaders 三个值必须与运行时一致,否则归档不能复用。这就是为什么换 JDK、换容器基础镜像、改 -XX:-UseCompressedOops 之后,旧归档会失效。
二、静态归档:-Xshare:dump 到底生成了什么
-Xshare:dump 生成一个替换默认归档的静态归档。用自定义路径,避免覆盖 JDK 安装目录:
java -Xshare:dump -XX:SharedArchiveFile=/tmp/vol4-e/cds/static.jsa "-Xlog:cds"
本机输出末尾:
[0.537s][info][cds] Number of classes 1670
[0.537s][info][cds] instance classes = 1527, aot-linked = 0, inited = 0
[0.537s][info][cds] boot = 1527, aot-linked = 0, inited = 0
[0.537s][info][cds] app = 0, aot-linked = 0, inited = 0
[0.566s][info][cds] Shared file region (rw) 0: 4895680 bytes, addr 0x0000000800004000 file offset 0x00004000 crc 0xc1b3e222
[0.567s][info][cds] Shared file region (ro) 1: 9330552 bytes, addr 0x00000008004b0000 file offset 0x004b0000 crc 0x35f19925
[0.575s][info][cds] Shared file region (bm) 2: 208592 bytes, addr 0x0000000000000000 file offset 0x00d98000 crc 0x9af10a28
[0.575s][info][cds] Shared file region (hp) 3: 1178208 bytes, addr 0x00000000ffe00000 file offset 0x00dcc000 crc 0x8131b2fb
1670 个类、14.9 MB,app 计数是 0。静态 dump 默认只归档 JDK 类;要把应用类放进去,需要提供 -XX:SharedClassListFile 类列表,或者走下一节的动态 AppCDS。对大多数服务,动态 AppCDS 更省事,因为它不需要提前列出类名。
归档由多个 region 组成:rw 是可读写元数据,ro 是只读元数据,bm 是位图,hp 是 Java 堆对象(例如字符串常量)。-Xlog:cds 里的 crc 可以用来判断文件是否被改动,但不要手工编辑归档。
三、动态 AppCDS:ArchiveClassesAtExit 与 SharedArchiveFile
动态 AppCDS 分两步:先跑一次训练,让 JVM 在退出时把已加载的应用类写进归档;再用归档启动。
第一步,训练 Hello:
java -XX:ArchiveClassesAtExit=/tmp/vol4-e/cds/hello.jsa -cp hello.jar Hello
本机归档大小:
147456 /tmp/vol4-e/cds/hello.jsa
147,456 字节,正好 144 KiB。这个数字很小,因为默认 CDS 已经覆盖了 412 个核心类,动态归档只追加 Hello 自己和少量关联类。
第二步,用归档启动:
java -XX:SharedArchiveFile=/tmp/vol4-e/cds/hello.jsa -Xshare:on -cp hello.jar Hello
-Xlog:cds 会显示两个归档依次映射:先是 JDK 的 classes.jsa,再是 hello.jsa。第二个归档的 region 很小,映射成本几乎可以忽略。
对最小 HTTP 服务,训练时要真的打几个请求,否则 handler 类和 Loan 不会加载:
java -XX:ArchiveClassesAtExit=/tmp/vol4-e/cds/minihttpd.jsa -jar minihttpd.jar 18179
curl -s "http://127.0.0.1:18179/health"
curl -s "http://127.0.0.1:18179/api/loans/42"
curl -s "http://127.0.0.1:18179/api/reports/summary?n=200"
curl -s "http://127.0.0.1:18179/shutdown"
本机归档 1,736,704 字节(约 1.66 MiB)。训练日志里有一批 Skipping:
[7.561s][warning][cds] Skipping jdk/internal/event/VirtualThreadEndEvent: Not in loaded state
[7.561s][warning][cds] Skipping jdk/internal/event/VirtualThreadStartEvent: JFR event class
[7.561s][warning][cds] Skipping jdk/internal/event/SocketWriteEvent: JFR event class
JFR 事件类、未进入 loaded 状态的类不会被归档。这不是错误,但说明「归档里没有的东西,启动时仍然要加载」。如果某个类在训练时没被触发,它就不会进归档。
启动对比仍然是 12 次交替测量:
default CDS: median=137.8 ms
AppCDS: median=129.2 ms
-Xshare:off: median=179.7 ms
AppCDS 比默认 CDS 快约 6%,比关闭 CDS 快约 28%。对 Hello 这种只有一个应用类的程序,AppCDS 没有优势:
default CDS: median=75.8 ms
AppCDS: median=79.3 ms
-Xshare:off: median=102.7 ms
AppCDS 的 79.3 ms 与默认的 75.8 ms 在噪声范围内,差异不构成结论。归档的收益取决于应用类占启动类加载的比例;libraryd 的真实类数量远多于最小服务,收益需要用自己的 jar 测,不能拿 Hello 或最小服务外推。
四、目录 classpath 在 JDK 25 上不能做动态归档
很多教程仍然写 -cp target/classes。本机 Temurin 25.0.5+8 上,动态归档直接失败:
java -XX:ArchiveClassesAtExit=app.jsa -cp out Hello
[0.111s][error][cds] Error: non-empty directory 'out'
Error occurred during CDS dumping
Cannot have non-empty directory in paths
即使目录里只有一个 Hello.class,结果相同:
[0.192s][error][aot] Error: non-empty directory '/tmp/vol4-e/hello-only'
Cannot have non-empty directory in paths
归档文件是 0 字节。9.3 的 AOT 缓存也有同样的限制。结论很直接:动态 CDS 和 AOT 训练都必须用 jar,不能用展开的 classes 目录。Maven 构建后先 jar --create,再训练。
如果 classpath 里同时有 jar 和目录,目录仍然可能导致失败;把目录从训练命令里去掉,最稳。
五、失效条件与 jcmd VM.cds
归档不是一劳永逸的。以下变化会让它失效:
- JDK 版本或构建号变化。
- 压缩 oops、压缩类指针、compact object headers 配置变化。
- 最大堆大小变化(归档记录了
max heap size)。 - 应用 classpath 的条目名称或顺序变化。
classpath 不匹配时,本机输出:
[0.040s][warning][cds] The name of app classpath [1] does not match: expected 'hello.jar', got 'hello-stale.jar'
[0.040s][error ][cds] shared class paths mismatch (hint: enable -Xlog:class+path=info to diagnose the failure)
[0.040s][error ][cds] unrecoverable error
Error occurred during initialization of VM
Unable to use shared archive. Unrecoverable archive loading error
同一条命令把 -Xshare:on 换成 -Xshare:auto,行为完全不同:
[0.015s][warning][cds] The name of app classpath [1] does not match: expected 'hello.jar', got 'hello-stale.jar'
[0.015s][warning][cds,dynamic] Unable to use shared archive. The top archive failed to load: hello.jsa
libraryd hello
auto 是默认值,会降级继续启动;on 要求归档必须可用,失败就退出。生产环境用 auto,CI 验证用 on,两边职责不同。
还有一个更隐蔽的坑:归档记录的是字面 classpath。在 /tmp/vol4-e 下用 -jar minihttpd.jar 训练,换到仓库根目录启动就会看到:
[0.031s][warning][cds] Required classpath entry does not exist: minihttpd.jar
不是归档坏了,而是相对路径解析到了另一个目录。训练和运行必须使用同一个工作目录,或者都用绝对路径。
jcmd VM.cds 在本机不是「查看状态」的命令。执行 jcmd <pid> VM.cds 会报:
java.lang.IllegalArgumentException: The argument 'subcmd' is mandatory.
jcmd <pid> help VM.cds 给出的用法只有两个子命令:
subcmd : static_dump | dynamic_dump (STRING, no default value)
VM.cds status 会得到 Invalid command for VM.cds, valid input is static_dump or dynamic_dump。动态 dump 还要求启动时加 -XX:+RecordDynamicDumpInfo,而且这个参数不能和动态归档同时使用:
-XX:+RecordDynamicDumpInfo is unsupported when a dynamic CDS archive is specified in -XX:SharedArchiveFile
只用记录模式启动一个服务,再执行:
java -XX:+RecordDynamicDumpInfo -jar minihttpd.jar 18096
jcmd <pid> VM.cds dynamic_dump /tmp/vol4-e/cds/dump.jsa
本机输出:
Dynamic dump: /tmp/vol4-e/cds/dump.jsa
文件大小 950,272 字节。它只包含执行 dump 时已经加载的类,不等于训练归档;适合调试「运行时到底有哪些类可归档」,不适合替代发布归档。
JDK 19 起还有 -XX:+AutoCreateSharedArchive:归档不存在时自动创建,存在时直接使用。本机两次运行:
run1 exit=0 exists=True size=1720320
run2 exit=0 exists=True size=1720320
第一次创建,第二次复用,大小与手工训练的 1,736,704 字节接近(差异来自训练时请求覆盖的类不同)。它省掉了单独的 ArchiveClassesAtExit 步骤,但同样要求 jar classpath 和正常退出。
在 libraryd 里落地
发布流程固定成四步:
mvn -q -DskipTests package生成libraryd-app.jar。- 用生产同样的 JDK、同样的 JVM 参数、同样的工作目录跑一次训练。
- 用
-Xshare:on验证归档可加载,记录归档大小和启动中位数。 - 生产启动参数加
-XX:SharedArchiveFile=/opt/libraryd/app.jsa -Xshare:auto。
训练脚本:
#!/usr/bin/env bash
set -euo pipefail
JAVA="$HOME/.jdks/jdk-25.0.5+8/Contents/Home/bin/java"
JAR="/opt/libraryd/libraryd-app.jar"
ARCHIVE="/opt/libraryd/app.jsa"
rm -f "$ARCHIVE"
"$JAVA" -XX:ArchiveClassesAtExit="$ARCHIVE" -jar "$JAR" --train &
PID=$!
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:18080/health >/dev/null && break
sleep 0.2
done
curl -fsS http://127.0.0.1:18080/api/loans/1 >/dev/null
curl -fsS "http://127.0.0.1:18080/api/reports/summary?n=200" >/dev/null
kill -TERM "$PID"
wait "$PID" || true
"$JAVA" -XX:SharedArchiveFile="$ARCHIVE" -Xshare:on -jar "$JAR" --dry-run
ls -l "$ARCHIVE"
--train 和不带参数的启动走同一套初始化,只是不接收外部流量;--dry-run 用来验证归档能被 -Xshare:on 加载。最后一行打印归档大小,方便和上一版对比。若归档显著变小,通常意味着训练请求少打了一条路径,某些类没有被归档。
生产用 -Xshare:auto,并在启动日志里检查是否有 Unable to use shared archive。有这条警告说明归档失效,服务仍能启动,但启动时间会回到默认 CDS 水平。CI 用 -Xshare:on,让失效变成构建失败,而不是发布后的性能回退。
常见错误与边界
错误一:用 target/classes 训练。 本机会直接报 Error: non-empty directory 'target/classes' 和 Cannot have non-empty directory in paths,归档 0 字节。先 jar --create,再训练。
错误二:训练和运行的工作目录不同。 归档记录字面 classpath,相对路径 minihttpd.jar 在另一个目录会变成 Required classpath entry does not exist。用绝对路径,或者固定 WORKDIR。
错误三:生产用 -Xshare:on。 基础镜像升级 JDK、改堆参数、换 jar 名称都会让归档失效,on 会让服务直接起不来。生产用 auto,CI 用 on。
错误四:把 AppCDS 当成「应用类越多省得越多」的线性收益。 Hello 的 AppCDS 中位数 79.3 ms,默认 CDS 75.8 ms,没有收益;最小服务是 129.2 对 137.8 ms,约 6%。收益取决于应用类在启动类加载中的占比,必须用自己的 jar 测。
边界一:归档不能缓存动态生成的类。 训练时出现的代理类、Lambda 生成的类、JFR 事件类,日志里会显示 Skipping ...。这些类在每次启动时仍然要生成或加载,AppCDS 不解决这部分。
边界二:归档大小不是堆内存节省。 归档映射的是共享只读页,多个 JVM 可以共享物理内存;它不是把类从堆里删掉。评估收益要看启动时间和 RSS,不要只看 .jsa 文件大小。
小结
- 默认 CDS 已经归档 JDK 核心类;最小服务启动的 873 个类里 834 个来自共享归档,关掉 CDS 会让就绪中位数从 137.8 ms 升到 179.7 ms。
-Xshare:dump生成 1670 个类、14.9 MB 的静态归档,app计数为 0;应用类要靠动态 AppCDS。ArchiveClassesAtExit为Hello生成 144 KiB 归档,为最小服务生成 1.66 MiB 归档;后者启动中位数 129.2 ms,比默认 CDS 快约 6%。- 动态归档和 AOT 训练都不支持非空目录 classpath,必须打成 jar;训练和运行要用相同的字面路径。
jcmd VM.cds只有static_dump和dynamic_dump两个子命令,动态 dump 需要-XX:+RecordDynamicDumpInfo。- 生产用
-Xshare:auto容忍失效,CI 用-Xshare:on让失效暴露;归档失效的常见原因是 JDK、堆参数、classpath 或工作目录变化。
AppCDS 缓存的是类元数据。JDK 25 还提供了更进一步的 AOT 缓存,把类链接结果和方法 profile 也存下来。下一节看它的三阶段命令、目录 classpath 的真实报错,以及为什么 Native Image 是另一个问题。
阅读导航:上一节:9.1 启动耗时分解:类加载、解释执行与首请求 · 下一节:9.3 AOT 缓存与 Native Image:边界与取舍 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。