【JVM深度解析】第02篇:类加载机制深度解析

张开发
2026/4/17 2:13:22 15 分钟阅读

分享文章

【JVM深度解析】第02篇:类加载机制深度解析
摘要类加载机制是 JVM 将磁盘上的.class字节码文件转化为内存中可执行类对象的完整过程。本文深入解析类加载的五个核心阶段加载→验证→准备→解析→初始化揭示双亲委派模型Parent Delegation Model的工作原理与设计意图探讨破坏双亲委派的三种合法场景JDBC/JNDI/OSGi/模块化热部署并通过自定义 ClassLoader 实战和常见类加载问题ClassNotFoundException vs NoClassDefFoundError、类冲突帮助读者建立对类加载机制的完整认知。掌握本文内容是解决框架冲突、热部署、插件化架构等工程难题的必备基础。引言你有没有遇到过这些诡异的问题明明 classpath 里有这个 jar却在运行时报ClassNotFoundException两个框架都依赖同一个库的不同版本导致NoSuchMethodError热部署后内存泄漏、元空间不断增长Tomcat 里不同应用可以使用同一个类的不同版本是怎么做到的这些问题的答案都在类加载机制里。类加载是 JVM 整个运行时体系的入口所有 Java 程序的执行都始于此。理解它不仅能帮你排查上述问题更能让你在设计插件系统、热更新方案、框架隔离时做出正确决策。一、类加载的五个阶段1.1 总体流程一个.class文件从磁盘到可以被 JVM 执行要经历以下五个阶段.class 文件 │ ▼ ┌─────────────────────────────────────────────────────────┐ │ 类加载生命周期 │ │ │ │ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────────┐ │ │ │ 加载 │→│ 验证 │→│ 准备 │→│ 解析 │→│ 初始化 │ │ │ │Loading│ │Verify│ │Prepare│ │Resolve│ │ Init │ │ │ └──────┘ └──────┘ └──────┘ └──────┘ └──────────┘ │ │ │ │ ◀──────────────── 连接阶段 (Linking) ────────────────▶ │ │ │ │ 注解析阶段可以在初始化后进行支持动态绑定 │ └─────────────────────────────────────────────────────────┘ │ ▼ 类对象在方法区就绪可以创建实例1.2 第一阶段加载Loading做什么把字节码二进制流读取到内存在方法区创建类的数据结构在堆中生成对应的java.lang.Class对象作为访问入口。三件事通过类的全限定名获取字节码来源不限于文件可以是网络、数据库、加密文件、动态生成将字节流所代表的静态存储结构转化为方法区的运行时数据结构在堆中生成Class对象作为该类的元数据访问入口字节码来源多样性// 常见来源// 1. 本地文件系统 .class 文件// 2. JAR/WAR/ZIP 包// 3. 网络传输Applet历史遗物// 4. 动态生成CGLib/Javassist/ASM 在运行时生成// 5. 数据库如 JDBC 驱动曾有此方案// 6. 加密字节码反破解方案1.3 第二阶段验证Verification做什么确保字节码内容合法合规防止恶意字节码危害 JVM。四层验证验证类型验证内容示例文件格式验证魔数是否为0xCAFEBABE版本号是否兼容.class文件开头必须是CA FE BA BE元数据验证类的继承关系是否合法父类不能是 final 类不能继承String等 final 类字节码验证操作数栈和局部变量类型是否匹配控制流是否正确int 操作数不能用 double 指令处理符号引用验证被引用的类/方法/字段是否存在且有访问权限调用 private 方法→抛 IllegalAccessError性能提示验证阶段是类加载中最耗时的一步。如果你对 jar 包来源完全信任如自己打包的 jar可以用-Xverify:none跳过验证加速启动生产环境慎用1.4 第三阶段准备Preparation做什么为类的静态变量分配内存在方法区/元空间并赋零值注意不是代码中写的初始值。publicclassDemo{// 准备阶段value 0零值不是 100// 初始化阶段value 100执行赋值字节码staticintvalue100;// 特例final static 常量编译期常量在准备阶段直接赋 123staticfinalintCONST123;}各类型零值对照数据类型零值int0long0Lfloat0.0fdouble0.0dbooleanfalsechar‘\u0000’引用类型null1.5 第四阶段解析Resolution做什么将常量池中的符号引用Symbolic Reference文本形式的类名/方法名/字段名替换为直接引用内存地址。符号引用编译期 com.example.UserService.findById(I)Lcom/example/User; ↓ 解析 直接引用运行期 方法在内存中的实际入口地址如 0x7f3a8b4c0解析的对象类和接口的解析字段的解析类方法的解析接口方法的解析延迟解析JVM 规范允许解析发生在初始化之后即懒解析这是实现动态绑定多态的基础。1.6 第五阶段初始化Initialization做什么执行类的clinit()方法——这是由编译器自动合成的方法包含所有静态变量赋值语句和静态代码块按源文件中的顺序执行。publicclassInitDemo{staticinta10;// ① 赋值 a10static{a20;// ② 修改 a20System.out.println(静态代码块执行);// ③}staticintba*2;// ④ b 40此时 a20// 编译器生成的 clinit() 方法等价于// a 10; a 20; print(); b a*2;}六种触发初始化的时机主动引用new实例化对象、读写静态字段、调用静态方法用反射Class.forName(xxx)且未初始化初始化子类时先触发父类初始化JVM 启动时指定的主类含main()方法JDK 7 中MethodHandle解析到的类JDK 11 中定义了 default 方法的接口实现类初始化时触发接口初始化不会触发初始化的场景被动引用// 1. 通过子类引用父类静态字段只初始化父类System.out.println(Child.parentStaticField);// 2. 通过数组定义引用类不会触发类初始化SuperClass[]arrnewSuperClass[10];// 3. 引用编译期常量不会触发类初始化常量已内联到调用方字节码System.out.println(ConstClass.CONST_VALUE);二、类加载器体系2.1 类加载器的层次结构JVM 内置三层类加载器形成父子关系通过组合而非继承┌────────────────────────────────────────┐ │ Bootstrap ClassLoader启动类加载器 │ │ - C 实现JVM 核心的一部分 │ │ - 加载$JAVA_HOME/jre/lib/*.jar │ │ (rt.jar, charsets.jar...) │ │ - Java 代码中获取null │ └────────────────┬───────────────────────┘ │父子关系非继承 ┌────────────────▼───────────────────────┐ │ Extension ClassLoader扩展类加载器 │ │ - Java 实现sun.misc.Launcher) │ │ - 加载$JAVA_HOME/jre/lib/ext/*.jar │ │ - 或 java.ext.dirs 系统属性指定目录 │ └────────────────┬───────────────────────┘ │ ┌────────────────▼───────────────────────┐ │ Application ClassLoader应用类加载器│ │ - Java 实现也叫系统类加载器 │ │ - 加载classpath 下的类 │ │ - ClassLoader.getSystemClassLoader() │ └────────────────┬───────────────────────┘ │ ┌────────────────▼───────────────────────┐ │ 自定义 ClassLoader │ │ - 继承 ClassLoader重写 findClass() │ │ - 用于热部署、加密类、插件系统 │ └────────────────────────────────────────┘⚠️ JDK 9 模块化后Extension ClassLoader 改为Platform ClassLoader但核心机制相同。2.2 双亲委派模型Parent Delegation Model这是 JVM 类加载最核心的设计——当一个类加载器收到加载请求时先把请求委托给父加载器只有父加载器无法加载时才由自己加载。工作流程自定义 ClassLoader 收到加载请求 │ ├──→ 委托给 Application ClassLoader │ │ │ ├──→ 委托给 Extension ClassLoader │ │ │ │ │ ├──→ 委托给 Bootstrap ClassLoader │ │ │ │ │ │ │ ├── 在核心库中找到→ 加载成功 ✅ │ │ │ └── 没找到 ↓ │ │ │ │ │ ├── 在扩展目录中找到→ 加载成功 ✅ │ │ └── 没找到 ↓ │ │ │ ├── 在 classpath 中找到→ 加载成功 ✅ │ └── 没找到 ↓ │ └── 在自定义路径中找到→ 加载成功 ✅ 没找到 → 抛 ClassNotFoundException ❌源码逻辑ClassLoader.loadClass 核心逻辑protectedClass?loadClass(Stringname,booleanresolve)throwsClassNotFoundException{synchronized(getClassLoadingLock(name)){// 1. 先检查是否已经加载过Class?cfindLoadedClass(name);if(cnull){try{// 2. 委托给父加载器双亲委派核心if(parent!null){cparent.loadClass(name,false);}else{// 父加载器为null委托给BootstrapcfindBootstrapClassOrNull(name);}}catch(ClassNotFoundExceptione){// 父加载器加载失败继续往下}if(cnull){// 3. 父加载器无法加载自己尝试加载cfindClass(name);// 子类应重写此方法}}if(resolve){resolveClass(c);}returnc;}}双亲委派的价值安全性防止用户自定义类冒充核心库如自定义java.lang.StringBootstrap ClassLoader 永远优先加载核心库唯一性同一个类只会被加载一次避免多份相同类在 JVM 中共存导致类型不兼容稳定性核心库不会被随意替换JVM 行为可预期三、打破双亲委派的三大场景双亲委派是建议而非强制某些场景下必须打破它3.1 场景一SPI 机制JDBC/JNDI问题java.sql.Driver在核心库Bootstrap 加载但具体实现如com.mysql.jdbc.Driver在 classpathApplication ClassLoader 加载。Bootstrap ClassLoader 无法向下加载 classpath 中的类。解决方案线程上下文类加载器Thread Context ClassLoader// JDBC DriverManager 核心加载逻辑// java.sql.DriverManager 由 Bootstrap 加载// 但需要加载 SPI 实现类MySQL Driver// 解决方案使用线程上下文类加载器ClassLoadercontextClassLoaderThread.currentThread().getContextClassLoader();// 上下文类加载器默认是 Application ClassLoader// 通过它来加载 MySQL Driver 等 SPI 实现// 这本质上是父委托子打破了双亲委派ServiceLoaderDriverloadedDriversServiceLoader.load(Driver.class,contextClassLoader);流程示意Bootstrap CL加载 DriverManager │ 需要加载 MySQL Driver │ 但 Bootstrap 找不到 ▼ Thread.currentThread().getContextClassLoader() │ Application ClassLoader ▼ Application CL 加载 com.mysql.jdbc.Driver ✅3.2 场景二OSGi 模块化框架OSGiEclipse 插件系统、Karaf 容器将双亲委派改为网状结构每个 Bundle模块有独立的 ClassLoader模块间通过声明 Import/Export 包来控制类的可见性。Bundle A ClassLoader ←──Export org.foo──→ Bundle B ClassLoader ↕ ↕ Bundle C ClassLoader ←──Import org.foo─────────────┘这使得不同 Bundle 可以使用同一个类的不同版本而互不干扰。3.3 场景三热部署Tomcat/Spring DevToolsTomcat 为每个 Web 应用创建独立的WebAppClassLoader打破双亲委派优先自己加载而非委托父加载器实现不同 Web 应用可以有不同版本的同名类重新部署时销毁旧 ClassLoader创建新 ClassLoader 重新加载Common ClassLoaderTomcat 共享类 │ ┌───────────┴───────────┐ ▼ ▼ WebApp1 ClassLoader WebApp2 ClassLoader 加载 /WEB-INF/classes 加载 /WEB-INF/classes 加载 /WEB-INF/lib 加载 /WEB-INF/lib 两个应用可以有 MyService.class 的不同版本互不干扰四、自定义 ClassLoader 实战4.1 基础实现加载自定义路径的类importjava.io.*;importjava.nio.file.*;/** * 自定义类加载器从指定目录加载 .class 文件 */publicclassFileSystemClassLoaderextendsClassLoader{privatefinalStringclassDir;publicFileSystemClassLoader(StringclassDir){// 指定父加载器为 Application ClassLoader默认行为super(ClassLoader.getSystemClassLoader());this.classDirclassDir;}OverrideprotectedClass?findClass(Stringname)throwsClassNotFoundException{// 将类名转为文件路径com.example.Foo → com/example/Foo.classStringfilePathclassDirFile.separatorname.replace(.,File.separatorChar).class;try{byte[]classBytesFiles.readAllBytes(Paths.get(filePath));// 核心方法将字节码转为 Class 对象returndefineClass(name,classBytes,0,classBytes.length);}catch(IOExceptione){thrownewClassNotFoundException(找不到类文件: filePath,e);}}publicstaticvoidmain(String[]args)throwsException{FileSystemClassLoaderloadernewFileSystemClassLoader(/tmp/classes);// 加载类Class?clazzloader.loadClass(com.example.HelloWorld);// 验证类加载器System.out.println(ClassLoader: clazz.getClassLoader());// 输出ClassLoader: FileSystemClassLoader...// 调用方法Objectinstanceclazz.getDeclaredConstructor().newInstance();clazz.getMethod(sayHello).invoke(instance);}}4.2 进阶实现加密类加载器/** * 加密类加载器解密后加载简单 XOR 示例 */publicclassEncryptedClassLoaderextendsClassLoader{privatestaticfinalbyteXOR_KEY0x5A;// 加密密钥OverrideprotectedClass?findClass(Stringname)throwsClassNotFoundException{StringfilePathname.replace(.,/).encrypted;try(InputStreamisgetClass().getResourceAsStream(/filePath)){if(isnull)thrownewClassNotFoundException(name);byte[]encryptedis.readAllBytes();byte[]decrypteddecrypt(encrypted);// 解密returndefineClass(name,decrypted,0,decrypted.length);}catch(IOExceptione){thrownewClassNotFoundException(name,e);}}privatebyte[]decrypt(byte[]encrypted){byte[]decryptednewbyte[encrypted.length];for(inti0;iencrypted.length;i){decrypted[i](byte)(encrypted[i]^XOR_KEY);}returndecrypted;}}4.3 热部署类的卸载与重加载/** * 热部署示例监听文件变化重新加载类 */publicclassHotDeployDemo{privatevolatileClassLoadercurrentLoader;privatevolatileClass?currentClass;publicvoidreload()throwsException{// 旧的 ClassLoader 和 Class 会在 GC 时被回收// 前提没有其他地方持有该类的引用currentLoadernewFileSystemClassLoader(/hot/classes);currentClasscurrentLoader.loadClass(com.example.HotService);System.out.println(类已重新加载版本: getVersion());}privateStringgetVersion()throwsException{return(String)currentClass.getMethod(getVersion).invoke(currentClass.getDeclaredConstructor().newInstance());}}⚠️类卸载条件一个类要被 GC 卸载必须同时满足该类所有实例已被回收加载该类的 ClassLoader 已被回收该类对应的java.lang.Class对象没有被任何引用这就是为什么热部署不及时会导致元空间内存泄漏。五、类加载常见问题排查5.1 ClassNotFoundException vs NoClassDefFoundError这两个异常经常被混淆本质不同特征ClassNotFoundExceptionNoClassDefFoundError类型受检异常Exception错误Error触发时机运行时动态加载时找不到类编译时存在运行时 classpath 中不存在常见来源Class.forName()、反射调用静态初始化失败、编译后 jar 被删除典型场景忘记把 jar 加入 classpath静态块抛异常、运行时 jar 丢失// ClassNotFoundException 示例try{Class.forName(com.mysql.jdbc.Driver);// jar 不在 classpath}catch(ClassNotFoundExceptione){System.err.println(MySQL 驱动未找到请检查 classpath);}// NoClassDefFoundError 示例编译时有运行时无// 假设编译时有 Foo.class但运行时 jar 被删除FoofoonewFoo();// 抛 NoClassDefFoundError5.2 类冲突ClassCastException / LinkageError同名类被不同 ClassLoader 加载JVM 认为它们是两个不同的类// 错误场景同一个类被两个不同的 ClassLoader 加载ClassLoaderloader1newURLClassLoader(urls);ClassLoaderloader2newURLClassLoader(urls);Class?clazz1loader1.loadClass(com.example.User);Class?clazz2loader2.loadClass(com.example.User);Objectuser1clazz1.newInstance();// 虽然都叫 User但它们是不同的 Class 对象clazz2.cast(user1);// 抛 ClassCastException排查思路// 打印类加载器确认是否为同一个System.out.println(obj.getClass() loaded by obj.getClass().getClassLoader());5.3 静态初始化异常导致的 ExceptionInInitializerErrorpublicclassConfigLoader{// 静态初始化失败staticfinalPropertiesprops;static{propsnewProperties();try{props.load(newFileInputStream(config.properties));// 文件不存在}catch(IOExceptione){thrownewRuntimeException(配置加载失败,e);// 抛出运行时异常}}}// 第一次使用 ConfigLoader 时// → ExceptionInInitializerError包装了 RuntimeException//// 第二次使用 ConfigLoader 时即使文件已存在// → NoClassDefFoundErrorJVM 认为该类初始化已失败不再重试六、JDK 9 模块化对类加载的影响JDK 9 引入的模块系统JPMS对类加载带来了显著变化JDK 8 及以前 JDK 9 ───────────────────────────────────────────── Bootstrap ClassLoader Bootstrap ClassLoader rt.jar巨型jar →分解→ java.base 模块及其他核心模块 Extension ClassLoader Platform ClassLoader ext/*.jar →替换→ java.se 及平台模块 Application ClassLoader Application ClassLoader classpath classpath 模块路径模块化的主要影响强封装sun.misc.Unsafe等内部 API 默认不可访问需--add-opens开放类加载路径--module-path替代部分-classpath场景模块访问检查在类加载阶段增加七、总结类加载机制是 JVM 架构的第一道关卡五个阶段加载获取字节码→验证安全检查→准备零值初始化→解析符号引用→直接引用→初始化执行静态代码双亲委派从下往上委托从上往下尝试加载确保核心类的唯一性与安全性打破双亲委派SPI 机制用线程上下文加载器反向委托Tomcat/OSGi 为隔离性设计自己的加载体系自定义 ClassLoader继承ClassLoader重写findClass()可实现热部署、加密类加载等场景常见问题ClassNotFoundException运行时找不到vsNoClassDefFoundError编译后丢失类冲突不同 ClassLoader 加载同名类下一篇预告类加载完成后对象和数据住在哪里运行时数据区各个区域的精确边界在哪里内存溢出到底是哪个区域先撑爆的第03篇将带你深入解剖运行时数据区。系列导航上一篇【JVM深度解析】第01篇JVM前世今生与技术架构全景下一篇【JVM深度解析】第03篇运行时数据区深度剖析系列目录JVM深度解析参考资料《深入理解Java虚拟机第3版》第7章 — 周志明著JVM Specification: Chapter 5 - Loading, Linking, and InitializingJava ClassLoader API 文档Understanding the Java ClassLoaderTomcat ClassLoader HOW-TOJEP 261: Module System

更多文章