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

前言

之因此写这么一篇文章是由于在Spring中,常常会出现下面这种代码html

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

这些代码对我以前也形成了不小疑惑,在完全弄懂后经过本文分享出来,也能减小你们在阅读代码过程当中的障碍!java

桥接方法

何时会出现桥接方法?

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

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类中就出现了桥接方法程序员

看到上面的代码的执行结果,你们确定会有这么两个疑问web

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

有这些疑问不要紧,咱们带着疑问往下看。spring

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

子类对父类的方法进行了重写,而且子类方法中的返回值类型跟父类方法的返回值类型不同!!!!微信

那么究竟是不是这个缘由致使的呢?咱们不妨将上面例子中Son类的代码更改以下:oracle

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上申明的 复制代码

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

那么到如今咱们就基本能肯定了是由于重写的时候子父类方法返回值不一致致使出现了桥接方法。ide

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

参考连接: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,一个在学习路上匍匐前行的小菜鸟!

相关文章
相关标签/搜索