点赞关注,再也不迷路,你的支持对我意义重大!前端
🔥 Hi,我是丑丑。本文 GitHub · Android-NoteBook 已收录,这里有 Android 进阶成长路线笔记 & 博客,欢迎跟着彭丑丑一块儿成长。(联系方式在 GitHub)java
首先,尝试写出如下程序的输出:git
public class Base {
public static void funcStatic(String str){
System.out.println("Base - funcStatic - String");
}
public static void funcStatic(Object obj){
System.out.println("Base - funcStatic - Object");
}
public void func(String str){
System.out.println("Base - func - String");
}
public void func(Object obj){
System.out.println("Base - func - Object");
}
}
public class Child extends Base {
public static void funcStatic(String str){
System.out.println("Child - funcStatic - String");
}
public static void funcStatic(Object obj){
System.out.println("Child - funcStatic - Object");
}
@Override
public void func(String str){
System.out.println("Child - func - String");
}
@Override
public void func(Object obj){
System.out.println("Child - func - Object");
}
}
复制代码
public class Test{
public static void main(String[] args){
Object obj = new Object();
Object str = new String();
Base base = new Base();
Base child1 = new Child();
Child child2 = new Child();
base.funcStatic(obj); // 正常编程中不该该用实例去调用静态方法
child1.funcStatic(obj);
child2.funcStatic(obj);
base.func(str);
child1.func(str);
child2.func(str);
}
}
复制代码
程序输出:github
Base - funcStatic - Object
Base - funcStatic - Object
Child - funcStatic - Object
Base - func - Object
Child - func - Object
Child - func - Object
复制代码
程序输出是否与你的预期一致呢?遇到困难了吗,相信这篇文章必定能帮到你...面试
对于Java
编译过程不了解,请阅读:《Java | 聊一聊编译过程(编译前端 & 编译后端)》编程
对于Class 文件 & 符号引用
不了解,请阅读:《Java | 请概述一下 Class 文件的结构》后端
对于类加载
的流程不太了解,请阅读:《Java | 谈谈你对类加载过程的理解》markdown
每个变量都有两种类型:静态类型(Static Type) & 实际类型(Actual Type)。例以下面代码中,Base
为变量base
的静态类型,Child
为实际类型:ide
Base base = new Child();
复制代码
二者的具体区别以下:函数
这里先谈到这里,后文会从字节码的角度理解继续讨论两个类型。
这一节,咱们来讨论Java
中方法调用的本质。咱们知道,Java
前端编译的产物是字节码,与C/C++
不一样,前端编译过程当中并无连接步骤,字节码中全部的方法调用都是使用符号引用。举个例子:
- 源码:
public class Child extends Base {
@Override
void func() {
}
void test1(){
func();
}
void test2(){
super.func();
}
}
- 字节码(javap -c Child.class):
Compiled from "Child.java"
public class com.Child extends com.Base {
// 构造函数,默认调用父类构造函数
public com.Child();
Code:
0: aload_0
1: invokespecial #1 // Method com/Base."<init>":()V
4: return
void func();
Code:
0: return
void test1();
Code:
0: aload_0
// invokevirtual 调用实例方法
1: invokevirtual #2 // Method func:()V
4: return
void test2();
Code:
0: aload_0
// invokespecial 调用静态方法
1: invokespecial #3 // Method com/Base.func:()V
4: return
}
复制代码
上面的字节码中,invokespecial
和invokevirtual
都是方法调用的字节码指令,具体细节下文会详细解释。后面的#1 #2 #3
表示符号引用在常量池中的索引号,根据这个索引号检索常量表,能够查到最终表示的是一个字符串字面量,例如func:()V
,这个就是方法的符号引用。
为了方便理解字节码,
javap
反编译的字节码已经在注释中提示了最终表示的值,例如Method func:()V
。
符号引用(Symbolic References)是一个用来无歧义地标识一个实体(例如方法/字段)的字符串,在运行期它会翻译为直接引用(Direct Reference)。对于方法来讲,就是方法的入口地址。
下图描述了方法符号引用的基本格式:
这个符号引用包含了变量的静态类型(若是是变量的静态类型与本类相同,不须要指明)、简单方法名以及描述符(参数顺序、参数类型和方法返回值)。经过这个符号引用,Java虚拟机就能够翻译出该方法的直接引用。可是,同一个符号引用,运行时翻译出来的直接引用多是不一样的,为何会这样呢?
1. 方法调用的本质是根据方法的符号引用肯定方法的直接引用(入口地址)
为何同一个符号引用,运行时翻译出来的直接引用多是不一样的? 这与使用的方法调用指令的处理过程有关,Java
字节码的方法调用指令一共有如下 5 种:
其中,根据调用方法的版本是否在编译期能够肯定,(注意:只是版本,而不是入口地址,入口地址只能在运行时肯定)能够将方法调用划分为静态解析 & 动态分派两种。
# 误区(重要)#
《深刻理解Java虚拟机》中将方法调用分为解析、静态分派、动态分派三种,又根据宗量的数量引入了静态多分派,动态单分派的概念。这些概念事实上过于字典化,也很容易让读者误认为静态分派与动态分派是非此即彼的互斥关系。事实上,一个方法能够同时重写与重载 ,重载 & 重写是方法调用的两个阶段,而不是两个种类。
下面,我将介绍Java
中方法选择的三个步骤:
上一节咱们提到过方法符号引用的基本格式,分为三个部分:
类的全限定名中将.
替换为/
,例如java.lang.Object
对应java/lang/Object
方法的名称,例如Object#toString()
的简单名称为:toString
方法的参数列表和返回值,例如Object#toString()
的描述符为()LJava/lang/String;
描述符的规则不是本文重点,这里便再也不赘述了,若不了解可阅读延伸文章。这里咱们用两段程序验证上述规则,这两段程序中咱们考虑了重载 & 重写、静态 & 实例两个维度的因素:
程序一(重载 & 重写)
public class Base {
public void func() {}
public void func(int i){}
}
public class Child extends Base {
@Override
public void func() {}
@Override
public void func(int i){}
}
public class Test{
public static void main(String[] args){
Base base1 = new Base();
Base child1 = new Child();
Child child2 = new Child();
base1.func(); // invokevirtual com.Base.func:():V
child1.func(); // invokevirtual com.Base.func:():V
child2.func(); // invokevirtual com.Child.func:():V
base1.func(1); // invokevirtual com.Base.func:(I):V
child1.func(1); // invokevirtual com.Base.func:(I):V
child2.func(1); // invokevirtual com.Child.func:(I):V
}
}
复制代码
能够看到,符号引用中的类名确实是变量的静态类型,而不是变量的实际类型;方法名不用多说,方法描述符则选择重载方法中最合适的一个方法。这个例程很容易判断重载方法选择结果,具体选择规则其实更为复杂。
程序二(静态 & 实例)
public class Base {
public static void func() {}
public void func(int i){}
}
public class Child extends Base {
public static void func() {}
@Override
public void func(int i){}
}
public class Test{
public static void main(String[] args){
Base base1 = new Base();
Base child1 = new Child();
Child child2 = new Child();
符号引用与程序一相同,仅指令不一样
base1.func(); // invokestatic com.Base.func:():V
child1.func(); // invokestatic com.Base.func:():V
child2.func(); // invokestatic com.Child.func:():V
base1.func(1); // invokevirtual com.Base.func:(I):V
child1.func(1); // invokevirtual com.Base.func:(I):V
child2.func(1); // invokevirtual com.Child.func:(I):V
}
}
复制代码
能够看到,static
对符号引用没有影响,仅影响使用的指令(静态方法调用使用invokestatic
)。而经过对象实例去调用静态方法是javac
的语法糖,编译时会转换为使用变量的静态类型固化到符号引用中。
1. 方法的符号引用在编译期肯定,并固化到字节码中方法调用指令的参数中
2. 是否有static
修饰对符号引用没有影响,仅影响使用的字节码指令,对象实例去调用静态方法是javac
的语法糖
为何静态方法、私有实例方法、实例构造器、父类方法以及final修饰这五种方法(对应的关键字: static、private、<init>、super、final
)能够在编译期肯定版本呢?由于不管运行时加载多少个类,这些方法都保证惟一的版本:
方法 | 缘由 |
---|---|
static |
相同签名的子类方法会隐藏父类方法 |
private |
只在本类可见 |
<init> |
由编译器生成,源码没法编写 |
super |
Java 是单继承,只有一个父类 |
final |
禁止被重写 |
既然能够肯定方法的版本,虚拟机在处理invokestatic
、invokespecial
、invokevirtual(final)
时,就能够提早将符号引用转换为直接引用,没必要延迟到方法调用时肯定,具体来讲,是在类加载的解析阶段完成转换的。
1)类加载解析阶段:根据符号引用中类名(以下例中java/lang/String
变量的静态类型中),在对应的类中找到简单名称与描述符相符合的方法,若是找到则将符号引用转换为直接引用;不然,按照继承关系从下往上依次在各个父类中搜索
2)调用阶段:符号引用已经转换为直接引用;调用invokestatic
不须要将对象加载到操做数栈,只须要将所须要的参数入栈就能够执行invokestatic
指令。例如:
源码:
String str = String.valueOf("1")
字节码:
0: iconst_1
1: invokestatic #2 // Method java/lang/String.valueOf:(I)Ljava/lang/String;
4: astore_1
复制代码
1)类加载解析阶段:同invokestatic
,也是从符号引用中的静态类型开始查找
2)调用阶段:同invokestatic
,符号引用已经转换为直接引用;、父类方法、私有实例方法这3种状况都是属于实例方法,因此调用invokespecial
指令须要将对象加载到操做数栈。例如:
一、源码(实例构造器):
String str = new String();
字节码:
0: new #2 // class java/lang/String
3: dup
4: invokespecial #3 // Method java/lang/String."<init>":()V
7: astore_1
--------------------------------------------------------------------
二、源码(父类方法):
super.func();
字节码:
0: aload_0
1: invokespecial #2 // Method com/Base.func:()V
--------------------------------------------------------------------
三、源码(私有方法):
funcPrivate();
字节码:
0: aload_0
1: invokespecial #2 // Method funPrivate:()V
复制代码
动态分派分为invokevitrual
、invokeinterface
与 invokedynamic
,其中动态调用invokedynamic
是 JDK 1.7 新增的指令,咱们单独在另外一篇中解析。有些同窗可能会以为方法不重写不就只有一个版本了吗?这个想法忽略了Java
动态连接的特性,Java
能够从任何途径加载一个class
,除非解析的 5 种的状况外,没法保证方法不被重写。
虚拟机为每一个类生成虚方法表vtable(virtual method table)
的结构,类中声明的方法的入口地址会按固定顺序存放在虚方法表中;虚方法表还会继承父类的虚方法表,顺序与父类保持一致,子类新增的方法按顺序添加到虚方法末尾(这以Java
单继承为前提);若子类重写父类方法,则重写方法位置的入口地址修改成子类实现;
Class B
继承与Class A
,并重写了A
中的方法:Object
是全部类的父类,全部每一个类的虚方法表头部都会包含Object
的虚方法表。另外,B
重写了A#printMe()
,因此对应位置的入口地址方法被修改成B
重写方法的入口地址。
须要注意的是,被final
、static
或private
修饰的方法不会出如今虚方法表中,由于这些方法没法被继承重写。
接口方法的选择行为与类方法的选择行为略有区别,主要缘由是Java
接口是支持多继承的,就没办法像虚方法表那样直接继承父类的虚方法表。虚拟机提供了itable(interface method table)
来支持多接口,itable
由偏移量表offset table
与方法表method table
两部分组成。
当须要调用某个接口方法时,虚拟机会在offset table
查找对应的method table
,随后在该method table
上查找方法。
invokestatic & invokespecial
能够直接调用方法入口地址,最快invokevirtual
经过编号在vtable
中查找方法,次之invokeinterface
如今offset table
中查找method table
的偏移位置,随后在method table
中查找接口方法的实现invokestatic & invokespecial
指令在(类加载时)解析时根据静态类型完成转换invokevirtual & invokeinterface
在(调用时)根据实际类型,查找vtable & itable
完成转换vtable & itable
来支持虚方法的方法选择。创做不易,你的「三连」是丑丑最大的动力,咱们下次见!