从字节码层面理解泛型

命令行

//编译成 class 文件
javac Test.java 

//反汇编 class 文件
javap -V Test.class

Android Studio 编译的 class
文件位于 build/intermediates/clases/debug/包名

IDEA 插件

  • jclasslib Bytecode viewer

  • ASM Bytecode Viewer

这两款插件都可以在 Android Studio Plugins 里直接下载安装

字节码的组成

方法调用在JVM中转换成的是字节码执行,字节码指令执行的数据结构就是栈帧。

栈帧的数据结构主要分为四个部分:局部变量表、操作数栈、动态链接以及方法返回地址(包括正常调用和异常调用的完成结果)。

局部变量表(local variables)

当方法被调用时,参数会传递到从0开始的连续的局部变量表的索引位置上。local variables的最大长度是在编译期间决定的。一个局部变量表的占用了32位的存储空间(一个存储单位称之为slot,槽),所以可以存储一个boolean、byte、char、short、float、int、refrence和returnAdress数据,long和double需要2个连续的局部变量表来保存,通过较小位置的索引来获取。如果被调用的是实例方法,那么第0个位置存储“this”关键字代表当前实例对象的引用。

操作数栈

操作数栈同局部变量表一样,也是编译期间就能决定了其存储空间(最大的单位长度)。

操作数栈是在JVM字节码执行一些指令(第二部分会介绍一些指令集)时创建的,主要是把局部变量表中的变量压入操作数栈,在操作数栈中进行字节码指令的操作,再将变量出操作数栈,结果入操作数栈。

动态链接

每个栈帧指向运行时常量池中该栈帧所属的方法的引用,也就是字节码的发放调用的引用。动态链接就是将符号引用所表示的方法,转换成方法的直接引用。加载阶段或第一次使用时转化为直接引用的(将变量的访问转化为访问这些变量的存储结构所在的运行时内存位置)就叫做静态解析。JVM的动态链接还支持运行期转化为直接引用。也可以叫做Late Binding,晚期绑定。

方法返回地址

方法正常退出会把返回值压入调用者的栈帧的操作数栈,PC计数器的值就会调整到方法调用指令后面的一条指令。这样使得当前的栈帧能够和调用者连接起来,并且让调用者的栈帧的操作数栈继续往下执行。

方法的异常调用完成,主要是JVM抛出的异常,如果异常没有被捕获住,或者遇到athrow字节码指令显示抛出,那么就没有返回值给调用者。

Java 代码:

public class MyTest {
    private int myNum = 20;

    public void func() {
        myNum = 50;
    }
}

编译后的 class 源文件:

cafe babe 0000 0033 0015 0a00 0400 1109
0003 0012 0700 1307 0014 0100 056d 794e
756d 0100 0149 0100 063c 696e 6974 3e01
0003 2829 5601 0004 436f 6465 0100 0f4c
696e 654e 756d 6265 7254 6162 6c65 0100
124c 6f63 616c 5661 7269 6162 6c65 5461
626c 6501 0004 7468 6973 0100 1a4c 636f
6d2f 7961 7a68 6964 6576 2f64 656d 6f2f
4d79 5465 7374 3b01 0004 6675 6e63 0100
0a53 6f75 7263 6546 696c 6501 000b 4d79
5465 7374 2e6a 6176 610c 0007 0008 0c00
0500 0601 0018 636f 6d2f 7961 7a68 6964
6576 2f64 656d 6f2f 4d79 5465 7374 0100
106a 6176 612f 6c61 6e67 2f4f 626a 6563
7400 2100 0300 0400 0000 0100 0200 0500
0600 0000 0200 0100 0700 0800 0100 0900
0000 3900 0200 0100 0000 0b2a b700 012a
1014 b500 02b1 0000 0002 000a 0000 000a
0002 0000 0007 0004 0008 000b 0000 000c
0001 0000 000b 000c 000d 0000 0001 000e
0008 0001 0009 0000 0035 0002 0001 0000
0007 2a10 32b5 0002 b100 0000 0200 0a00
0000 0a00 0200 0000 0b00 0600 0c00 0b00
0000 0c00 0100 0000 0700 0c00 0d00 0000
0100 0f00 0000 0200 10

javap 反汇编后的代码:

Classfile /Users/zengyazhi/Documents/zyzdev/AndroidDemo/app/build/intermediates/classes/debug/com/yazhidev/demo/MyTest.class
  Last modified 2018-12-28; size 393 bytes
  MD5 checksum 2872209fbe3efb46c70b23bf85be75fd
  Compiled from "MyTest.java"
public class com.yazhidev.demo.MyTest
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#17         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#18         // com/yazhidev/demo/MyTest.myNum:I
   #3 = Class              #19            // com/yazhidev/demo/MyTest
   #4 = Class              #20            // java/lang/Object
   #5 = Utf8               myNum
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/yazhidev/demo/MyTest;
  #14 = Utf8               func
  #15 = Utf8               SourceFile
  #16 = Utf8               MyTest.java
  #17 = NameAndType        #7:#8          // "<init>":()V
  #18 = NameAndType        #5:#6          // myNum:I
  #19 = Utf8               com/yazhidev/demo/MyTest
  #20 = Utf8               java/lang/Object
{
  public com.yazhidev.demo.MyTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: bipush        20
         7: putfield      #2                  // Field myNum:I
        10: return
      LineNumberTable:
        line 7: 0
        line 8: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/yazhidev/demo/MyTest;

  public void func();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: bipush        50
         3: putfield      #2                  // Field myNum:I
         6: return
      LineNumberTable:
        line 11: 0
        line 12: 6
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       7     0  this   Lcom/yazhidev/demo/MyTest;
}
SourceFile: "MyTest.java"

操作码

opcode(指令) = 操作码 + 操作数

例如 bipush 10 这是一条指令,是由操作码 bipush 后跟一个操作数 10 组成,该指令的作用是将整型数 10 压到操作数栈中。

  • aload_0(指令码:0x2a)

    从局部变量数组中加载一个对象引用到操作数栈的栈顶,最后的数字对应的是局部变量数组中的位置,只能是0,1,2,3。(第一个局部变量是this引用)

  • invokespecial(0xb7)

    只能调用三类方法:<init>方法;私有方法;super.method()。因为这三类方法的调用对象在编译时就可以确定

  • invokevirtual(0xb6)

    是一种动态分派的调用指令

  • bipush(0x10)

    用来把一个字节作为整型压到操作数栈中

  • putfield(0xb5)

    后面跟一个操作数(该操作数引用的是运行时常量池里的一个字段,在这里这个字段是 myNum),将栈顶的值赋给这个。赋给这个字段的值,以及包含这个字段的对象引用,在执行这条指令的时候,都会从操作数栈顶上 pop 出来

  • ldc(0x12)

    常量池中的常量值入栈

  • CHECKCAST(0xc0)

    类型强转

部分字节码指令集可见:

《Java二进制指令代码解析》

《JVM 虚拟机字节码指令表》

解析

回到上面 MyTest 的构造函数里:

0: aload_0
1: invokespecial #1                  // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush        20
7: putfield      #2                  // Field myNum:I
10: return

ASM Bytecode viewer 显示的字节码为:

// class version 51.0 (51)
// access flags 0x21
public class com/yazhidev/demo/MyTest {

  // compiled from: MyTest.java

  // access flags 0x2
  private I myNum

  // access flags 0x1
  public <init>()V
   L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKESPECIAL java/lang/Object.<init> ()V
   L1
    LINENUMBER 8 L1
    ALOAD 0
    BIPUSH 20
    PUTFIELD com/yazhidev/demo/MyTest.myNum : I
    RETURN
   L2
    LOCALVARIABLE this Lcom/yazhidev/demo/MyTest; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1

  // access flags 0x1
  public func()V
   L0
    LINENUMBER 11 L0
    ALOAD 0
    BIPUSH 50
    PUTFIELD com/yazhidev/demo/MyTest.myNum : I
   L1
    LINENUMBER 12 L1
    RETURN
   L2
    LOCALVARIABLE this Lcom/yazhidev/demo/MyTest; L0 L2 0
    MAXSTACK = 2
    MAXLOCALS = 1
}

从字节码看泛型

Java 的泛型是完全在编译器中实现的,由编译器执行类型检查和类型推断,然后生成普通的非泛型的字节码,虚拟机完全不感知泛型的存在。编译器使用泛型类型信息保证类型安全,然后在生成字节码之前将其清除。

Java 代码:

public class Generic<T> {

    private T data;

    public T get() {
        return data;
    }

    public void set(T data) {
        this.data = data;
    }
}

从生成的字节码中可以看到,泛型 T 已经被擦除了:

private Ljava/lang/Object; data

public getData()Ljava/lang/Object;

public setData(Ljava/lang/Object;)V

类型擦除与多态冲突的问题

子类 B,指定了泛型类型:

public class B extends Generic<Number> {

    private Number n;

    public Number get() {
        return n;
    }

    public void set(Number n) {
        this.n = n;
    }
}

子类 C,未指定泛型类型:

public class C extends Generic {

    private Number n;

    public Number get() {
        return n;
    }

    public void set(Number n) {
        this.n = n;
    }
}

我们在写 B 类时,指定了泛型类型为 Number,对于 B 类的方法 get()Numberset(Number),我们的本意应该是对父类的 get()Tset(T) 方法进行重写。但上面我们知道了,父类的 get()Tset(T) 在字节码中实际上是 get()Objectset(Object),与类 B 的方法 set(Number) 方法参数不一样,理论上应该算重载而不是重写。为了解决这一冲突,JVM 采用了一种特殊的方法:桥接。

我们先看 B 类的字节码:

public get()Ljava/lang/Number;
public set(Ljava/lang/Number;)V

// access flags 0x1041
public synthetic bridge set(Ljava/lang/Object;)V
    L0
    LINENUMBER 7 L0
    ALOAD 0
    ALOAD 1
    CHECKCAST java/lang/Number
    INVOKEVIRTUAL com/yazhidev/demo/B.set (Ljava/lang/Number;)V
    RETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/B; L0 L1 0
    MAXSTACK = 2
    MAXLOCALS = 2

// access flags 0x1041
public synthetic bridge get()Ljava/lang/Object;
    L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKEVIRTUAL com/yazhidev/demo/B.get ()Ljava/lang/Number;
    ARETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/B; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

可以发现编译器自动生成了 set(Object)get()Object 两个桥接方法来重写父类方法,同时这两个桥接方法实际上调用了对应的 set(Number) 方法和 get()Number 方法。虚拟机通过使用桥接方法,来解决了类型擦除和多态的冲突。对于开发者来说,对于指定了泛型类型为 Number 的 B 类来说,其 set(Number) 方法就是对父类方法 set(T) 的重写,同理 get()Number 也是对父类方法 get()T 的重写。

但 C 类则有些不同,C 类未指定泛型类型,所以父类中的方法为 get()Objectset(Object),C 类中的 set(Number) 与父类 set(Object) 方法参数不同,理所当然是重载,我们都知道,只有返回值不同不满足重载条件,所以对 C 类的 get()Number 方法来说,应该算是对父类方法 get()T 的重写。

我们来看 C 类的字节码:

public get()Ljava/lang/String;
public set(Ljava/lang/String;)V

// access flags 0x1041
public synthetic bridge get()Ljava/lang/Object;
    L0
    LINENUMBER 7 L0
    ALOAD 0
    INVOKEVIRTUAL com/yazhidev/demo/C.get ()Ljava/lang/String;
    ARETURN
    L1
    LOCALVARIABLE this Lcom/yazhidev/demo/C; L0 L1 0
    MAXSTACK = 1
    MAXLOCALS = 1

可以发现编译期自动生成了 get()Object 桥接方法来重写父类方法。但我们发现字节码里却同时存在了两个只有返回值类型不同的同名方法,这是为什么呢?

这里就需要提到方法特征签名,只有特征签名不同的方法才可以共存。

  • Java 层方法签名 = 方法名 + 参数类型 + 参数顺序

    所以在 Java 语言里,重载一个方法需要两个同名方法的参数类型不同,或者参数顺序不同,只有返回值类型不同是无法通过编译的。

  • JVM 层方法签名 = 方法名 + 参数类型 + 参数顺序 + 返回值类型 + 可能抛出的异常

    所以在 class 文件里,是可以存在两个只有返回值类型不同的同名方法。也就是上面的 get()Objectget()Number

参考

《描述符与特征签名的区别》

推荐阅读更多精彩内容