1. 类加载子系统到底在做什么1.1 一次JVM启动里类加载发生在哪一步很多Java开发者写了几年代码对new一个对象背后发生了什么却说不清楚。类加载子系统是整个JVM运行链路的第一站它负责把.class字节码文件加载到内存、校验数据格式、把常量池里的符号引用翻译成直接引用最后让类进入可被使用状态。JVM启动时并不是把所有类一次性全加载进来而是按需加载——某个类第一次被主动使用时类加载子系统才会把它的二进制字节流读进来经过一系列处理后放进方法区。这个懒加载设计和我平时做工程有点像一个几百号人的系统如果上线时把所有模块全部初始化启动时间会被拖垮。但如果是按路由触发模块加载就能做到快速启动、按需释放。JVM在1.5之后就开启了一个叫类数据共享CDS的机制JDK 12以后又引入了应用类数据共享AppCDS核心思路也是在启动阶段提前把常用类的元数据映射到归档文件里降低类加载的开销。对这个子系统理解不透彻后面排查线上ClassNotFoundException、NoClassDefFoundError、依赖冲突、内存泄漏时会非常被动。1.2 类加载的三大阶段加载、连接、初始化类加载子系统的工作流程可以用三个大阶段概括加载Loading、连接Linking、初始化Initialization。这三者不是完全割裂的但职责非常清晰。加载阶段做的事情是通过一个全限定名比如com.example.UserService获取该类的二进制字节流然后将字节流中的静态存储结构转换为方法区的运行时数据结构最后在堆中生成一个java.lang.Class对象作为方法区数据的外部访问入口。注意一个坑很多人以为这个Class对象只存反射信息实际上它同时也是宿主在堆中的一个普通对象生命周期受GC管理。连接阶段又细分三步验证Verify、准备Prepare、解析Resolve。验证是要确认字节流符合JVM规范不会危害JVM自身安全准备阶段是为类变量分配内存并设置零值解析阶段是把常量池内的符号引用替换为直接引用。这三个步骤中解析是最灵活的JVM规范允许在类被初始化之后才触发某些符号引用的解析这种行为叫做惰性解析。初始化阶段是真正执行类构造器clinit方法的阶段。这里要区分开clinit是类级别初始化init是实例级别构造。很多面试者把两者混在一起聊这其实是两个完全不同的执行时机。理解了这三层结构后续所有的排查和调优思路才立得住。2. 类加载器家族与双亲委派模型2.1 JVM内置的三层类加载器JVM自带的类加载器分为三层自顶向下分别是启动类加载器Bootstrap ClassLoader、平台类加载器Platform ClassLoaderJDK 9之前叫扩展类加载器Extension ClassLoader、应用类加载器App ClassLoader。启动类加载器负责加载JAVA_HOME/lib目录下的核心类库常见如rt.jar里的java.lang.*、java.util.*。这个加载器不是由Java代码实现的而是JVM自身的一部分在HotSpot里对应C语言实现。所以在Java代码层面String.class.getClassLoader()结果为null这就是原因。平台类加载器负责加载一些JVM平台相关的类JDK 8时代的扩展类加载器加载JAVA_HOME/lib/ext下的jar包从JDK 9模块化之后平台类加载器配合模块系统工作加载java.sql、java.xml等模块。应用类加载器负责加载ClassPath环境变量指定的类库也是我们平时代码里默认使用的加载器。在实际生产中用户自己的类绝大多数是由App ClassLoader加载的。2.2 双亲委派为什么明明叫双亲却只有一个爹双亲委派模型的核心规则是如果一个类加载器收到了类加载请求它首先不会自己去尝试加载这个类而是把请求委派给父加载器完成每一层都是如此。只有当父加载器反馈自己无法完成加载它的搜索范围找不到这个类时子加载器才会尝试自己去加载。这个双亲的说法其实是从英文Parents翻译过来的这里的双并不是指两个父亲而是泛指上级加载器。现代JVM里三层加载器通过组合关系形成父子链App ClassLoader的父是Platform ClassLoaderPlatform ClassLoader的父是Bootstrap ClassLoader。从JVM源码来看这里并不是传统面向对象意义上的继承关系而是通过parent字段保存了父加载器实例。为什么非要这样设计最直接的动机是避免核心类被篡改。举个例子如果用户自己写了一个java.lang.String类并且放在ClassPath里没有双亲委派机制的话这个自定义String很可能被App ClassLoader加载导致JVM里出现一个假的String类引发各种安全隐患。而有了双亲委派加载java.lang.String时最终会由Bootstrap ClassLoader完成用户写的那个同名类根本没有机会被加载。这个机制本质上就是信任链的思想JVM只信自己原生提供的核心类其余类一律层层上报。2.3 双亲委派被破坏的三种经典场景双亲委派不是铁律只是推荐的实现方式。JDK里至少有三类场景主动破坏了这个模型这也是理解类加载器的进阶点。第一类是SPI机制Service Provider Interface。JDBC的DriverManager由Bootstrap ClassLoader加载但具体的JDBC驱动实现类是第三方jar包由App ClassLoader加载。按照双亲委派Bootstrap ClassLoader根本看不见这些第三方类。解决方式是引入线程上下文类加载器Thread Context ClassLoaderDriverManager通过它反向获取到App ClassLoader再加载具体驱动实现相当于把一个加载请求从最顶层空降到最底层。第二类是容器类应用典型代表是Tomcat。多个Web应用部署在同一容器里每个应用可能需要不同版本的Spring或Servlet API。Tomcat给每个Web应用创建独立的WebAppClassLoader打破了传统的父子委派链优先加载Web应用WEB-INF/classes目录下的类然后再委派给父加载器。这样才能实现应用隔离否则两个应用用不同版本的库就要打架。第三类是热部署与热替换。JRebel、Spring Boot DevTools、OSGi这些技术本质上都是通过自定义类加载器在内存里保留新旧两个版本的同名类用新加载器加载新版本再把请求转发过去。这里有一个关键点两个同名但不同加载器加载的类在JVM中被视为完全不同的类即使它们的字节码完全一致。这种类身份类本身定义它的加载器的规则是热部署和模块隔离的基础。3. 拆解类加载全过程的每个细节3.1 加载阶段字节码从哪来、怎么存加载阶段很多人只盯着ClassLoader。其实首先要回答的是二进制字节流从哪里来最常见的来源是文件系统里的.class文件其次是jar包但现代框架远不止于此Spring Boot Fat Jar里的嵌套jarJava Agent在运行时生成的字节码甚至网络传输中的字节数组都可以作为字节流来源。ClassLoader#defineClass方法接收的就是一个字节数组也就是说加载阶段的核心工作其实是把二进制字节流传给JVM内部校验并构建类元数据模型。字节流进入JVM后会被解析成方法区的类元数据。HotSpot里这块还有一个容易踩坑的点类的元数据存储在Native Memory区域的Metaspace中在JDK 8以前则是堆内存里的永久代PermGen。永久代会因为元数据累积导致java.lang.OutOfMemoryError: PermGen space而Metaspace默认使用系统可用内存但如果不设上限类加载器泄漏时干脆撑爆整个进程。我后面会展开讲这个泄漏场景。加载结束后JVM在堆中创建一个java.lang.Class实例。这里有个容易被忽略的细节类的加载是线程安全的。同一时刻多个线程同时请求加载同一个类JVM会保证只有一个线程真正执行加载动作其余线程等待结果。这个保证在ClassLoader.loadClass()内部实现通过synchronized加锁机制完成。3.2 连接阶段验证、准备、解析的三步走验证阶段是JVM安全的第一道防线主要做四类检查文件格式验证、元数据验证、字节码验证、符号引用验证。文件格式验证保证字节流能解析成符合规范的ClassFile结构元数据验证保证类与类之间语义正确比如一个类不能同时继承两个父类字节码验证最复杂通过数据流分析确认操作数栈和局部变量表使用安全符号引用验证保证引用的类、字段、方法真实存在且有访问权限。准备阶段给类变量static字段分配内存并设置零值。这里有个几乎所有面试官都爱问的细节在准备阶段static int value 123中的value被设置为0而不是123。123这个字面量要等初始化阶段的clinit执行putstatic指令才会赋值。但如果是static final int value 123准备阶段直接就会赋值为123因为常量不需要后面再计算。如果是static final String name abc这种编译期常量同理。解析阶段是把常量池里的符号引用替换为直接引用。符号引用其实就是字符串字面量比如类的全限定名、字段名和描述符直接引用则是指向目标对象实际内存地址的句柄或指针。解析动作可能发生在类初始化之后一个典型场景是类A的方法里调用了类B的方法但B在A初始化时还没准备好JVM会先完成初始化在首次执行调用指令时才解析对应的方法引用。3.3 初始化阶段触发时机与被动引用初始化阶段执行clinit方法主动触发时机有六种遇到new、getstatic、putstatic、invokestatic四条字节码指令之一使用反射调用类时初始化子类时要求先初始化父类JVM启动时初始化含main方法的主类JDK 7开始支持动态语言时java.lang.invoke.MethodHandle解析结果为某个类的方法句柄时JDK 8接口默认方法时如果某个接口实现类被初始化接口也要初始化。与之对应被动引用不会触发初始化至少有三种经典场景通过子类引用父类的静态字段此时只初始化父类不初始化子类通过数组定义来引用类比如new CustomClass[10]只会创建一个数组类对象不会触发CustomClass的初始化引用编译期常量时比如static final String MSG hello常量在编译期就被写入了调用类的常量池整个类初始化过程根本不会执行。理解这六次触发和三次不触发的场景对排查线上问题很有价值。我遇到过几次诡异的类没初始化现象业务代码里调用了一个工具类的静态常量但这个工具类的static块迟迟没执行排查半天才发现那个常量被final修饰后在编译期就已经被内联到调用方类压根没被加载。3.4 类的卸载与生命周期类的生命周期和对象不同。一个对象没有引用后被GC回收这个大家都知道但一个类只有同时满足三个条件才会被卸载该类所有的实例都已被回收、该类的Class对象没有任何地方被引用、加载这个类的ClassLoader实例已经被回收。这意味着什么如果是Bootstrap ClassLoader加载的核心类几乎永远无法卸载因为它们对应的ClassLoader是JVM本身压根不会被回收。自定义ClassLoader加载的类则有可能被卸载。在Metaspace里被卸载类占用的元数据空间会被释放。这个机制是热部署和热替换能够长期运行的底层支撑。实际开发中有一个反直觉的现象通过ClassLoader泄漏导致OOM时不是类卸载不了而是整个ClassLoader实例根本回收不了。比如某个框架在静态字段里持有了ClassLoader的引用或者ClassLoader加载的类实例被长期持有的对象引用着就会导致整个依赖链上的类都无法卸载。后面我在第5部分专门讲这个坑的排查思路。4. 实操如何真正看见类加载过程4.1 用JVM参数记录每一个加载动作理论讲再多不如动手观察一次。最直接的方法是启动一个Java程序时加入-verbose:class参数它会在控制台打印所有类加载动作格式长这样[Loaded java.lang.Object from rt.jar] [Loaded com.example.DemoApplication from file:/Users/xxx/target/classes/]如果你想更精细地观察可以加-XX:TraceClassLoading和-XX:TraceClassUnloading前者记录所有加载动作后者记录类卸载。生产环境不建议开TraceClassLoading因为输出量巨大会严重影响性能。我一般在本地调试或者写DEMO验证时才开。一个实用的组合是在启动参数里打印类加载器层级关系java -XX:TraceClassLoading -verbose:class -cp target/classes com.example.DemoApplication发布前如果想确认第三方依赖里某个类到底会被哪个加载器加载或者想知道某个日志组件的class是从哪个jar包里来的可以配合-verbose:class输出再结合jar包扫描工具定位。线上环境我更喜欢用jcmd和Arthas不需要重启应用。4.2 用Arthas和jcmd观察运行时类加载器状态线上排查类加载问题第一选择是Arthas。用classloader命令可以查看当前JVM中所有类加载器的层级、加载类数量、以及每个加载器下有哪些类classloader -t-t参数会以树形结构展示类加载器的父子关系。如果想查看某个类是从哪个jar包加载的用sc -d命令sc -d com.example.UserService输出里会包含类加载器、代码来源CodeSource等关键信息一眼就能看出ClassPath冲突的问题。另外还有一个非常实用的场景排查多个jar包里存在同名类导致运行时加载的版本不对。这时候用sc查看实际加载的jar路径就能直击问题。jcmd也是JDK自带的利器。JDK 9以上可以直接jcmd pid VM.class_hierarchy jcmd pid VM.classloader_statsVM.classloader_stats会输出每个类加载器的实例个数、加载的总类数是排查类加载器泄漏的快速晴雨表。如果某个自定义ClassLoader的实例数量在线上不断增长几乎可以断定存在泄漏。4.3 手写一个自定义类加载器自定义类加载器是实现热部署、插件化、字节码加密解密的基础。核心只需要继承ClassLoader并重写findClass方法。一个最常见的模板是这样public class MyClassLoader extends ClassLoader { private String classPath; public MyClassLoader(String classPath) { this.classPath classPath; } Override protected Class? findClass(String name) throws ClassNotFoundException { String fileName classPath / name.replace(., /) .class; try { byte[] bytes Files.readAllBytes(Paths.get(fileName)); return defineClass(name, bytes, 0, bytes.length); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } }这里的逻辑极简根据类名找到文件位置读出字节数组调用defineClass把字节流转成Class对象。有个关键细节是自定义加载器应该重写findClass而不是loadClass。因为loadClass方法里已经实现了双亲委派逻辑默认先让父加载器尝试父加载器加载不到时再回调findClass。如果你直接重写loadClass又不调用父加载器方法就会破坏双亲委派造成核心类被重复加载或类冲突。实战中我写过一个带解密逻辑的类加载器从加密的class文件中读取字节流先解密再交给defineClass。这种方案的初衷是为了防止代码被轻易反编译也能在jar包被篡改时快速失败。每次加载类都动态计算校验和并做完整性校验在银行、金融类项目里比纯混淆方案靠谱。5. 高频故障与排查经验5.1 ClassNotFoundException 和 NoClassDefFoundError 有什么区别这两个异常长相相似但根因完全不同。ClassNotFoundException是一个受检异常出现在显式加载类时比如Class.forName(className)、ClassLoader.loadClass()、ClassLoader.findSystemClass()。它的含义是要加载的类根本找不到通常是ClassPath里缺少对应的jar包或者类名写错了。NoClassDefFoundError是一个Error比异常级别更高。它的场景是这个类在编译期存在但在运行时缺失了。常见触发路径是类A的初始化代码中抛出了异常导致初始化失败之后JVM再次引用A的时候就不会再重新初始化而是直接抛出NoClassDefFoundError或者某个类的依赖类在运行时被GC卸载但实例还在使用。从排查角度看定位ClassNotFoundException相对直接先检查ClassPath、检查jar包是否打进去、检查类名是否写全、检查使用哪个类加载器加载。定位NoClassDefFoundError要难不少需要结合日志看类初始化之前那个真正的异常是什么经常是static块抛空指针或者加载器回收没释放。5.2 依赖冲突与类重复加载依赖冲突是大型项目里最经典的类加载问题。多个jar包引入了同一个库的不同版本时Maven的依赖仲裁会选一个版本但实际上ClassPath里可能还是同时存在多个版本的class。真正被加载哪个版本取决于ClassLoader的搜索路径顺序。这时候用Arthas的sc -d查实际加载的jar路径是最快的方式。类重复加载的问题更隐蔽。有些框架自己实现了ClassLoader但没有做严格的父子委派导致同一个类被同一个加载器加载多次或者被不同加载器各加载一次。JVM会把这些同名类当成完全不同的类型处理。表现就是instanceof判定为false、ClassCastException、静态变量出现多份副本。排查时优先打印每个关键对象的getClass().getClassLoader()确认是不是同一个加载器。5.3 类加载器导致的内存泄漏这个是我在实际项目中踩过的最深的坑之一。场景是这样的分布式调度平台在不停重启任务每次重启都会创建一个新的ClassLoader来加载任务类任务执行完释放。本来是标准的热加载套路但上线几个月后应用直接OOM。排查思路分三步先用jcmd VM.classloader_stats确认自定义ClassLoader实例数量是否持续增长再用jmap -dump导堆快照用MAT分析重复Class对象倒灌最终定位到任务框架的一个静态Map里缓存了任务执行上下文而这个上下文持有ClassLoader引用导致每次任务运行后ClassLoader都无法被回收。这类问题想靠System.gc()解决是徒劳的关键是从根上切断引用链。修复方案通常是任务上下文用完立即清理、不把上下文放进静态集合、或者使用弱引用。另外Metaspace本身也常被坑——很多人设置了-XX:MaxMetaspaceSize但还是OOM就是因为没意识到类加载器泄漏时Metaspace会一路涨到进程崩溃。6. 关于类加载的几个高频问题补充分享6.1 线程上下文类加载器和SPI机制有不少朋友对线程上下文类加载器Thread Context ClassLoader一知半解面试时只会背概念。我先解释清楚它解决的问题当高层类加载器加载的代码需要调用底层类加载器范围内的实现时双亲委派就失效了。JVM提供的解法是每个线程都有一个contextClassLoader默认是App ClassLoader。像ServiceLoader、DriverManager这些核心API在查找服务实现时不通过自己的加载器而是通过当前线程的上下文加载器去加载。用代码来形象展示一下这个切换动作ClassLoader old Thread.currentThread().getContextClassLoader(); Thread.currentThread().setContextClassLoader(targetClassLoader); try { // 这里执行的业务代码使用 targetClassLoader 加载类 } finally { Thread.currentThread().setContextClassLoader(old); }这其实是一种剥夺式加载它的父加载器主动把加载权下放给子加载器。JDBC场景中DriverManager属于java.sql模块由Platform ClassLoader加载但它要加载的MySQL驱动是第三方jar在应用ClassPath里只能靠线程上下文加载器中转。如果你的应用在特殊环境里发现DriverManager.getConnection找不到驱动优先检查线程上下文加载器是否在某个地方被篡改成不合理的值。6.2 Tomcat为什么打破双亲委派实现Web应用隔离Tomcat是我认为最能展示类加载器工程价值的一个中间件。它默认的WebAppClassLoader不再优先请求父加载器而是先加载WEB-INF/classes和WEB-INF/lib下的类只有找不到时才委派给父加载器。这里唯一保留双亲委派原则的是JVM核心类比如java.lang.String这种还是必须由Bootstrap加载。这么做的原因非常现实第一一个Tomcat里部署多个Web应用它们可能使用不同版本的Spring、不同版本的日志库如果不隔离全局ClassPath里就只有一个版本能活下来另一个必然报错第二Web应用通常需要支持热部署修改class文件后要能快速创建新的类加载器重新加载整个应用而不影响其他应用。理解了Tomcat的取舍再看OSGi就顺理成章了。OSGi把双亲委派模型改成了更复杂的伴侣加载器机制每个Bundle有自己的类加载器类可以相互导入导出。它的思路比Tomcat更彻底但复杂度和性能代价也更大这也是为什么OSGi在企业应用中逐渐边缘化而Tomcat这种局部打破的方案长期占据主流。在我个人经验里学习类加载子系统最大的收获不是背会了双亲委派那张图而是养成了遇到诡异问题先问问是哪个类加载器加载的的习惯。线上很多莫名其妙的问题比如同样的代码在不同环境表现不同、同一类出现两套静态变量、强制转换抛异常根子都在这个底层机制上。遇到这类问题别急着改业务逻辑先用Arthas看加载器层级再查ClassPath里的重复jar往往能比瞎试配置更快定位。