ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM夯实之路——类加载机制与双亲委派模型深入解析

JVM夯实之路——类加载机制与双亲委派模型深入解析 JVM夯实之路——类加载机制与双亲委派模型深入解析上一篇我们分析了 Class 文件的结构。Java 源代码经过编译以后会形成.java ↓ javac ↓ .class但是.class文件本身仍然只是磁盘上或者其他数据源中的一段二进制数据。它想要真正被 Java 程序使用还必须经过一个非常重要的过程Class Loading类加载。JVM 会把 Class 文件中的二进制信息加载到内存并进一步完成验证、准备、解析和初始化最终形成 JVM 可以真正使用的 Java 类型。因此可以先把整个过程简单理解成Class File ↓ Loading ↓ Linking ↓ Initialization ↓ Java Type ↓ 程序使用一、什么是类加载机制Class 文件只是静态存在的二进制数据。只有经过 JVM 的加载和处理它才能真正参与程序运行。在这个过程中JVM 会完成读取 Class 二进制数据 ↓ 验证是否合法 ↓ 建立运行时数据结构 ↓ 解析符号引用 ↓ 执行初始化逻辑 ↓ 形成 JVM 可以使用的 Java 类型Java 的一个重要特点是很多类型的连接和初始化工作都发生在运行期间。原笔记将这种运行期机制与 Java 中的反射、动态代理、热部署和 OSGi 等技术联系起来。Class 的二进制数据来源也并不局限于磁盘中的一个.class文件。原笔记中列出的来源还包括JAR / WAR / EAR 网络 运行期间动态生成 JSP 编译结果 数据库 加密文件二、一个类的完整生命周期一个类从进入 JVM 到最终被卸载会依次经历多个阶段Loading ↓ Verification ↓ Preparation ↓ Resolution ↓ Initialization ↓ Using ↓ Unloading其中Verification Preparation Resolution Linking也就是连接阶段。因此更完整的结构可以表示成Linking ┌───────────────┐ Loading → Verification → Preparation → Resolution ↓ Initialization ↓ Using ↓ Unloading需要注意的是原笔记指出加载、连接和初始化的开始顺序是固定的但 Resolution 可以采用延迟方式在初始化之后再执行。Loading Verification Preparation Resolution Initialization Using Unloading这部分即可。不要把上面上一篇文章中的access_flags内容一起截进去。三、什么时候会初始化一个类并不是 JVM 看到一个类以后就一定立刻执行它的初始化逻辑。原笔记强调只有主动使用一个类时才会触发初始化。原笔记列出了六类典型情况。四、new 一个对象最直观的情况就是创建对象new MyClass();当程序真正创建某个类型的实例时就属于对这个类的主动使用。五、访问类的静态字段例如System.out.println(MyClass.value);如果真正使用了某个类定义的静态字段也可能触发对应类的初始化。不过原笔记特别排除了编译期final常量。这类情况后面还会再次出现。六、调用静态方法调用一个类的静态方法例如MyClass.test();同样属于主动使用类的场景。七、通过反射使用类例如Class.forName(MyClass);使用反射主动加载对应类也是原笔记列出的初始化触发场景之一。八、JVM 启动时的主类Java 程序启动时public static void main(String[] args)所在的主类会被 JVM 主动使用因此需要进行初始化。九、MethodHandle 相关调用原笔记还列出了 JDK 7 之后与MethodHandle相关的调用场景。因此可以把主动使用概括成new 对象 访问静态字段 调用静态方法 反射 JVM 主类 MethodHandle十、什么是被动引用与主动使用相对还有一些代码虽然“看起来使用了这个类”却不会触发这个类自身的初始化。原笔记给出了三个非常典型的例子。十一、通过子类访问父类静态字段假设System.out.println(SubClass.value);但value实际定义在SuperClass那么原笔记中的结论是初始化 SuperClass 不会因为这次访问而初始化 SubClass因此输出可能只看到父类初始化相关结果。这里说明真正触发初始化的是定义该静态字段的类而不是写在访问表达式前面的子类名字。十二、创建类的数组不会初始化元素类例如SuperClass[] array new SuperClass[10];这并不是new SuperClass();原笔记指出这种数组创建不会触发SuperClass的初始化。数组类在这里属于比较特殊的 JVM 类型结构。十三、访问编译期常量例如System.out.println(ConstClass.HELLOWORLD);其中static final String HELLOWORLD hello world;如果属于编译期可以确定的常量编译器会把常量值直接内联到使用方。因此运行时不再需要真正访问ConstClass从而不会因为这次读取触发该类初始化。十四、接口的初始化规则接口同样可能存在clinit()但接口和普通类存在一定区别。原笔记指出接口初始化并不要求先把所有父接口初始化。只有在真正使用相应接口中的内容时才按照实际需要进行初始化。十五、Loading类是怎样进入 JVM 的进入类加载具体流程以后第一个阶段是Loading。JVM 在 Loading 阶段主要完成三件事1. 根据类的全限定名获取二进制字节流 2. 将字节流转换成 JVM 中的运行时数据结构 3. 在内存中生成对应的 java.lang.Class 对象这里也能进一步理解MyClass.class背后的Class对象和磁盘上的MyClass.classClass 文件并不是同一个概念。前者已经是 JVM 运行期间使用的数据结构。十六、Class 文件来源并不固定Loading 阶段并不要求 Class 数据一定来自磁盘上的 .class 文件原笔记列出了多种来源JAR / WAR / EAR 网络 动态代理运行期生成 JSP 编译生成 数据库 加密文件这也说明类加载的核心是获得符合要求的二进制字节而不是固定从某个目录读取文件。十七、Verification为什么必须验证 ClassClass 文件进入 JVM 以后不能直接无条件信任。JVM 必须判断这个 Class 文件是合法的吗 会不会破坏 JVM 的类型系统 字节码执行会不会产生危险行为因此需要进行Verification。原笔记把它视为 JVM 安全体系中非常重要的一环。验证主要分成四个方面。十八、文件格式验证第一层是检查这个二进制数据本身是不是一个合法 Class 文件。例如是否以 0xCAFEBABE 开头 Class Version 是否支持 Constant Pool 结构是否合法 UTF-8 数据是否合法原笔记指出这一阶段的数据还没有正式进入方法区对应的运行时结构。这和上一篇 Class 文件结构中的Magic Number Version Constant Pool正好对应起来。十九、元数据验证第二层是检查类的语义关系。例如是否存在合法父类 是否错误继承 final 类 接口实现是否满足要求 字段或方法是否与父类发生非法冲突也就是说文件格式正确还不能证明这个“类”在 Java 类型系统中一定合理。二十、字节码验证第三层进一步验证方法中的字节码能不能安全执行。原笔记列出的检查包括操作数栈类型是否正确 局部变量类型是否匹配 跳转指令是否合法 类型转换是否合法这一层和 JVM 的类型安全关系非常密切。二十一、符号引用验证最后还需要保证Class 文件中记录的符号引用能够正确工作。例如不能引用不存在的 Class 不存在的 Field 不存在的 Method等结构。二十二、Preparation为类变量准备内存Verification 之后进入Preparation。准备阶段主要处理Class Variable也就是 static 类变量。原笔记将它概括为为 static 变量分配内存 设置初始零值这里要特别注意三个问题只处理类变量 static 不处理普通实例变量 不会执行 static {} 中的 Java 代码二十三、Preparation 阶段的“零值”例如有public static int value 123;在普通情况下Preparation 更关注为 value 分配类变量空间 先放入类型对应的初始零值真正执行代码中value 123;的初始化逻辑属于后面的初始化阶段。二十四、static final 的特殊情况原笔记单独强调了一种特殊情况public static final int value 123;如果编译器为这个字段生成了ConstantValue属性那么 JVM 可以在 Preparation 阶段就把value直接设置为123而不是等到 Initialization。这也和前面访问编译期常量不会触发类初始化形成对应。二十五、Resolution符号引用变成直接引用Class 文件 Constant Pool 中保存了大量Symbolic Reference。但是 JVM 真正执行程序时需要能够定位内存中的真实结构。于是就需要Resolution。可以简单概括成Symbolic Reference 符号引用 ↓ Resolution ↓ Direct Reference 直接引用二十六、符号引用是什么符号引用主要通过“名字和描述”来确定目标。例如上一篇 Constant Pool 中已经见过CONSTANT_Class CONSTANT_Fieldref CONSTANT_Methodref CONSTANT_InterfaceMethodref这种引用与某一个 JVM 的具体内存布局没有直接绑定。它首先表达的是“我要找哪个类” “我要找哪个字段” “我要找哪个方法”二十七、什么是直接引用Direct Reference 则与 JVM 的具体实现关系更加紧密。原笔记将其描述为指针 偏移量 句柄等能够定位 JVM 中真实运行时结构的形式。因此可以理解成符号引用 “我需要 xxx 方法” ↓ Resolution ↓ 直接引用 “真正去哪里找到它”二十八、Resolution 一定发生在固定时间吗不一定。原笔记指出JVM 对解析时机保留了比较大的灵活性。它可以发生在类加载过程 初始化之前 第一次真正使用时也就是所谓的Lazy Resolution。原笔记还单独提到了invokedynamic作为解析行为比较特殊的一种调用点。二十九、哪些内容需要解析原笔记将需要解析的符号引用归纳为类或接口 字段 类方法 接口方法 方法类型 方法句柄 invokedynamic 调用点三十、字段解析当 JVM 解析字段引用时需要寻找真正对应的字段。原笔记中的查找顺序为当前类 ↓ 当前类实现的接口 ↓ 父类如果最终没有找到NoSuchFieldError如果找到了但当前代码没有对应访问权限IllegalAccessError三十一、方法解析类方法的查找过程也会沿着类型关系寻找。原笔记中给出的基本顺序是当前类 ↓ 父类 ↓ 接口可能涉及AbstractMethodError NoSuchMethodError等情况。三十二、Initialization真正执行初始化逻辑完成前面的步骤以后就进入Initialization。这个阶段的核心是执行类构造器clinit()。clinit()主要来源于static 类变量赋值 static {} 静态代码块例如static int a 1; static { a 2; }这些逻辑会共同参与形成类初始化方法clinit()三十三、clinit() 的执行顺序原笔记指出静态变量赋值和静态代码块会按照源代码中的出现顺序参与初始化。并且还存在一个非常重要的继承关系父类 clinit() ↓ 子类 clinit()JVM 会保证初始化子类之前其父类初始化已经完成。三十四、clinit() 的线程安全如果多个线程同时第一次使用一个还没有完成初始化的类会怎么样原笔记指出JVM 会对clinit()进行同步控制。同一时刻只有一个线程执行类初始化逻辑其他线程需要等待。因此如果出现static { while (true) { } }就可能产生非常严重的后果Thread A 进入 clinit() ↓ 死循环 Thread B 等待类初始化 Thread C 等待类初始化 Thread D 等待类初始化最终看起来就像程序发生了“假死”。三十五、ClassLoader 到底负责什么前面的 Loading 阶段解决的是Class 数据怎么进入 JVM。而 ClassLoader 决定的两个核心问题是类从哪里加载 这个类究竟是谁在 JVM 中一个类的唯一性并不能只由类的全限定名决定。原笔记给出的定义是类的唯一性 类的全限定名 加载这个类的 ClassLoader三十六、为什么同一个 Class 文件可能是两个不同的类假设同一个ClassLoaderTest.class分别被Application ClassLoader Custom ClassLoader加载。即使字节码完全相同JVM 仍然可能把它们看成两个不同的 Class。于是可能出现obj instanceof ClassLoaderTest结果为false同样这也会影响instanceof 类型转换 equals isAssignableFrom等类型相关判断。三十七、JVM 中有哪些经典 ClassLoader从 JVM 实现视角来看原笔记首先区分Bootstrap ClassLoader和其他 Java 实现的 ClassLoader。Bootstrap ClassLoader 是 JVM 自身的一部分负责加载 Java 核心类库其他 ClassLoader 则通过 Java 的ClassLoader体系实现。原笔记随后以JDK 8 及之前的经典体系说明三层类加载器。三十八、Bootstrap ClassLoader最顶层是Bootstrap ClassLoader。原笔记中它主要负责加载JAVA_HOME/lib 或者 -Xbootclasspath指定的核心 Java 类库。在 Java 代码层面获取某些由 Bootstrap 加载的类型时可能看到ClassLoader.getClassLoader()返回null用来表示它由 Bootstrap ClassLoader 加载。三十九、Extension ClassLoader第二层是Extension ClassLoader。原笔记以 JDK 8 及之前的体系描述它主要处理 Java 扩展类库例如JAVA_HOME/lib/ext等位置。原笔记同时指出JDK 9 模块化以后这一传统角色已经被弱化。四十、Application ClassLoader第三层是Application ClassLoader也常被称为System ClassLoader。它主要加载Classpath中的应用程序类。绝大多数普通 Java 应用中的业务类都会和这个加载器密切相关。四十一、自定义 ClassLoader开发者还可以通过继承java.lang.ClassLoader创建自定义类加载器。原笔记列出的使用场景包括热部署 模块隔离 插件系统 OSGi四十二、双亲委派模型是什么有了多个 ClassLoader 以后会产生一个关键问题当 Application ClassLoader 收到一个类加载请求时是不是应该马上自己去加载经典的 Parent Delegation Model 给出的答案是不是。核心规则可以概括成类加载请求优先向父加载器委派只有父加载器无法完成时子加载器才尝试自己加载。四十三、双亲委派的完整过程可以把整个过程表示为Application ClassLoader │ │ 委派 ↓ Extension ClassLoader │ │ 委派 ↓ Bootstrap ClassLoader如果 Bootstrap 能够加载直接返回结果如果 Bootstrap 无法加载返回到 ExtensionExtension 再尝试。如果 Extension 也不行返回 Application最后才由当前 ClassLoader 尝试自己的findClass()逻辑。换句话说收到 Load Request ↓ 这个类是否已经加载 ↓ No ↓ 先交给 Parent ↓ Parent 再交给它的 Parent ↓ Bootstrap ↓ 父加载器全部失败 ↓ 当前加载器 findClass()图中能够清楚看到Bootstrap ClassLoader ↓ Extension ClassLoader ↓ Application ClassLoader ↓ User ClassLoader特别适合放在双亲委派执行流程这一部分。四十四、双亲委派有什么价值双亲委派最重要的价值之一是保护 Java 类型体系的一致性。例如java.lang.Object java.lang.String java.lang.Class这些核心类型应该最终由受信任的核心类加载机制提供。假设用户能够在自己的 ClassPath 中放入一个java.lang.Object并让应用加载器优先加载它就可能破坏整个 Java 类型系统。于是JDK Core Class ↓ Parent 优先加载 ↓ 应用代码无法轻易替换这就是双亲委派带来的安全与一致性价值。四十五、双亲委派是不是 JVM 强制规定不是。原笔记特别指出双亲委派并不是 JVM 规范强制要求的唯一规则。它属于 Java 类加载体系中的经典实践而ClassLoader本身也允许开发者重写相应行为。这也解释了为什么后面会出现所谓的“破坏双亲委派”。四十六、为什么需要破坏双亲委派“破坏”并不意味着双亲委派设计错了而是某些实际需求和传统的Parent ↓ Child委派结构并不完全兼容。原笔记主要整理了三类典型情况。四十七、第一次历史兼容问题双亲委派机制是在 JDK 1.2 之后形成经典体系的。但ClassLoader抽象体系在更早版本中已经存在。因此为了兼容早期已经存在的自定义类加载代码无法强制所有旧实现全部立即遵循新的委派模型。原笔记把这种情况理解成由于历史兼容性造成的被动破坏。四十八、第二次SPI 的反向加载问题第二种情况更加经典SPIService Provider Interface。它遇到的问题可以概括为Java 核心框架 由 Bootstrap 加载 但是 框架需要寻找应用程序提供的具体实现传统双亲委派的方向是Child ↓ Parent但 SPI 出现了一个反过来的需求Parent 需要找到 Child 可以看到的实现类因此形成Parent ← Child式的需求。四十九、SPI 的典型例子原笔记列出的 SPI 场景包括JDBC JNDI JCE JAXP例如 JDBCDriverManager.getConnection(...)DriverManager属于 Java 核心类体系。但是数据库厂商提供的具体 JDBC DriverMySQL Driver Oracle Driver ...位于应用程序自己的 ClassPath 中。于是核心类 ↓ 需要找到 ↓ 应用层实现类就形成了和传统父子委派方向相反的需求。五十、TCCL线程上下文类加载器原笔记给出的解决方案是Thread Context ClassLoaderTCCL。获取方式Thread.currentThread().getContextClassLoader();原笔记中它默认可以与应用程序类加载器对应并可被 SPI 框架用来加载应用提供的实现类。可以简单理解为Bootstrap 加载的框架代码 ↓ 无法直接看到应用实现 ↓ 获取当前线程的 TCCL ↓ 借助应用层 ClassLoader ↓ 加载具体实现因此原笔记把它描述成父加载器“借用”子加载器能力。也就是双亲委派的一种“逆向使用”。五十一、第三次热部署与模块化第三类破坏来自对动态性的要求。原笔记列出的场景包括HotSwap HotDeploy 模块隔离 插件系统 OSGi传统双亲委派更像一棵Tree而某些模块化系统希望不同模块具有独立的类空间 不同的依赖关系 灵活的类可见性五十二、OSGi 的类加载思路原笔记以 OSGi 为典型例子。其中每一个 Bundle 拥有自己的 ClassLoader类查找过程可能依次考虑java.* 核心包 Import 的包 当前 Bundle 的 ClassPath Fragment Bundle Dynamic Import如果最终仍然无法找到ClassNotFoundException这时整个 ClassLoader 关系就不再只是传统的父 ↓ 子简单树形结构而可能形成更加复杂的网状搜索关系。五十三、双亲委派和破坏双亲委派并不矛盾理解这一部分最重要的是不要把它简单理解成双亲委派 VS 破坏双亲委派谁对谁错。原笔记最后把二者总结成两个目标双亲委派 ↓ 强调安全性和类型体系稳定 突破传统委派 ↓ 获得更多动态性和灵活性因此可以把 Java 类加载机制的发展理解成在安全性和动态性之间不断进行权衡。五十四、完整类加载流程总结到这里可以把这一篇所有内容串成一条完整链路Class File ↓ Loading 获取二进制数据 ↓ Verification 检查是否合法 ↓ Preparation 为 static 类变量准备内存 ↓ Resolution Symbolic Reference → Direct Reference ↓ Initialization 执行 clinit() ↓ Using ↓ Unloading而真正负责“从哪里取得 Class”的核心组件就是ClassLoaderClassLoader 之间再通过Parent Delegation Model维持核心 Java 类型体系的一致性。但在历史兼容 SPI 热部署 / 模块化等场景下又需要打破简单的父子委派关系以获取更高的灵活性。因此整个类加载机制最终可以概括成Class 文件进入 JVM ↓ 完成验证和运行时转换 ↓ ClassLoader 决定类型身份 ↓ Parent Delegation 保证安全 ↓ 特殊场景突破委派 ↓ 换取动态性
返回列表