提问:什么时候触发类加载?什么时候类会初始化?
一个类的醉生梦死
概述
千言万语不如一张图生动
类加载分为7个阶段,分别为加载、验证、准备、解析、初始化、使用和卸载,而验证、准备、解析三个阶段又被称为连接阶段。
注意:对于“解析”阶段,它在某些情况下可以在初始化之后再开始,这样做是 为了支持 java 的运行时绑定特征(也称为动态绑定或晚期绑定),而“验证”符号引用是在“解析阶段”解析符号引用的时候做。
加载
该阶段是类加载的第一阶段
主要工作:
- 通过类的全限定名获得此类的二进制字节流;
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
注意:比如“通过一个类的全限定名来获取定义此类的二进制字节流”没有指定一定得从某个 class 文件中获取,所以我们可以从 zip 压缩包、从网络中 获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等。
验证
该阶段是为了确保Class文件的字节流包含的信息符合当前虚拟机的要求,及是否有恶意信息,威胁到JVM。
该阶段依次执行以下4步:
文件格式验证(阶段1):
验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
小结:
这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进人 Java 虚拟机内存的方法区中进行存储,所以后面 的三个验证阶段全部是基于方法区的存储结构(内存)上进行的,不会再直接读取、操作字节流了。
元数据验证(阶段2):
是对字节码描述的信息进行语义分析,对类的元数据信息进行语义校验,保证不存在与《Java 语言规范》定义相悖的元数据信息(例如:继承关系是否正确,final修饰的是否有被继承等等)。
字节码验证(阶段3):
该阶段比较复杂,主要是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的(例如:int类型和long之间的转换,一个方法字节码跳转到另一个方法中,等等)。这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做 出危害虚拟机安全的行为。
注意:元数据验证是《Java 语言规范》规范,而字节码验证主要针对的是类的方法体。
符号引用验证(阶段4):
符号引用验证可以看 作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源(例如:符号引用能否通过全限定名去找到对应类,是否能够正常的访问)。
小结:该阶段是对引用进行校验。
注意:该阶段在解析阶段符号引用转化成直接引用的时候进行的。
验证阶段总结:验证阶段对于类加载阶段是非常重要的一步,但是不一定是必不可少的。如果项目中的代码被反复的使用后者验证过,那么不必要再被验证了。事实上验证一次通过,就可以不再验证,我们可以通过这个参数(-Xverify:none)进行关闭,缩短类加载的时间。
准备
该阶段是正式为类中定义的变量(被 static 修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。
注意:此时的分配内存是在方法区进行,因此只与类中的静态变量有关(static修饰的变量),与实例无关(因为在堆中)。而且这个初始值只是置0操作,即,不是赋值等号右边的值,而是根据各自的数据类型,进行置0;
例如:public static int count = 88;
这里进行的初始化是 count = 0,至于赋值88操作在后面进行。
各个数据类型置零的值:
解析
该阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。
常常出现以下的异常就跟此阶段有关:
java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。(字段解析异常)
java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。(类或接口的解析异常)
java.lang.NoSuchMethodError 找不到相关方法时的错误。(类方法解析、接口方法解析时发生的异常)
初始化(重要)
该主要是对一个 class 中的 static{}语句进行操作(对应字节码就是 clinit 方法)。
初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):
1)遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是:
1. 使用 new 关键字实例化对象的时候。
2 . 读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候
3. 调用一个类的静态方法的时候。
2)使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含 main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
6)当一个接口中定义了 JDK1.8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
代码分析
父类代码:
package com.tofu.firstappdemo.jvm;
/**
* @author TofuCai
* 父类
**/
public class FatherClass {
static {
System.out.println("我是父类,我初始化了!");
}
public static int fatherValue = 88;
public static final String FATHEER_STR ="我是父类的字符串常量";
public static final int FATHER_VALUE = fatherValue;
}
子类代码:
package com.tofu.firstappdemo.jvm;
/**
* @author TofuCai
* 子类
**/
public class BeByClass extends FatherClass {
static {
System.out.println("我是子类,我初始化了!");
}
public static int value = 88;
public static final String BTBY_STR ="我是子类的字符串常量";
public static final int FATHERSTRFATHER = value;
}
测试代码:
package com.tofu.firstappdemo.jvm;
/**
* @author TofuCai
* 验证类初始化及加载的各种场景
**/
public class ClassInit {
public static void main(String[]args){
ClassInit classInit = new ClassInit();
classInit.test1();//打印子类的静态字段
// classInit.test2();//打印父类的静态字段
// classInit.test3();//使用数组的方式创建
// classInit.test4();//打印一个常量
// classInit.test5();//如果使用常量去引用另外一个常量
}
public void test1(){
//如果通过子类引用子类静态字段,触发子类的初始化(父类不会被加载)
System.out.println(BeByClass.value);
}
public void test2(){
//如果通过子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化(但是子类会被加载)
System.out.println(FatherClass.value);
}
public void test3(){
//使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)
FatherClass[]sca = new FatherClass[10];
}
public void test4(){
//打印一个常量,不会触发初始化(同样不会触类加载、编译的时候这个常量已经进入了自己class的常量池)
System.out.println(FatherClass.FATHEER_STR);
}
public void test5(){
//使用常量去引用另外一个常量,会初始化
System.out.println(FatherClass.FATHER_VALUE);
}
}
配置VM参数:-XX:+TraceClassLoading(打印类加载的日志)
执行test1()
日志结果:
日志分析:父类和子类都加载进来并初始化了。
结论:当我们引用子类静态字段,父类和子类都会初始化(当然也都会加载蓝线框部分)。
执行test2()
日志结果:
日志分析:父类加载进来,并已经初始化
结论:通过子类引用父类的静态字段,只有父类才会初始化,但是子类会被加载进来。
执行test3()
日志分析:父类只加载,并没有初始化。
疑问:产生 new、getstatic、putstatic 或 invokestatic这样指令的字节码是有new出一个实列这样的场景的,而有这四个指令就一定会初始化,那现在为什么没有初始化呢?
反编译:
我们发现红色框test3(),并没有new、getstatic、putstatic 或 invokestatic这里面任何一个字节码指令。同时我们的蓝色框在有new指令对应main方法new ClassInit()代码。
再提问:数组的实例化和一般对象的实例化不一样吗?
新建数据的源码:
我们发现数组的实现是个navtive是我们java方法库实现的。
分析:创建数组虽然我们在代码new了一个对象,但是由于其具体实现是在Java方法库中实现,因此字节码指令不会有new指令,并没有进行初始化。
结论:使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载)。
执行test4()
日志结果:
日志分析 :仅仅只打印了父类的字符串常量,没有加载,没有初始化。
结论:被final修饰的静态(static)字段在编译的时候,就已经在Class常量池中,所以引用这样的常量是不会被初始化的。
执行test5()
日志结果:
日志分析 :父类加载并初始化。
结论:使用常量去引用另外一个常量会初始化(因为被引用的常量没有final编译时无法确定)。
总结:注意要以字节码为准,当创建数组的时候即使在我们的代码有new,但是由于数组创建的是由底层的方法库决定的,真正在字节码中是没有new指令。final修饰的静态变量是在编译期间已经存放在Class的常量池中,使用时,不会触发加载和初始化。