Tianji's Blog.

jvm-深入拆解java虚拟机

Word count: 9,337 / Reading time: 33 min
2018/09/26 Share

参考:《深入拆解Java虚拟机》 ———- 极客时间

Java与c语言运行差别

JRE和JDK的区别

JRE: Java代码可以在开发工具中运行,可以双击jar文件执行,也可以在命令行中执行,网页中同样可以运行Java代码,这些都需要JRE,也就是Java运行时环境。

JDK: JRE仅仅包含运行Java代码的必须组件,包括Java虚拟机以及Java核心类库,程序员经常接触的JDK(Java开发工具包)同样包含JRE,同时附带一系列开发

C语言程序编译结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
; 最左列是偏移;中间列是给机器读的机器码;最右列是给人读的汇编代码
0x00: 55 push rbp
0x01: 48 89 e5 mov rbp,rsp
0x04: 48 83 ec 10 sub rsp,0x10
0x08: 48 8d 3d 3b 00 00 00 lea rdi,[rip+0x3b]
; 加载 "Hello, World!\n"
0x0f: c7 45 fc 00 00 00 00 mov DWORD PTR [rbp-0x4],0x0
0x16: b0 00 mov al,0x0
0x18: e8 0d 00 00 00 call 0x12
; 调用 printf 方法
0x1d: 31 c9 xor ecx,ecx
0x1f: 89 45 f8 mov DWORD PTR [rbp-0x8],eax
0x22: 89 c8 mov eax,ecx
0x24: 48 83 c4 10 add rsp,0x10
0x28: 5d pop rbp
0x29: c3 ret

c语言程序编译之后,就是机器码,为了方便开发人员理解,可以反汇编,转换成汇编代码

Java为什么需要在虚拟机中运行

Java 作为一门高级程序语言,它的语法非常复杂,抽象程度也很高。因此,直接在硬件上运行这种复杂的程序并不现实。所以呢,在运行 Java 程序之前,我们需要对其进行一番转换。

当前的主流思路是这样子的,设计一个面向 Java 语言特性的虚拟机,并通过编译器将 Java 程序转换成该虚拟机所能识别的指令序列,也称 Java 字节码。 Java 字节码指令的操作码(opcode)固定为一个字节。

Java程序反编译结果

1
2
3
4
5
# 最左列是偏移;中间列是给虚拟机读的机器码;最右列是给人读的代码
0x00: b2 00 02 getstatic java.lang.System.out
0x03: 12 03 ldc "Hello, World!"
0x05: b6 00 04 invokevirtual java.io.PrintStream.println
0x08: b1 return

我们可以看到C语言类似,反编译之后也是一个一个的字节码,不同的是结果更加精简,更容易阅读,Java虚拟机相对于物理机而言,抽象程度更高。

Java虚拟机可以由硬件实现,也可以有软件实现,这样一旦程序被转换成字节码,将可以在不同平台上的虚拟机运行,也就是一次编写,到处运行。托管环境还提供了诸如自动内存管理与垃圾回收,数组越界、动态类型、安全权限等等的动态检查,避免了一些业务无关的代码。

Java如何运行Java字节码

以HotSpot虚拟机为例,从虚拟机和底层硬件两个角度来看:

虚拟机角度:

虚拟机首先需要将编译成成class文件加载到Java虚拟机中,加载后的Java类会存放在方法区Method area中,实际运行时,虚拟机会执行方法区内的代码。

类似于X86,Java虚拟机同样会在内存中分配出堆和栈来存储运行时的数据。不同的是,Java 虚拟机会将栈细分为面向 Java 方法的 Java 方法栈,面向本地方法(用 C++ 写的 native 方法)的本地方法栈,以及存放各个线程执行位置的 PC 寄存器。

在运行过程中,每当调用进入一个 Java 方法,Java 虚拟机会在当前线程的 Java 方法栈中生成一个栈帧,用以存放局部变量以及字节码的操作数。这个栈帧的大小是提前计算好的,而且 Java 虚拟机不要求栈帧在内存空间里连续分布。当退出当前执行的方法时,不管是正常返回还是异常返回,Java 虚拟机均会弹出当前线程的当前栈帧,并将之舍弃。

硬件角度:

Java字节码无法直接运行,Java虚拟机需要将Java字节码翻译成机器码。

在HotSpot中,翻译方式有两种,1. 解释执行,逐条将字节码翻译成机器码并执行 2. 即时编译(Just-In-Time compilation, JIT),即将一个方法中包含的所有字节码变异成机器码后运行。

解释执行: 类似于python,php等。优势:无需等待编译

JIT: 实际运行速度更快

HotSpot默认采用混合模式,综合二者的邮电,首先解释执行字节码,而后将其中反复执行的热点代码,以方法为单位进行即时编译。

Java虚拟机运行效率如何

HotSpot采用多种技术来提高启动性能以及峰值性能。即时编译即为其中最重要的技术之一。该技术建立在二八定律上,也就是20%的代码占据%80的计算资源。

理论上讲,即时编译后的 Java 程序的执行效率,是可能超过 C++ 程序的。这是因为与静态编译相比,即时编译拥有程序的运行时信息,并且能够根据这个信息做出相应的优化。

比如:我们知道虚方法是用来实现面向对象语言多态性的。对于一个虚方法调用,尽管它有很多个目标方法,但在实际运行过程中它可能只调用其中的一个。这个信息便可以被即时编译器所利用,来规避虚方法调用的开销,从而达到比静态编译的 C++ 程序更高的性能。

为了满足不同用户的场景的需要,HotSpot内置多个即时编译器:C1,C2和Graal。Graal是Java 10引入的实验性即时编译器。之所以引入多个即时编译器,是为了在编译时间和生成代码的执行效率之间进行取舍。

C1 又叫做 Client 编译器,面向的是对启动性能有要求的客户端 GUI 程序,采用的优化手段相对简单,因此编译时间较短。

C2 又叫做 Server 编译器,面向的是对峰值性能有要求的服务器端程序,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高。

从 Java 7 开始,HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。

在计算资源充足的情况下,字节码的解释执行和即时编译可同时进行。编译完成后的机器码会在下次调用该方法时启用,以替换原本的解释执行。

问题:为什么不把java代码全部编译成机器码?很多服务端应用发布频率不会太频繁,但是对运行时的性能和吞吐量要求较高。如果发布或启动时多花点时间编译,能够带来运行时的持久性能收益,不是很合适么?

事实上JVM确实有考虑做AOT (ahead of time compilation) 这种事情。AOT能够在线下将Java字节码编译成机器码,主要是用来解决启动性能不好的问题。

对于这种发布频率不频繁(也就是长时间运行吧?)的程序,其实选择线下编译和即时编译都一样,因为至多一两个小时后该即时编译的都已经编译完成了。另外,即时编译器因为有程序的运行时信息,优化效果更好,也就是说峰值性能更好。

问题:如何区别热点代码

热点代码的区别,在git里面涉及到的热点代码有两种算法,基于采样的热点探测和基于计数器的热点探测。一般采用的都是基于计数器的热点探测。基于计数器的热点探测又有两个计数器,方法调用计数器,回边计数器,他们在C1和C2又有不同的阈值。

Java的基本类型

boolean类型:在 Java 虚拟机规范中,boolean 类型则被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。这个编码规则约束了 Java 字节码的具体实现。

在 Java 虚拟机规范中,局部变量区等价于一个数组,并且可以用正整数来索引。除了 long、double 值需要用两个数组单元来存储之外,其他基本类型以及引用类型的值均占用一个数组单元。

也就是说,boolean、byte、char、short 这四种类型,在栈上占用的空间和 int 是一样的,和引用类型也是一样的。因此,在 32 位的 HotSpot 中,这些类型在栈上将占用 4 个字节;而在 64 位的 HotSpot 中,他们将占 8 个字节。

当然,这种情况仅存在于局部变量,而并不会出现在存储于堆中的字段或者数组元素上。对于 byte、char 以及 short 这三种类型的字段或者数组单元,它们在堆上占用的空间分别为一字节、两字节,以及两字节,也就是说,跟这些类型的值域相吻合。

因此,当我们将一个 int 类型的值,存储到这些类型的字段或数组时,相当于做了一次隐式的掩码操作。举例来说,当我们把 0xFFFFFFFF(-1)存储到一个声明为 char 类型的字段里时,由于该字段仅占两字节,所以高两位的字节便会被截取掉,最终存入“\uFFFF”。

boolean 字段和 boolean 数组则比较特殊。在 HotSpot 中,boolean 字段占用一字节,而 boolean 数组则直接用 byte 数组来实现。为了保证堆中的 boolean 值是合法的,HotSpot 在存储时显式地进行掩码操作,也就是说,只取最后一位的值存入 boolean 字段或数组中。

讲完了存储,现在我来讲讲加载。Java 虚拟机的算数运算几乎全部依赖于操作数栈。也就是说,我们需要将堆中的 boolean、byte、char 以及 short 加载到操作数栈上,而后将栈上的值当成 int 类型来运算。

对于 boolean、char 这两个无符号类型来说,加载伴随着零扩展。举个例子,char 的大小为两个字节。在加载时 char 的值会被复制到 int 类型的低二字节,而高二字节则会用 0 来填充。

对于 byte、short 这两个类型来说,加载伴随着符号扩展。举个例子,short 的大小为两个字节。在加载时 short 的值同样会被复制到 int 类型的低二字节。如果该 short 值为非负数,即最高位为 0,那么该 int 类型的值的高二字节会用 0 来填充,否则用 1 来填充。

Java如何加载类

Java两大类: 基本类型,引用类型

基本类型: boolean, byte, short, char, int,long,float,double

引用类型:类,接口,数组类,泛型参数,泛型参数会在编译过程中被擦除,实际上只有前三种。数组类有java虚拟机直接生成,其他两种有对应字节流。

字节流: 主要是Java编译器生成class文件,除此之外,还可以直接在程序内部生成,或者从网络中获取。最终会被加载到Java虚拟机中,成为类或接口。无论哪种类型,Java虚拟机都会对其进行链接和初始化。

加载:

查找字节流,并据此创建类。对于数组而言,没有对应字节流,有Java虚拟机直接生成,对于其他类,Java虚拟机则需要借助类加载器完成查找字节流的过程。

启动类加载器: C++实现,没有对应Java对象,负责加载$JAVA_HOME中jre/lib/rt.jar里的所有class,在Java中只能用null指代。

其他类加载器都是java.lang.ClassLoader的子类,有对应Java对象。这些类加载器有另一个类加载器比如启动类加载器,加载到Java虚拟机中,方能执行类加载。

双亲委派模型: 每当一个类加载器接收到加载请求是,先将请求转发给父类加载器,在父类加载器没有找到所请求类的情况下,该类加载器才会尝试去加载。

在 Java 9 之前

启动类加载器负责加载最为基础、最为重要的类,比如存放在 JRE 的 lib 目录下 jar 包中的类(以及由虚拟机参数 -Xbootclasspath 指定的类)。除了启动类加载器之外,另外两个重要的类加载器是扩展类加载器(extension class loader)和应用类加载器(application class loader),均由 Java 核心类库提供。

扩展类加载器父类是启动类加载器,负责加载相对次要、但又通用的类,比如存放在JRE的lib/ext目录下jar包中的类(以及由系统变量java.ext.dirs指定的类)。

应用类加载器的父类是扩展类加载器,负责加载应用程序路径(虚拟机参数-cp / -classpath、系统变量java.class.path或者环境变量CLASSPATH所指定的路径)下的类。默认情况下,应用程序中包含的类便是由应用类加载器加载的。

Java 9

引入了模块系统,并且略微更改了上述的类加载器,。扩展类加载器被改名为平台类加载器(platform class loader)。Java SE 中除了少数几个关键模块,比如说 java.base 是由启动类加载器加载之外,其他的模块均由平台类加载器所加载。

除了加载功能,类加载器还提供命名空间的作用。

类的唯一性是由类加载器实例以及类的全名一同确定。同一串字节流,经由不同类加载器加载,也会得到不同的类。

链接

将创建成的类合并至Java虚拟机中,使之能够执行的过程。分为验证、准备以及解析三个阶段。

验证:确保被加载类能够满足Java虚拟机约束条件。通常情况下,Java编译器生成的类文件必然满足Java虚拟机约束条件。(此处留坑

todo

准备: 为被加载类的静态字段分配内存。Java代码中对静态字段的具体初始化,则会在稍后的初始化阶段进行。除了分配内存,部分Java虚拟机还会在此阶段构造其他跟类层次相关的数据结构,比如用来实现虚方法的动态绑定的方法表。

在class文件被加载值Java虚拟机之前,类无法知道其他类及其方法、字段所对应的具体位置,甚至不知道自己的方法、字段的地址。因此,每当需要引用这些成员时,Java编译器会生成一个符号引用。在运行阶段,符号引用一般会无歧义地定位到具体目标上。

解释: 将符号引用解析成为实际引用,如果符号引用指向一个未被加载类,或者未被加载类的字段或方法,那么解析将触发该类的加载(但未必触发该类的链接及初始化)。

Java虚拟机规范不要求在链接过程中完成解析,仅规定: 如果某些字节码使用符号引用,那么在执行这些字节码之前,需要完成对这些符号引用的解析。

初始化

\ 也就是class init.

在Java代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。

如果直接赋值静态字段被final修饰,并且类型是基本类型或者字符串,那马该字段会被Java编译器标记为常量值(ConstantValue),其初始化直接有Java虚拟机完成。除此以外的直接赋值操作,以及所有静态代码块中的代码,则会被Java编译器置于统一方法中,命名为\<clinit>。

类加载的最后一个是初始化,便是为标记为常量值的字段赋值,以及执行\<clinit>方法的进程,Java虚拟机会通过加锁确保类的\<clinit>方法仅被执行一次。

类的初始化何时会被触发呢?JVM 规范枚举了下述多种触发情况:

  1. 当虚拟机启动时,初始化用户指定的主类;

  2. 当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;

  3. 当遇到调用静态方法的指令时,初始化该静态方法所在的类;

  4. 当遇到访问静态字段的指令时,初始化该静态字段所在的类;

  5. 子类的初始化会触发父类的初始化;

  6. 如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;

  7. 使用反射 API 对某个类进行反射调用时,初始化这个类;

  8. 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。

1
2
3
4
5
6
7
8
9
public class Singleton {
private Singleton() {}
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}

只有当调用 Singleton.getInstance 时,程序才会访问 LazyHolder.INSTANCE,才会触发对 LazyHolder 的初始化(对应第 4 种情况),继而新建一个 Singleton 的实例。由于类初始化是线程安全的,并且仅被执行一次,因此程序可以确保多线程环境下有且仅有一个 Singleton 实例。

  1. 新建数组会加载元素类LazyHolder;不会初始化元素类

  2. 新建数组不会链接元素类LazyHolder;在getInstance(false)时才真正链接和初始化

JVM如何执行方法调用

1
2
3
4
5
6
7
void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }

invoke(null, 1); // 调用第二个 invoke 方法
invoke(null, 1, 2); // 调用第二个 invoke 方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
// 才能调用第一个 invoke 方法

重载和重写

如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。

小知识:这个限制可以通过字节码工具绕开。也就是说,在编译完成之后,我们可以再向 class 文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在 Java 编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?当前版本的 Java 编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。

重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

  • 不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法
  • 如果在1个阶段中没有找到适配的方法,那么允许自动装拆箱,但不允许可变长参数的情况下选取重载方法
  • 如果在第2个阶段中没有找到适配的方法啊,那么允许自动装拆箱以及可变长参数的情况下选取重载方法

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

参考如上代码: 当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java 编译器会认为第二个方法更为贴切。

除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。

那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?

如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。

JVM的静态绑定和动态绑定

Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。。至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。

Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。

Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。

对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。

桥接方法: https://blog.csdn.net/jiaobuchong/article/details/83722193

由于对重载方法的区分在编译阶段已经完成,可以认为 Java 虚拟机不存在重载这一概念。因此,在某些文章中,重载也被称为静态绑定(static binding),或者编译时多态(compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。这个说法在 Java 虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此 Java 编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。

具体来说,Java 字节码中与调用相关的指令共有五种。

  1. invokestatic: 用于调用静态方法
  2. invokespecial: 用于调用私有实例方法、构造器,以及使用super关键字调用父类的实例方法或构造器和所实现接口的默认方法。
  3. invokevirtual: 用于调用非私有实例方法。
  4. invokeinterface: 用于调用接口方法
  5. invokedynamic: 用于调用动态方法

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface 客户 {
boolean isVIP();
}

class 商户 {
public double 折后价格 (double 原价, 客户 某客户) {
return 原价 * 0.8d;
}
}

class 奸商 extends 商户 {
@Override
public double 折后价格 (double 原价, 客户 某客户) {
if (某客户.isVIP()) { // invokeinterface
return 原价 * 价格歧视 (); // invokestatic
} else {
return super. 折后价格 (原价, 某客户); // invokespecial
}
}
public static double 价格歧视 () {
// 咱们的杀熟算法太粗暴了,应该将客户城市作为随机数生成器的种子。
return new Random() // invokespecial
.nextDouble() // invokevirtual
+ 0.8d;
}
}

对于 invokestatic 以及 invokespecial 而言,Java 虚拟机能够直接识别具体的目标方法。

而对于 invokevirtual 以及 invokeinterface 而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。

唯一的例外在于,如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为 final那么它可以不通过动态类型,直接确定目标方法。

调用指令的符号引用

在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。

查看方式: javap -v **.class

在执行使用了符号引用的字节码前,Java 虚拟机需要解析这些符号引用,并替换为实际引用。

对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

  • 在 C 中查找符合名字及描述符的方法。
  • 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  • 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

  • 在 I 中查找符合名字及描述符的方法。
  • 如果没有找到,在 Object 类中的公有实例方法中搜索。
  • 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

虚方法调用

Java里所有非私有实例调用都会被编译成invokevirtual指令,而接口方法调用都会变编译成invokeinterface指令。这两种指令,均属于Java虚拟机中的虚方法调用。

动态调用: 在绝大多数情况下,Java虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

在Java虚拟机中,静态绑定包括嗲用静态方法的invokestatic指令,和用于调用构造器、私有实例方法以及超类非私有实例方法的invokespecial指令。如果虚方法调用指向一个标记为final的方法,那么Java虚拟机也可以静态绑定该虚方法调用的目标方法。

Java虚拟机中采取了一种用空间换时间的策略实现动态绑定,它为每个类生成一张方法表,用以快速定位目标方法。

方法表

在类加载的准备阶段,除了为静态字段分配内存外,还会构造与该类相关联的方法表。这个数据结构,便是Java虚拟机实现动态绑定的关键所在。

方法表本质上是一个数组,每个数组元素都只想一个当前类以及其祖先类中的非私有的实例方法。

这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。

在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。

实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。

内联缓存和方法内联

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

在针对多态的优化手段中,我们通常会提及以下三个术语。

  • 单态(monomorphic)指的是仅有一种状态的情况。
  • 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
  • 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。

单态内联缓存:只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。

多态内联缓存:缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。

Note:一般来说,我们会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。

在最坏情况下,用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。

另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

对于极其简单的方法而言,比如说 getter/setter,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性,我们会在专栏的第二部分详细介绍方法内联的内容。

方法内联:https://blog.csdn.net/ke_weiquan/article/details/51946174

JVM如何处理异常

异常处理的两大组成要素是抛出异常和捕获异常。这两大要素共同实现程序控制流的非正常转移。

抛出异常可分为显式和隐式两种。显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。

隐式抛异常的主体则是 Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常。举例来说,Java 虚拟机在执行读取数组操作时,发现输入的索引值是负数,故而抛出数组索引越界异常(ArrayIndexOutOfBoundsException)。

try 代码块:用来标记需要进行异常监控的代码。

catch 代码块:跟在 try 代码块之后,用来捕获在 try 代码块中触发的某种指定类型的异常。除了声明所捕获异常的类型之外,catch 代码块还定义了针对该异常类型的异常处理器。在 Java 中,try 代码块后面可以跟着多个 catch 代码块,来捕获不同类型的异常。Java 虚拟机会从上至下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错。

finally 代码块:跟在 try 代码块和 catch 代码块之后,用来声明一段必定运行的代码。它的设计初衷是为了避免跳过某些关键的清理代码,例如关闭已打开的系统资源。

异常基本概念:

在 Java 语言规范中,所有异常都是 Throwable 类或者其子类的实例。Throwable 有两大直接子类。第一个是 Error,涵盖程序不应捕获的异常。当程序触发 Error 时,它的执行状态已经无法恢复,需要中止线程甚至是中止虚拟机。第二子类则是 Exception,涵盖程序可能需要捕获并且处理的异常。

CATALOG
  1. 1. Java与c语言运行差别
    1. 1.0.1. JRE和JDK的区别
    2. 1.0.2. C语言程序编译结果
    3. 1.0.3. Java为什么需要在虚拟机中运行
    4. 1.0.4. Java程序反编译结果
  • 2. Java如何运行Java字节码
  • 3. Java虚拟机运行效率如何
  • 4. Java的基本类型
  • 5. Java如何加载类
    1. 5.0.1. 加载:
      1. 5.0.1.1. 在 Java 9 之前
      2. 5.0.1.2. Java 9
    2. 5.0.2. 链接
  • 6. todo
    1. 6.0.1. 初始化
  • 7. JVM如何执行方法调用
    1. 7.1. 重载和重写
    2. 7.2. JVM的静态绑定和动态绑定
    3. 7.3. 调用指令的符号引用
      1. 7.3.1. 虚方法调用
      2. 7.3.2. 方法表
      3. 7.3.3. 内联缓存和方法内联
      4. 7.3.4. 方法内联:https://blog.csdn.net/ke_weiquan/article/details/51946174
  • 8. JVM如何处理异常
    1. 8.0.1. 异常基本概念: