文章目录

0. 前言

最近在学 JVM,但学的很痛苦,因为 JVM 的知识点都不连贯,而且也没找到什么资料,也很难进行验证。如标题,在学习的过程中遇到了这些概念,刚开始很难理解,后来不停找资料、看视频、看书,算是得出了一些个人总结,不一定完全准确,希望对大家有个借鉴。

下面的讲解,需要大家提前对 JVM 有一部分了解,比如类加载、JVM 内存模型等、字节码文件等知识,会按照源代码,然后编译成字节码文件,然后字节码文件被加载进虚拟机内存的顺序讲起。

1. 正文

1.1 虚方法和非虚方法

先来看广义上的定义(即指 Java 代码层面):

  • 非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。

    静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法

  • 虚方法:其他方法就叫做非虚方法

咦?怎么理解上面的这两个定义呢?大家来抓一下重点,编译期就确定了具体的调用版本,这个是虚方法和非虚方法的本质区别,方法在编译期确定?这个是什么意思,难道方法还能不确定的?

下面来看个具体的代码例子:

class Animal{
    void test(){
        System.out.println("动物");
    }
}
 
class Cat extends Animal{
    @Override
    void test(){
        System.out.println("猫");
    }
}
 
class Test {
    void test(Animal animal){
    	// 此时方法就是无法确定的
        animal.test();
    }
}

Test类里的test方法里面的这句代码animal.test();就是无法确认的,只有在运行的时候根据实际参数,才知道调用的Animal还是Cat这下子明白方法的编译期就确定了具体的调用版本是什么意思了吧。

你也可以这么理解,不可能被重写的方法就叫做非虚方法,比如静态方法、final 定义的方法、私有方法等;有可能被重写的方法就叫做虚方法,注意噢,只要是有可能重写的都是虚方法,尽管你还没重写。

再来看进一步的定义,这一个大家了解一下就好:

  • 非虚方法:invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法
  • 虚方法:其余的(final 修饰的除外)称为虚方法

这个是什么意思呢?我们知道源文件要经过编译变成 class 文件,在编译过程中,编译器就会对我们的源代码进行修改,改成一些 JVM 能识别的指令,JVM 提供了下面四个指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法

在编译的时候,编译器根据源代码中方法是什么内容,就会对该方法加上什么指令。在编译的时候,编译器怎么识别一个方法是否是可以确定的其实很简单,就是看它是不是静态的、是不是私有的、是不是构造器还是调用了父类方法、是不是接口的方法,然后直接判断是否是确定的。

1.2 符号引用和直接引用

大家有兴趣可以看下这个文章,R 神写的:https://www.zhihu.com/question/30300585/answer/51335493,我下面的内容就是对这篇文章的一个总结而已。

先看定义:

  • 符号引用:字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置
  • 直接引用:就是地址,就是我们使用的类的方法在内存中的地址

我们先来看一个类:

class Test {
    public static void main(String[] args) {
        System.out.println("wqewqeqwe");
    }
}

这个类经过编译变成一个 class 文件,我们可以用javap -v来反编译这个文件,会得到以下内容(只是截取部分内容):

Constant pool:
 #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
 #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
 #3 = String             #23            // wqewqeqwe
 #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
 #5 = Class              #26            // Test
 #6 = Class              #27            // java/lang/Object
 #7 = Utf8               <init>
 #8 = Utf8               ()V
 #9 = Utf8               Code
 #10 = Utf8               LineNumberTable
 #11 = Utf8               LocalVariableTable
 #12 = Utf8               this
 #13 = Utf8               LTest;
 #14 = Utf8               main
 #15 = Utf8               ([Ljava/lang/String;)V
 #16 = Utf8               args
 #17 = Utf8               [Ljava/lang/String;
 #18 = Utf8               SourceFile
 #19 = Utf8               Solution.java
 #20 = NameAndType        #7:#8          // "<init>":()V
 #21 = Class              #28            // java/lang/System
 #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
 #23 = Utf8               wqewqeqwe
 #24 = Class              #31            // java/io/PrintStream
 #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
 #26 = Utf8               Test
 #27 = Utf8               java/lang/Object
 #28 = Utf8               java/lang/System
 #29 = Utf8               out
 #30 = Utf8               Ljava/io/PrintStream;
 #31 = Utf8               java/io/PrintStream
 #32 = Utf8               println
 #33 = Utf8               (Ljava/lang/String;)V

我们在源代码中,其实是使用了System这个类,但是在 class 文件中,这个使用就会使用字节码文件中 #28 对应的java/lang/System来替代,这个java/lang/System就是传说中的符号引用。

为什么要使用符号引用呢?这个是比较好理解的,因为在编译的时候,它是不知道你具体的内存使用情况的,所以是不清楚直接引用是什么,所以就使用一个唯一标识的字符串来表示。

那什么时候会转化为直接引用呢?类加载的时候,类加载的链接阶段中有一个小阶段,叫做解析,该阶段的任务就是把类的符号引用转化为直接引用。

而且这些符号引用和直接引用的翻译关系是可以复用的,也就是说虚拟机里有一个区域叫做方法区,方法区里有一个区域叫做运行时常量池,这个常量池可以看成一张表,这张表记录了符号引用和直接引用的对应关系。进行类加载的时候,会把 class 文件的所有符号引用加进这张表。

如果新增的符合引用已经在表中存在了,那就说明这个符号引用已经翻译过了,可以直接转化为直接引用;如果没有,那就说明该符号引用第一次出现,那就要根据字符串的内容进行搜索。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了。

以上是个人理解,不一定正确,供大家参考讨论。

1.3 栈帧中的动态链接

我们知道栈帧中有一个内容叫做动态链接,这个是什么呢?

先来看一个类,代码如下:

class Test {
    public static void main(String[] args) {
        System.out.println("wqewqeqwe");
    }
}

使用javap -v反编译一下:

标红的这些就是所谓的动态链接,我个人不喜欢这么叫,我认为有歧义,应该叫做栈里面指向方法区运行时常量池的引用,这样才比较好理解。

也就是说,动态链接就是一个指针,该指针指向方法区的运行时常量池中的符号引用,如果指向的对象已经经过解析了,那么就是一个直接引用,也就是指向一个具体的地址了。

还有一对概念:动态链接和静态解析

  • 静态解析:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接

  • 动态链接:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

注意,这里的动态链接和栈帧中的动态链接完全没有关系,不要弄混。我个人把这一堆概念理解为,类加载的解析阶段发生在不同时间。如果解析阶段发生在类加载期间,那就是静态解析;如果是运行的时候再发生解析,那就是动态链接。

1.4 虚方法表

先来看一下方法执行的实质:

  1. 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作 C。
  2. 如果在类型 C 中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
  3. 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出 java.1ang.AbstractMethodsrror 异常。

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM 采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

什么是虚方法表,看下下面两张图就懂了:

总结一下,前面我们说过 4 个指令,这 4 个指令可以用来判断一个方法是不是虚方法,执行引擎在执行指令过程中,假如遇到了一个虚方法,那他就会去查这个类的虚方法表,然后实现具体的方法。