本文我们来研究一个 Java 字节码文件 (Class 文件) 是如何加载入内存中的, 在這個过程中涉及类加载过程中的加载, 验证, 准备, 解析(连接), 初始化, 使用, 销毁过程, 并探讨实行这些过程的类加载器, 以及其加载的逻辑.
概述
Java 拥有动态加载类和动态连接的特性, 因此其加载过程并不像其他语言在编译时就已经完成, 它是动态进行的, 即在程序运行过程中动态加载入内存中.
加载过程
在这里需要记住的是, 图中的顺序说明的是阶段开始的顺序, 并不是后面的阶段需要等到前面的执行完成后才能够执行, 其在运行过程中是一个交叉混合执行的过程.
此外解析阶段也是一个特殊的阶段, 为了支持 Java 语言的动态绑定, 很多时候 Java 只要在运行后才能知道实际调用的对象是什么, 因此解析阶段有时是开始在初始化后的.
加载
加载阶段完成的是将虚拟机外部的二进制字节流按照虚拟机所需的格式存储在方法区之中. 而为了完成这步需要完成哪些功能呢:
通过一个类的全限定名获取二进制流;
将二进制流定义的静态存储结构转化为方法区的运行时数据结构;
在内存中生成一个代表这个类的 Class 对象, 作为方法区数据的访问接口.
需要注意的是, 上面所说的 3 个步骤, 都只是规范要求的部分, 这个要求其实是比较松的, 很多东西并没有限制的很死, 比如说第一步的获取二进制流, 其并没有要求二进制流必须从 Class 文件获取, 因此在使用过程中类的二进制流可以从网络获取, 可以动态计算生成等等.
验证
验证作用是确保文件的字节流包含信息符合当前虚拟机要求, 保证其并不会危害虚拟机的安全. 因为以前说过 Class 文件并不都是源码编译而来的, 人是可以手动修改生成 Class 文件的, 因此这一步验证工作就十分有必要了. 那么验证都需要验证哪些地方呢:
文件格式验证
这一步主要是保证 Class 文件格式上符合 Java 信息的要求. 例如文件类型, 版本号, 常量池, 常量池数据等等......
此外在这一步字节流就会进入内存的方法区之中了, 后面的操作都是基于方法区内的存储结构进行的.
元数据验证
对字节码描述信息进行语义分析, 例如类是否有父类, 重载是否正确, final,abstract 有没有用错等, 其主要目的是对类的元数据进行语义分析, 保证符合 Java 语言规范.
字节码验证
对数据流和控制流进行分析. 例如字节码指令集的正确, 程序跳转的安全. 其主要目的是检查方法体内的数据安全, 确保程序语义合法, 符合逻辑.
符号引用验证
符号引用验证也是一个比较特殊的阶段, 其为解析阶段服务(这也验证了前面所说的, 这几个过程并不是依次执行完成的). 在解析过程中, 虚拟机将符号引用转换为直接引用, 其主要是对常量池中的各种符号引用做匹配性校验. 检验内容包括以下几个:
符号引用指向的类能否找到;
指定的类有没有描述的方法和字段;
符号引用指向的各种信息的访问权限是不是对的;
...............
准备
为类变量 (被 static 修饰的变量) 分配内存并设置类变量初始值.
这里需要注意的是设初始值值得是为其设置零值, 例如数值量的 0,boolean 值的 false 等. 但是特殊情况下, 如类变量是一个常量, 那么在准备阶段, 虚拟机就会将其设置为常量指代的值.
解析
在验证阶段的符号引用验证说过解析阶段就是将符号引用转换为直接引用, 那么符号引用和直接引用分别指什么呢, 他们之间又有何区别:
符号引用. 是能够无歧义定位目标的任何形式的字面量, 其与虚拟机实现的内存布局无关, 引用的目标不一定需要加载入内存中;
直接引用. 可以直接指向目标指针, 偏移量的引用, 其和虚拟机实现的内存布局相关, 引用的目标一定需要在内存中.
在这一步虚拟机会将类 / 接口, 字段, 类方法, 接口方法等进行解析, 变为直接引用.
初始化
初始化阶段主要是初始化类变量和其他资源, 主要是通过 < clint>()方法.
<clint>()是通过编译器自动收集所有类变量的赋值动作和静态语句块 (static{} 块)并按照顺序合并生成的.
static 块可以为前面未定义的变量赋值, 但无法访问
- static{
- i = 111;
- // 下面语句无法编译通过, 会提示 Illegal forward reference
- // System.out.println(i);
- }
- static int i = 0;
- public static void main(String[] args){
- System.out.println(i);`
- }
程序输出为 0, 因为其初始化操作是按照顺序进行的, 但如果这里 static int i;, 不为其赋值, 那么结果就是 111.
虚拟机并没有要求什么时候进行其他阶段的工作, 但初始化阶段不同. 当发生一下几种情况时, 虚拟机必须要开始初始化工作.(作为初始化前的加载, 验证, 准备, 解析也就都按部就班开始了).
当在字节码层面遇到以下指令时, new(对象都要生成了, 肯定要初始化了),get/put static(使用静态变量了, 肯定要赋值了),invoke static(调用静态方法了都, 肯定要为静态量赋值);
反射调用. 当使用
java.lang.reflect
中的方法对类进行反射调用;
初始化一个类的时候, 发现父类还有初始化, 那么需要先初始化其父类,(父接口不用立即初始化, 只有使用到其常量时, 才需要将其初始化);
虚拟机需要一个入口, 因此主类需要初始化;
动态方法解析, 解析出方法是其他类的静态方法, 那么需要将其初始化.
虚拟机规定有且仅有以上 5 种方法需要立即初始化, 还有一些调用, 看起来像需要初始化, 但其实并不需要, 可以称之为被动调用.
子类直接使用父类的静态变量. 虚拟机规定只有直接定义静态变量的类需要初始化, 因此这种情况下, 只会触发父类的初始化, 而子类并不会触发;
数组对象. 当定义对象数组时, 只会触发数组类的初始化, 其内的对象的类并不会初始化;
当一个类调用另一个类的常量时. 此时并不会对常量类 (被调用者) 进行出初始化. 只会将调用者初始化. 因为在编译阶段, 根据常量传播优化, 会将常量类的常量放置到调用者的常量池中, 此时这两个类已经没有了瓜葛, 因此也就不存在将其初始化了.
总结
在本文中着重介绍了一个类加载入内存中的各个阶段过程, 了解这个阶段过程可以明白虚拟机是如何将一个静态的类文件, 经过一系列的动作变为 Java 内存中的各种数据结构.
在下一篇文章我们将会介绍执行加载阶段的主体, 类加载器, 明白类加载器的模型以及其背后的逻辑, 并尝试自定义一个类加载器, 来完成加载工作.
文章在公众号 "iceWang" 第一手更新, 有兴趣的朋友可以关注公众号, 第一时间看到笔者分享的各项知识点, 谢谢! 笔芯!
本系列文章主要借鉴自《深入分析 Javaweb 技术内幕》和《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》.
来源: https://www.cnblogs.com/JRookie/p/11027016.html