启动变慢从哪里开始怀疑
一个容器化的 Java 服务在镜像升级后启动时间明显拉长,日志里没有任何异常,应用功能也正常,只是 readiness 探针比上一版晚了几十秒才通过。镜像里已经带了 AppCDS 归档,启动命令也写着 -Xshare:auto,看起来该做的都做了。这种“没有报错但变慢”的现象,往往不是应用代码的问题,而是 CDS 归档根本没有被用上。
-Xshare:auto 的设计目标就是让 JVM 在归档不可用时继续运行,而不是启动失败。它在归档映射失败、类路径不匹配、归档与当前 JDK 不一致等情况下都会静默禁用 CDS,退回普通类加载路径。对生产环境来说这是合理的韧性选择,代价是归档失效这件事本身不会以错误形式暴露出来。要定位它,必须主动去看 CDS 自己的日志,而不是等应用报错。
下面以“镜像升级后启动变慢”为贯穿场景,说明归档如何被映射、JVM 如何判断一个类能否从归档加载、失效时回退到什么路径,以及怎样用日志和强制模式把问题钉死。
归档里到底存了什么,映射发生在什么时候
CDS 的核心思路是:JVM 启动时要加载一批类,这个过程在不同应用之间高度重复。JDK 5 引入 CDS 时,允许把一批类预处理成共享归档文件,运行时以只读方式内存映射进来,从而跳过解压、校验、生成字节码等步骤。JEP 341 进一步让 JDK 构建过程直接生成默认归档,放在 $JAVA_HOME/lib/server/classes.jsa(Windows 为 $JAVA_HOME/bin/server/classes.jsa),64 位构建自带,用户不再需要手动执行 -Xshare:dump。
默认归档只覆盖 JDK 自身的核心类。JEP 310 把 CDS 扩展成 AppCDS,允许把应用类路径上的类、平台类加载器和自定义类加载器加载的类也放进归档。JEP 350 又引入动态归档:用 -XX:ArchiveClassesAtExit=<file> 在应用退出时自动生成归档,省去先跑一次、再 dump、再运行的繁琐流程。动态归档是叠在默认归档之上的“顶层归档”,它依赖默认归档作为基础层。
映射发生在 JVM 初始化早期。归档被映射到进程地址空间后,JVM 在加载类时会先查归档里有没有这个类。如果有,就直接从映射区读取类元数据,不再走常规的读取 class 文件、校验、解析流程。这一步省下的是启动阶段最密集的 I/O 和 CPU 工作,所以对启动时间的影响最直接。
一个类能否命中归档,JVM 校验了什么
归档不是“按类名匹配”这么简单。JVM 要确认归档里的这份类元数据与当前运行环境一致,否则加载进来的类可能和实际类路径上的类不是同一个东西。校验大致涉及几个层面。
类路径匹配。JEP 310 明确要求:-Xshare:dump 时使用的类路径必须与 -Xshare:on 时相同,或者是它的前缀,否则 JVM 会报告类路径不匹配并拒绝启动。镜像升级时如果新增、删除或重排了 classpath 条目,归档就可能不再匹配。
归档与 JDK 的绑定。默认归档是随 JDK 构建生成的,与具体 JDK 版本和构建绑定。换 JDK 版本、换基础镜像、甚至同一版本不同构建,都可能导致归档不可用。
动态归档对基础层的依赖。JEP 350 说明,动态归档里记录了基础归档头部和所有共享空间的 CRC 值。运行时映射动态归档时,JVM 会把记录的 CRC 与当前映射的基础归档 CRC 逐一比对,任何一个不匹配,动态归档就被禁用,但已映射的基础归档不受影响。用 CRC 而不是文件名、大小、时间戳来校验,是为了更可靠地发现基础层变化。
这些校验中任何一项失败,在 -Xshare:auto 下都不会中断启动,只是让 CDS 不再生效。应用照常跑,只是每个类都走普通加载路径。
回退之后,类加载走了哪条路
归档不可用时,JVM 回到常规类加载。以应用类为例,类加载器按双亲委派顺序查找:先看父加载器是否已加载,再尝试从 classpath 读取 class 文件,经历读取、校验、准备、解析、初始化等阶段。相比从归档映射区直接取元数据,这条路径多了文件读取和校验开销。
对单个类来说这点开销不大,但启动阶段要加载成千上万个类,累积起来就是可观测的启动时间差异。JEP 310 给出的动机数据里提到,大型企业应用常把数万个类加载进应用类加载器,AppCDS 对这类应用的内存节省可达每个 JVM 进程数十到数百 MB;JEP 341 引用 JDK 11 早期构建在 Linux/x64 上运行 HelloWorld 的测量,启动时间减少约 32%。这些数字来自特定基准,不能直接套到你的服务上,但方向是清楚的:类越多,回退的代价越大。
下面的流程图展示镜像升级后一次启动中,归档从映射到回退的判断路径。
flowchart TD
A[启动 JVM 带 -Xshare:auto] --> B[尝试映射 CDS 归档]
B --> C{归档文件存在且可映射?}
C -- 否 --> H[禁用 CDS 回退普通类加载]
C -- 是 --> D{类路径与 dump 时匹配?}
D -- 否 --> H
D -- 是 --> E{归档与当前 JDK 绑定一致?}
E -- 否 --> H
E -- 是 --> F{动态归档 CRC 与基础层一致?}
F -- 否 --> G[禁用动态归档保留基础归档]
F -- 是 --> I[类从归档映射区加载]
G --> H
H --> J[每个类走常规读取校验路径]
图中的关键转折是那几个判断节点。-Xshare:auto 下它们全部失败也不会抛错,所以从应用日志里看不到任何线索。
用 -Xlog:cds 和 -Xshare:on 把问题钉死
诊断的第一步是打开 CDS 日志。-Xlog:cds 会输出归档映射和类匹配的详细信息,-Xlog:class+load=info 会打印每个类的加载来源,从归档加载的类会标记为 source: shared objects file。如果启动日志里几乎看不到 shared objects file,说明归档基本没命中。
JEP 310 还提到 -Xlog:class+path=info 可以打印 JVM 期望的类路径和实际使用的类路径,这对排查类路径不匹配特别有用。
第二步是用 -Xshare:on 做强制验证。-Xshare:on 要求必须使用 CDS,一旦归档有问题就打印错误并退出。JEP 310 的建议很明确:先在测试环境用 -Xshare:on 确认没有类路径不匹配,再在生产环境用 -Xshare:auto。dev.java 的教程也强调 -Xshare:on 只适合测试,不应在生产使用。
把这两步结合起来,就能区分几种情况:-Xshare:on 直接启动失败并报类路径不匹配,说明归档与当前 classpath 不一致;启动成功但 -Xlog:cds 显示动态归档被禁用,说明基础层 CRC 不匹配,通常是 JDK 或基础归档变了;启动成功且日志显示归档已映射,但 -Xlog:class+load 里应用类仍标记为普通来源,说明这些类不在归档里,可能是 dump 时没被加载到。
归档重建与版本绑定的判断
确认归档失效后,处理方式取决于失效原因。如果是类路径变化,需要重新生成归档。静态归档用 -Xshare:dump 配合 -XX:SharedClassListFile,动态归档用 -XX:ArchiveClassesAtExit 在应用退出时重新生成。JEP 350 指出,动态归档只归档应用执行期间实际加载的类,JAR 里存在但没被加载的类不会进归档,所以重建时要用与生产一致的启动路径跑一次。
如果是 JDK 版本或基础镜像变化,默认归档本身已经变了,动态归档必须基于新的基础归档重建。JEP 350 的 CRC 校验机制意味着旧动态归档无法跨基础层复用。
版本绑定还有一个容易忽略的点:归档与 JDK 构建绑定,不是与 JDK 主版本号绑定。同一主版本的不同构建之间,归档也不保证兼容。容器镜像里如果只固定了主版本标签,实际拉到的构建可能已经变化。
重建归档本身有成本。dev.java 的教程提醒,生成共享归档会在 JVM 初始化和终止阶段带来明显性能开销,所以不应在每次启动时都重建。JDK 19 引入的 -XX:+AutoCreateSharedArchive 允许 JVM 在归档缺失或无效时自动生成,但同样要理解它的触发条件,避免在启动关键路径上反复重建。
与替代方案的权衡和适用边界
CDS 不是唯一缩短启动时间的手段,也不是所有场景都划算。下表对比几种常见方案在启动、内存、兼容性和实现复杂度上的差异。
| 方案 | 启动收益 | 内存收益 | 兼容性约束 | 实现复杂度 |
|---|---|---|---|---|
| 默认 CDS 归档 | 对核心类加载有收益,应用越大相对越小 | 多进程共享映射时省内存 | 随 JDK 构建自带,基本无额外约束 | 无需额外操作 |
| AppCDS 静态归档 | 覆盖应用类,收益随类数量增加 | 可跨进程共享 | 类路径必须匹配,需维护 classlist | 需要 dump 和重建流程 |
| AppCDS 动态归档 | 覆盖运行期实际加载的类 | 依赖基础层共享 | 依赖基础归档 CRC,JDK 变化需重建 | 一条启动参数即可生成 |
| AOT/Native Image | 启动收益通常更大 | 运行时内存结构不同 | 反射、动态代理等需额外配置 | 构建流程改动大 |
| 不做任何共享 | 无 | 无 | 无 | 无 |
这张表里的收益都是定性描述。具体到某个服务,收益取决于它加载多少类、类路径多复杂、镜像更新频率多高。
适用边界也要说清楚。JEP 310 的非目标里明确,该实现使用的共享归档存储格式不会被标准化,这意味着归档格式是 HotSpot 实现细节,不同 JVM 实现之间不保证互通。JEP 310 还指出,该版本中 CDS 不能归档用户定义模块(如 --module-path 指定的模块)中的类,后续版本才补充支持。如果你的应用大量使用模块路径,需要确认所用 JDK 版本是否支持。
另一个边界是内存共享。dev.java 的文章提到,CDS 早期“跨进程共享内存映射”的卖点如今不再被积极支持,因为内存成本下降、服务器部署普遍是紧配容器,一个 JVM 独占一个容器,跨进程共享的前提不成立。所以现在用 CDS 主要图的是启动时间,而不是省内存。
回到那个变慢的镜像
诊断路径可以收束成几个动作。先看 -Xlog:cds 和 -Xlog:class+load=info,确认归档是否映射、应用类是否来自 shared objects file。再用 -Xshare:on 在测试环境强制验证,让静默回退变成显式失败。根据失败信息区分是类路径变化、JDK 变化还是基础层 CRC 不匹配,然后决定重建静态归档还是动态归档。最后确认镜像里的 JDK 构建是否被固定,避免下次升级再次踩到同样的坑。
-Xshare:auto 的静默回退是特性不是缺陷,它保证归档问题不会拖垮生产启动。代价是归档失效这件事必须靠日志和强制模式主动发现。把 CDS 日志纳入启动可观测性,比等到用户抱怨启动慢再回头查要省事得多。