关注公众号java后端技术全栈

回复“000”获取优质面试资料

大家好,我是老田,今天来和大家聊聊Java方法调用的底层原理。

我们在日常开发中,其实很少去关注字节码层面的东西。但,作为我们的吃饭家伙,个人觉得还是很有必要了解的。

Java源码(我们开发出来的.java结尾的文章)在运行之前都要编译成为字节码格式(如.class文件),然后由ClassLoader将字节码载入运行。在字节码文件中,指令代码只是其中的一部分,里面还记录了字节码文件的编译版本、常量池、访问权限、所有成员变量和成员方法等信息。

Java指令是基于栈的体系结构,大部分的指令默认的操作数在栈中。映像中ARM是基于寄存器的操作指令,而x86好像是混合寄存器和存储器的,发现基于栈的操作指令确实简单,学起来很快。

我的理解,网络是Java一个非常重要的特性,而且Java在设计之初就认为字节码是要在网络中传输的,为了减少网络传输流量,字节码就要尽量设计精简、紧凑。因而Java增加了很多重复指令,比如尽量减少操作数,因而我们会发现Java的很多指令都是没有操作数的;并且指令中的操作数基本上都是当无法将值放到栈中的数据,比如局部变量的索引号和常量池中的索引号。

字节码结构

基本结构

在开始之前,我们先简要地介绍一下class文件的内容。关于class 文件结构的资料已经非常多了,这里不再展开讲解了。

官网:

https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-4.html

大体介绍如下:

下面来简单介绍一下图中的一些关键字:

magic:魔法数字,用于标识当前 class 的文件格式,JVM 可据此判断该文件是否可以被解析,目前固定为 0xCAFEBABE

major_version:主版本号。

minor_version:副版本号,这两个版本号用来标识编译时的 JDK 版本,常见的一个异常比如 Unsupported major.minor version 52.0 就是因为运行时的 JDK 版本低于编译时的 JDK 版本(52 是 Java 8 的主版本号)。

constant_pool_count:常量池计数器,等于常量池中的成员数加 1。

constant_pool:常量池,是一种表结构,包含 class 文件结构和子结构中引用的所有字符串常量,类或者接口名,字段名和其他常量。

access_flags:表示某个类或者接口的访问权限和属性。

this_class:类索引,该值必须是对常量池中某个常量的一个有效索引值,该索引处的成员必须是一个 CONSTANT_Class_info类型的结构体,表示这个 class 文件所定义的类和接口。

super_class:父类索引。

interfaces_count:接口计数器,表示当前类或者接口直接继承接口的数量。

interfaces:接口表,是一个表结构,成员同 this_class,是对常量池中 CONSTANT_Class_info 类型的一个有效索引值。

fields_count:字段计数器,当前 class 文件所有字段的数量。

fields:字段表,是一个表结构,表中每个成员必须是 filed_info 数据结构,用于表示当前类或者接口的某个字段的完整描述,但它不包含从父类或者父接口继承的字段。

methods_count:方法计数器,表示当前类方法表的成员个数。

methods:方法表,是一个表结构,表中每个成员必须是 method_info 数据结构,用于表示当前类或者接口的某个方法的完整描述。

attributes_count:属性计数器,表示当前 class 文件 attributes属性表的成员个数。

attributes:属性表,是一个表结构,表中每个成员必须是 attribute_info数据结构,这里的属性是对 class 文件本身,方法或者字段的补充描述,比如 SourceFile 属性用于表示 class 文件的源代码文件名。

当然,class 文件结构的细节是非常多的,如上图,展示了一个简单方法的字节码描述,可以看到真正的执行指令在整个文件结构中的位置。

实际观测

为了避免枯燥的二进制对比分析,直接定位到真正的数据结构,这里介绍一个小工具,使用这种方式学习字节码会节省很多时间。这个工具就是 asmtools,官网

https://wiki.openjdk.java.net/display/CodeTools/asmtools

为了方便使用,我已经编译了一个 jar 包,放在了仓库里。

执行下面的命令,将看到类的 JCOD 语法结果。

java -jar asmtools-7.0.jar jdec LambdaDemo.class

输出的结果类似于下面的结构,它与我们上面介绍的字节码组成是一一对应的,对照官网或者资料去学习,速度飞快。若想要细挖字节码,一定要掌握好它。

class LambdaDemo {0xCAFEBABE;0; // minor version52; // version[] { // Constant Pool; // first element is emptyMethod #8 #25; // #1InvokeDynamic 0s #30; // #2InterfaceMethod #31 #32; // #3Field #33 #34; // #4String #35; // #5Method #36 #37; // #6class #38; // #7class #39; // #8Utf8 "<init>"; // #9Utf8 "()V"; // #10Utf8 "Code"; // #11

了解了类的文件组织方式,下面我们来看一下,类文件在加载到内存中以后,是一个怎样的表现形式。

内存表示

准备以下代码,使用javac -g InvokeDemo.java进行编译,然后使用 java 命令执行。程序将阻塞在 sleep 函数上,我们来看一下它的内存分布:

interface I {default void infMethod() { }void inf();
}abstract class Abs {abstract void abs();
}public class InvokeDemo extends Abs implements I {static void staticMethod() { }private void privateMethod() { }public void publicMethod() { }@Overridepublic void inf() { }@Overridevoid abs() { }public static void main(String[] args) throws Exception{InvokeDemo demo = new InvokeDemo();InvokeDemo.staticMethod();demo.abs();((Abs) demo).abs();demo.inf();((I) demo).inf();demo.privateMethod();demo.publicMethod();demo.infMethod();((I) demo).infMethod();Thread.sleep(Integer.MAX_VAL)}
}

为了更加明显的看到这个过程,下面介绍一个jhsdb工具,这是在 Java 9 之后 JDK 先加入的调试工具,我们可以在命令行中使用jhsdb hsdb来启动它。注意,要加载相应的进程时,必须确保是同一个版本的应用进程,否则会产生报错。

attach 启动 Java 进程后,可以在 Class Browser菜单中查看加载的所有类信息。我们在搜索框中输入InvokeDemo,找到要查看的类。

@符号后面的,就是具体的内存地址,我们可以复制一个,然后在 Inspector 视图中查看具体的属性,可以大体认为这就是类在方法区的具体存储。

Inspector 视图中,我们找到方法相关的属性 _methods,可惜它无法点开,也无法查看。

接下来使用命令行来检查这个数组里面的值。打开菜单中的Console,然后输入examine 命令,可以看到这个数组里的内容,对应的地址就是 Class 视图中的方法地址。

examine 0x000000010e650570/10

我们可以在 Inspect 视图中看到方法所对应的内存信息,这确实是一个 Method 方法的表示。

相比较起来,对象就简单了,它只需要保存一个到达 Class 对象的指针即可。我们需要先从对象视图中进入,然后找到它,一步步进入 Inspect 视图。

由以上的这些分析,可以得出下面这张图。执行引擎想要运行某个对象的方法,需要先在栈上找到这个对象的引用,然后再通过对象的指针,找到相应的方法字节码。

方法调用指令

关于方法的调用,Java 共提供了 5 个指令,来调用不同类型的函数:

  • invokestatic 用来调用静态方法;

  • invokevirtual 用于调用非私有实例方法,比如 public 和 protected,大多数方法调用属于这一种;

  • invokeinterface和上面这条指令类似,不过作用于接口类;

  • invokespecial用于调用私有实例方法、构造器及 super 关键字等;

  • invokedynamic用于调用动态方法。

我们依然使用上面的代码片段来看一下前四个指令的使用场景。代码中包含一个接口 I、一个抽象类 Abs、一个实现和继承了两者类的InvokeDemo

回想类加载机制,在 class 文件被加载到方法区以后,就完成了从符号引用到具体地址的转换过程。

我们可以看一下编译后的 main 方法字节码,尤其需要注意的是对于接口方法的调用。使用实例对象直接调用,和强制转化成接口调用,所调用的字节码指令分别是invokevirtualinvokeinterface,它们是有所不同的。

public static void main(java.lang.String[]);descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=2, locals=2, args_size=10: new           #2                  // class InvokeDemo3: dup4: invokespecial #3                  // Method "<init>":()V7: astore_18: invokestatic  #4                  // Method staticMethod:()V11: aload_112: invokevirtual #5                  // Method abs:()V15: aload_116: invokevirtual #6                  // Method Abs.abs:()V19: aload_120: invokevirtual #7                  // Method inf:()V23: aload_124: invokeinterface #8,  1            // InterfaceMethod I.inf:()V29: aload_130: invokespecial #9                  // Method privateMethod:()V33: aload_134: invokevirtual #10                 // Method publicMethod:()V37: aload_138: invokevirtual #11                 // Method infMethod:()V41: aload_142: invokeinterface #12,  1           // InterfaceMethod I.infMethod:()V47: return

另外还有一点,和我们想象中的不同,大多数普通方法调用,使用的是 invokevirtual指令,它其实和invokeinterface是一类的,都属于虚方法调用。很多时候,JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程。

invokevirtual指令有多态查找的机制,该指令运行时,解析过程如下:

  • 找到操作数栈顶的第一个元素所指向的对象实际类型,记做 c;

  • 如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法直接引用,查找过程结束,不通过则返回 java.lang.IllegalAccessError

  • 否则,按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程;

  • 如果始终没找到合适的方法,则抛出 java.lang.AbstractMethodError异常,这就是 Java 语言中方法重写的本质。

相对比,invokestatic 指令加上invokespecial指令,就属于静态绑定过程。

所以静态绑定,指的是能够直接识别目标方法的情况,而动态绑定指的是需要在运行过程中根据调用者的类型来确定目标方法的情况。

可以想象,相对于静态绑定的方法调用来说,动态绑定的调用会更加耗时一些。由于方法的调用非常的频繁,JVM 对动态调用的代码进行了比较多的优化,比如使用方法表来加快对具体方法的寻址,以及使用更快的缓冲区来直接寻址( 内联缓存)。

invokedynamic

有时候在写一些Python 脚本或者JS 脚本时,特别羡慕这些动态语言。如果把查找目标方法的决定权,从虚拟机转嫁给用户代码,我们就会有更高的自由度。

之所以单独把 invokedynamic抽离出来介绍,是因为它比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高得多。

这个指令通常在 Lambda语法中出现,我们来看一下一小段代码:

public class LambdaDemo {public static void main(String[] args) {Runnable r = () -> System.out.println("Hello Lambda");r.run();}
}

使用javap -p -v命令可以在 main 方法中看到invokedynamic指令:

public static void main(java.lang.String[]);descriptor: ([Ljava/lang/String;)Vflags: ACC_PUBLIC, ACC_STATICCode:stack=1, locals=2, args_size=10: invokedynamic #2,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;5: astore_16: aload_17: invokeinterface #3,  1            // InterfaceMethod java/lang/Runnable.run:()V12: return

另外,我们在javap 的输出中找到了一些奇怪的东西:

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 LambdaDemo.lambda$main$0:()V#28 ()V

BootstrapMethods属性在 Java 1.7 以后才有,位于类文件的属性列表中,这个属性用于保存invokedynamic指令引用的引导方法限定符。

和上面介绍的四个指令不同,invokedynamic并没有确切的接受对象,取而代之的,是一个叫CallSite的对象。

static CallSite bootstrap(MethodHandles.Lookup caller, String name, MethodType type);

其实,invokedynamic 指令的底层,是使用方法句柄(MethodHandle)来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法,以及虚构的 get 和 set 方法,从 IDEA 中可以看到这些函数。

句柄类型MethodType)是我们对方法的具体描述,配合方法名称,能够定位到一类函数。访问方法句柄和调用原来的指令基本一致,但它的调用异常,包括一些权限检查,在运行时才能被发现。

下面这段代码,可以完成一些动态语言的特性,通过方法名称和传入的对象主体,进行不同的调用,而 Bike 和 Man 类,可以没有任何关系。

import java.lang.invoke.MethodHandle;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.MethodType;public class MethodHandleDemo {static class Bike {String sound() {return "ding ding";}}static class Animal {String sound() {return "wow wow";}}static class Man extends Animal {@OverrideString sound() {return "hou hou";}}String sound(Object o) throws Throwable {MethodHandles.Lookup lookup = MethodHandles.lookup();MethodType methodType = MethodType.methodType(String.class);MethodHandle methodHandle = lookup.findVirtual(o.getClass(), "sound", methodType);String obj = (String) methodHandle.invoke(o);return obj;}public static void main(String[] args) throws Throwable {String str = new MethodHandleDemo().sound(new Bike());System.out.println(str);str = new MethodHandleDemo().sound(new Animal());System.out.println(str);str = new MethodHandleDemo().sound(new Man());System.out.println(str);}
}

可以看到Lambda语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着 Lambda 性能低呢?对于大部分“非捕获”的 Lambda 表达式来说,JIT 编译器逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,则需要通过方法句柄,不断地生成适配器,性能自然就低了很多(不过和便捷性相比,一丁点性能损失是可接受的)。

除了Lambda表达式,我们还没有其他的方式来产生invokedynamic指令。但可以使用一些外部的字节码修改工具,比如ASM,来生成一些带有这个指令的字节码,这通常能够完成一些非常酷的功能,比如完成一门弱类型检查的 JVM-Base语言。

总结

从 Java 字节码的顶层结构介绍开始,通过一个实际代码,了解了类加载以后,在 JVM内存里的表现形式,并学习了 jhsdb 对 Java 进程的观测方式。

关于每个字节代码的含义,我建议给大家推荐一个已经翻译好的字节码对应表:

字节码指令有200来个,所以这里就不一一贴出来了。如果想了解更多字节码相关文章,我给大家找了一个博客:

http://www.blogjava.net/DLevin/category/48888.html

建议自己写一个简单Java代码,然后通过文章提到的命令,在结合我给大家推荐博客,看起来你会觉得很爽。

好了,今天就分享到这里了。

三连===点赞+在看+关注,老铁三连走起

推荐阅读

  • 离职后,10天面试 4 家公司的总结

  • 一套Spring Cloud Alibaba视频教程

  • 后端开发人员,学习之路

  • 后端面试 23万,500多页,牛逼!

  • 21篇MySQL面试文章汇总

  • Spring Boot + Redis 实现接口幂等性(附代码)

揭密 Java方法调用的底层原理相关推荐

  1. java方法调用之动态调用多态(重写override)的实现原理——方法表

    转自:http://blog.csdn.net/fan2012huan/article/details/51007517 上两篇篇博文讨论了java的重载(overload)与重写(override) ...

  2. 并发编程五:java并发线程池底层原理详解和源码分析

    文章目录 java并发线程池底层原理详解和源码分析 线程和线程池性能对比 Executors创建的三种线程池分析 自定义线程池分析 线程池源码分析 继承关系 ThreadPoolExecutor源码分 ...

  3. JAVA方法调用中的解析与分派

    JAVA方法调用中的解析与分派 本文算是<深入理解JVM>的读书笔记,参考书中的相关代码示例,从字节码指令角度看看解析与分派的区别. 方法调用,其实就是要回答一个问题:JVM在执行一个方法 ...

  4. java方法调用机制_Java方法调用机制 - osc_bkdv2it5的个人空间 - OSCHINA - 中文开源技术交流社区...

    最近在编程时,修改方法传入对象的对象引用,并没有将修改反映到调用方法中.奇怪为什么结果没有变化,原因是遗忘了Java对象引用和内存分配机制.本文介绍3个点: ① 该问题举例说明 ② 简要阐述Java内 ...

  5. java方法调用之单分派与多分派(二)

    上篇博文java方法调用之重载.重写的调用原理(一) 讨论了重写与重载的实现原理,这篇博文讨论下单分派与多分派. 单分派.多分派 方法的接收者和方法的参数统称为方法的宗量. 根据分派基于宗量多少(接收 ...

  6. java调用方法出现i 2a_性能-Java方法调用与使用变量

    性能-Java方法调用与使用变量 最近,我与团队负责人就使用临时变量与调用getter方法进行了讨论. 很长时间以来,我一直认为,如果我必须多次调用一个简单的getter方法,我会将其放入一个temp ...

  7. 没有与参数列表匹配的 重载函数 getline 实例_面试题:方法重载的底层原理?...

    前语:微信改版后,大量读者还没养成点赞的习惯,如写得好,望大家阅读后在右下边"好看"处点个赞,以示鼓励!长期坚持原创真的很不容易,多次想放弃,坚持是一种信仰,专注是一种态度. 关于 ...

  8. 深究Java中的RMI底层原理

    原博客地址:http://blog.csdn.net/sinat_34596644/article/details/52599688 前言:随着一个系统被用户认可,业务量.请求量不断上升,那么单机系统 ...

  9. 深入分析 Java 方法反射的实现原理

    2019独角兽企业重金招聘Python工程师标准>>> 方法反射实例 public class ReflectCase { public static void main(Strin ...

最新文章

  1. 图解:人性的7种兵器(互联网商业逻辑)
  2. 凸现三围的健身运动法
  3. java 参数 string_关于Java中String类型的参数传递问题
  4. wxWidgets:库LIB清单
  5. 上传jar包到nexus私服
  6. 工厂方法与抽象工厂模式的区别
  7. SpringMVC之安全性(二)登录界面
  8. 3D游戏中的画质与效率适配(转)
  9. 机械制图计算机识图,机械制图与识图基础.ppt
  10. 沈阳农业大学计算机专业排名,沈阳农业大学王牌专业排名
  11. 印能捷服务器中文字显示方块,修改Preps中文标记字体解决PJTF/JDF无法导入印能捷问题...
  12. Craw the data of the web page and parse to pdf
  13. win10计算机卸载了,win10 如何卸载软件_win10电脑如何卸载软件-win7之家
  14. 湖北移动CM201-1-CH _S905L3B-UWE5621DS_线刷固件包
  15. Spark项目实战:购物网站评价标签生成(非常详细的Spark算子操作)
  16. P3387 【模板】缩点
  17. android+面试题
  18. 畅捷通T+ v2接口 发布IIS报错 RsaUsingSha with PSS
  19. SaltStacks三:写法和高级状态
  20. jsp处理的生命周期

热门文章

  1. 基于布谷鸟优化算法(CS)在微电网优化中的应用研究(Matlab代码实现)
  2. 模拟实现商品加购物车
  3. 艾司博讯:拼多多开店选择那些类目好
  4. springMVC中URL中文乱码问题
  5. 企业竞争中的三大经典公关案例分享!
  6. 公民身份号码 校验码 检证程序
  7. 中秋节快乐,送点福利。
  8. Vue实现语音录制——PC端
  9. 安全帽佩戴识别 安全帽识别 安全帽检测 yolo安全帽识别 ssd安全帽识别 fastertcnn安全帽识别 retinanet安全帽识别 lbp安全帽识别 cnn安全帽检测 神经网络安全帽识别
  10. Unity 几种优化建议