类加载、双亲委派与类初始化:身份、锁与错误状态
本页语义基线为 Java SE 17,HotSpot 源码固定到
jdk-17.0.12+7,实验运行 Oracle JDK 17.0.12(macOS x86_64)。规范语义、HotSpot 实现与本机实测分三层表述;图解把类生命周期画成带分支的状态机,实验分别写预期与实测,闭卷题指向对应小节。
把类生命周期画成有分支的状态机
加载是取得字节并创建运行时类型,链接是验证、准备与解析,初始化才执行静态字段初始化表达式与 <clinit>。解析允许延迟,所以生命周期不是“全量解析完才能执行任何代码”的单向流水线。类加载失败、验证失败、解析失败、初始化失败需要分别解释,修复方式也不同(JVMS §5)。
准备期的零值不等于初始化表达式的结果。常量变量还涉及 ConstantValue:static final int LIMIT=100 可以在使用者编译时内联,读取不触发声明类初始化;static final Integer LIMIT=100 不是 JLS 意义的常量变量。不要用“所有 final 都在准备时完成赋值”代替类型和常量表达式判断。实例字段初始化与构造器是另一条流程,不能混入 <clinit>(JLS §12.4、§4.12.4)。
主动使用包括创建实例、调用声明类的静态方法、写静态字段、读非编译期常量静态字段,以及某些反射操作。Class.forName(name) 默认初始化,Class.forName(name,false,loader) 可以只加载不初始化;ClassLoader.loadClass 也不等价于初始化。访问 Sub.parentField 时,初始化的是声明这个字段的类,不是根据表达式文本把 Sub 全部初始化。创建 Parent[] 不初始化 Parent。初始化一个类还要初始化父类及规定的声明 default 方法的超接口;初始化接口自身并不简单地递归初始化全部父接口。这些边界可按 JLS §12.4.1 逐条核对。
图中的流水线对应加载、验证、准备、解析、初始化与就绪;解析框标注“可延迟”是因为规范允许推迟到首次使用。初始化框向下分出两条分支:并发初始化时的等待与重入,以及失败后进入错误状态、后续使用得到 NoClassDefFoundError。读图时先判断一个请求走到了哪个环节,再看它落入哪条分支,不要把整张图读成一次性的线性动作。
初始化触发边界:加载不等于初始化
“类已加载”与“类已初始化”是两个状态,诊断时混用会把“静态块没执行”误报成“类缺失”。三个加载入口的初始化语义不同:Class.forName(name) 加载、链接并初始化;Class.forName(name, false, loader) 加载并链接但不初始化;ClassLoader.loadClass(name) 连接与否取决于 resolve 参数,默认不链接也不初始化。驱动类初始化(主动使用)的清单以 JLS §12.4.1 为准,最常被背错的是三个否定项与一个声明类规则:
- 读常量变量(
static final且为常量表达式的原始类型/String)不触发初始化——编译器把它内联成ConstantValue,使用方根本不发 getstatic。static final Integer不是常量变量,读取即触发。 - 创建
Parent[]不初始化Parent;数组类型自己的链接独立于元素类。 - 通过子类名读继承静态字段(
Sub.PARENT_FIELD)只初始化声明该字段的类 Parent,不初始化 Sub。 - 初始化一个类要先初始化其父类与含 default 方法的超接口;接口自身的字段初始化按各自首次使用触发,不递归全量初始化。
原码:initialize 参数原样传给本地入口
原码连续摘录(仅统一公共缩进,截取方法整体):Class.java,jdk-17.0.12+7,L447–L468。
@CallerSensitive
public static Class<?> forName(String name, boolean initialize,
ClassLoader loader)
throws ClassNotFoundException
{
Class<?> caller = null;
@SuppressWarnings("removal")
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
// Reflective call to get caller class is only needed if a security manager
// is present. Avoid the overhead of making this call otherwise.
caller = Reflection.getCallerClass();
if (loader == null) {
ClassLoader ccl = ClassLoader.getClassLoader(caller);
if (ccl != null) {
sm.checkPermission(
SecurityConstants.GET_CLASSLOADER_PERMISSION);
}
}
}
return forName0(name, initialize, loader, caller);
}initialize 不在任何 Java 层分支里被改写:安全检查只处理 loader 权限,最后原样传给本地 forName0,由 VM 决定是否走初始化协议。所以“forName 一定初始化”是误传——它只是单参数重载默认 initialize=true;两参数/三参数形式、loadClass、MethodHandles 类查找各自的初始化语义必须分开记。观察通道用 -Xlog:class+init=info(JDK 17 真实开关):每行 Initializing '类名' 对应一次 <clinit> 执行,本页实验里它与断言互证——forName(initialize=false) 段没有该类日志行,forName(initialize=true) 断言处恰好出现。同线程重入读零值的时序边界(x=readY(); y=7 首轮读到 0)也一并在实验里以可运行断言固定,纸面推演见下节初始化同步。
双亲委派的锁、身份与失败分支
默认实现入口是 ClassLoader.loadClass(String,boolean)。实际步骤是先在加载锁内查 findLoadedClass,未命中则找 parent 或 bootstrap,只有找不到时才执行自己的 findClass,最后按 resolve 参数调用 resolveClass。源码中的 catch 只把 ClassNotFoundException 当作继续查找的信号;LinkageError、安全异常等不能一概吞掉再加载另一个副本(ClassLoader.java)。
原码:父加载器失败后才进入本加载器查找
原码连续摘录(仅统一公共缩进,截取方法局部):ClassLoader.java,jdk-17.0.12+7,L574–L592。
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);第一步 findLoadedClass 查已加载身份,避免重复定义;未找到才向 parent 或 bootstrap 委派。捕获的是 ClassNotFoundException,且只有 c 仍为空才 findClass。不能把任意 LinkageError 都吞掉并强行重定义,也不能把这个 loadClass 路径当作类初始化已经执行;锁与 resolve 分支在该片段前后。
原码:并行加载器按类名取同一把锁
原码连续摘录(仅统一公共缩进,截取方法整体):ClassLoader.java,jdk-17.0.12+7,L667–L677。
protected Object getClassLoadingLock(String className) {
Object lock = this;
if (parallelLockMap != null) {
Object newLock = new Object();
lock = parallelLockMap.putIfAbsent(className, newLock);
if (lock == null) {
lock = newLock;
}
}
return lock;
}parallelLockMap 只对登记为 parallel capable 的加载器非空;putIfAbsent 保证同一类名稳定映射到同一把锁,先到的线程拿走自己新建的锁,后到的线程拿到已有锁,不会两个都 new 出各自的锁后同时 defineClass。未注册并行能力的加载器回退为 this,整个加载器共用一把锁。它避免同名类的重复定义竞争;自定义 child-first 若绕过查重和锁,两个线程可能同时 defineClass,造成重复定义的 LinkageError。反过来,仅在一个大锁里任意回调其他加载器又可能形成锁顺序环。不能把“改成并行加载”当作无条件消除死锁;要检查依赖图和锁顺序。
运行时类身份是二进制名加定义加载器;发起加载器不一定是定义加载器。两个不同加载器定义的 com.example.Plugin 即使字节相同也不是同一个类型,强转会失败,反射的参数类型匹配也会失败。插件 API 应由共同祖先定义,实现可由插件加载器隔离;如果每个插件把 API 也打包并 child-first 定义,宿主将无法按同一 API 类型接收实现。类身份及加载约束由 JVMS §5.3、§5.3.4 定义。
双亲委派能共享基础类型并减少意外重复定义,但不是一个完整沙箱。JDK 17 的模块封装、字节码验证、包定义约束与权限也是独立机制;把委派改成 child-first 不表示能合法替换所有核心类。服务发现使用 TCCL,是让父层代码有机会找到子层实现;TCCL 改变查找上下文,不自动改变所有引用类型的定义加载器。
初始化同步:重入、等待、错误状态
HotSpot 的核心入口是 InstanceKlass::initialize_impl。先确保 link 完成,再获取初始化锁,检查正在初始化的线程与状态。同线程重入可直接返回,另一线程必须等待初始化完成;初始化工作本身在释放短期状态锁后执行,不应把它讲成“始终持有 Class 对象 Java monitor 执行静态块”。完成后重新设置状态并唤醒等待者(instanceKlass.cpp)。
状态转移的教学摘要:
link_class()
lock(init_lock)
while state==being_initialized && owner!=current: wait_uninterruptibly()
if owner==current && state==being_initialized: return
if state==fully_initialized: return
if state==initialization_error: throw NoClassDefFoundError
state=being_initialized; owner=current
unlock(init_lock)
initialize_required_supers(); call_class_initializer()
if success: set_state_and_notify(fully_initialized)
else: record_error(); set_state_and_notify(initialization_error); throw_error()原码:等待、重入、已完成与错误状态分支
原码连续摘录(仅统一公共缩进,截取方法局部,步骤 2–6):instanceKlass.cpp,jdk-17.0.12+7,L1100–L1137。
while (is_being_initialized() && !is_reentrant_initialization(jt)) {
wait = true;
jt->set_class_to_be_initialized(this);
ol.wait_uninterruptibly(jt);
jt->set_class_to_be_initialized(NULL);
}
// Step 3
if (is_being_initialized() && is_reentrant_initialization(jt)) {
DTRACE_CLASSINIT_PROBE_WAIT(recursive, -1, wait);
return;
}
// Step 4
if (is_initialized()) {
DTRACE_CLASSINIT_PROBE_WAIT(concurrent, -1, wait);
return;
}
// Step 5
if (is_in_error_state()) {
DTRACE_CLASSINIT_PROBE_WAIT(erroneous, -1, wait);
ResourceMark rm(THREAD);
Handle cause(THREAD, get_initialization_error(THREAD));
stringStream ss;
ss.print("Could not initialize class %s", external_name());
if (cause.is_null()) {
THROW_MSG(vmSymbols::java_lang_NoClassDefFoundError(), ss.as_string());
} else {
THROW_MSG_CAUSE(vmSymbols::java_lang_NoClassDefFoundError(),
ss.as_string(), cause);
}
}
// Step 6
set_init_state(being_initialized);
set_init_thread(jt);while 循环对应“另一线程正在初始化”:把当前线程登记为等待者后 wait_uninterruptibly,等待不可被中断。循环退出后按顺序判断——同线程重入直接返回(Step 3),已完成直接返回(Step 4),错误状态构造 NoClassDefFoundError 并携带首个 cause(Step 5),都不是才把自己登记为初始化线程并进入执行阶段(Step 6)。整段源码里没有“持有 Java monitor 跑 <clinit>”,把初始化同步讲成锁住 Class 对象只是近似说法。
同线程递归初始化可以读到后续静态字段的默认值。例如初始化 A.x 时调用方法读取 A.y,而 y 的赋值在后面,读取到 0 是允许的,不是初始化同步失效。另一线程则不能绕过初始化完成直接读取正常结果。初始化完成与后续使用之间有规范要求的 happens-before 关系;它不能让一个初始化后仍可变的静态集合自动线程安全(JLS §12.4.2)。
<clinit> 抛出的对象若不是 Error,首个触发线程通常得到 ExceptionInInitializerError;若本身是 Error 则按规范传播。类进入 erroneous 状态,后续主动使用得到 NoClassDefFoundError,不是重新运行静态块。这解释了“日志里明明类文件存在,为什么报 NoClassDefFoundError”:应回找第一次初始化失败,而不是立即查缺 jar。父类初始化失败也会让依赖初始化失败。短暂的配置中心故障若发生在静态块里,可能把一次网络失败固化为进程内永久不可用状态;把可恢复 I/O 移出静态初始化更合理。
两个线程分别初始化 A、B,而 A 的静态块需要 B、B 的静态块需要 A,可形成初始化等待环。这不是普通 synchronized 双锁环,Thread.print 可能显示“等待类初始化”,Java monitor 死锁检测未必替你报告同一种结论。避免在静态初始化中启动并 join 一个反过来使用本类的线程,也避免跨类静态块执行阻塞 I/O。
加载器泄漏:谁在让旧部署继续存活
普通命名类的卸载以定义加载器可回收为关键条件;hidden class 等有单独的生命周期,不应拿来反驳或套用普通插件规则。堆里的实例、Class 镜像、加载器与元空间有关联,形成环不等于泄漏,真正阻止卸载的是从活动根到环的路径。旧加载器自己的静态 Map 指向自己的实例,若整组无外部根,仍可能整体回收(JLS §12.7)。
三个可核对的保留链:活线程 → contextClassLoader → 旧加载器;长期线程 → ThreadLocalMap → value → 插件实例 → 类/加载器;父加载器定义的全局注册表 → 插件监听器 → 旧加载器。ThreadLocalMap 的 key 是弱引用,value 仍强引用;key 被清除不代表 value 立即清除,清理依赖后续相关访问等路径。线程池复用使这条链能跨部署存在(ThreadLocal.java 的 Entry、expungeStaleEntry)。
在 HotSpot 卸载入口 ClassLoaderDataGraph::do_unloading 中,data->is_alive() 为真就保留;否则调用 unload 并从活动链移到待卸载链。该处说明为什么单纯降低 Metaspace 上限不会清理仍存活的加载器;它只会让失败更早到来。回收时机仍依赖所选 GC、相关开关和周期,URLClassLoader.close() 主要关闭打开的资源,不是强制卸载指令(classLoaderDataGraph.cpp)。
排障应同时看 VM.classloader_stats、类加载/卸载日志、NMT Class 趋势和 heap dump 的加载器根路径。类数高可能是正常生成代理;类数随部署次数阶梯增长且旧加载器始终被存活线程保留才支持泄漏判断。修复责任在部署生命周期:取消定时器、停止并 join 线程、注销监听器/JDBC 驱动等宿主注册项、在 finally 中清 ThreadLocal 并恢复 TCCL。只把变量置 null,无法切断其他外部根。
可运行实验与确定性推演
两套入口都在 labs/jvm-senior。旧入口从仓库根目录执行(本机为 macOS x86_64、Oracle JDK 17.0.12),编译只输出到新临时目录:
lab_jdk=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
lab_build=$(JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/build.sh)
"$lab_jdk/bin/java" -cp "$lab_build" JvmSeniorLab init
"$lab_jdk/bin/java" -Xlog:class+load=info,class+unload=info \
-cp "$lab_build" JvmSeniorLab loaderinit 的输入是一个静态字段调用必抛 IllegalStateException 的方法,两次主动读取。loader 从父加载器资源读取 Payload 字节,仅对该名 child-first,先比较类型身份,再由当前线程 TCCL 保留加载器。预期:init 第一次得到 EIIE、第二次得到 NCDFE;loader sameClass=false、parentInstance=false、tcclHeld=true。实测(本次运行):
# 实测(Oracle 17.0.12,macOS x86_64):
init: attempt=1 ExceptionInInitializerError
init: attempt=2 NoClassDefFoundError
loader: sameClass=false parentInstance=false tcclHeld=true afterRelease=false
class+unload 日志: [class,unload] unloading class JvmSeniorLab$PayloadsameClass=false 证实两个加载器定义的同类不是同一类型;afterRelease=false 是“恢复 TCCL 并发出 GC 建议后,弱引用是否立即被清除”的常见观察,不是规范承诺;本例的卸载日志说明 Payload 类确实被卸载,判断卸载要配合类卸载日志而不是只看弱引用。第一次访问 init 模式时原始 cause 才指向业务错误,第二次只剩 NCDFE,说明错误状态被记住。源码把局部加载操作隔在独立方法,减少局部变量活跃性对弱引用观测的干扰。
U10 新入口 run.sh classjit 把本页“触发边界”五分支与重入零值固化为断言,并在 stdout 混入 -Xlog:class+init=info 真实开关输出(需显式 JAVA_HOME 指向 JDK 17,入口含版本守卫与用法串):
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
bash labs/jvm-senior/run.sh classjit实测摘录(完整输出与退出状态见证据):
ok: forName(initialize=false) returns the class but does NOT run <clinit>
ok: ClassLoader.loadClass loads but does NOT initialize
ok: reflective getstatic on a non-constant field triggers initialization
ok: reading a constant variable is inlined and does NOT initialize the declaring class
ok: static final Integer is NOT a constant variable: reading it DOES initialize
ok: creating Parent[] does not initialize Parent
ok: Sub.PARENT_FIELD initializes the declaring class Parent, not Sub
ok: reentrant readY() during <clinit> observes y's default 0, so x=0 and y=7
[0.081s][info][class,init] 589 Initializing 'ClassJitProbe$LazyForName' (0x...) ← 恰在 forName(true) 断言处同线程重入零值由此从纸面推演升级为可运行断言;跨线程初始化等待仍按下述推演理解。再做无需 JVM 调度的初始化推演:输入 A.x=readY(); A.y=7; readY()返回A.y,同线程初始化的第一次 readY 返回 0,最终 x=0、y=7。变化条件是 readY 从另一个线程调用且初始化线程等待它完成,此时形成初始化等待,不再是简单地返回 0。两个推演分别检查同线程重入与跨线程同步,不能混用。
高级答案与三轮追问
主问:“双亲委派怎样工作,为什么还能出现同名类转换失败?”答:默认 loadClass 在加载锁内先查 findLoadedClass、委派父级,父级找不到再 findClass;类身份还包括定义加载器,自定义隔离加载器能定义同名但不同身份的类。宿主与插件应共享 API 定义,隔离实现。
第一轮:“两个线程同时加载同一类?”答:getClassLoadingLock 对 parallel capable 加载器用 putIfAbsent 按类名映射同一把锁,findLoadedClass 与 defineClass 处在同一临界区,防重复定义。评分点:说出按名锁与查重,而不是只说 JVM 自动安全。
第二轮:“初始化失败后补上配置能恢复吗?”答:同一类身份已经进入错误状态,initialize_impl 的 Step 5 直接抛 NoClassDefFoundError(带首个 cause),不会重新跑静态块;应追第一条异常并改生命周期,必要时更换加载器或重启。评分点:首错/后错、恢复边界。
第三轮:“每次热部署元空间涨,扩大上限还是换 GC?”答:先看旧加载器到根的保留链、存活线程和宿主注册表;do_unloading 只回收不可达的加载器数据,GC 无法卸载仍存活的类图。评分点:对象图证据及部署清理责任。
架构师变化条件:插件隔离与进程隔离
假设每个插件版本元数据加关联堆对象占 20 MiB,每小时部署 6 次、每次漏一个加载器,8 小时累计保留约 960 MiB。测得不一定正好等于该数,但它揭示增长与部署频率的关系。统一父加载器共享依赖节省内存,却带来版本耦合;每插件独立加载器允许依赖隔离,却必须设计共享 API、关闭协议和卸载验证;进程隔离进一步切断线程/native 状态泄漏边界,代价是进程基线内存、RPC 和运维成本。
若插件是可信且依赖稳定,可用加载器隔离并在持续部署测试中检查加载器数量回落;若插件会加载未知 JNI、长期自建线程或要求不同 JDK,进程隔离通常更容易定义恢复边界。验收不能只看 close 成功,而要连续部署多轮、观察旧版本类卸载/根路径和 native 资源归还,验证没有随部署次数线性增长。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JVMS SE 17 §5 加载、链接与初始化 | Java SE 17 | 链接与初始化的规范语义、解析延迟与失败分类 |
| JLS SE 17 §12.4 初始化过程 | Java SE 17 | 主动使用、<clinit>、初始化同步与 happens-before |
| JLS SE 17 §12.4.1 何时触发初始化 | Java SE 17 | 常量变量、数组、声明类等触发边界(forName/loadClass 实验断言的规范依据) |
| JLS SE 17 §12.7 卸载 | Java SE 17 | 普通命名类卸载的规范条件 |
| OpenJDK 17u ClassLoader.java | jdk-17.0.12+7 | loadClass(L574–L592)、getClassLoadingLock(L667–L677) |
| OpenJDK 17u Class.java | jdk-17.0.12+7 | forName(name, initialize, loader)(L447–L468)initialize 参数直传 forName0 |
| OpenJDK 17u instanceKlass.cpp | jdk-17.0.12+7 | initialize_impl 等待、重入与错误状态分支(L1100–L1137) |
| OpenJDK 17u classLoaderDataGraph.cpp | jdk-17.0.12+7 | do_unloading 的存活判断与卸载入口 |
| OpenJDK 17u ThreadLocal.java | jdk-17.0.12+7 | 弱 key 的 Entry 与陈旧条目清理 |
本地 JvmSeniorLab init/loader 实验 | Oracle JDK 17.0.12 | 初始化错误状态与加载器身份、卸载日志的实测输出 |
本地 ClassJitProbe classjit 实验 | Oracle JDK 17.0.12 | 触发边界五分支、重入零值与 -Xlog:class+init 的实测输出(证据) |
访问日期:2026-10-23(Class.java/ClassJitProbe 相关为 2026-10-25)。源码行号以 jdk-17.0.12+7 标签为准;实测数字来自本机 Oracle JDK 17.0.12(macOS x86_64),更换 JDK、平台或启动参数后需重跑实验。