引言
本文着重介绍 JVM 中字节码执行引擎相关的内容,更多关于 JVM 的文章均收录于<JVM系列文章>。
字节码执行
Java 字节码对于虚拟机,就好像汇编语言对于计算机一样,属于基本执行指令。每一个 Java 字节码指令都有一个 byte 数字与之对应,并且有一个对应的助记符。目前所有的字节码指令大约有 200 余个。下面列举了部分字节码及其对应的助记符:
0x00 nop // None
0x01 aconst_null // 将 null 推送至栈顶
0x02 iconst_m1 // 将 int 型 -1 推送至栈顶
0x03 iconst_0 // 将 int 型 0 推送至栈顶
0x04 iconst_1 // 将 int 型 1 推送至栈顶
0x05 iconst_2 // 将 int 型 2 推送至栈顶
0x06 iconst_3 // 将 int 型 3 推送至栈顶
0x07 iconst_4 // 将 int 型 4 推送至栈顶
...
我们前面已经说过,一个函数的 Java 字节码指令都被变异到类文件中对应方法的 Code 属性中,我们可以通过 jclasslib 或 javap 工具来查看指令的具体内容,接下来,我们以下面的 ByteCodeDemo 为例,介绍字节码执行的过程。
public class ByteCodeDemo {
private int a = 1;
public int add( ) {
int b = 2;
int c = a + b;
System.out.println(c) ;
return c;
}
}
上述的 add 函数编译出来的字节码如下所示:
0 iconst_2 // 将 2 推入操作数栈
1 istore_1 // 将操作数栈顶的元素保存到局部变量表的第一个槽位
2 aload_0 // 将 0 号槽位的局部变量压入操作数栈,0 号槽位是 this
3 getfield #2 <bbm/jvm/ByteCodeDemo.a> // 获取 this 的 a 字段值,并压入操作数栈
6 iload_1 // 将 1 号槽位的局部变量压入操作数栈
7 iadd // 操作数栈上的元素相加,结果压入操作数栈
8 istore_2 // 将操作数栈的元素存储在 2 号槽位
9 getstatic #3 <java/lang/System.out> // 获取 System.out 这个静态字段的值,并压入操作数栈
12 iload_2 // 将 2 号局部变量的值压入操作数栈
13 invokevirtual #4 <java/io/PrintStream.println> // 执行 System.out 的 println 函数
16 iload_2 // 将 2 号局部变量的值压入操作数栈
17 ireturn // 将操作数栈的元素返回
我们前面提过,JVM 的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个 FILO 结构,需要频繁压栈出栈)。另外,由于栈是在内存实现的,而寄存器是在 CPU 的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。
如果上述字节码的注释不能让大家清晰的感受到操作数栈,局部变量表的变化的话,可以看一下下面的 GIF 图,相较于注释来说它更加的直观。
常用指令介绍
Java 虚拟机的字节码指令多达 200 余个,完全介绍和学习这些指令需要花费大量时间。为了让大家能够更快地熟悉和了解这些基本指令,接下来会对这些字节码指令进行归类介绍。熟悉虚拟机的指令对于动态字节码生成、反编译 Class 文件、Class 文件修补都有着非常重要的价值。在介绍指令之前,我觉得有必要介绍一下下文使用的指令参数这个术语,一般来说很多指令所需要的数据都是从操作数栈中提取的,例如前面例中的 istore_1 就是将操作数栈中的数据存入局部变量,但本文并不会说栈顶元素就是该指令的参数。相反的,指令参数特指那些紧跟在指令之后的内容,比如上例中的 getfield #2
其中 2 就是 getfield 的指令参数,它并不是在栈顶中,而是直接写在字节码中,紧跟着指令。在后面的介绍中,会频繁出现指令参数这一术语,大家要注意区分。
常量入栈指令
常量入栈指令的功能是将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为 const 系列、push 系列和 ldc 指令。
- const 系列:const 系列用于特定的常量入栈,每个 const 指令都对应了一个比较常用的常量,比如 aconst_null 将 null 压入操作数栈;iconst_0, iconst_1 等指令负责将整数 0,1,等压入栈。fconst_0,fconst_1 等指令负责将浮点数 0,1 等压入栈。dconst_0, dconst_1 等负责将 double 类型 0, 1 压入栈。从指令名上,会发现一个规律,首字母 i 表示整数,f 表示浮点数,d 表示双精度浮点,a 用来表示对象引用。指令隐含的操作数,会接在下划线后面。
- push 系列:push 系列包含 bipush 和 sipush。bipush 接受 8 位整数作为参数,sipush 接收 16 位整数作为参数,它们将参数压入栈。
- ldc 系列:ldc 负责处理上述指令中无法表达的操作,它接收 8 位的参数,该参数指向常量池中的 int,float 或者 String 类型的项,将该项压入栈。如果常量池项的索引大于 8 位所能描述的范围,可以使用 ldc_w, 它接收两个 8 位参数,所以范围更大。如果要压入的元素是 long 或者 double,则使用 ldw2_w 指令。
局部变量压栈指令
局部变量压栈指令将给定的局部变量表中的数据压入操作数栈。这类指令大体可以分为: xload (x 为 i、l、f、d、a)、xload_n (x为i、l、f、d、a, n 为 0 到 3)、xaload (x 为 i、l、f、d、a、b、c、s)。
在这里,x 的取值表示了数据类型,其对应关系如下所示:
- xload_n:表示将第 n 个局部变量压入操作数栈,但是 n 的取值范围只是 0 - 3,比如 iload_1 表示将局部变量槽位 1 的整数压入栈内。
- xload 通过指定参数的形式,把局部变量压入操作数栈。使用这个指令可以压入槽位大于 3 的局部变量,来弥补 xload_n 的不足。
- xaload 表示将数组的元素压栈,比如 saload 表示压入 short 数组中的某一元素。执行该指令时,要求操作数栈顶存放目标元素在数组中的下标 i,栈顶第二顺位的元素要求是目标数组的引用 a。该指令会弹出栈顶的两个元素,并将
a[i]
压入栈顶。
出栈存入局部变量指令
出栈装入局部变量表指令用于将操作数栈中栈顶元素弹出后, 装入局部变量表的指定位置, 用于给局部变量赋值。这类指令主要以 store 的形式存在, 比如 xstore(x为i、l、f、d、a)、xstore_n(x 为 i、l、f、d、a, n 为 0 到 3)和 xastore(x 为 i、l、f、d、a、b、c、s) 。上述的指令都和局部变量入栈指令用法相似,只不过操作相反。
通用型操作
通用性操作提供的是无需指明数据类型的操作。
- NOP:特殊指令,字节码 0x00,和汇编的 nop 一样,代表什么都不做,一般用于调试,占位等。
- dup:意为 duplicate 复制,将栈顶元素(32 位)复制一份重新压入,如果要连续复制栈顶 2 个 字长,则需要使用 dup2。在一个函数中,如果一个对象实例化后,要频繁使用该对象的函数时,往往会在最开始将该对象引用频繁 dup。
- pop:把栈顶元素弹出(一个字长,32 位),直接丢弃。如果要丢弃栈顶 64 位数据,则需要 pop2 指令。
类型转换指令
软件开发中,数据类型的转换是很常用的功能,所以 JVM 提供了一套用于类型转换的指令,它们形如 x2y,这里的 x 可能是 i、f、l、d,y 可能是 i、f、l、d、c、s、b。每个都对应了一个基础数据类型,对照关系和前面相同。比如 i2l 就表示将 int 数据转为 long 数据。它会将栈顶 int 数据弹出,转化为 long 数据后重新压入。注意:转换后,long 型数据要占两个字空间。
这里大家可能会有疑问,为什么只有 l2b,没有 b2l,即只有 long 转 byte,没有 byte 转 long 指令。实际上像是 byte,short 这些小于一个字空间大小的类型,在操作数栈中也会占用一个字空间的大小(32 位),所以当需要进行 byte 转 int 时,直接使用 i2l 指令就行了,这样设计可以减少指令数量。
运算指令
运算指令为 Java 虚拟机提供了基本的加减乘除等运算功能。基本运行可以分为:加法、减法、乘法、除法、取余、数值取反、位运算、自增运算。每一类指令也有自己支持的数据类型, 与前文所述的指令类似, 将数据类型使用一个字符表示。
- 加法指令有: iadd、ladd、fadd、dadd
- 减法指令有: isub、lsub, fsub、dsub
- 乘法指令有: imul、lmul、fmul、dmul
- 除法指令有: idiv、ldiv、fdiv、ddiv
- 取余指令有: irem、lrem、frem、drem
- 数值取反有: ineg、lneg、fneg、dneg
- 自增指令: iinc
-
位运算指令, 又可分为如下几种。
- 位移指令: ish1、ishr、iushr, lsh1、lshr、lushr
- 按位或指令: ior、lor
- 按位与指令: iand、land
- 按位异或指令: ixor、lxor
这类指令都十分类似,最前面的字符代表数据类型,和前面的指令类似,后面的部分表示指令,有的指令需要两个操作数,比如 iadd,那么它就从操作数栈中弹出 2 个元素,处理完成后,结果重新压入栈。而有的只需要一个操作数,比如取反 ineg。
值得一提的是自增指令 iinc,它完全不需要操作数栈,而是使用两个参数,第一个参数表示操作的局部变量槽位,第二个参数表示自增的值。例如 iinc 1 1
,表示将局部变量槽位 1 的数,自增 1 并保存回去。
对象/数组操作指令
Java 是面向对象的程序设计语言, 虚拟机平台从字节码层面就对面向对象做了深层次的支持。有一系列指令专门用于对象操作, 可进一步细分为创建指令、字段访问指令、类型检查指令、数组操作指令。
- 创建指令:new,newarray,anewarray,multianewarray,分别用于创建普通对象,数组对象,对象数组,多维数组。
- 字段访问指令:getfield,putfield,getstatic,putstatic,分辨用于操作实例对象,和操作类的静态字段。
- 类型检查指令:checkcast,instanceof。前者用于检查类型强制转换是否可行,它不会改变操作数栈,如果不可行它会抛出 ClassCastException。后者用来判断给定对象是否是一个类的实例,它会将结果压入栈。
- 数组操作指令:xastore,xaload。
比较控制指令
比较指令
比较指令的作用是比较栈顶两个元素的大小, 并将比较结果入栈。比较指令有:dcmpg, dcmpl、fcmpg, fcmpl、lcmp。指令首字母代表了数据类型。因为 double 和 float 有 NaN 的存在,所以末尾的 g 和 l 表示了遇到 NaN 时的处理方式。fcmpg 和 fcmpl 都从栈中弹出两个数,假设栈顶数 v2,栈顶第二顺位 v1,当 v1 = v2 时,则压入 0,v1 > v2 时,压入 1,反之压入 -1。它们的不同是,当遇到 NaN 时,fcmpg 会压入 1,fcmpl 会压入 -1。
条件跳转指令
条件跳转指令通常和比较指令结合使用。条件跳转指令有: ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull。这些指令都接收两个字节的参数, 用于计算跳转的位置(16位符号整数作为当前位置的 offset)。它们的统一含义为: 弹出栈顶元素, 测试它是否满足某一条件, 如果满足条件, 则跳转到给定位置。在条件跳转指令执行前, 一般可以先用比较指令进行栈顶元素的准备, 然后再进行条件跳转。
比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体, 它将比较和跳转两个步骤合二为一, 这类指令有:if_icmpeq, if_icmpne, if_icmplt, if_icmpgt, if_icmple、if_icmpge、if_acmpeq和 if_acmpne。
这些指令都接收两个字节的操作数作为参数, 用于计算跳转的位置。同时在执行指令时, 栈顶需要准备两个元素进行比较。指令执行完成后, 栈顶的这两个元素被清空, 且没有任何数据入栈。如果预设条件成立, 则执行跳转, 否则, 继续执行下一条语句。
多条件分支跳转
多条件分支跳转指令是专为 switch-case 语句设计的, 主要有 tableswitch 和 lookupswitch。它们的区别在于 tableswitch 要求多个条件分支值是连续的, 它内部只存放起始值和终止值, 以及若干个跳转偏移量, 通过给定的操作数index, 可以立即定位到跳转偏移量上, 因此效率比较高。指令 lookupswitch 内部存放着各个离散的 case-offset 对, 每次执行都要搜索全部的 case-offset 对, 找到匹配的 case 值, 并根据对应的 offset 计算跳转地址, 因此效率较低。
值得一提的是,JDK 1.7 之后,String 类型也能使用在 switch 语句中,在实现这类 switch 语句时,是通过 lookupswitch 指令实现的。编译器使用 String 实例的 hashCode 作为 case 的 index,也就是说每次进行 switch 操作是,都要计算目标值的 hashCode,而且为了避免 hash 冲突的问题,在 hashCode 相同时,还会通过 String.equals 进行比较,比较通过时才算匹配命中,否则则跳转到 default 对应的地址。很显然,当使用 String 类型的 switch-case 时效率相较于 int 是很低的。
无条件跳转
无条件跳转指令主要是 goto,它接收两个字节的参数,代表了一个带符号的整数,用于指定目标指令的偏移。如果指令跨度过大,可以使用 goto_w,它接收 4 个字节的参数(一个字空间)。
函数调用与返回
就函数调用而言,相关的指令有 invokevirtual,invokeinterface,invokespecial,invokestatic 和 invokedynamic。而函数返回时,需要先将返回值压入操作数栈,然后使用 xreturn,x 可以是 i,l,f,d,a 或空,分别对应了不同的返回值类型。
上述的 invokeX 指令都有各自的使用场景:
- invokevirual: 虚函数调用, 调用对象的实例方法, 根据对象的实际类型进行派发, 支持多态, 也是最常见的 Java 函数调用方式。
- invokeinterface: 指接口方法的调用, 当被调用对象申明为接口时, 使用该指令调用接口的方法。
- invokespecial: 调用特殊的一些方法, 比如构造函数、类的私有方法、父类方法。这些方法都是静态类型绑定的, 不会在调用时进行动态派发。
- invokestatic: 调用类的静态方法, 这个也是静态绑定的。
- invokedynamic: 调用动态绑定的方法, 这个是 JDK1.7 后新加入的指令, 我们前面介绍过它。
同步控制
为实现多线程的同步, Java 虚拟机还提供了 monitorenter、monitorexit 两条执行来完成临界区的进入和离开操作。当一个线程进入同步块时, 它使用 monitorenter 指令请求进入, 如果当前对象的监视器计数器为 0, 则它会被准许进入, 若为1, 则判断持有当前监视器的线程是否为自己, 如果是, 则进入, 否则进行等待, 直到对象的监视器计数器为 0, 才会被允许进入同步块。当线程退出同步块时, 需要使用 monitorexit 声明退出。在 Java 虚拟机中, 任何对象都有一个监视器与之相关联, 用来判断对象是否被锁定, 当监视器被持有后, 对象处于锁定状态。指令 monitorenter 和 monitorexit 在执行时, 都需要在操作数栈项压入对象, 之后, monitorenter 和 monitorexit 的锁定和释放都是针对这个对象的监视器进行的。
package bbm.jvm;
public class TestMonitor {
public TestMonitor() {
}
public synchronized void test() {
int i = false;
}
public void test1() {
synchronized(this) {
boolean var2 = false;
}
}
}
当我们编译上述代码时,你会发现 test 函数的字节码中不包含 monitorenter 和 monitorexit 指令,只是因为 synchronized 是声明在函数上的,在执行该函数时,monitorenter 和 monitorexit 是隐含的。虚拟机在执行该函数时 JVM 会自动执行 monitorenter,而当函数退出或者抛出异常时 JVM 会自动执行 monitorexit。
而 test1 函数的字节码中,就会有 monitorenter 和 monitorexit 指令。
这里大家可能会有疑问,为什么 test1 函数中有多个 monitorexit 指令,这是因为编译器自动的对同步代码块进行了拓展,为其增加了 try-catch 的处理。在正常结束,和异常发生时,都会执行 monitorexit 指令。
代码优化
当使用 javac 把 Java 源码转为字节码时,编译器会有一些优化以获得更好的性能。目前,对于执行的字节码会从两处进行优化:
- 使用 javac 编译时
- 通过 JIT(Just-In-Time) 即时编译,在运行时
编译优化
- 编译时计算:如果程序中有计算表达式,并且可以在编译时确定,那么表达式的计算会提前到编译期,而不是运行时计算。比如
int hour = 60 * 60 * 1000
, 在编辑后的 class 文件中直接就会变成int hour = 3600000
。 - 变量字符串的连接:当进行两个字符串变量的拼接操作时,比如
String temp = param1 + param2
,编译期会将字符串的简单拼接转化为 StringBuilder 操作,String temp = (new StringBuilder(String.valueOf(param1)).append(param2).toString())
。 - 条件语句裁剪:如果一个条件跳转语句的判断条件是一个定值,比如
if (true) { ... } esle { ... }
, 当编译上述代码时,else 语句后面的代码就不会被编译进字节码。 - switch 优化:就像前面所说的,tableswitch 的效率比 lookupswitch 高很多,所以编译期总是期望使用 tableswitch,所以当 case index 有空缺时,编译器会将空缺的 index 补上,并使其跳转到 default 对应的 offset。
JIT 优化
JIT (Just-In-Time) 编译器是 Java 虚拟机的执行机制的性能保证。由于 Java 的字节码是解释执行的,因此其效率很低。在 Java 发展历史中,有两套解释执行器: 古老的字节码解释器、现在被广泛使用的模板解释器。字节码解释器在执行时,通过纯软件代码模拟字节码的执行,效率非常低下。相比之下,模板解释器将每一条字节码和一个模板函数相关联,而模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。但即便如此,仅凭借解释器,虚拟机的执行效率依然很低,为了解决这个问题,虚拟机平台支持一种叫做即时编译的技术。
即时编译的目的是避免函数被解释执行,而是将整个函数体编译成机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
Java 虚拟机有 3 种执行方式,分别是解释执行、混合模式、编译模式。默认情况下处于混合模式。在混合模式中,函数起初会被解释执行,当一个函数被执行的频率很高时,就会成为热点代码,这些代码就可能被编译执行。
如果想让 JVM 只运行在解释模式的话,可以通过 -Xint
JVM 参数,反之,如果想让 JVM 只运行在编译模式的话,可以通过 -XComp
JVM 参数。而模式使用的混合模式对应的参数是 -Xmixed
。在混合模式时,虚拟机根据函数调用的次数阈值来衡量一个函数是不是热点代码,Client 模式下(虚拟机参数 -client),这个阈值是 1500,在 Server 模式下(虚拟机参数 -server)这个阈值是 10000。不过我们也可以通过 -XX:CompileThreshold
参数控制这个阈值,通过 -XX:+PrintCompilation
参数可以打印即使编译的日志。
多级编译
Java 虚拟机中拥有客户端编译器和服务端编译器两种编译系统,一般称为 C1 和 C2 编译器。当使用 Client 模式时,虚拟机会使用 C1 编译器,使用 Server 模式时,会使用 C2 编译器。C1 编译器的特点是编译速度快,C2 的特点是会做更多的编译时优化,因此编译时间会长于 C1, 但是编译后的代码质量会高于 C1。为了使 C1 和 C2 在编译速度和执行效率之间取得一个平衡,虚拟机支持一种叫做多级编译的策略。多级编译将编译的层次分为 5 级:
- 0级(解释执行): 采用解释执行,不采集性能监控数据。
- 1级(简单的 C1 编译): 采用 C1 编译器,进行最简单的快速编译,根据需要采集性能数据。
- 2级(有限的 C1 编译): 采用 C1 编译器,进行更多的优化编译,可能会根据第 1 级采集的性能统计数据,进一步优化编译代码。
- 3级(完全 C1 编译): 完全使用 C1 编译器的所有功能,会采集性能数据进行优化。
- 4级(C2 编译): 完全使用 C2 进行编译,进行完全的优化。
要使用多级编译器可以使用参数 -XX:+TieredCompilation
打开多级编译器的策略。如果使用该参数,那么虚拟机必须使用 Server 模式启动,如果使用 Client 模式启动,那么分级模式依然不会开启。
现在我们以参数 -server -XX:+TieredCompilation -XX:+PrintCompilation
运行下列程序,然后观察输出的 JIT 日志。
package bbm.jvm;
public class TestComp {
public static void main(String[] args) {
TestComp testComp = new TestComp();
for(int i = 0; i < 2000000000; i++) {
testComp.test(i);
}
}
public void test(int i) {
int x = 0;
int y = x + i;
}
}
执行上述代码后,打印的 JIT 日志如下,其中第一列是时间戳毫秒值,第二列是编译任务的 ID,第三列是描述代码状态的 5 个属性,% 表示这是一个栈上替换。s 表示这个是一个同步方法。!表示方法有异常块。b 表示阻塞模式编译。n 表示这是一个本地方法的包装。第四列是编译级别,0-4,级别越高编译时间越长,编译的代码质量越好。最后两列是方法名和该方法对应 Java 字节码的大小。
94 1 3 java.util.HashMap::getNode (148 bytes)
94 3 n 0 java.lang.Thread::currentThread (native) (static)
95 2 3 java.lang.String::equals (81 bytes)
95 4 3 java.lang.String::length (6 bytes)
96 6 3 java.lang.System::getSecurityManager (4 bytes)
96 7 3 java.lang.String::hashCode (55 bytes)
96 5 3 java.util.HashMap::hash (20 bytes)
97 9 3 java.io.UnixFileSystem::normalize (75 bytes)
97 10 3 java.lang.Object::<init> (1 bytes)
97 8 4 java.lang.String::charAt (29 bytes)
97 11 3 java.lang.Math::min (11 bytes)
98 12 1 java.lang.ref.Reference::get (5 bytes)
98 13 3 java.lang.AbstractStringBuilder::ensureCapacityInternal (16 bytes)
98 15 n 0 java.lang.System::arraycopy (native) (static)
98 14 3 java.util.Arrays::copyOf (19 bytes)
99 17 3 sun.nio.cs.UTF_8$Encoder::encode (359 bytes)
100 20 3 java.lang.String::indexOf (70 bytes)
100 16 3 java.lang.String::startsWith (72 bytes)
100 22 3 java.lang.String::lastIndexOf (52 bytes)
100 23 3 bbm.jvm.TestComp::test (7 bytes)
100 19 3 java.lang.String::<init> (82 bytes)
101 24 1 bbm.jvm.TestComp::test (7 bytes)
101 23 3 bbm.jvm.TestComp::test (7 bytes) made not entrant
101 18 1 java.util.ArrayList::size (5 bytes)
101 21 1 sun.instrument.TransformerManager::getSnapshotTransformerList (5 bytes)
102 25 % 3 bbm.jvm.TestComp::main @ 10 (28 bytes)
103 26 3 java.lang.StringBuilder::<init> (7 bytes)
103 27 3 bbm.jvm.TestComp::main (28 bytes)
103 28 % 4 bbm.jvm.TestComp::main @ 10 (28 bytes)
104 25 % 3 bbm.jvm.TestComp::main @ -2 (28 bytes) made not entrant
104 28 % 4 bbm.jvm.TestComp::main @ -2 (28 bytes) made not entrant
104 29 3 java.lang.String::indexOf (7 bytes)
104 30 3 java.lang.Character::toLowerCase (9 bytes)
105 31 3 java.lang.CharacterDataLatin1::toLowerCase (39 bytes)
made not entrant 状态只是表示新的代码调用不能使用这块编译结果,但可能存在正在使用该段代码块的程序,故还不能完全从系统中清理。如果代码块已经没有被使用,并处于 made not entrant 状态,一旦被清理线程发现,代码块会被进一步标记为 made zombie 状态(僵尸状态),此时,代码库将被彻底清除。
栈上替换
这里我们解释一下上述的栈上替换是什么,一般来说,一次函数调用要么使用解释执行,要么使用即时编译后的机器码执行。从解释执行切换到机器码执行,是在这个函数两次调用之间产生的,即一个函数的前一次函数调用发生时,尚未准备好编译后版本,则会进行解释执行,而下一次调用发生时,发现其编译版本已经准备好,那么就会使用编译后的版本执行。这种情况覆盖了绝大部分场合,但是,不适合那些调用次数不多,但是方法体内包含大量循环的函数,比如前面 TestComp 类的 main 函数。该函数只执行了一次,但是其中的循环部分执行了很多次,虽然循环内只有一行代码,但是对应的字节码会有很多条,所以是有编译价值的。
所以,编译期会记录循环的执行次数,一旦达到某一阈值就会认为这是一段热点代码,并触发即使编译。编译完成后,由于 main 函数不会再被从头执行,所以虚拟机需要一个切入点来用编译好的代码替换掉当前解释执行的代码。这种不等待函数执行结束,再循环体内就将代码替换的技术,叫做栈上替换(OSR)。
严格来说,并不是通过循环次数和循环入口来进行 OSR 判断的,而是使用一种叫做回边(back-edge)计数器和回边指令来进行判断的。所谓回边,就是字节码指令中,向回跳转的指令。当然,大部分情况下,这种向回跳转的指令是由循环产生的。
方法内联
方法内联的意思是说,如果一个循环内频繁的调用同一个函数 targetMethod,而 targetMethod 的函数内容又很少时,一次调用的成本就会很高,所以我们可以直接把循环函数调用剥离,将原有函数体直接嵌入到循环体内,从而减少调用函数的开销。
代码缓存大小
字节码被编译为机器码后,得到的结果需要在内存中保存,以便下次函数调用时可以直接使用。存放这些代码的内存区域称为代码缓存(Code Cache)。一旦代码缓存区域被用完,虚拟机并不会像堆或者永久区那样暴力地直接抛出内存溢出错误,而是简单地停止 JIT 编译,并保持系统继续运行。系统停止 JIT 编译后,后续未编译的代码全部以解释方式运行,故系统性能会受到影响。代码缓存空间的清理工作也是在系统 GC 时完成的。设置代码缓存区间的大小可以使用参数 XX:ReservedCodeCacheSize
指定。大家可以根据自己应用的需要设定这个值。
文章说明
更多有价值的文章均收录于贝贝猫的文章目录
版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
创作声明: 本文基于下列所有参考内容进行创作,其中可能涉及复制、修改或者转换,图片均来自网络,如有侵权请联系我,我会第一时间进行删除。
参考内容
[1]《实战 Java 虚拟机》
[2]《深入理解 Java 虚拟机》
[3] GC复制算法和标记-压缩算法的疑问
[4] Java中什么样的对象才能作为gc root,gc roots有哪些呢?
[5] concurrent-mark-and-sweep
[6] 关于 -XX:+CMSScavengeBeforeRemark,是否违背cms的设计初衷?
[7] Java Hotspot G1 GC的一些关键技术
[8] Java 垃圾回收权威指北
[9] [[HotSpot VM] 请教G1算法的原理](https://hllvm-group.iteye.com/group/topic/44381)
[10] [[HotSpot VM] 关于incremental update与SATB的一点理解](https://hllvm-group.iteye.com/group/topic/44529)
[11] Java线程的6种状态及切换
[12] Java 8 动态类型语言Lambda表达式实现原理分析
[13] Java 8 Lambda 揭秘
[14] 字节码增强技术探索
[15] 不可不说的Java“锁”事
[16] 死磕Synchronized底层实现--概论
[17] 死磕Synchronized底层实现--偏向锁
[18] 死磕Synchronized底层实现--轻量级锁
[19] 死磕Synchronized底层实现--重量级锁