阅读 32

Spring杂谈 | 从桥接方法到JVM方法调用

前言

之所以写这么一篇文章是因为在Spring中,经常会出现下面这种代码

// 判断是否是桥接方法,如果是的话就返回这个方法
BridgeMethodResolver.findBridgedMethod(specificMethod);
复制代码

这些代码对我之前也造成了不小疑惑,在彻底弄懂后通过本文分享出来,也能减少大家在阅读代码过程中的障碍!

桥接方法

什么时候会出现桥接方法?

第一种情况:方法重写的时候子父类方法返回值不一致导致

public class Parent {
    public Number get(Number number){
        System.out.println("parent's method invoke");
        return 1;
    }
}

public class Son extends Parent {
    // 这里对父类的方法进行了重写,但是返回值类型跟父类中不一样,父类中的返回值类型为Number,子类中的返回值类型为Integer,Integer是Number的子类
    @Override
    public Integer get(Number number) {
        System.out.println("son's method invoke");
        return 2;
    }
}

public class PMain {
    public static void main(String[] args) {
        Son son = new Son();
        Method[] declaredMethods = son.getClass().getDeclaredMethods();
        for (int i = 0; i < declaredMethods.length; i++) {
            Method declaredMethod = declaredMethods[i];
            String methodName = declaredMethod.getName();
            Class<?> returnType = declaredMethod.getReturnType();
            Class<?> declaringClass = declaredMethod.getDeclaringClass();
            boolean bridge = declaredMethod.isBridge();
            System.out.print("第" + (i+1) + "个方法名称:" + methodName + ",方法返回值类型:" + returnType + "  ");
            System.out.print(bridge ? " 是桥接方法" : " 不是桥接方法");
            System.out.println("  这个方法是在"+declaringClass.getSimpleName()+"上申明的");
        }
    }
}

// 程序打印如下:
1个方法名称:get,方法返回值类型:class java.lang.Integer   不是桥接方法  这个方法是在Son上申明的
第2个方法名称:get,方法返回值类型:class java.lang.Number   是桥接方法  这个方法是在Son上申明的
复制代码

可以看到在上面的例子中Son类中就出现了桥接方法

看到上面的代码的执行结果,大家肯定会有这么两个疑问

  1. 为什么再Son中会有两个get方法?明明实际申明的只有一个啊
  2. 为什么其中一个方法还是桥接方法呢?这个桥接到底桥接的是什么?
  3. 它的返回值为什么跟父类中被复写的参数类型一样,也是Number类型?

有这些疑问没关系,我们带着疑问往下看。

如果你认真看了上面的代码,你应该就会知道上面例子的特殊之处在于:

子类对父类的方法进行了重写,并且子类方法中的返回值类型跟父类方法的返回值类型不一样!!!!

那么到底是不是这个原因导致的呢?我们不妨将上面例子中Son类的代码更改如下:

public class Son extends Parent {
//    @Override
//    public Integer get(Number number) {
//        System.out.println("son's method invoke");
//        return 2;
//    }

    @Override
    public Number get(Number number) {
        System.out.println("son's method invoke");
        return 2;
    }
}
// 运行结果
1个方法名称:get,方法返回值类型:class java.lang.Number   不是桥接方法  这个方法是在Son上申明的
复制代码

再次运行代码,会发现,桥接方法不见了,也只能看到一个方法。

那么到现在我们就基本能确定了是因为重写的时候子父类方法返回值不一致导致出现了桥接方法。

第二种情况:子类重写了父类中带有泛型的方法

参考链接:https://docs.oracle.com/javase/tutorial/java/generics/bridgeMethods.html#bridgeMethods

public class Node<T> {

    public T data;

    public Node(T data) { this.data = data; }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
}

public class MyNode extends Node<Integer> {

    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
}

public class Main {
    public static void main(String[] args) {
        MyNode mn = new MyNode(5);
        Method[] declaredMethods = mn.getClass().getDeclaredMethods();
        for (int i = 0; i < declaredMethods.length; i++) {
            Method declaredMethod = declaredMethods[i];
            String methodName = declaredMethod.getName();
            Class<?>[] parameterTypes = declaredMethod.getParameterTypes();
            Class<?> declaringClass = declaredMethod.getDeclaringClass();
            boolean bridge = declaredMethod.isBridge();
            System.out.print("第" + (i + 1) + "个方法名称:" + methodName + ",参数类型:" + Arrays.toString(parameterTypes) + "  ");
            System.out.print(bridge ? " 是桥接方法" : " 不是桥接方法");
            System.out.println("  这个方法是在" + declaringClass.getSimpleName() + "上申明的");
        }
    }
}

// 运行结果:
1个方法名称:setData,参数类型:[class java.lang.Integer]   不是桥接方法  这个方法是在MyNode上申明的
第2个方法名称:setData,参数类型:[class java.lang.Object]   是桥接方法  这个方法是在MyNode上申明的
复制代码

看完上面的代码可能你的问题又来了

  1. 为什么再MyNode中会有两个setData方法?明明实际申明的只有一个啊
  2. 为什么其中一个方法还是桥接方法呢?这个桥接到底桥接的是什么?
  3. 它的参数类型为什么跟父类中被复写的方法的参数类型一样,也是Integer类型?

这些问题基本跟第一种情况的问题一样,所以不要急,我们还是往下看

上面例子的特殊之处在于,子类重写父类中带有泛型参数的方法。实际上子类重写父类带有泛型返回值的方法也会出现上面这种情况,比如,我们将代码改成这样

public class Node<T> {

    public T data;

    public Node(T data) {
        this.data = data;
    }

    public void setData(T data) {
        System.out.println("Node.setData");
        this.data = data;
    }
 // 新增一个getData方法,返回值为泛型T
    public T getData() {
        System.out.println("Node.getData");
        return this.data;
    }
}

public class MyNode extends Node<Integer> {

    public MyNode(Integer data) { super(data); }

    @Override
    public void setData(Integer data) {
        System.out.println("MyNode.setData");
        super.setData(data);
    }
 
    // 子类对新增的那个方法进行复写
    @Override
    public Integer getData() {
        System.out.println("MyNode.getData");
        return super.getData();
    }
}
// 程序运行结果
1个方法名称:setData,参数类型:[class java.lang.Object]   是桥接方法  这个方法是在MyNode上申明的
第2个方法名称:setData,参数类型:[class java.lang.Integer]   不是桥接方法  这个方法是在MyNode上申明的
第3个方法名称:getData,参数类型:[]   是桥接方法  这个方法是在MyNode上申明的
第4个方法名称:getData,参数类型:[]   不是桥接方法  这个方法是在MyNode上申明的
复制代码

可以发现,又出现了一个桥接方法。

为什么需要桥接方法?

接下来回牵涉到一些JVM的知识,希望大家能耐心看完哦。

我一直认为最好的学习方式是带着问题去学习,但是在这个过程中你可能又会碰到新的问题,那么怎么办呢?

坚持,就是最好的办法,再难的事情不过也就是打怪升级!

在上面我们探究什么时候会出现桥接方法时,应该能感觉到,桥接方法的出现都是要满足下面两个条件才会出现

  1. 子类重写了父类的方法
  2. 子类中进行重写的方法跟父类不一致(参数不一致或者返回值不一致)

当满足了上面两个条件时,编译器会自动为我生成桥接方法,因为编译的后文件是交由JVM执行的,生成的这个桥接方法肯定就是为了JVM进行方法调用时服务的,我们不妨大胆猜测,在这种情况下,是因为JVM在进行方法调用时,没有办法满足我们的运行时多态,所以生成了桥接方法。要弄清楚这个问题,我们还是要从JVM的方法调用说起。

JVM是怎么调用方法的?

我们应该知道,JVM要执行一个方法时必定需要先找到那个方法,对计算机而言,就是要定位到方法所在的内存地址。那么JVM是如何定位到方法所在内存呢?我们知道JVM所执行的是class文件,我们的.java文件会经过编译生成class文件后才能被JVM执行。如图所示:

未命名文件
未命名文件

因为目前我们关注的是方法的调用,所以对class文件的具体结构我们就不做过多分析了,我们主要就看看常量池方法表

常量池

常量池中主要保存下面三类信息

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

方法表

  • 方法标志,比如public,native,abstract,以及本文所探讨的桥接(bridge)
  • 方法名称索引,因为具体的方法名称保存在常量池中,所以这里保存的是对常量池的索引
  • 描述符索引,即返回值+参数
  • 属性表集合,方法具体的执行代码便保存在这里

对于常量池跟方法表我们不做过多介绍,这两个随便一个拿出来都能写一篇文章,对于阅读本文而言,你只需要知道它们保存了上面的这些信息即可。如果大家感兴趣的话,推荐阅读周志明老师的《深入理解Java虚拟机》

字节码分析

接下来我们就通过一段字节码的分析来看看JVM到底是如何调用方法的,这里就以我们前文中第一个例子中的代码来进行分析。java代码如下:

public class Parent {
 public Number get(Number number){
  return 1;
 }
}

public class Son extends Parent {
 // 重写了父类的方法,返回值类型只要是Number类的子类即可
 @Override
 public Integer get(Number number)  {

  return 2;
 }
}

/**
 * @author 程序员DMZ
 * @Date Create in 21:03 2020/6/7
 * @Blog https://daimingzhi.blog.csdn.net/
 */
public class LoadMain {
 public static void main(String[] args) {
  Parent person = new Son();
  person.get(1);
 }
}
复制代码

对编译好的class文件执行javap -v -c指令,得到如下字节码

Classfile /E:/spring-framework/spring-dmz/out/production/classes/com/dmz/spring/java/LoadMain.class
  Last modified 2020-6-7; size 673 bytes
  MD5 checksum 4b8832849fb5f63e472324be91603b1b
  Compiled from "LoadMain.java"
public class com.dmz.spring.java.LoadMain
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
// 常量池
Constant pool:
   #1 = Methodref          #7.#23         // java/lang/Object."<init>":()V
   #2 = Class              #24            // com/dmz/spring/java/Son
   #3 = Methodref          #2.#23         // com/dmz/spring/java/Son."<init>":()V
   #4 = Methodref          #25.#26        // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
   #5 = Methodref          #27.#28        // com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;
   #6 = Class              #29            // com/dmz/spring/java/LoadMain
   #7 = Class              #30            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lcom/dmz/spring/java/LoadMain;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               person
  #20 = Utf8               Lcom/dmz/spring/java/Parent;
  #21 = Utf8               SourceFile
  #22 = Utf8               LoadMain.java
  #23 = NameAndType        #8:#9          // "<init>":()V
  #24 = Utf8               com/dmz/spring/java/Son
  #25 = Class              #31            // java/lang/Integer
  #26 = NameAndType        #32:#33        // valueOf:(I)Ljava/lang/Integer;
  #27 = Class              #34            // com/dmz/spring/java/Parent
  #28 = NameAndType        #35:#36        // get:(Ljava/lang/Number;)Ljava/lang/Number;
  #29 = Utf8               com/dmz/spring/java/LoadMain
  #30 = Utf8               java/lang/Object
  #31 = Utf8               java/lang/Integer
  #32 = Utf8               valueOf
  #33 = Utf8               (I)Ljava/lang/Integer;
  #34 = Utf8               com/dmz/spring/java/Parent
  #35 = Utf8               get
  #36 = Utf8               (Ljava/lang/Number;)Ljava/lang/Number;
{
  public com.dmz.spring.java.LoadMain();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/dmz/spring/java/LoadMain;guan

  public static void main(java.lang.String[]);
    // 方法的描述符,括号中的是参数,[Ljava/lang/String代表参数是一个String数组,V是返回值,代表void
    descriptor: ([Ljava/lang/String;)V
    // 方法的标志,public,static
    flags: ACC_PUBLIC, ACC_STATIC
    // 方法执行代码对应的字节码
    Code:
      // 操作数栈深为2,本地变量表中有2两个元素,参数个数为1
      stack=2, locals=2, args_size=1
         // 前三行指定对应的代码就是Parent person = new Son()
         // new指定,创建一个对象,并返回这个对象的引用
         0: new           #2                  // class com/dmz/spring/java/Son
         // dup指令,将new指令返回的引用进行备份,一个赋值给局部变量表中的值,另外一个用于执行invokespecial指令
         3: dup
         // 进行初始化
         4: invokespecial #3                  // Method com/dmz/spring/java/Son."<init>":()V   // 将创建出来的对象的引用存储到局部变量表中下标为1也就是第二个元素中,第一个元素存储的是main方法的参数
         7: astore_1
         // 将引用压入到操作数栈中,此时栈顶保存的是一个指向son类型对象的引用
         8: aload_1
         // 常数1压入操作数栈
         9: iconst_1
         // 执行常量池中 #4所对应的方法,也就是java/lang/Integer.valueOf方法
        10: invokestatic   #4                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
        // 真正调用get方法的指令          
        13: invokevirtual #5                  // Method com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;
        // 弹出操作数栈顶的值
        16: pop
        17: return
        // 代码行数跟指令的对应关系,比如在我的idea中,第10行代码对应的就是Parent person = new Son()
      LineNumberTable:
        line 10: 0
        line 11: 8
        line 12: 17
        // 局部变量表中的值
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      18     0  args   [Ljava/lang/String;
            8      10     1 person   Lcom/dmz/spring/java/Parent;
}
SourceFile: "LoadMain.java"

复制代码

接下来,我们使用图解的方式来对上面的字节码做进一步的分析

字节码图解1
字节码图解1
字节码分析2
字节码分析2
字节码分析3
字节码分析3

接下来就要执行invokevirtual指令,在执行这个指令我们将操作数栈的状态放大来看看

字节码图解4
字节码图解4

栈顶保存的是1,也就是执行对应方法的参数,栈底保存的是执行Parent person = new Son()得到的一个引用。

在上面的字节码中,我们发现invokevirtual指令后面跟了一个#5,这代表它引用了常量池中的第五号常量,对应的就是这个方法引用:

com/dmz/spring/java/Parent.get:(Ljava/lang/Number;)Ljava/lang/Number;

上面整个表达式代表了方法的签名,com/dmz/spring/java/Parent代表了方法所在类名,get代表方法名,(Ljava/lang/Number;)代表方法执行参数,Ljava/lang/Number代表方法返回值。

根据操作数栈的信息以及invokevirtual所引用的方法签名信息,我们不难得出这条指令要去执行person引用所指向的对象中的一个方法名为get方法参数为Number返回值为Number的方法,但是请注意,我们的Son对象中没有这样的一个方法,我们在Son中重写的方法是这样的

public Integer get(Number number)  {

    return 2;
}
复制代码

其返回值类型是Integer,可能有的同学会有疑问,Integer不是Number的子类吗?为什么不能识别呢?

嗯,我也没办法回答这个问题,JVM在对方法覆盖的定义就是这样,必须要方法签名相同

但是Java对于重写的定义呢?只是要求方法的返回值类型相同就行了,正是因为这二者的差异,导致了编译器不得不生成一个桥接方法来进行平衡。

那么到底是不是这样呢?我们不妨再来看看生成桥接方法的类的字节码,也就是Son.class的字节码,对应如下(只放关键的部分了,实在太占篇幅了):

 public java.lang.Integer get(java.lang.Number);
    descriptor: (Ljava/lang/Number;)Ljava/lang/Integer;
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=2, args_size=2
         0: iconst_2
         1: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
         4: areturn
      LineNumberTable:
        line 13: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/dmz/spring/java/Son;
            0       5     1 number   Ljava/lang/Number;

  public java.lang.Number get(java.lang.Number);
    descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
 // 看到这个ACC_BRIDGE的标记了吗,代表它就是桥接方法
    // ACC_SYNTHETIC,代表是编译器生成的,编译器生成的方法不一定是桥接方法,但是桥接方法一定是编译器生成的
 // ACC_PUBLIC不用说了吧
    flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         // 这一步看到了吗?调用了那个被桥接的方法,也就是我们真正定义的重写的方法
         2: invokevirtual #3                  // Method get:(Ljava/lang/Number;)Ljava/lang/Integer;
         5: areturn
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcom/dmz/spring/java/Son;

复制代码

总结

到这里你明白了吗?桥接方法到底桥接的什么?其实就是编译器对JVM到JAVA的一个桥接,编译器为了满足JAVA的重写的语义,生成了一个方法描述符与父类一致的方法,然后又调用了真实的我们定义的逻辑。这样既满足了JAVA重写的要求,也符合了JVM的规范。

如果本文对你由帮助的话,记得点个赞吧!也欢迎关注我的公众号,微信搜索:程序员DMZ,或者扫描下方二维码,跟着我一起认认真真学Java,踏踏实实做一个coder。

公众号
公众号

我叫DMZ,一个在学习路上匍匐前行的小菜鸟!