有兴趣可以先参考前面的几篇 JVM 总结:
JVM 自动内存管理机制 - Java 内存区域(上)
JVM 自动内存管理机制 - Java 内存区域(下)
JVM 垃圾收集器与内存分配策略(一)
我们知道, 在编写一个 Java 程序后, 需要由虚拟机将描述类的数据从 Class 文件 (这里面的 Class 文件不是指某个特定存在于磁盘上面的文件, 而是一串二进制字节流) 加载到内存, 并对数据进行校验, 转换解析和初始化, 最终形成可被虚拟机使用的 Java 类型, 这就是虚拟机的类加载机制. 与编译时需要进行连接工作的语言不同, Java 中类型的加载, 连接和初始化过程都是在程序运行期间完成的, 虽然会有一些性能的开销但是也为其提供了比较高的灵活性, Java 中可以动态扩展和依赖运行期动态加载和动态连接就是依赖于这种特点的.
一, 类加载概述
1, 类加载过程概述
类从被夹在到虚拟机内存中开始, 到被卸载出内存的整个生命周期大概包括: 加载(Loading), 验证(Verification), 准备(Preparation), 解析(Resolution), 初始化(Initialization), 使用(Using), 卸载(Unloading). 这其中的加载, 验证, 准备, 初始化和卸载 5 个阶段的顺序是一定的, 类的加载过程必须要按照这几个顺序来开始(这些不同的阶段可能是混合的交叉执行, 可能再一个阶段执行的时候激活另一个阶段).
而解析阶段不同的特点就是: 为了支持 Java 的运行时绑定(动态绑定或者晚期绑定), 在某些情况下可以在初始化之后再执行.
2, 初始化阶段的 5 中情况(必须对类进行初始化)
a)遇到 new,getstatic,putstatic,invokstatic 这四条字节码指令的时候, 如果类没有进行初始化, 那么必须要对类触发其初始化. 典型场景
1使用 new 实例化对象的时候;
2读取或者设置一个类的静态字段(除开被 final 修饰, 编译器将结果放入常量池的静态字段);
3调用一个类的静态方法的时候;
b)使用反射包的时候(java.lang.reflect), 使用其中的方法进行反射调用时必须对类触发其初始化(如果类没有被初始化过)
c)当初始化一个类的时候, 其父类如果还没有被初始化过, 那么必须先触发器父类的初始化
d)当用户在虚拟机启动时候指定需要执行的主类 (包含 main() 方法的那个类), 会首先初始化这个类
e)使用 JDK1.7 的动态语言支持时, 如果一个 java.lang.invoke.MethodHandle 实例的最后解析结果 REF_getStatic,REF_putStatic,REF_invokeStatic 的方法句柄, 并且这个方法句柄所对应的类没有进行过初始化, 则要先触发其初始化.
3, 不会执行初始化的几种情况
1通过子类引用父类的静态字段, 只会触发父类的初始化, 而不会触发子类的初始化(后面有示例程序).
2定义对象数组, 不会触发该类的初始化.
3常量在编译期间会存入调用类的常量池中, 本质上并没有直接引用定义常量的类, 不会触发定义常量所在的类.
4通过类名获取 Class 对象, 不会触发类的初始化.
5通过 Class.forName 加载指定类时, 如果指定参数 initialize 为 false 时, 也不会触发类初始化, 其实这个参数是告诉虚拟机, 是否要对类进行初始化.
6通过 ClassLoader 默认的 loadClass 方法, 也不会触发初始化动作.
4, 主动引用和被动引用
a)主动引用: 上面的 5 中情况中的场景会触发类进行初始化, 这些行为被称为对一个类进行主动引用
b)被动引用: 所引用类的方式不会被初始化. 下面介绍集中被动引用的例子以及测试代码
1通过子类引用父类的静态字段, 只会初始化父类, 子类不会被初始化. 在下面的例子中, 子类继承自父类, 但是输出的时候只会输出 "父类被初始化", 而没有 "子类被初始化".
- package cn.jvm.classLoad;
- class SuperClass {
- static {
- System.out.println("父类被初始化");
- }
- public static int test = 666;
- }
- class SubClass extends SuperClass {
- static {
- System.out.println("子类被初始化");
- }
- }
- public class TestClass1 {
- public static void main(String[] args) {
- System.out.println(SubClass.test);
- }
- }
总结来说就是: 对于静态字段, 只有直接定义这个字段的类才会被初始化, 索引通过子类引用父类中定义的静态字段只会触发父类的初始化而不会触发子类的初始化.
2通过数组定义来引用类, 不会触发该类的初始化. 下面的测试程序也很明显, 运行之后不会输出 "父类被初始化"
- package cn.jvm.classLoad;
- class SuperClass1 {
- static {
- System.out.println("父类被初始化");
- }
- public static int test = 666;
- }
- public class TestClass2 {
- public static void main(String[] args) {
- SuperClass[] sc = new SuperClass[6];
- }
- }
当初始化对象数组时, 并不会实际触发对象的初始化操作. 但是会触发一个是由虚拟机自动生成的, 直接继承于 java.lang.Object 的子类, 创建动作由字节码指令 newarray 触发. 值得注意的是: 该类代表了实际的对象数组, 数组中应有的方法和属性都实现在这个类里.
3常量在编译阶段就会存入调用类的常量池中, 本质上没有直接引用到定义常量的类, 因此不会触发定义常量类的初始化. 如同下面的测试程序, 只会输出定义的常量字符串, 而不会输出 "定义常量的 Test 类被初始化".
虽然在 TestClass3 类中引用了 Test 类的常量 test, 但是在编译阶段经过常量传播优化, 将常量的值存到了 TestClass3 类的常量池中, 以后再使用 test 的引用实际上都是转换为 TestClass3 类对自身常量池的引用
- package cn.jvm.classLoad;
- class Test {
- static {
- System.out.println("定义常量的 Test 类被初始化");
- }
- public static final String test = "TestClass";
- }
- public class TestClass3 {
- public static void main(String[] args) {
- System.out.println(Test.test);
- }
- }
5, 关于接口的初始化
a)接口也有自己的初始化过程: 接口中没有 static 静态代码块来输出初始化信息, 编译器会为接口生成 "<clinit>()" 类构造器, 用于初始化接口中所定义的成员变量.
b)接口和类初始化的区别: 当一个类在初始化时, 其父类都基本上初始化过了, 然而接口在初始化的时候, 只有真正用到父接口的时候 (如引用接口中定义的常量) 才会进行初始化.
二, 类加载全过程
1, 加载
a)在加载阶段虚拟机通过一个类的限定名来获取定义此类 (某个 Class 文件) 的二进制字节流, 然后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构, 之后在内存中生成一个代表这个类的 java.lang.Class 对象, 作为方法区这个类的各种数据的访问入口.
1对于非数组类的加载阶段中获取类的二进制字节流的动作, 是可控性最强的. 因为在加载阶段中既可以使用系统提供的引导类加载器完成类加载, 也可以由用户自定义的类加载器完成, 开发人云通过定义自己的类加载器去控制字节流的获取方式(重写一个累加器的 LoadClass 方法, 后面会有例子程序作为示例)
2对于数组类而言, 由于数组类本身不通过类加载器创建, 而是有 JVM 直接创建的, 但是数组类与类加载器仍然有比较密切的关系(数组类的元素类型最终是需要通过类加载器去进行加载的)
2, 连接阶段(分为验证, 准备和解析三个阶段)
a)验证
验证是连接阶段的第一步, 这一步的目的是为了确保 Class 文件的字节流包含的信息对于虚拟机是安全的. 验证阶段大概会包括四个小的检验动作: 文件格式验证, 元数据验证, 字节码验证, 符号引用验证.
1文件格式验证: 这一阶段的验证主要指验证字节流是否符合 Class 文件的规范, 并且能够被当前版本的虚拟机处理(魔数 0XCAFEBABE 开头, 主次版本号是否在虚拟机处理范围之内, 常量池中常量是否有不被支持的常量类型等等));
2元数据验证: 这个阶段是对字节码的信息进行语义描述, 保证描述的信息符合 Java 语言规范(除了 java.lang.Object 类之外的类是否有父类, 这个类是否继承了不应被继承的类, 如果这个类不是抽象类是否实现类其父类或者接口中的所有方法);
3字节码验证: 通过数据流和控制流进行分析, 确定程序语义是合法的, 符合逻辑的, 在元数据验证完毕之后这个阶段对类的方法进行验证, 保证类的方法运行时是对虚拟机安全的;
4符号引用验证: 该阶段发生在虚拟机将符号引用转换为直接引用的时候, 这个转换的动作会发生在连接的第三个阶段 (解析), 符号引用验证可以看做是对类自身之外的信息进行匹配性校验(符号引用中字符串描述的全限定名是否能够找到对应的类; 在指定类中是否存在方法的字段描述符以及简单名称所描述的方法和字段; 符号引用中的类, 字段, 方法的访问权限(private,protected,public,default) 是否可以被当前类访问)
b)准备
准备阶段是为类变量分配内存并设置类的初始值 (通常情况下指的是数据类型的默认零值, 如果被是常量值比如 public static final int test = 123, 这就会在准备阶段将变量 test 初始化为 123) 的阶段, 这些变量所使用的内存都会在方法区中进行分配.
注意:
1这里进行内存分配的变量只包括类变量(static 变量), 而不包括实例化变量, 实例变量将会在对象实例化的时候随着对象一起分配在堆中;
2初始值(通常情况下指的是数据类型的默认零值, 如果被是常量值比如 public static final int test = 123, 这就会在准备阶段将变量 test 初始化为 123)
c)解析
解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程, 符号引用在 Class 文件中以 CONSTANT_Class_info,CONSTANT_Field_info 等类型的变量出现.
1符号引用: 是以一组符号来描述所引用的目标, 可以使任何形式的字面量(使用时候没有歧义的量)
2直接引用: 可以直接指向目标的指针, 相对偏移量或者是一个能够间接定位到目标的句柄
3, 初始化阶段
初始化阶段是类加载过程的最后一个阶段, 在前面基本上都是类加载器参与执行(包括自定义的类加载器), 在初始化阶段才是执行定义的 Java 程序(字节码). 前面在准备阶段变量被赋以所属数据类型的默认值, 在初始化阶段是通过程序制定的值去进行变量的初始化.
初始化阶段是执行类的 < clinit()>方法的过程:
1<clinit()>方法是有编译器自动收集的所有类变量的赋值动作和 static 块中的语句产生的(顺序就是在源文件中定义的顺序出现的, 所以在静态语句块之中, 只能定义在其后定义的 static 变量而不能访问)
2<clinit()>方法和类的构造器 (这里指的是实例化的构造器 < init>()) 不同, 它不需要显示的调用分类的构造器, 因为虚拟机会保证在子类的 < clinit()>方法执行之前一定会将父类的 < clinit()>方法执行完毕 (侧面说明虚拟机中第一个被执行的 < clinit()> 肯定是 java.lang.Object 的)
3由第2条可以得出的是, 由于父类的 < clinit()>方法先执行, 所以在父类中定义的 static 语句块要先于子类的变量赋值操作. 下面的测试代码中输出的 Son 类的 testSon 值应该是 2, 而不是 1.
- package cn.jvm.classLoad;
- class parent {
- public static int testPar = 1;
- static {
- testPar = 2;
- }
- }
- class Son extends parent {
- public static int testSon = testPar;
- }
- public class TestClass5 {
- public static void main(String[] args) {
- System.out.println(Son.testSon);
- }
- }
4如果一个类中没有静态语句块, 也没有变量赋值的操作, 那么编译器可以不用为这个类生成 < client()>方法;
5接口中虽然没有静态语句块, 但是可以存在变量赋值的操作, 所以接口中也会生成 <client()>方法. 但是接口中的 < client()>方法不需要先执行父接口中的 < client()>方法方法, 只需要在父接口中变量被使用的时候才会初始化, 同理接口的实现类在初始化的时候也不需要先执行接口中的 < client()>方法;
6虚拟机会保证一个类在多线程环境中的 < client()>方法被正确加锁, 同步. 如果多个线程去同时初始化一个类, 那么只有一个线程会执行 < client()>方法, 其他的线程会阻塞等待知道执行完毕.
四, 再看堆, 栈, 方法区
可以参考前面的 JVM 自动内存管理机制 - Java 内存区域 (上) 中讲到的这三个区域的详细概念和联系, 这里我们通过一个简单的程序并结合类加载的过程来看一下这三者的关系. 首先先简单描述一下方法区和堆区, 方法区: 用于存储已被虚拟机加载的类信息, 常量, 静态变量, 即时编译器编译后的代码等数据; 堆区: 存放对象实例, 几乎所有的对象实例都在这里分配内存.
- package cn.jvm.classLoad;
- class TestDemo{
- public static int test=100; // 静态变量, 静态域
- static{ // 静态代码块
- System.out.println("静态初始化类 A");
- test = 300 ;
- }
- public TestDemo() {
- System.out.println("创建 A 类对象的实例化构造方法");
- }
- }
- public class TestClass4 {
- public static void main(String[] args) {
- TestDemo testDemo = new TestDemo();
- System.out.println(testDemo.test);
- }
- }
类加载程序测试
我们先看下上面的代码, 代码中 TestDemo 类中定义了静态区域 (包括代码块和静态变量), 然后在 main 中实例化 TestDemo 类的对象并访问他的静态变量, 这里我们先再看一下这张图, 结合这个图(简单描述堆, 栈, 方法区的关系) 通过类加载的过程来具体的分析一下堆, 栈, 方法区在类加载过程以及完毕之后里面存放的信息.
1JVM 加载 TestClass 的时候, 首先在方法区中形成 TestClass 类对应静态数据(类变量, 类方法, 代码...), 同时在堆里面也会形成 java.lang.Class 对象(反射对象), 代表 TestClass 类, 通过对象可以访问到类二进制结构(方法区). 然后加载 TestDemo 类信息, 同时也会在堆里面形成 TestDemo 对象, 代表 TestDemo 类.
2main 方法执行时会在栈里面形成 main 方法栈帧, 一个方法对应一个栈帧. 如果 main 方法调用了别的方法, 会在栈里面挨个往里压, main 方法里面有个局部变量 A 类型的 a, 一开始 testDemo 值为 null, 通过 new 调用类 A 的构造器, 栈里面生成 TestDemo()方法同时堆里面生成 testDemo 对象, 然后把 TestDemo 类的对象地址赋给栈中的 testDemo, 此时 testDemo 拥有类 TestDemo 的对象的地址.
3当调用 testDemo.test 时, 调用方法区数据.
反正总结下来就是: 类加载最终在堆区中生成的 Class 对象, 堆中的 Class 对象封装了类在方法区内的数据结构, 并且提供了访问方法区内的数据结构的接口.
来源: https://www.cnblogs.com/fsmly/p/10394972.html