一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第4天,点击查看活动详情
本系列专栏:JVM专栏
前言
前面文章我们说了Java类要能被JVM使用要经过3个步骤:加载、链接和初始化,其中加载我们上一篇文章介绍过了,简单一句话概括就是利用类加载器把字节码文件加载到JVM内存中,在方法区中保存类的结构,在堆中创建Class对象。
那本篇文章我们继续来说剩下的2个步骤:链接和初始化。
正文
链接
链接这里又分为3个小步骤:验证、准备和解析,我们以此来分析一下这3个步骤的作用。
验证
验证阶段的目的,是确保被加载的类能够满足JVM的约束条件,前面我们说过在加载阶段,会把类的信息加载到JVM的方法区,而后续操作就直接从方法区里来获取信息,而不用再操作字节码,所以一定要确保加载进来的类是符合JVM的约束条件的。
这里验证的内容还是比较多的,不过大致可以分为下面几种:
-
文件格式验证,保证输入的字节流能正确地解析并且存储到方法区中,比如是否是以魔树(0XCAFEBABE)开头,主、次版本号在不在当前JVM处理范围内等;这些信息都是保存在字节流文件中,后续文章我们来专门来说一下这个字节流class文件。
-
元数据验证,保证不存在不符合Java语言规范地元数据信息;比如这个类是否有父类,因为除了object类都应该有父类;比如这个类的父类是不是被final修饰的类等等这种不符合Java语言规范的错误。
-
符号引用验证,JVM将符号引用转换成实际引用时,进行的匹配性校验,这个是啥意思呢 我们的字节码文件中的引用都是符号引用,而不是真正地指向实际引用,所以在进行真的把符号引用转换成实际引用前需要进行简单验证。比如符号引用中通过全限定名是否能够找到对应的类,在指定类中是否存在符合方法的字段描述等信息。
准备
准备阶段的目的是为被加载类的静态字段分配内存并且设置初值,这些变量所使用的内存都将在方法区中进行分配。
注意这个阶段进行内存分配的仅仅包括static修饰的类变量,而不包括实例变量,实例变量将会在对象实例化时伴随着对象一起分配在Java堆中。
其次,这里所说的初值,就是数据类型即前面说的基础类型的零值,假设一个类的变量定义如下:
public static int value = 11
那变量value在准备阶段过后初始值是0,而不是11;
而把value的值赋值为11的putstatic指令是程序被编译后,存放于类构造器<clinit>方法中,而这个方法的调用是在初始化阶段才会执行。
这里也有个特殊情况,假如类字段是ConstantValue属性,即static fianl修饰,比如下面代码:
public static final int value = 12
编译时Javac会将value生成ConstantValue属性,在准备阶段就会把value设置为12。
解析
解析阶段是JVM将常量池内的符号引用替换为实际引用的过程,这是啥意思呢
在class文件被加载至JVM之前,这个类无法知道其他类以及其方法、字段对应的具体地址,甚至不知道自己方法、字段的地址,因此每当需要引用这些成员时,Java编译器会生成一个符号引用。
比如对于一个方法的调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接受参数类型以及返回值类型的符号引用,来代指要调用的方法,而解析阶段的目的就是将这些符号引用解析为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载。
而这个由符号引用找到实际引用的过程还是比较复杂的,我们后面文章再介绍。
初始化
初始化是类加载的最后一步,也是特别重要的一步,我们来仔细梳理一下。
<clinit>函数
在Java代码中,我们要初始化一个静态字段,可以在声明时直接赋值,或者在静态代码块中进行赋值;在前面链接步骤中的准备阶段我们说过:如果直接赋值的静态字段被final修饰,并且它的类型是基本类型或字符串时,那么该字段便会被Java编译器标记为常量值(ConstantValue),在准备阶段由Java虚拟机完成赋值。
而除了上述情况之外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器放入同一个方法中,并且命名为<clinit>。
我们来看个简单例子:
public class TestC {
//常量值
public static final int a = 100;
//静态字段
public static int b = 90;
//成员变量
public int c = 90;
}
这里定义了3个变量a、b、c,其中a是常量,b是静态字段,c是成员变量,按照Java语言规范,a和b都可以直接通过类名来访问,所以在类的初始化时a和b的值就应该赋值完成;而a是常量,在准备阶段即完成赋值,而b是静态字段赋值则放入了上面说的<clinit>中来进行。
public static void main(String[] args){
//a和b可以直接类名访问
int a = TestC.a;
int b = TestC.b;
//常量值不能再被赋值 这句错误
TestC.a = 1000;
//静态字段可以
TestC.b = 1000;
}
好了,既然我们知道静态字段的赋值在<clinit>函数中,那类的初始化何时会被触发呢
初始化触发情况
下面列举一些会触发类的初始化操作的行为。
1、当JVM启动时,初始化用户指定的主类;
2、当遇到用以新建目标实例的new指令时,初始化new指定的明白类;
3、当遇到调用静态方法的指令时,初始化该静态方法所在的类;
4、当遇到访问静态字段指令时,初始化该静态字段所在的类;
5、子类的初始化会触发父类的初始化;
6、使用反射API对某个类进行反射调用时,初始化这个类;
这些常见的会触发类的初始化操作必须要熟悉,其中调用静态方法、静态字段等都有很好的使用场景。
静态内部类单例原理
这里同时有个关键点,就是为了确保初始化只会执行一次,这个<clinit>方法是线程安全的,利用这个特性我们可以来实现一个大名鼎鼎的单例,比如下面代码:
public class Singleton {
//私有构造函数
private Singleton() {}
//内部类
private static class LazyHolder {
static Singleton INSTANCE = new Singleton();
}
//调用
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
这里getInstance()是静态方法,当调用该方法时,其值是返回LazyHolder类的静态字段,这时根据上面触发条件中的第4条会触发LazyHolder类的初始化。
所以外面不管在哪个线程访问getInstance()时,当发现LazyHolder没有被初始化时,会调用其线程安全的<clinit>方法,然后对INSTANCE进行赋值,再当访问时,由于初始化完成,就会返回INSTANCE变量了,所以这种是线程安全的懒加载单例模式。
总结
本章内容主要介绍了JVM加载类时的后面2大步骤:链接(验证、准备、解析)和初始化,其中解析部分内容较多,我们后面再说。而初始化,则是为标记为常量值的字段赋值以及执行线程安全的<clinit>方法,利用这个特性可以实现单例的延迟初始化。