尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JVM类加载机制详解:双亲委派模型、ClassLoader体系与面试实战指南

JVM类加载机制详解:双亲委派模型、ClassLoader体系与面试实战指南 干Java这行的不管你是刚毕业准备面试还是已经写了三五年业务代码JVM类加载机制迟早是你绕不过去的一道坎。很多人一开始觉得这东西很虚不就是按需加载吗双亲委派不就是往上抛吗背下来就完了。可等你真的遇到NoClassDefFoundError、遇到自定义ClassLoader、遇到热部署导致Metaspace溢出的时候才知道当年没吃透的东西全都在线上等你。这篇文章我就把JVM类加载机制从头到尾捋一遍重点讲清楚三件事双亲委派模型为什么这么设计ClassLoader体系到底怎么分工以及当面试官追问如何打破双亲委派时你到底该从哪个角度切入。文章不会只停留在概念层面我会把源码逻辑、常见异常链路、自定义ClassLoader的完整代码、以及跟Metaspace内存相关的排查经验都放进来。适合正在准备JVM面试的人也适合工作中遇到类加载异常却没头绪的人。1. 先搞懂设计意图JVM为什么要动态加载类1.1 不是所有类都在启动时一股脑加载很多初学者以为JVM启动的时候会把整个项目所有类都加载进内存这是一个非常普遍的误解。实际上JVM对类的加载是极其抠门的只有在一个类被真正使用到的时候它才会被加载进内存。这个使用到的时机JVM规范里有一个明确的原则只有当类需要进行初始化的时候才会触发类的加载、验证、准备、解析、初始化这整个链路。这样的设计有几个明显的好处。第一是启动速度一个大型微服务项目可能有几万个类如果全部在启动时加载启动时间会变成不可接受的分钟级。第二是内存占用很多类是条件分支里才会用到的如果无脑全加载内存会被大量永远不会执行的代码占住。第三是灵活性动态加载是热部署、插件化、动态代理这些高级特性的基础底座。这里我多说一句很多人把加载和初始化混为一谈这是面试里很容易被追问的细节。类加载机制里加载只是把class文件的二进制字节流读进来生成一个Class对象而初始化是执行类的静态代码块和静态变量赋值逻辑。JVM规范划定了六种主动使用场景会触发初始化new实例、访问静态字段、调用静态方法、反射、初始化子类时父类未初始化、以及作为启动入口类。而通过数组定义引用类、访问编译期常量、通过父类引用子类调用被子类覆写的方法这些被动使用场景都不会触发初始化。1.2 类的一生从字节流到可实例化JVM类加载机制总共分成五个阶段加载、验证、准备、解析、初始化。前四个阶段加上最后的卸载构成一个类的完整生命周期。这里有一个容易混淆的点加载和加载机制不是一个概念那个后面细说这里先看生命周期。加载阶段要做的事可以概括为三个步骤通过类的全限定名获取定义此类的二进制字节流把字节流中的静态存储结构转化为方法区的运行时数据结构在Java堆中生成一个代表这个类的Class对象作为方法区数据的访问入口。验证阶段很多人不理解为什么要做觉得即使不做也不影响功能。但你要知道class文件并不一定是Java源码编译出来的它可以是任何十六进制字节流拼出来的。如果JVM不验证就把字节码直接执行恶意代码完全可以构造一个伪造的class文件攻破虚拟机。验证工作包括文件格式验证、元数据验证、字节码验证和符号引用验证四层其中最核心的是字节码验证它通过数据流和控制流分析确保字节码指令不会操作错误类型的数据、不会跳转到错误位置、不会违反语言规范。准备阶段是为类的静态变量分配内存并设置初始值。注意这个阶段设置的初始值是指类型的零值比如int是0、boolean是false、引用类型是null而不是代码里写的初始值。但有一个特例如果静态字段被final修饰那么在准备阶段就会直接赋上代码里写的值因为JVM规范允许编译器把ConstantValue属性打包在字段表里。解析阶段是把常量池中的符号引用替换为直接引用的过程。符号引用是一组用字符串描述目标的字面量直接引用则是已经可以定位到目标内存位置的指针或偏移量。这里有个细节值得记住HotSpot虚拟机中解析动作并没有严格被限制在初始化之前执行某些情况下可以推迟到字节码指令真正执行时再解析这是为了支持Java的动态绑定特性。初始化阶段是整个加载过程的临门一脚这个时候才开始执行类中定义的Java代码静态变量赋值语句和静态代码块。编译后它们会汇总到clinit()方法中这个方法是JVM自动生成的不需要你在代码里调用JVM会保证在多线程环境下对clinit()的执行加锁同步保证一个类的初始化只发生一次。为了让你一眼看明白我整理一个阶段对照表阶段核心动作主要产出加载读取二进制字节流生成Class对象方法区数据结构 堆中Class对象验证检查字节流语义安全通过校验的class数据准备静态变量分配内存并设零值静态变量的内存空间解析符号引用转为直接引用可直接访问的内存指针/句柄初始化执行clinit()静态赋值与静态代码块类的完整可用状态2. 双亲委派模型JVM里最有名的责任链2.1 双亲委派到底是怎么转的双亲委派模型是JVM类加载机制里最核心的一个设计也是面试必问。它的工作流程看似简单理解起来却有不少弯弯绕绕。先说结论当一个类加载器收到类加载请求时它不会自己先去尝试加载这个类而是把这个请求委派给父类加载器去完成。每一层的类加载器都是如此因此所有的加载请求最终都会传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求时子加载器才会尝试自己去加载。注意一点这里的双亲并不是两个父亲而是指每一个类加载器都有一个父加载器这个父子关系不是通过继承实现的而是通过组合方式维护的父类加载器实例引用。所以整个模型是一个层级递进的树状结构。光看文字不好理解我强烈建议你直接看一遍ClassLoader.loadClass的源码这个方法是整个双亲委派模型的载体。核心逻辑是这样的protected Class? loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 第一步检查类是否已经加载过 Class? c findLoadedClass(name); if (c null) { try { // 第二步父加载器存在则委派给父加载器 if (parent ! null) { c parent.loadClass(name, false); } else { // 没有父加载器说明当前是启动类加载器 c findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器抛异常说明父加载器无法完成加载请求 } if (c null) { // 第三步父加载器没有加载到子加载器自己加载 c findClass(name); } } if (resolve) { resolveClass(c); } return c; } }这段代码信息量很大。注意它第一步是调用findLoadedClass做缓存检查说明类的加载是有缓存机制的同一个类加载器对同一个全限定名的类只会加载一次。第二步是整个双亲委派的核心parent.loadClass(name, false)这里的false表示不触发类的解析。第三步只有在父加载器抛出ClassNotFoundException或者最终返回null的时候子加载器才调用自己的findClass(name)去真正加载。一定要理解这里面的顺序先查缓存、再向上委派、最后才自己加载。这个顺序保证了同一个类在全系统中只会被加载一份至少在没有人为打破双亲委派的情况下是这样。2.2 双亲委派解决的核心问题很多人会问设计这个模型到底图什么答案可以概括成两点安全性和唯一性。安全性体现在防止核心API被篡改。你想一下如果用户可以自定义一个名为java.lang.String的类而且不经过双亲委派模型直接加载那JVM里的String类就可能被替换成任意实现整个Java体系就崩了。有了双亲委派模型加载java.lang.String的请求最终一定会被交给启动类加载器处理由它加载JDK自带的rt.jar中的String类。这样不管应用层怎么写核心类库永远不会被覆盖。唯一性体现在类被加载的路径上。由于每一层的加载请求都先向上传递最终同一个类在全JVM范围内只会被加载一次不会出现内存中同时存在两个不同来源的相同类的情况。因为JVM判断两个类是否相同的依据是类的全限定名 定义它的类加载器实例如果不同加载器各自加载了一份这两个类就算是二进制字节完全相同在运行时也不是同一个类互相强制转换会抛ClassCastException。用一个生活化的比喻你是一个项目经理想申请一台服务器流程是你先报给你的技术主管技术主管再报给CTO最后CTO去找CEO批。如果CTO说这事他决定不了才会逐级下放给你自己处理。你永远不会跳过上级直接找到更高层或者自己拍板。这样审批路径清晰也避免同一件事被多个人重复批准。2.3 破坏双亲委派其实不是背叛面试官几乎一定会问你了解破坏双亲委派吗什么时候需要破坏这里的关键是破坏双亲委派不等于推翻整个模型而是在特定场景下加载流程要绕开先父后子的默认顺序。第一个经典场景是JDBC的SPI机制。JDBC的核心接口定义在java.sql包下由启动类加载器加载。但具体驱动实现比如MySQL的驱动jar包在应用的classpath里启动类加载器根本看不到。标准的双亲委派模型下应用类加载器加载com.mysql.cj.jdbc.Driver是没问题的问题是DriverManager在启动类加载器的命名空间里它要去找驱动实现类时根本加载不到。为了解决这个问题JVM引入了线程上下文类加载器。DriverManager使用Thread.currentThread().getContextClassLoader()来获取应用类加载器再通过它完成对SPI实现类的加载。这其实是打破了双亲委派的层级关系让上层的类可以反向调用下层的类加载器。第二个典型场景是Tomcat等Web容器。Tomcat需要为每个Web应用维护独立的类加载器因为不同应用可能依赖同一个类的不同版本而双亲委派模型下父加载器加载过的类子加载器不会重复加载这会导致版本冲突。Tomcat的WebAppClassLoader打破了这个规则它优先自己加载WEB-INF/classes和WEB-INF/lib下的类加载不到时才委派给父加载器。这样每个应用都有自己独立的类空间。第三个场景是OSGi和代码热更新。OSGi甚至把双亲委派发展成了网状的类加载架构每个模块有独立类加载器模块之间通过导入导出机制协作。热部署的原理也类似用一个新的类加载器加载同一份class文件就能得到一个全新的Class对象旧的类加载器可以被回收从而实现类的替换。3. ClassLoader体系全景与自定义实战3.1 三兄弟启动、扩展/平台、应用加载器JVM内置了三层类加载器从上到下依次是启动类加载器、扩展类加载器JDK9后改名为平台类加载器、应用类加载器。启动类加载器是三层结构的最顶层由C实现是JVM自身的一部分。它负责加载JAVA_HOME/lib目录下的核心类库比如rt.jar、resources.jar还负责加载-Xbootclasspath参数指定的类。这个加载器在Java代码中取不到引用你打印会得到null这也是面试里一个经典小坑String.class.getClassLoader()返回null不是没有类加载器而是启动类加载器是C实现的Java层看不到。扩展类加载器在JDK8中负责加载JAVA_HOME/lib/ext目录下的类库JDK9引入模块化系统后它被改名平台类加载器职责也变成只加载JDK自身模块化后的平台类不再从ext目录加载任意扩展包。应用类加载器也叫系统类加载器负责加载classpath下的所有类。它是我们写代码时接触最多的类加载器ClassLoader.getSystemClassLoader()返回的就是它。我们自己写的业务类绝大多数情况下都是由它直接或间接加载的。在双亲委派模型里这三层加载器的父子关系是应用类加载器的父加载器是扩展/平台类加载器扩展/平台类加载器的父加载器是启动类加载器。3.2 类找不到时的异常链路分析类加载相关的异常在实际开发中非常常见但很多人分不清ClassNotFoundException和NoClassDefFoundError的区别面试官也很喜欢拿这两个问。ClassNotFoundException是一个受检异常意思是根据类的全限定名找不到对应的class文件。触发它的常见原因包括class文件不在classpath里、包的路径和代码声明的包名不一致、依赖的jar包没有打包进来。它的排查思路很直接先确认类是否存在于classpath再看类加载器的加载范围是否正确。NoClassDefFoundError就绕一些了它是一个Error不是Exception。这个错误的本质是类在编译期存在但运行期加载失败而且往往是某个类的静态初始化过程中抛了异常或者某个类依赖的另一个类在运行时缺失。我踩过的一个典型的坑是项目里用到了commons-lang3的某个工具类编译没问题但部署时lib目录里漏了jar包启动直接NoClassDefFoundError。还有一种情况是类本身在加载时依赖了某个加载不到的资源第一次加载失败后JVM会标记该类为不可用后续再引用它还是继续抛这个错误。还有一个高频问题是LinkageError。这个错误的经典场景是jar包冲突比如同一个类在多个jar包里以不同版本存在类加载器在加载时会遇到不兼容的字节码。排查手段通常是借助java -verbose:class或-XX:TraceClassLoading参数把类加载过程打印出来看目标类到底是从哪个jar包里加载进来的。3.3 手写一个自定义ClassLoader一点都不难理解ClassLoader体系最直接的方式是自己写一个。自定义ClassLoader的核心要点是重写findClass方法而不是loadClass方法。因为loadClass里已经实现了双亲委派逻辑直接重写它会破坏掉模型而findClass是loadClass最后一步调用的钩子方法重写它并不会影响委派流程。下面我用一个实际场景演示从指定文件目录加载class文件这在开发私有插件系统、做简单热更新时非常实用。import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; public class FileClassLoader extends ClassLoader { // class文件所在的根目录 private final String classPath; public FileClassLoader(String classPath) { // 让父加载器默认使用应用类加载器 super(); this.classPath classPath; } Override protected Class? findClass(String name) throws ClassNotFoundException { // 把包路径转换为文件路径 String fileName classPath name.replace(., /) .class; Path filePath Paths.get(fileName); try { byte[] classBytes Files.readAllBytes(filePath); // defineClass 把字节数组转换为Class对象 return defineClass(name, classBytes, 0, classBytes.length); } catch (IOException e) { throw new ClassNotFoundException(无法从 fileName 加载类, e); } } }这段代码的核心是defineClass方法它是ClassLoader提供的模板方法负责把byte数组转换成Class对象。整个流程就是三步先由loadClass走双亲委派逻辑父加载器加载不到时进入findClass最后由findClass内部调用defineClass完成类的正式定义。使用这个类加载器时要注意几个细节。第一如果类加载后还要执行静态初始化必须在加载后显式调用Class.forName(name, true, classLoader)来触发初始化。第二同路径下如果class文件被修改直接用同一个类加载器实例再次加载是没有效果的因为findLoadedClass已经有缓存了必须新建一个类加载器实例才能实现热替换。这也是为什么热部署工具的本质就是新ClassLoader。4. 与面试和调优强相关的排查实录4.1 类加载与Metaspace内存的关联很多做JVM调优的人会在不经意间碰到Metaspace溢出排查到最后发现根源是类加载器没有释放。这里要理清一个概念类的元数据包括类的结构信息、方法字节码、常量池等是存在Metaspace里的而Metaspace是方法区在HotSpot中的实现。常见的Metaspace溢出场景是频繁热部署。每次热部署都会新建一个类加载器重新加载类如果旧的类加载器无法被GC回收它加载过的所有类元数据就会一直占着Metaspace。旧类加载器为什么回收不掉根本原因是它的对象实例还被某些业务代码引用着比如静态变量持有类加载器引用或者线程上下文类加载器没有被重置。排查这类问题我建议按这个步骤走用jstat -gcutil pid看Metaspace的使用率是不是持续上涨。用jcmd pid GC.class_stats统计每个类加载器加载的类数量。用jmap -dump导出堆快照用MAT分析java.lang.ClassLoader实例的引用链。调优层面合理设置-XX:MetaspaceSize和-XX:MaxMetaspaceSize是基本操作。前者是触发类元数据回收的初始阈值后者是Metaspace上限。网上很多资料强调调大MaxMetaspaceSize但我个人更建议先定位是谁在源源不断地加载新类盲目调大只是把问题往后推而且这个上限也直接关系到堆内存耗尽的风险因为元数据空间不够时会触发Full GC频繁Full GC会导致应用卡顿。平时在监控里看到Metaspace接近上限配合类加载器的存活数量基本就能锁定是不是热部署场景下的类加载器泄漏。4.2 关于类加载的高频面试题怎么答这块我结合这么多年面试和被面的经验整理几个出现频率最高的题以及我认为能够加分的回答方向。第一题什么是双亲委派模型回答时先讲层级结构再讲请求传递流程最后一定要落到两个核心目的上避免核心类被篡改、避免相同类被重复加载。缺失最后一步回答就不完整。第二题双亲委派模型有什么缺点这个问题很多人没想过。它的缺点是父加载器加载的类无法反向调用子加载器加载的类导致JDK核心类库无法调用应用层实现。SPI就是为了解决这个矛盾才出现的。这样答面试官就知道你不仅背了概念还理解了模型边界。第三题如何打破双亲委派模型答三个层次重写loadClass方法直接改变加载流程通过SPI机制使用线程上下文类加载器在容器环境下设计独立类加载器典型如Tomcat。如果你能补一句热部署的本质就是新建类加载器这题基本就满分了。第四题能不能自己写一个java.lang.String类要分情况回答。如果使用的是标准类加载器双亲委派会保证String由启动类加载器加载你的自定义版本永远不会被加载。但如果你自定义类加载器并重写loadClass方法绕开双亲委派同时不把请求传给父加载器确实可以加载一个同名但完全不同的String类。不过这样做的后果是系统中会出现两个不同的String类业务代码里互相赋值就会ClassCastException。这个问题考察的就是你对类相同性的理解全限定名类加载器共同决定唯一性。第五题String.class.getClassLoader()为什么返回null因为String由启动类加载器加载启动类加载器由C实现Java代码无法获得其引用所以返回null。注意不要回答它没有类加载器。4.3 一次生产环境NoClassDefFoundError排查实录我拿一个真实案例来讲排查过程。有一次线上应用启动时报NoClassDefFoundError内容指向某个内部公共库的类但从classpath和编译日志看这个类确实存在。我第一反应是去确认是不是jar包冲突。执行java -verbose:class过滤目标类名结果发现类确实被加载了但加载路径来自另一个旧版本的jar包。这个旧版本里没有目标方法导致字节码验证阶段出现LinkageError而应用层抛出的表现就是NoClassDefFoundError。问题根源是构建工具在打包时把两个版本的依赖都带上了classpath顺序决定了旧版本被优先使用。处理方式是排除掉传递依赖里的旧版本统一使用公共库的新版本。这里我要给个建议遇到NoClassDefFoundError不要在业务代码层面死磕先用-verbose:class或者-XX:TraceClassLoading把类的加载路径打印出来再确认加载到的版本是不是你期望的版本。线上排查时不方便加JVM参数重启可以用jcmd pid VM.command_line确认已有参数或者用Arthas的sc -d指令查看类的实际加载路径和ClassLoader信息。Arthas的classloader命令还可以直接查看线程上下文类加载器对排查SPI加载问题特别有用。再分享一个小的排查技巧如果应用出现了大量不同类都报ClassNotFoundException的情况优先检查是不是某个类加载器scope的问题尤其是用到了Java Agent或者自定义ClassLoader的框架比如字节码增强工具它们对类加载器命名空间非常敏感。这种时候不要一个个类去核对直接看ClassName和ClassLoader的对应关系从框架层切入往往能快速定位。我在实际工作中还有一个体会是类加载相关的很多问题在本地环境无法复现因为我们本地IDEA启动时classpath是IDE计算的而线上服务器上classpath可能因为脚本拼装方式不同、通配符展开顺序不同导致加载结果不一样。所以遇到类加载异常时先确认线上环境的classpath和本地一致这一步看似简单却常常能省下好几个小时的排查时间。踩过几次坑之后我才真正意识到类加载机制不是面试前背几天就能应付的它跟线程上下文、内存泄漏、jar包冲突这些问题深度绑定。不写一次自定义ClassLoader不排查一次NoClassDefFoundError很难把这些知识点串成一条线。希望你看完这篇文章后能自己动手写一个类加载器再用-verbose:class参数观察一下应用启动时类的加载顺序。当你亲手看到那些熟悉的类一行行被加载出来的时候这一章的知识点才算真正长在你脑子里了。
返回列表