JVM笔记 - 虚拟机执行子系统(虚拟机字节码执行引擎)

《深入理解Java虚拟机:JVM高级特性与最佳实践(第2版)》笔记

1、概述

物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

2、运行时栈帧结构

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧。

2.1、局部变量表

Java 语言中明确的( reference 类型则可能是 32 位也可能是 64 位) 64 位的数据类型只有 long 和 double 两种。

为了尽可能节省栈帧空间,局部变量表中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体。

局部变量表复用对垃圾收集的影响

public static void main(String[] args){
byte[] placeHolder = new byte[64 1024 1024];
System.gc();
}

placeholder 原本所占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。

书籍《 Practical Java》 中把“不使用的对象应手动赋值为 null” 作为一条推荐的编码规则,赋 null 值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为 null 就是没有意义的。

以下代码片段1在经过 JIT 编译后, System.gc() 执行时就可以正确地回收掉内存,无须写成代码清单2的样子。

代码片段1

public static void main(String[] args){
{
byte[] placeHolder = new byte[64 1024 1024];
}
System.gc();
}

代码片段2

public static void main(String[] args){
{
byte[] placeHolder = new byte[64 1024 1024];
// 这样直接设置为null也是可以被及时回收的
// placeHolder = null;
}
// 离开了placeHolder的作用域之后,执行对局部变量表的读写,让垃圾回收器能够回收到placeHolder占用的内存
int a = 0;
System.gc();
}

局部变量不像前面介绍的类变量那样存在“准备阶段”。如果一个局部变量定义了但没有赋初始值是不能使用的。

2.2、操作数栈

2.3、动态连接

Class 文件的常量池中存有大量的符号引用。

这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

2.4、方法返回地址

方法退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。

2.5、附加信息

3、方法调用

3.1、解析

调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析( Resolution)。

在 Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,因此它们都适合在类加载阶段进行解析。

在 Java 虚拟机里面提供了 5 条方法调用字节码指令,分别如下。

  • invokestatic: 调用静态方法。
  • invokespecial: 调用实例构造器< init >方法、私有方法和父类方法。
  • invokevirtual: 调用所有的虚方法。
  • invokeinterface: 调用接口方法,会在运行时再确定一个实现此接口的对象。

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。

这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去 final 方法)

Java 中的非虚方法除了使用 invokestatic、 invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派( Dispatch) 调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。

3.2、分派

静态分派:

static abstract class Human{}

static class Man extends Human{}

static class Woman extends Human{}

public static void sayHello(Human guy){
System.out.println(“Hello, guy!”);
}

public static void sayHello(Man guy){
System.out.println(“Hello, man!”);
}

public static void sayHello(Woman guy){
System.out.println(“Hello, woman!”);
}

public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
sayHello(man);
sayHello(woman);
}

“ Human” 称为变量的静态类型( Static Type), 或者叫做的外观类型( Apparent Type), 后面的” Man” 则称为变量的实际类型( Actual Type)。

虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派

如果同时出现两个参数分别为 Serializable 和 Comparable < Character >的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如: sayHello(( Comparable < Character >)’ a’)。

动态分派:

它和多态性的另外一个重要体现[ 3]—— 重写( Override) 有着很密切的关联。

static abstract class Human{
protected abstract void sayHello();
}

static class Man extends Human{
protected void sayHello(){
System.out.println(“man say hello”);
}
}

static class Woman extends Human{
protected void sayHello(){
System.out.println(“woman say hello”);
}
}

public static void sayHello(Woman guy){
System.out.println(“Hello, woman!”);
}

public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
}

Java 虚拟机是如何根据实际类型来分派方法执行版本的呢?

原因就需要从 invokevirtual 指令的多态查找过程开始说起,由于 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 Java 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

单分派与多分派:

static class QQ{}

static class _360{}

public static class Father{
public void hardChoice(QQ arg){
System.out.println(“father choose qq”);
}

public void hardChoice(_360 arg){
    System.out.println("father choose 360");
}

}

public static class Son extends Father{
public void hardChoice(QQ arg){
System.out.println(“son choose qq”);
}

public void hardChoice(_360 arg){
    System.out.println("son choose 360");
}

}

public static void main(String[] args){
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}

Java 语言的静态分派属于多分派类型

Java 语言的动态分派属于单分派类型

今天(直至还未发布的 Java 1. 8) 的 Java 语言是一门静态多分派、动态单分派的语言。

虚拟机动态分派的实现:

3.3、动态类型语言支持

随着 JDK 7 的发布,字节码指令集终于迎来了第一位新成员—— `invokedynamic`指令。这条新增加的指令是 JDK 7 实现“动态类型语言”( Dynamically Typed Language) 支持而进行的改进之一。

3.3.1. 动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。

“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。

3.3.2、JDK 1. 7 与动态类型

3.3.3、 java. lang. invoke 包

JDK 1. 7 实现了 JSR- 292, 新加入的 java. lang. invoke 包[ 2] 就是 JSR- 292 的一个重要组成部分。

MethodHandle演示 link

static class ClassA{
public void println(String s){
System.out.println(s);
}
}

public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out:new ClassA();
/* 无论obj最终是那个实现类,下面这句都能正确调用到println方法 /
getPrintlnMH(obj).invokeExact(“icyfenix”);
/\
output:
* icyfenix
*/
}

private static MethodHandle getPrintlnMH(Object receiver) throws Throwable{
/* MethodType: 代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)
* 和具体参数(methodType()第二个及以后的参数) /
MethodType mt = MethodType.methodType(void.class, String.class);
/\
lookup()方法来自于MethodHandles.lookup,
* 这句的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
* 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,
* 也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,而现在提供给了bindTo()方法来完成这件事情 */
return MethodHandles.lookup().findVirtual(receiver.getClass(), “println”, mt)
.bindTo(receiver);
}

MethodHandle 的使用方法和效果与 Reflection 有众多相似之处,不过,它们还是有以下这些区别:

Reflection 是在模拟 Java 代码层次的方法调用,而 MethodHandle 是在模拟字节码层次的方法调用。

Reflection 是重量级,而 MethodHandle 是轻量级。

Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成可服务于所有 Java 虚拟机之上的语言,其中也包括 Java 语言。

3.3.4、invokedynamic 指令

在某种程度上, invokedynamic 指令与 MethodHandle 机制的作用是一样的,都是为了解决原有 4 条” invoke*” 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。

3.3.5、掌控方法分派规则

invokedynamic 指令与前面 4 条” invoke*” 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。

可以通过” super” 关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?

使用 MethodHandle 来解决相关问题 link

4、基于栈的字节码解析执行引擎

4.1、解析执行

Java 语言中, Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。

4.2、基于栈的指令集与基于寄存器的指令集

Java 编译器输出的指令流,基本上[ 1] 是一种基于栈的指令集架构。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供[ 2], 程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。

栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。

虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。

4.3、基于栈的解析执行过程

一段简单的算术代码的字节码表示 link

在 HotSpot 虚拟机中,有很多以” fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多[ 1]。

arthinking wechat
欢迎关注itzhai公众号