深入探究JVM之方法调用及Lambda表达式实现原理
@
前言
在最开始讲解JVM内存结构的时候有简单分析过方法的执行原理——每一次方法调用都会生成一个栈帧并压入栈中,方法链的执行就是一个个栈帧弹出栈的过程,本篇就从字节码层面详细分析方法的调用细节。
正文
解析
Java中方法的调用对应字节码有5条指令:
- invokestatic:用于调用静态方法。
- invokespecial:用于调用实例构造器<init>方法、私有方法和父类中的方法。
- invokevirtual:用于调用所有的虚方法。
- invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
invokedynamic与前4条指令不同的是,该指令分派的逻辑是由用户指定,用于支持动态类型语言特性(相关概念后文会详细描述)。
Java中有非虚方法和虚方法,前者是指在解析阶段可以确定的唯一的调用版本,如静态方法、构造器方法、父类方法(特指在子类中使用super调用,而不是在客户端使用对象引用调用)、私有方法(即使用invokestatic和invokespecial调用的方法)以及被final修饰的方法(使用invokevirtual调用),这些方法在类加载阶段就会把方法的符号引用解析为直接引用;除此之外的都是虚方法,虚方法则只能在运行期进行分派调用。
分派
分派分为静态和动态,同时还会根据宗量数(可以简单理解为影响方法选择的因素,如方法的接收者和参数)分为静态单分派、静态多分派、动态单分派、动态多分派。
静态分派
静态分派就是指根据静态类型(方法中定义的变量)来决定方法执行版本的分派动作,Java中典型的静态分派就是方法重载。下面先来看段代码示例:
public class StaticDispatch{
static abstract class Human{}
static class Man extends Human{ }
static class Woman extends Human{}
public void sayHello(Human guy){
System.out.println("hello,guy!");
}
public void sayHello(Man guy){
System.out.println("hello,gentleman!");
}
public void sayHello(Woman guy){
System.out.println("hello,lady!");
}
public static void main(String[] args) {
StaticDispatch sr = new StaticDispatch();
Human man = new Man();
Human woman = new Woman();
sr.sayHello(man);
sr.sayHello(woman);
}
}
下面的结果是否跟你想的是否一样呢?
hello,guy!
hello,guy!
这里全都是调用的参数为Human类型的方法,原因就是在main方法中定义的变量类型都是Human,这个就属于静态类型,而等于后面的对象则属于实际类型,实际类型只能在运行期间获取到,因此编译器在编译阶段时只能根据静态类型选取到对应的方法,所以这里打印的都是"hello,guy!"。
不过不要想当然的认为静态类型就只会匹配到一个唯一的方法,如果有自动拆、装箱,变长参数,向上转型等参数,就可以匹配到多个,不过它们是存在优先级关系的。
动态分派
Java里面的动态分派与它的多态性息息相关,即方法重写,如下面的代码:
public class DynamicDispatch {
static abstract class Virus{ //病毒
protected abstract void ill();//生病
}
static class Cold extends Virus{
@Override
protected void ill() {
System.out.println("感冒了,好不舒服!");
}
}
static class CoronaVirus extends Virus{//冠状病毒
@Override
protected void ill() {
System.out.println("粘膜感染,空气传播,请带好口罩!");
}
}
public static void main(String[] args) {
Virus clod=new Cold();
clod.ill();
clod = new CoronaVirus();
clod.ill();
}
}
这里的输出结果相信大家都清楚,但你是否深入考虑过它的调用细节呢?先来看看字节码:
public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/DynamicDispatch$Cold
3: dup
4: invokespecial #3 // Method ex8/DynamicDispatch$Cold."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
12: new #5 // class ex8/DynamicDispatch$CoronaVirus
15: dup
16: invokespecial #6 // Method ex8/DynamicDispatch$CoronaVirus."<init>":()V
19: astore_1
20: aload_1
21: invokevirtual #4 // Method ex8/DynamicDispatch$Virus.ill:()V
24: return
可以看到调用方法时都是通过invokevirtual指令调用的,但注释显示两次调用的常量池以及符号引用都是一样的,那为什么就会产生不同的结果呢?在《Java虚拟机规范》中规定了invokevirtual的调用逻辑:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常.
这里面第一步就是在运行期间找到接收者的实际类型,在真正调用方法时就是根据这个类型进行调用的,所以会产生不同的结果。不过需要注意的是字段不存在多态的概念,即invokevirtual指令对字段是无效的,当子类声明与父类同名的字段时,就会掩盖父类中的字段,如下面的代码:
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
}
输出结果如下:
I am Son, i have $0
I am Son, i have $4
This gay has $2
在创建Son对象时,首先会调用父类的构造器,而父类构造器又调用了showMeTheMoney方法,该方法会调用子类的版本,对应的拿到的字段也是子类中的,而此时子类构造器还没有执行,所以输出的money是0,但最后根据gay的静态类型输出money是2,即没有拿到运行中的实际类型,所以Java中字段是不存在动态分派的。
这里的解释看似合情合理,但仍然有一个问题,调用子类构造器首先会调用父类构造器,也就是说这时候子类还没有初始化完成,那为什么父类就可以调用子类的实例方法呢?这时候可以反编译main的字节码看看:
public static void main(java.lang.String[]);
Code:
0: new #2 // class ex8/Test$Son
3: dup
4: invokespecial #3 // Method ex8/Test$Son."<init>":()V
7: astore_1
8: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
11: new #5 // class java/lang/StringBuilder
14: dup
15: invokespecial #6 // Method java/lang/StringBuilder."<init>":()V
18: ldc #7 // String This gay has $
20: invokevirtual #8 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
23: aload_1
24: getfield #9 // Field ex8/Test$Father.money:I
27: invokevirtual #10 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
30: invokevirtual #11 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
33: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
36: return
}
重点看到第一句,首先就是调用new字节码创建对象并将其压入栈顶,也就是说在调用构造方法之前对象在内存中已经分配好了,所以在父类构造器中可以调用子类的实例方法。
单分派和多分派
Java是一门静态单分派,动态单分派的语言,读者如果充分理解了上文,这里是非常好理解的。再来看一段代码:
public class Dispatch {
static class QQ{}
static class WX{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose qq");
}
public void hardChoice(WX arg){
System.out.println("father choose weixin");
}
}
public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println("son choose qq");
}
public void hardChoice(WX arg){
System.out.println("son choose weixin");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new WX());
son.hardChoice(new QQ());
}
}
通过这段代码,我们可以看出,在编译阶段选取方法有两个影响因素:一是需要看静态类型是Father还是Son,二是方法参数。所以Java中静态分派属于静态多分派。而在运行阶段,调用的方法签名是已经确定了的,即不管参数的实际类型是“腾讯QQ”还是“奇瑞QQ”,走的都是hardChoice(QQ arg)方法,唯一的影响就是该方法的实际接收者,所以Java中的动态分派属于动态单分派。
动态分派的实现
说了这么多,虚拟机到底是怎么实现动态分派的呢?不可能在整个方法区去搜索寻找,那样效率是非常低的。实际上虚拟机在方法区会为每个类型建立一个虚方法表(支持invokevirtual 指令)以及接口方法表(支持invokeinterface指令),如下图:
方发表中存的是各个方法的实际入口地址,如果子类没有重写父类中的方法,那么父子类指向同一个地址,否则,子类就会指向自己重写后的方法入口地址。
Lambda表达式的实现原理
java8增加了对Lambda表达式的支持:
public static void main(String[] args) {
Runnable r = () -> System.out.println("Hello Lambda!");
r.run();
}
上面代码是Lambda表达式最简单的运用,有没有想过它的底层是怎么实现的呢?直接用javap -v命令反编译看看:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
0: invokedynamic #2, 0 // InvokeDynamic #0:run:()Ljava/lang/Runnable;
5: astore_1
6: aload_1
7: invokeinterface #3, 1 // InterfaceMethod java/lang/Runnable.run:()V
12: return
}
SourceFile: "LambdaDemo.java"
InnerClasses:
public static final #57= #56 of #60; //Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles
BootstrapMethods:
0: #27 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#28 ()V
#29 invokestatic ex8/LambdaDemo.lambda$main$0:()V
#28 ()V
我删掉了不重要的部分,可以看到Lambda的调用时通过invokedynamic指令实现的,另外从字节码中我们可以看到会生成Bootstrap Method引导方法,该方法存在于BootstrapMethods属性中,这个是JDK1.7新加入的。从这个属性我们可以发现Lambda表达式的最终是通过MethodHandle方法句柄来实现的,虚拟机会执行引导方法并获得返回的CallSite对象,通过这个对象最终调用到我们自己实现的方法上。
Lambda还分为捕获和非捕获,当从表达式外部获取了非静态的变量时,这个表达式就是捕获的,反之就是非捕获的,如下面两个方法:第一个方法就是非捕获的,第二个是捕获的。
public static void repeatMessage() {
Runnable r = () -> {
System.out.println("Hello Lambda!");
};
}
public static void repeatMessage(String msg, int num) {
Runnable r = () -> {
for (int i = 0; i < num; i++) {
System.out.println(msg);
}
};
}
非捕获的比捕获的Lambda表达式性能更高,因为前者只需要计算一次,而后者每次都要重新计算,但无论如何,最差的情况下和内部类性能也是差不多的,所以尽量使用非捕获的Lambda表达式。
关于Lambda的实现就讲解到这,下面主要来看看MethodHandle的使用。
MethodHandle
var arrays = {"abc", new ObjectX(), 123, Dog, Cat, Car..}
for(item in arrays){
item.sayHello();
}
上面这段代码在动态类型语言(类型检查的主体过程是在运行期而不是编译期进行)中是没有什么问题的,但是在Java中实现的话就会产生很多副作用,比如额外的性能开销(数组中每个类型都不一样,就会导致方法内联失去它本来的作用,还会带来更大的负担)。因此JDK1.7新加入invokedynamic指令和java.lang.invoke包,MethodHandle就存在于该包中,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制。下面来看看MehtodHandler的使用:
public class MethodHandleDemo {
static class Bike {
String sound() {
return "Bike sound";
}
}
static class Animal {
String sound() {
return "Animal sound";
}
}
static class Man extends Animal {
@Override
String sound() {
return "Man sound";
}
String listen() {
return "listen";
}
}
String invoke(Object o, String name) throws Throwable {
//方法句柄
MethodHandles.Lookup lookup = MethodHandles.lookup();
// MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)。
MethodType methodType = MethodType.methodType(String.class);
// 在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
MethodHandle methodHandle = lookup.findVirtual(o.getClass(), name, methodType);
String obj = (String) methodHandle.invoke(o);
return obj;
}
public static void main(String[] args) throws Throwable {
String str = new MethodHandleDemo().invoke(new Bike(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Animal(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "sound");
System.out.println(str);
str = new MethodHandleDemo().invoke(new Man(), "listen");
System.out.println(str);
}
}
MethodType是用于指定方法的返回类型和参数,然后通过MethodHandles.Lookup模拟字节码的调用,因此对应的有findVirtual、findStatic、findSpecial等方法,这些方法就会返回一个MethodHandle的对象,最终通过这个对象的invoke或者invokeExact方法就能调用实际想要调用的对象方法(这里需要注意的是前者是松散匹配,即可以自动转型,而后者则必须是精确匹配,参数返回值类型都必须一样,否则就会报错)。
通过上面的代码我们知道,在运行中不论实际类型是什么,只要有方法签名以及返回值能对应上,就能调用成功,相当于动态的替换了符号引用中的静态类型部分,也解决了动态语言对方法内联等编译优化的不良影响。
另外我们可以发现MethodHandle在功能和使用上都和反射差不多,但是使用更加简单,也更轻量级,对应的性能也比反射要高。
总结
静态分派和动态分派在Java中都是支持的,并且是静态多分派,动态单分派;深刻理解分派的原理以及方法的分派规则,才能更好的理解程序的运行过程。另外为什么会出现MethodHandle类,它能给我们带来哪些便利,熟悉并掌握可以让我们写出更灵活的程序。
深入探究JVM之方法调用及Lambda表达式实现原理的更多相关文章
- Java 8新特性探究(一) JEP126特性lambda表达式和默认方法
Lambda语法 函数式接口 函数式接口(functional interface 也叫功能性接口,其实是同一个东西).简单来说,函数式接口是只包含一个方法的接口.比如Java标准库中的java.la ...
- JVM系列-方法调用的原理
JVM系列-方法调用的原理 最近重新看了一些JVM方面的笔记和资料,收获颇丰,尤其解决了长久以来心中关于JVM方法管理的一些疑问.下面介绍一下JVM中有关方法调用的知识. 目的 方法调用,目的是选择方 ...
- Effective Java 第三版——43.方法引用优于lambda表达式
Tips <Effective Java, Third Edition>一书英文版已经出版,这本书的第二版想必很多人都读过,号称Java四大名著之一,不过第二版2009年出版,到现在已经将 ...
- 反射调用与Lambda表达式调用
想调用一个方法很容易,直接代码调用就行,这人人都会.其次呢,还可以使用反射.不过通过反射调用的性能会远远低于直接调用——至少从绝对时间上来看的确是这样.虽然这是个众所周知的现象,我们还是来写个程序来验 ...
- 委托+内置委托方法+多播委托+lambda表达式+事件
委托概念:如果我们要把方法当做参数来传递的话,就要用到委托.简单来说委托是一个类型,这个类型可以赋值一个方法的引用. 声明委托: 在C#中使用一个类分两个阶段,首选定义这个类,告诉编译器这个类由什么字 ...
- 多播委托和匿名方法再加上Lambda表达式
多播委托就是好几个方法全都委托给一个委托变量 代码: namespace 委托 { class Program { static void math1() { Console.WriteLine(&q ...
- Lambda表达式运行原理
目录 一.创建测试样例 二.利用Java命令编译分析 三.文末 JDK8引入了Lambda表达式以后,对我们写代码提供了很大的便利,那么Lambda表达式是如何运用简单表示来达到运行效果的呢?今天,我 ...
- 推迟调用以及Lambda表达式
背景 GMock 我们项目中现在的模块测试框架使用了CATCH+GMock的方式实现回归测试和打桩. GMock的介绍在官网上有,这里为了铺垫,大概地描述一下GMock能实现的效果.大约可以看成这样: ...
- Java 8新特性:新语法方法引用和Lambda表达式及全新的Stream API
新语法 方法引用Method references Lambda语法 Lambda语法在AndroidStudio中报错 Stream API 我正参加2016CSDN博客之星的比赛 希望您能投下宝贵 ...
随机推荐
- java 面向对象(二十):类的结构:代码块
类的成员之四:代码块(初始化块)(重要性较属性.方法.构造器差一些)1.代码块的作用:用来初始化类.对象的信息2.分类:代码块要是使用修饰符,只能使用static分类:静态代码块 vs 非静态代码块3 ...
- 数据可视化之powerBI技巧(三)这个Power BI技巧很可爱:利用DAX制作时钟
周末放松一下,给大家分享一个小技巧,仅利用DAX制作一个简易的时钟. 时钟效果如下: 这个时钟的制作只需一个度量值,你信吗? 事实上确实如此,制作步骤介绍如下: 1,新建参数,生成一个数字序列作为小时 ...
- vpp之clib.h分析
vpp代码中有一个clib.h,其中封装了很一些很经典的位运算: //计算以2为底的对数,log2(x) //也就是计算2的N次方为x.x为uint32类型 #if defined (count_le ...
- 用Vue实现一个简单的图片轮播
本文已收录至https://github.com/likekk/studyBlog欢迎大家star,共同学习,共同进步.如果文章有错误的地方,欢迎大家指出.后期将在将GitHub上规划前端学习的路线和 ...
- 树形dp 之 小胖守皇宫
题目描述 huyichen世子事件后,xuzhenyi成了皇上特聘的御前一品侍卫. 皇宫以午门为起点,直到后宫嫔妃们的寝宫,呈一棵树的形状:有边相连的宫殿间可以互相望见.大内保卫森严,三步一岗,五步一 ...
- Module not found: Error: Can't resolve './style':配置 extensions 的坑
ERROR in ./src/index.js Module not found: Error: Can't resolve './style' in 'D:\gitcode\github\learn ...
- Monster Audio 使用教程 (七) 防止声音过大,出现爆音
有用户反映,如果音乐音量过大,会出现爆音. 这其实是音频信号过载了.只要最后输出的音量超过0db,就会出现爆音,这是数字音频都应该注意的问题. 所以,为了解决这个问题,限制器就出现了,它能把音频信号压 ...
- justoj connect(边的处理)
CONNECT https://oj.ismdeep.com/contest/problem?id=1702&pid=2 Problem Description 有nn个顶点,每个顶点有自己的 ...
- Java对象公约
灵魂static关键字 Java规定:方法只能由对象来调用. 换句话来说,在面向对象的思维下,方法与对象存在一种强耦合. static作用:即使没有初始化对象,也可以调用方法.(类比到属性上同样如此) ...
- Java基础-语法基础
一.Java中的关键字和保留字 关键字:某种语言赋予了特殊含义的单词 保留字:没有赋予特殊含义,但是准备日后要使用的单词 二.Java中的标识符 其实就是在从程序中自定义的名词.比如类名.变量名,函数 ...