大家好,我是此林。

Java 字节码进阶1:深入解析 class 文件结构-CSDN博客

Java 字节码进阶2:为什么 switch 比 if-else 高效(阿里代码规约)?-CSDN博客

前两章,我们深入分析了 class 字节码文件的十大组成,

介绍了阿里代码规约中 switch-case 比 if-else 高效的字节码原理。

今天,我们来深入分析 面向对象三大特性(封装、继承、多态)之一:多态 在字节码层面的原理。

1. new 创建一个对象的时候发生了什么?

在 Java 中,我们通常使用 new 关键字去创建一个对象,例如:

public class Hello {
    public void test() {
        Hello h = new Hello();
    }
}

我们使用 javac Hello.java 编译成 Hello.class

然后使用 javap -v Hello 去查看字节码。

这里,贴出了 test 函数的字节码。

我们发现,一个对象的创建需要三条指令:newdupinvokespecial 调用 <init> 方法

问:什么是 <init> 方法?

这个 <init> 方法,实际上就是 JVM 中类的实例初始化方法,类的构造方法、非静态变量的初始化、对象初始化代码块都会被编译进这个方法中。比如:

public class Hello {

    // 初始化非静态变量
    private int a = 10;
    
    // 构造器方法
    public Hello() {
    }

    // 对象初始化代码块
    {
        int b = 9;
    }
}

在上面 Hello 这个类中,初始化变量、构造方法、非静态代码块在最终编译后都会统一编译进 <init> 方法。

再看这个图:

创建一个对象时,先调用 new 指令,只是创建了一个类实例的引用,并将这个引用压入操作数栈顶。

接着,使用 invokespecial 调用 <init> 方法才真正调用了构造方法。

问:那中间指令 dup 的作用是什么?

答:dup 其实就是 duplicate 的缩写,意思是复制。

invokespecial 指令调用 <init> 后,会消耗 操作数栈顶 的类实例引用。

那如果想要在 invokespecial 调用后栈顶还有指向新建类实例的引用,就需要 事先在 invokespecial 之前复制一份类对象实例的引用;

否则,调用完 <init> 之后就再也找不到刚刚创建的对象的引用了。

刚刚我们介绍了用 new 关键字去创建一个对象,底层字节码的流程。

我们提到了 <init> 方法是类实例的初始化方法,与之对应的还有个 <clinit> 方法

<clinit> 方法是类的静态初始化方法,类的静态代码块、静态变量初始化都会被编译进这个方法中,比如:

public class Hello {

    // 初始化静态变量
    private static int a = 10;
   

    // 静态代码块
    static {
        int b = 9;
    }
}

需要注意的是,<clinit> 的调用在四个字节码指令会触发:

        new

        getstatic

        putstatic

        invokestatic

比如下面的场景:

        创建类对象的实例

        访问类的静态方法或静态字段

        初始化某个类的子类

2. 方法调用字节码指令

JVM 的方法调用指令都以 invoke 开头,比如:

invokestatic:调用静态方法

invokevirtual:调用实例对象的非私有方法(支持多态的关键

invokeinterface:调用接口方法(支持多态的关键

invokespecial:调用实例对象的私有方法、构造器方法、super关键字调用父类的实例方法

invokedynamic:调用动态方法

2.1. invokestatic 指令

invokestatic 用于调用静态方法,也就是 static 修饰的方法。

它要调用的方法在 javac 编译器已经确定,运行期不会改变,属于静态绑定。所以调用 invokestatic 不需要将对象加载到操作数栈,只需要方法参数入栈即可。

这个我们在 Java 字节码进阶2:为什么 switch 比 if-else 高效(阿里代码规约)?-CSDN博客 已经说过:

非静态方法,比如:

public void test(int name) {
    // ...
}

它方法参数里看起来只有一个参数 name,实际上还会传入一个隐藏的 this 参数(当前实例)。

2.2. invokevirtual 指令 (多态在字节码层面的原理)

invokevirtual 指令用于调用普通实例方法,它调用的目标方法要在运行时才能更具对象实际的类型确定,在编译期无法知道,类似 C++ 中的虚方法。

在调用 invokevirtual 之前,会把 对象引用(this)、方法参数压入操作数栈

调用结束 对象引用(this)、方法参数出栈,

如果方法有返回值,返回值会进入操作数栈顶。

我们来看一个案例:

class Animal {
    public void speak() {
        System.out.println("Animal speaks");
    }
}

class Dog extends Animal {
    @Override
    public void speak() {
        System.out.println("Dog barks");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal a = new Dog();  // 向上转型
        a.speak();             // 多态调用
    }
}

我们使用 javac Main.java 编译后,使用 javap -c Main 反编译字节码。

可以看到,编译器只知道变量 a 的静态类型是 Animal,所以字节码里写的是调用 Animal.speak()

运行时 JVM 会查 a 的真实类型(这里是 Dog),通过 虚方法表 vtable 跳转到 Dog.speak()

结论:

  • 源代码层面:我们写的是 a.speak()

  • 字节码层面:表现为 invokevirtual 指令。

  • 运行时层面:依靠虚方法表完成 动态分派,最终执行 Dog.speak()

2.3. invokeinterface 指令 (多态在字节码层面的原理)

vtable 虚方法表用于支持类的继承的多态,而 itable 用于支持多接口的实现。

问:vtable 和 itable 的区别?

答:

  • vtable(虚方法表)
    用于 类的继承体系,存储类及其父类的虚方法实现。invokevirtual 就是通过 vtable 做动态分派。

  • itable(接口方法表)
    用于 接口方法的实现查找,当字节码里遇到 invokeinterface 指令时,会通过 itable 来找到具体的实现。

问: 为什么需要 itable?

:因为一个类可能实现 多个接口,而接口的方法签名可能相同,但来源不同。
如果只用 vtable,无法区分同一个方法属于哪个接口。
因此 JVM 在类加载时,会为每个接口建立一个 itable entry,里面保存该接口的方法到类中具体实现方法的映射。

案例:

interface Speakable {
    void speak();
}

class Dog implements Speakable {
    @Override
    public void speak() {
        System.out.println("Dog barks");
    }
}

public class Main {
    public static void main(String[] args) {
        Speakable s = new Dog();
        s.speak();  // 接口多态调用
    }
}

javap -c Main 结果:

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class Dog
       3: dup
       4: invokespecial #3                  // Method Dog."<init>":()V
       7: astore_1
       8: aload_1
       9: invokeinterface #4,  1            // InterfaceMethod Speakable.speak:()V
      14: return

注意这里不是 invokevirtual,而是 invokeinterface

Dog 加载后,JVM 内部大致结构:

Dog.klass
 ├── vtable
 │    [0] -> Dog.speak()   (覆盖 Object 的 toString() 等也会在这)
 │
 ├── itable
 │    [Speakable 接口]
 │         method[0] -> Dog.speak()

当执行:

Speakable s = new Dog();
s.speak();
  • 字节码是:invokeinterface #4, 1

  • JVM 执程:

    1. 找到 s 的实际类 Dog

    2. DogitableSpeakable 这个接口的 entry

    3. 在 entry 里查 speak() 的实现

    4. 跳转执行 Dog.speak()

所以总的来说,itable 会比 vtable 多一层,主要是因为在 Java 里,只支持单继承,但支持实现多个接口。

当然,感兴趣的同学还可以使用 HotSopt Debugger 去查看更深入的内容。

2.4. invokedynamic 指令

invokedynamic 出现的背景:

JDK 7 (JSR 292) 中引入,它的设计目标是:

  • 给 JVM 加入一个 动态调用机制,让 动态语言(Groovy、JRuby、Jython 等)能更高效运行在 JVM 上。

那什么是动态语言呢?它主要有两个特点:

  • 弱类型 / duck typing:方法调用时不依赖静态类型检查,而是运行时决定。

  • 方法、属性可以在运行时动态改变。

传统的 invokevirtual / invokeinterface 在编译期就要决定目标方法签名,而 invokedynamic 则允许 方法绑定延迟到运行时(动态绑定),由引导方法 bootstrap method 决定

所以, invokedynamic 指令初衷是为了支持动态语言(弱类型为主)而设计的

Java 本身是强类型语言,但从 Java 8 开始,invokedynamic 被大量用在 语法糖的底层实现,而不是仅仅是 “弱类型”,比如:

Lambda 表达式。通过 invokedynamic 调用 LambdaMetafactory.metafactory 生成 Function 对象。

今天的分享就到这里了,相信你一定有所收获!

我是此林,关注我吧,带你看不一样的世界!

更多推荐