虚拟机字节码执行引擎

 

转自https://juejin.im/post/5abc97ff518825556a727e66

所谓的「虚拟机字节码执行引擎」其实就是 JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。接下来我们详细看看这部分内容。

方法调用的本质

在描述「字节码执行引擎」之前,我们先从汇编层面看看基于栈帧的方法调用是怎样的。(以 IA32 型 CPU 指令集为例)

IA32 的程序中使用栈帧数据结构来支持过程调用(Java 语言中称作方法),每个过程对应一个栈帧,过程的调用对应与栈帧的入栈和出栈。某个时刻,只有位于栈顶的栈帧可用,它代表了某个方法正在执行中的各种状态。最顶端的栈帧用两个指针界定,栈指针,帧指针。他们对应于栈中的地址分别存储在寄存器 %ebp 和 %esp 中。栈中的大致结构如下:

栈指针始终指向栈顶元素,控制着栈中元素的出入栈,帧指针指向的是当前栈帧的底部,注意是当前栈帧,不是整个栈的底部。

下面我们看看一段 C 代码:

#include<stdio.h>
void sayHello(int age)
{
int x = 32;
int y = 2323;
age = x + y;
} void main()
{
int age = 22;
sayHello(age);
}

很简单的一段代码,我们汇编生成相应的汇编代码,省略了部分链接代码,留下的是核心的部分:

main:
pushl %ebp
movl %esp, %ebp
subl $20, %esp
movl $22, -4(%ebp)
movl -4(%ebp), %eax
movl %eax, (%esp)
call sayHello
leave
ret sayHello:
pushl %ebp
movl %esp, %ebp
subl $16, %esp
movl $32, -4(%ebp)
movl $2323, -8(%ebp)
movl -8(%ebp), %eax
movl -4(%ebp), %edx
addl %edx, %eax
movl %eax, -12(%ebp)
leave
ret

先看 main 函数的汇编代码,main 函数里的前两个汇编指令和 sayHello 中的前两条指令是一样的,我们在留到后者里介绍。

subl 指令将寄存器 %esp 中的地址减去 20,即栈指针向上扩展了 20 个字节(栈是倒着生长的),也就是为当前栈帧分配了 20 个字节大小。接着,movl 将值 20 写入地址 -4(%ebp),这个地址其实就是相对寄存器 %ebp 帧指针位置之上的四个字节处。假如 %ebp 的值为:0x14,那么 20 就被存储到地址 0x10 的栈地址中。

接着一条 movl 指令将参数 age 的值取出来存入寄存器 %eax 中。

这时就到了核心的 call 方法了,计算机中有程序计数器(PC)来指向下一条指令的位置,而常常我们的程序会调用到其他方法里,那么调用结束后又该如何恢复调用前的状态并继续执行呢?

这里的解决办法是,call 指令的第一步就是将返回地址压栈,然后跳向 sayHell 方法中执行,这里我们看不到它压栈的过程,被集成为一条指令了。

然后跳向了 sayHello 方法的第一条指令开始执行,pushl 将寄存器 %ebp 中的地址压栈,这时候的 %ebp 是上一个栈帧的帧指针地址,这个操作其实是一个保存的动作。然后,movl 指令将帧指针指向栈指针的位置,也就是栈顶位置,继而将栈指针向上扩展 16 个字节。

接着,将数值 32 和 2323 分别写入不同的栈地址中,这个地址相对于帧指针的地址,是可以计算出来的。

后面的操作是将 x 和 y 分别写入寄存器 %eax 和 %edx,然后 add 指令做加法运算并存入寄存器 %eax 中。接着将结果压栈。

leave 指令等效于以下两条指令之和:

movl %ebp %esp
popl %ebp

什么意思呢?

把栈指针退回到帧指针的位置,也就是当前栈帧的底部,接着弹栈,这样的话整个 sayHello 所占用的栈帧就已经无法引用了,相当于释放了当前栈帧。

ret 指令用于恢复调用前的状态,继续执行 main 方法。

整个 IA32 的方法调用基本如上,对于 64 位的 x86-64 来说,增加了 16 个寄存器,优先使用寄存器进行参数的计算与传递,效率提高了。但是与这个基于栈的存储方式来说,劣势之处在于「可移植性差」,不同的机器的寄存器使用肯定是有所差别的。所以我们的 Java 毋庸置疑使用的是栈。

运行时栈帧结构

在 Java 中,一个栈帧对应一个方法调用,方法中需涉及到的局部变量、操作数,返回地址等都存放在栈帧中的。每个方法对应的栈帧大小在编译后基本已经确定了,方法中需要多大的局部变量表,多深的操作数栈等信息早以被写入方法的 Code 属性中了。所以运行期,方法的栈帧大小早已固定,直接计算并分配内存即可。

局部变量表

局部变量表用来存放方法运行时用到的各种变量,以及方法参数。虚拟机规范中指明,局部变量表的容量用变量槽(slot)为最小单位,却没有指明一个 slot 的实际空间大小,只是说,每个 slot 应当能够存放任意一个 boolean,byte,char,short,int,float,reference 等。

按照我的理解,一个 slot 相当于一个黑盒子,具体占几个字节适情况而定,但是这个黑盒子明确可以保存一个任意类型的变量。

局部变量表不同于操作数栈,它采用索引机制访问元素,而不同于操作数栈的出入栈方式。例如:

public void sayHello(String name){
int x = 23;
int y = 43;
x++;
x = y - 2;
long z = 234;
x = (int)z;
String str = new String("hello wrold ");
}

我们反编译看看它的局部变量表:

可以看到,局部变量表第一项是名为 this 的一个类引用,它指向堆中当前对象的引用。接着就是我们的方法参数,局部变量 x,y,z 和 str。

这其实也间接说明了,我们的每个实例方法都默认传入了一个参数 this,指向当前类的实例引用。

操作数栈

操作数栈也称作操作栈,它不像局部变量表采用的索引机制访问其中元素,而是标准的栈操作,入栈出栈,先入后出。操作数栈在方法执行之初为空,随着方法的一步一步运行,操作数栈中将不停的发生入栈出栈操作,直至方法执行结束。

操作数栈是方法执行过程中很重要的一个部分,方法执行过程中各个中间结果都需要借助操作数栈进行存储。

返回地址

一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。

正如我们一开始介绍的汇编代码一样,这个返回地址往往会被提前压入调用者的栈帧中,当方法调用结束时,取出栈顶元素即可得到后续方法体执行入口。

方法调用

方法调用算是本篇的一个核心内容了,它解决了虚拟机对目标调用方法的确定问题,因为往往一条虚拟机指令要求调用某个方法,但是该方法可能会有重载,重写等问题,那么虚拟机又该如何确定调用哪个方法呢?这就是本阶段要处理的唯一任务。

首先我们要谈谈这个解析过程,从上篇文章中可以知道,当一个类初次加载的时候,会在解析阶段完成常量池中符号引用到直接引用的替换。这其中就包括方法的符号引用翻译到直接引用的过程,但这只针对部分方法,有些方法只有在运行时才能确定的,就不会被解析。我们称在类加载阶段的解析过程为「静态解析」。

那么哪些方法是被静态解析了,哪些方法需要动态解析呢?

比如下面这段代码:

Object obj = new String("hello");
obj.equals("world");

Object 类中有一个 equals 方法,String 类中也有一个 equals 方法,上述程序显然调用的是 String 的 equals 方法。那么如果我们加载 Object 类的时候将 equals 符号引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永远调用的都是 Object 的 equals 方法。那我们的多态就永远实现不了。

只有那些,「编译期可知,运行时不变」的方法才可以在类加载的时候将其进行静态解析,这些方法主要有:private 修饰的私有方法,类静态方法,类实例构造器,父类方法。

其余的所有方法统称为「虚方法」,类加载的解析阶段不会被解析。这些方法的调用不存在问题,虚拟机直接根据直接引用即可找到方法的入口,但是「非虚方法」就不同了,虚拟机需要用一定的策略才能定位到实际的方法,下面我们一起来看看。

静态分派

首先我们看一段代码:

public class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
public class Hello {
public void sayHello(Father father){
System.out.println("hello , i am the father");
}
public void sayHello(Daughter daughter){
System.out.println("hello i am the daughter");
}
public void sayHello(Son son){
System.out.println("hello i am the son");
}
}
public static void main(String[] args){
Father son = new Son();
Father daughter = new Daughter();
Hello hello = new Hello();
hello.sayHello(son);
hello.sayHello(daughter);
}

输出结果如下:

hello , i am the father

hello , i am the father

不知道你答对了没有?这是一道很常见的面试题,考的就是你对方法重载的理解以及方法分派逻辑懂不懂。下面我们来分析一下:

首先需要介绍两个概念,「静态类型」和「实际类型」。静态类型指的是包装在一个变量最外层的类型,例如上述 Father 就是所谓的静态类型,而 Son 或是 Daughter 则是实际类型。

我们的编译器在生成字节码指令的时候会根据变量的静态类型选择调用合适的方法。就我们上述的例子而言:

这两个方法就是我们 main 函数中调用的两次 sayHello 方法,但是你会发现传入的参数类型是相同的,Father,也就是调用的方法是相同的,都是这个方法:

(LStaticDispathch/Father;)V

也就是

public void sayHello(Father father){}

所有依赖静态类型来定位方法执行版本的分派动作称作「静态分派」,而方法重载是静态分派的一个典型体现。但需要注意的是,静态分派不管你实际类型是什么,它只根据你的静态类型进行方法调用。

动态分派

public class Father {
public void sayHello(){
System.out.println("hello world ---- father");
}
}
public class Son extends Father {
@Override
public void sayHello(){
System.out.println("hello world ---- son");
}
}
public static void main(String[] args){
Father son = new Son();
son.sayHello();
}

输出结果:

hello world ---- son

显然,最终调用了子类的 sayHello 方法,我们看生成的字节码指令调用情况:

看到没?编译器为我们生成的方法调用指令,选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?

当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual 指令需要完成以下几个步骤才能实现对一个方法的调用:

  • 弹出操作数栈顶部元素,判断其实际类型,记做 C
  • 在类型 C 中查找需要调用方法的简单名称和描述符相同的方法,如果有则返回该方法的直接引用
  • 否则,向 C 的父类再做搜索,有即返回方法的直接引用
  • 否则,抛出异常 java.lang.AbstractMethodError 异常

所以,我们此处的示例调用的是子类 Son 的 sayHello 方法就不言而喻了。

至于虚拟机为什么能这么准确高效的搜索某个类中的指定方法,各个虚拟机的实现各有不同,但最常见的是使用「虚方法表」,这个概念也比较简单,就是为每个类型都维护一张方法表,该表中记录了当前类型的所有方法的描述信息。于是虚拟机检索方法的时候,只需要从方法表中进行搜索即可,当前类型的方法表中没有就去父类的方法表中进行搜索。

动态类型特性的支持

动态类型语言的一个关键特征就是,类型检查发生在运行时。也就是说,编译期间编译器是不会管你这个变量是什么类型,调用的方法是否存在的。例如:

Object obj = new String("hello-world");
obj.split("-");

Java 中,两行代码是不能通过编译器的,原因就是,编译器检查变量 obj 的静态类型是 Object,而 Object 类中并没有 subString 这个方法,故而报错。

而如果是动态类型语言的话,这段代码就是没问题的。

静态语言会在编译期检查变量类型,并提供严格的检查,而动态语言在运行期检查变量实际类型,给了程序更大的灵活性。各有优劣,静态语言的优势在于安全,缺点在于缺乏灵活性,动态语言则是相反的。

JDK1.7 提供了两种方式来支持 Java 的动态特性,invokedynamic 指令和 java.lang.invoke 包。这两者的实现方式是类似的,我们只介绍后者的基本内容。

//该方法是我自定义的,并非 invoke 包中的
public static MethodHandle getSubStringMethod(Object obj) throws NoSuchMethodException, IllegalAccessException {
//定义了一个方法模板,规定了待搜索的方法的返回值和参数类型
MethodType methodType = MethodType.methodType(String[].class,String.class);
//查找符合指定方法简单名称和模板信息的方法
return lookup().findVirtual(obj.getClass(),"split",methodType).bindTo(obj);
}
public static void main(String[] args){
Object obj = new String("hello-world");
//定位方法,并传入参数执行方法
String[] strs = (String[]) getSubStringMethod(obj).invokeExact("-");
System.out.println(strs[0]);
}

输出结果:

hello

你看,虽然我们 obj 的静态类型是 Object,但是通过这种方式,我就是能够越过编译器的类型检查,直接在运行期执行我指定的方法。

具体如何实现的我就不带大家看了,比较复杂,以后有机会单独写一篇文章学习一下。反正通过这种方式,我们可以不用管一个变量的静态类型是什么,只要它有我想要调的方法,我们就可以在运行期直接调用。

总结一下,HotSpot 虚拟机基于操作数栈进行方法的解释执行,所有运算的中间结果以及方法参数等等,基本都伴随着出入栈的操作取出或存储。这种机制最大的优势在于,可移植性强。不同于基于寄存器的方法执行机制,对底层硬件依赖过度,无法很轻易的跨平台,但是劣势也很明显,就是同样的操作需要相对更多的指令才能完成。


微信公众号【Java技术江湖】一位阿里 Java 工程师的技术小站。(关注公众号后回复”Java“即可领取 Java基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送作者原创的Java学习指南、Java程序员面试指南等干货资源。

微信公众号【Java技术江湖】一位阿里 Java 工程师的技术小站。(关注公众号后回复”Java“即可领取 Java基础、进阶、项目和架构师等免费学习资料,更有数据库、分布式、微服务等热门技术学习视频,内容丰富,兼顾原理和实践,另外也将赠送作者原创的Java学习指南、Java程序员面试指南等干货资源)

深入理解JVM虚拟机5:虚拟机字节码执行引擎的更多相关文章

  1. JVM学习笔记:字节码执行引擎

    JVM学习笔记:字节码执行引擎 移步大神贴:http://rednaxelafx.iteye.com/blog/492667  

  2. JVM虚拟机(二):字节码执行引擎

    运行时栈帧结构     栈帧是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素.栈帧存储了方法的局部变量表.操作数栈.动态链接.和方法返回地址等信息. ...

  3. 深入理解java:1.2. 字节码执行引擎

    执行引擎是Java虚拟机的核心组成部分之一. 首先,想想C++和Java在编译和运行时到底有啥不一样? 下图左边,C++发布的就是机器指令, 而下图右边Java发布的是字节码,字节码在运行时通过JVM ...

  4. JVM基础结构与字节码执行引擎

    JVM基础结构 JVM内部结构如下:栈.堆. 栈 JVM中的栈主要是指线程里面的栈,里面有方法栈.native方法栈.PC寄存器等等:每个方法栈是由栈帧组成的:每个栈帧是由局部变量表.操作数栈等组成. ...

  5. 深入理解java虚拟机(5)---字节码执行引擎

    字节码是什么东西? 以下是百度的解释: 字节码(Byte-code)是一种包含执行程序.由一序列 op 代码/数据对组成的二进制文件.字节码是一种中间码,它比机器码更抽象. 它经常被看作是包含一个执行 ...

  6. 【java虚拟机系列】从java虚拟机字节码执行引擎的执行过程来彻底理解java的多态性

    我们知道面向对象语言的三大特点之一就是多态性,而java作为一种面向对象的语言,自然也满足多态性,我们也知道java中的多态包括重载与重写,我们也知道在C++中动态多态是通过虚函数来实现的,而虚函数是 ...

  7. 深入理解Java虚拟机(类文件结构+类加载机制+字节码执行引擎)

    目录 1.类文件结构 1.1 Class类文件结构 1.2 魔数与Class文件的版本 1.3 常量池 1.4 访问标志 1.5 类索引.父索引与接口索引集合 1.6 字段表集合 1.7 方法集合 1 ...

  8. 深入理解Java虚拟机(字节码执行引擎)

    深入理解Java虚拟机(字节码执行引擎) 本文首发于微信公众号:BaronTalk 执行引擎是 Java 虚拟机最核心的组成部分之一.「虚拟机」是相对于「物理机」的概念,这两种机器都有代码执行的能力, ...

  9. 《深入理解Java虚拟机》-----第8章 虚拟机字节码执行引擎——Java高级开发必须懂的

    概述 执行引擎是Java虚拟机最核心的组成部分之一.“虚拟机”是一个相对于“物理机”的概念 ,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器.硬件.指令集和操作系统层面上的,而 ...

随机推荐

  1. AJAX with JSP and Servlet(代码)

    欢迎任何形式的转载,但请务必注明出处. 本章内容来自YouTube需翻墙(点击进入视频学习) 服务器配置等可以参看我其他文章.注释等后续再加 效果图 结构   <body> <fie ...

  2. 如何实现高性能的IO及其原理?

    程序运行在内存以及IO的体现 首先普及一下常识,如图所示: 1.在整个内存空间中,跑着各种各样的程序,有Java程序.C程序,他们共用一块内存空间. 2.对于Java程序,JVM会申请一块堆空间,通过 ...

  3. JavaIO模型--装饰者模式

    JavaIO体现出装饰者的设计模式 今天在学SparkRDD之前,听了一堂复习JavaIO的课,觉得讲得不错 Java的IO一直让我觉得一层一层的很麻烦,刚接触的时候,理不太清楚 只知道要分解为输入输 ...

  4. Excel导入+写入数据库

    1.引用服务 2.前端 <h2>这里是上传Excel功能页面</h2> <div> <form action="/Improve_Excel/get ...

  5. sql sever2008 R2 检测到索引可能已损坏。请运行 DBCC CHECKDB。

    1.设置成单用户状态 USE MASTER ALTER DATABASE DBNAME SET SINGLE_USER; GO --DBNAME为修复的数据库名 2.执行修复语句,检查和修复数据库及索 ...

  6. Ubuntu armhf 版本国内源

    Ubuntu armhf 版本国内源: deb http://mirrors.ustc.edu.cn/ubuntu-ports/ xenial main multiverse restricted u ...

  7. pringBoot2.0启用https协议

    SpringBoot2.0之后,启用https协议的方式与1.*时有点儿不同,贴一下代码. 我的代码能够根据配置参数中的condition.http2https,确定是否启用https协议,如果启用h ...

  8. PHM与智慧运维落地实践案例集 — 机车运用数据智能诊断系统正式上线

    2019年9月20日,经过为期一个多月的紧张测试,北京润科通用技术有限公司为中车某机车单位倾力打造的“机车运用数据智能诊断系统”正式上线运行,标志着润科通用在轨道交通智慧运维领域的又一案例成功落地. ...

  9. Luogu P1276 校门外的树(增强版)

    Luogu P1276 校门外的树(增强版) 本来看着是道普及-,就不打算写博客了,结果因为出了3次错,调试了15min就还是决定写一下-- 本题坑点: 1.每个位置有三种情况:空穴,树苗,树(而不只 ...

  10. de4dot FAQ

    How to deobfuscate but make sure metadata tokens stay the same? --preserve-tokens will preserve all ...