【大厂突击】四、虚拟机加载机制(下)

258 阅读10分钟

1. 简介

在上问《虚拟机加载机制(上)》中,我们深入分析了类加载阶段的过程、类加载器、双亲委派、打破双亲委派等问题,并且提到了类加载过程分为加载、验证、准备、解析、初始化等阶段。那 JVM 是如何做验证、准备、解析、初始化等过程的呢?经常提到的符号引用和指针引用是什么呢?接下一一为看官解答,希望各位看的开心😄

类加载

2. 验证阶段

我们首先来看看验证,在上一篇文章中得知:类从被加载刀虚拟机内存中开始,到卸载出内存为止,整个生命周期一共经历七个阶段:加载、验证、准备、解析、初始化、使用、卸载。其中验证、准备、解析 3 个部分被统称为连接,所以验证是连接的第一个阶段。

验证的主要目的确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 我们知道如果我们的业务代码中有诸如:访问越界、对象转型给一个它未实现的类型等在编译运行的时候会报错。所以想要通过写出错误的 Java 代码来生成错误的字节码语言是无法做到的。那么这样是不是说不用验证字节码是否正确了呢? 其实并不是,比如你很简单粗暴直接编写 Class 文件,然后丢给虚拟机运行,这个时候如果没有对字节流进行验证就很容易因为导入有害的字节流导致了系统崩溃。

验证

验证阶段导致有 4 个阶段:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证: 文件格式验证简单的来说就是我们的 .class 文件是要符合 JVM 的规范,并且是否能够被当前的 JVM 处理。

元数据验证: 元数据验证主要是对类的元数据信息进行语义分析

字节码验证 字节码验证主要通过数据流和控制流分析对类中的方法体进行校验和分析,保证被校验的方法不会有危害虚拟机的代码。

符号引用验证 最后一个阶段是在将符号引用转换成直接引用(符号引用和直接引用会在下文解析阶段详细讲解)时候对类自身外,常量池中的各种符号引用进行匹配型校验,该校验是为了确保在解析阶段的时候能够确保正常执行

验证

3. 准备阶段

准备阶段就是正式为类变量(即静态变量、被 static 修饰的变量)分配内存并且设置初始值。 static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾(1.7之前存储于方法区,1.7之后存储于堆内存中)。

关于准备阶段,还有两个容易产生混淆的概念需要着重强调,首先是这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。其次是这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为:

public static int value = 123;

那变量value在准备阶段过后的初始值为 0 而不是 123 ,因为这时尚未开始执行任何Java方法,而把 value 赋值为 123 的 putstatic 指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把 value 赋值为 123 的动作要到类的初始化阶段才会被执行。 还有一种特殊的情况就是在字段被final修饰的属性,如果是被final修饰,那么它在准备阶段变量的value的值就是被指定的值,比如:

public static final int value = 123;

这个时候在准备阶段续JVM就会将 value 赋值为 123。 具体我们来看看测试代码:

public class A {
    static String s = "sssss";
    static final String c = "cccccc";

    public A() {
    }

    public static void main(String[] args) {
    }
}

上面代码中在A类里面定义了两个类属性,一个被 final 修饰,一个没有被final修饰。然后我们来看看编译后具体的字节码:

字节码 从字节码看,c 属性在准备阶段就赋值了,而 s 在初始化阶段才赋值。

4. 解析阶段

上面我们分别介绍了验证和准备两个阶段,接下来我们看看解析,解析阶段是虚拟机将常量池内的符号引用替换成直接引用的过程。 符号引用和直接引用刚刚在准备阶段我们有略微的提到过,现在来讲讲这两者的关联。 我们先通过代码来初步感受符号引用和直接引用,然后再通过文字定义加深理解。 首先我们来看代码:

public class Test {
  public void create() {
    add();
  }

  public void add() { }
}

上面有个Test类定义了两个方法 create()add()其中 在 create 方法中调用了add方法。我们把这个类编译成字节码文件 Test.class然后查看一下这个文件: 字节码文件 我们可以看到字节码文件的 12 行Constant pool 这部分就是常量池,里面存储的就是大部分常量。 然后我们再看看create这个函数的第一行字节码在第 47 行如下:

1: invokevirtual #2  // Method add:()V

invokevirtual 是一个操作码, 后面的 #2 是操作数,这里举一个简单的例子假设有指令如下:

// 以下指令是用于讲解,并非真实汇编指令
set a 1
set b 2
add a b c

上面的汇编指令可以翻译成下面的高级语言:

a = 1
b = 2
c = a + b

我们把每条汇编指令的第一个操作符的 16 十六进制编码叫做操作码,所以invokevirtual #2 通过查看指令信息表代表用于指定这个要调用的函数,#2是字节码文件里面常量池里面的下标。我们按图索骥找到第 14 行

#2 = Methodref          #3.#14         // Test.add:()V

这里又引用了另外两个常量池项分别是

#3 = Class              #15            // Test
#14 = NameAndType        #10:#6         // add:()V

我们一直按图索骥下去就会如下:

符号引用

我们可以看到最后都在常量池里面找到了标记为Utf8的常量池项,这些常量池项描述了此方法所属的“类,方法名,描述符”等信息,这就是常量池引用。 找到符号引用后在通过这些信息去对应的类的方法寻找方法表的偏移指针,这个偏移指针就是直接引用。 我们在上文有讲解验证阶段的时候提到验证的第四阶段是虚拟机将符号引用转化成直接引用,就是我们例子中按图索骥的过程。 虚拟机把第一次把符号引用转化成直接引用后,将指令修改为invokevirtual_quick,并把操作数修改成指向方法表的偏移量(指针)再调用该方法的时候就直接用了直接引用了

我们通过上述例子感受了直接引用和符号引用后,接下来看看对它们的文字定义描述:

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
  • 直接引用:直接引用可以是直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)。相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)。一个能间接定位到目标的句柄 直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

解析动作主要对类、接口、字段、类方法、接口方法、方法类型、方法句柄、和调用点限制符 7 类符号引用分别对常量池的 7 种常量类型,具体解析过程这里就不详细给出了。

5. 初始化

类初始化是加载过程的最后一个步骤,我们知道在准备阶段变量已经被赋值过一次零值了,而在初始化阶段,会根据程序员制定的值去初始化变量和其他资源。 准确的说初始化阶段程序的<clinit>() 方法会被执行,这里我们说所的<clinit>() 和类的构造器函数<init>() 是不同的,它不需要显式的调用父类构造器,虚拟机会保证子类<clinit>() 调用前父类的<clinit>() 已经调用完毕。 <clinit>() 方法是 static{} 代码块修饰的语句,虚拟机会收集 static{} 代码块中的语句统一处理,其中要注意static{}代码块中只能访问定义前的变量,定义在它之后的变量只能赋值不能访问。如下所示:

代码块 需要注意的是虚拟机会保证<clinit>() 在多线程环境中正确的加锁同步,如果多个线程同时初始化一个类的话,那么只有一个线程进入执行<clinit>() 方法,其余的线程会在等待执行。所以如果在静态代码块中有耗时较长的任务的情况下,可能会存在线程阻塞,大家要格外注意哟!

public class A {

   static {
     if (true ) {
         while (true) {  // 应该避免这种耗时的代码在静态代码块里面
             System.out.println("true");
         }
     }
   }

    public static void main(String[] args) {
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread() + " start");
            A a = new A();
            System.out.println(Thread.currentThread() + " end");
        };
        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);

    }
}

我是小刀,感谢各位人才的:点赞、收藏和评论,我们下期见!