一、JVM语言的无关性与class类文件
不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石,也是语言无关性的基础。
Java 虚拟机不和任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他辅助信息。
任何一个 Class 文件都对应着唯一一个类或接口的定义信息,而且Class 文件实际上它并不一定以磁盘文件的形式存在(比如可以动态生成、或者直接送入类加载器中)。
Class 文件是一组以 8 位字节为基础单位的二进制流。
二、字节码查看工具
- sublime text -- 查看.class文件16进制内容
- javap --JDK 自带的反解析工具(命令行),它的作用是将 .class 字节码文件解析成可读的文件格式。在使用 javap 时添加 -v 参数/-c 参数,尽量多打印一些信息。同时,也可以使用 -p 参数,打印一些私有的字段和方法。
- jclasslib -- jclasslib 是一个图形化的工具,能够直观的查看字节码中的内容。它还分门别类的对类中的各个部分进行了整理。同时,它还提供了 Idea 的插件,你可以从 plugins 中搜索到它。
三、class类文件格式
源码:
public class ByteCode {
public ByteCode(){}
}
- javap -v
class文件用sublime text打开后(以16进制展示)
整个 class 文件的格式就是一个二进制的字节流。
各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。
Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以 u1、u2、u4、u8 来分别代表 1 个字节(一个字节是由两位 16 进制数组成,一个16进制需要4位二进制表示。)、2 个字节、4 个字节和 8 个字节的无符号数
无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。
表用于描述有层次关系的复合结构的数据,整个Class 文件本质上就是一张表。
1字节=8位
Class文件格式详解
Class 的结构不像 XML 等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。
按顺序包括:
1.魔数与 Class 文件的版本
magic(u4)魔数
java的魔术cafe babe
每个 Class 文件的头 4 个字节(32位)称为魔数(Magic Number)。它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
3*16^1+4*16^0 = 52(代表 JDK1.8,java的版本号是从45开始ID)
minor_version(u2)次版本号和major_version(u2)主版本号
紧接着魔数后的 4 个字节存储的是 Class 文件的jdk版本号:第5第6个字节是次版本号(MinorVersion),第7第8个字节是主版本号(Major Version)。Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号加 1 ,高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。
2.常量池
常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。
与 Java 中语言习惯不一样的是,这个容量计数是从 1 而不是 0 开始的,所以计算结果要减1
1*16^1 + 0*16^0 = 16 说明有15个常量,
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。
字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。
而符号引用则属于编译原理方面的概念,包括三类常量:类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符
也可以使用jclasslib直观的查看字节码中的具体内容
3.访问标志
access_flags(u2)
用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被声明为 final 等
4.类索引、父类索引与接口索引集合
this_class(u2)、super_class(u2)
这三项数据来确定这个类的继承关系。
类索引用于确定这个类的全限定名,
父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承,所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0。
接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句(如果这个类本身是一个接口,则应当是 extends 语句)后的接口顺序从左到右排列在接口索引集合中
5.字段表集合
fields
描述接口或者类中声明的变量。字段(field)包括类级变量以及实例级变量。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。
6.方法表集合
methods
描述了方法的定义,但是方法里的 Java 代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。与字段表集合相类似的,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>”
7.属性表集合
attributes
存储 Class 文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在 Code 属性表中。
四、字节码指令
字节码指令属于方法表中的内容
方法表是一个表结构,表中的每个成员必须是method_info数据结构,用于表示当前类或者接口的某个方法的完成描述
Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。
由于限制了 Java 虚拟机操作码的长度为一个字节(即 0~255),这意味着指令集的操作码总数不可能超过 256 条。
大多数的指令都包含了其操作所对应的数据类型信息。例如:
iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。
大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型
阅读字节码作为了解 Java 虚拟机的基础技能,有需要的话可以去掌握常见指令。
指令和数据类型
指令分类
1.加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。
将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
扩充局部变量表的访问索引的指令:wide。
2.运算指令
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
加法指令:iadd、ladd、fadd、dadd。
减法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul 等等
3.类型转换指令
可以将两种不同的数值类型进行相互转换,
Java 虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):int 类型到 long、float 或者 double 类型。
long 类型到 float、double 类型。
float 类型到 double 类型。
处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和d2f。
4.对象创建与访问指令
new
5.创建数组的指令
newarray、anewarray、multianewarray。
6.访问字段指令
getfield、putfield、getstatic、putstatic。
7.数组存取相关指令
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
取数组长度的指令:arraylength。
8.检查类实例类型的指令
instanceof、checkcast。
9.操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
将栈最顶端的两个数值互换:swap。
10.控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。
条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne。
复合条件分支:tableswitch、lookupswitch。
无条件分支:goto、goto_w、jsr、jsr_w、ret。
11.方法调用指令
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令用于调用类方法(static 方法)。
invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关。
12.方法返回指令
是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。
13.异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现
14.同步指令——synchronized
有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义
15.字节码指令——异常处理
每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。
当一个方法执行完,要返回,那么有两种情况,一种是正常,另外一种是异常。
完成出口(返回地址):
正常返回:(调用程序计数器中的地址作为返回)
三步曲:
恢复上层方法的局部变量表和操作数栈、
把返回值(如果有的话)压入调用者栈帧的操作数栈中、
调整程序计数器的值以指向方法调用指令后面的一条指令、
异常的话:(通过异常处理表来确定)
异常处理
java异常机制
Error 和 RuntimeException 是非检查型异常(Unchecked Exception),也就是不需要 catch 语句去捕获的异常;而其他异常,则需要程序员手动去处理。
异常表
public class SynchronizedDemo {
public synchronized void m1(){
System.out.println("m1");
}
public static synchronized void m2(){
System.out.println("m2");
}
public final Object lock=new Object();
public void doLock(){
synchronized (lock){
System.out.println("lock");
}
}
}
下面是doLock()方法的反汇编
在 synchronized 生成的字节码中,其实包含两条 monitorexit 指令,是为了保证所有的异常条件,都能够退出。可以看到,编译后的字节码,带有一个叫 Exception table 的异常表,里面的每一行数据,都是一个异常处理器:
- from 指定字节码索引的开始位置
- to 指定字节码索引的结束位置
- target 异常处理的起始位置
- type 异常类型
也就是说,只要在 from 和 to 之间发生了异常,就会跳转到 target 所指定的位置。
我可以看到,第一条 monitorexit(16)在异常表第一条的范围中,如果异常,能够跳转到第 20 行
第二条 monitorexit(22)在异常表第二条的范围中,如果异常,能够跳转到第 20 行
每个对象都会与一个monitor相关联,当某个monitor被拥有之后就会被锁住,当线程执行到monitorenter指令时,就会去尝试获得对应的monitor。步骤如下:
1.每个monitor维护一个记录着拥有次数的计数器。未被拥有的monitor的该计数器为0,当一个线程获得monitor(执行monitorenter)后,该计数器自增变为 1 。
-
- 当同一个线程再次获得该monitor的时候,计数器再次自增;
- 当不同线程想要获得该monitor的时候,就会被阻塞。
2.当同一个线程释放 monitor(执行monitorexit指令)的时候,计数器再自减。当计数器为0的时候。monitor将被释放,其他线程便可以获得monitor。
同步方法是隐式的。一个同步方法会在运行时常量池中的method_info结构体中存放ACC_SYNCHRONIZED标识符。当一个线程访问方法时,会去检查是否存在ACC_SYNCHRONIZED标识,如果存在,则先要获得对应的monitor锁,然后执行方法。当方法执行结束(不管是正常return还是抛出异常)都会释放对应的monitor锁。如果此时有其他线程也想要访问这个方法时,会因得不到monitor锁而阻塞。当同步方法中抛出异常且方法内没有捕获,则在向外抛出时会先释放已获得的monitor锁
同步方法和同步代码块底层都是通过monitor来实现同步的。
两者的区别:同步方式是通过方法中的access_flags中设置ACC_SYNCHRONIZED标志来实现;同步代码块是通过monitorenter和monitorexit来实现
我们知道了每个对象都与一个monitor相关联。而monitor可以被线程拥有或释放
finally
通常我们在做一些文件读取的时候,都会在 finally 代码块中关闭流,以避免内存的溢出
public class StreamDemo {
public void read(){
InputStream in = null;
try {
in = new FileInputStream("A.java");
}catch(FileNotFoundException e){
e.printStackTrace();
} finally {
if (null != in) {
try {
in.close();
}catch(IOException e) {
e.printStackTrace();
}
}
}
}
}
上面的代码,捕获了一个 FileNotFoundException 异常,然后在 finally 中捕获了 IOException 异常。当我们分析字节码的时候,却发现了一个有意思的地方:IOException 足足出现了三次。
Java 编译器使用了一种比较傻的方式来组织 finally 的字节码,它分别在 try、catch 的正常执行路径上,复制一份 finally 代码,追加在正常执行逻辑的后面;同时,再复制一份到其他异常执行逻辑的出口处。
Try{
Table1
}catch{
Table1
}finally{
Table1
}
下面这段代码不报错的原因,都可以在字节码中找到答案
public class NoError {
public static void main(String[] args) {
NoError noError =new NoError();
noError.read();
}
public int read(){
try {
int a = 13/0;
return a;
}finally {
return 1;
}
}
}
程序的字节码,可以看到,异常之后,直接跳转到序号 9 了。
装箱拆箱字节码层面分析
Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer,包装类型的值可以为 null(基本类型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型),很多时候,它们都能够相互赋值。
public class Box {
public Integer cal() {
Integer a = 1000;//Inter.valueOf(1000)
int b = a * 10;//a.intValue()
return b;
}
}
cal()字节码
通过观察字节码,我们发现:
1、在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。
2、赋值操作使用的是 Integer.valueOf 方法。
3、在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。
这就是 Java 中的自动装箱拆箱的底层实现。
IntegerCache
但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
这个 IntegerCache,缓存了 low 和 high 之间的 Integer 对象
private static class IntegerCache {
static final int low = -128;
static final int high;
static final Integer cache[];
static {
// high value may be configured by property
int h = 127;
String integerCacheHighPropValue =
sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.high");
if (integerCacheHighPropValue != null) {
try {
int i = parseInt(integerCacheHighPropValue);
i = Math.max(i, 127);
// Maximum array size is Integer.MAX_VALUE
h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
} catch( NumberFormatException nfe) {
// If the property cannot be parsed into an int, ignore it.
}
}
high = h;
cache = new Integer[(high - low) + 1];
int j = low;
for(int k = 0; k < cache.length; k++)
cache[k] = new Integer(j++);
// range [-128, 127] must be interned (JLS7 5.1.7)
assert IntegerCache.high >= 127;
}
private IntegerCache() {}
}
一般情况下,缓存是的-128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。
字节码指令——数组
其实,数组是 JVM 内置的一种对象类型,这个对象同样是继承的 Object 类。
public class ArrayDemo {
int getValue() {
int[] arr = new int[]{1111, 2222, 3333, 4444};
return arr[2];
}
int getLength(int[] arr) {
return arr.length;
}
}
getValue()方法字节码
数组创建
可以看到,新建数组的代码,被编译成了 newarray 指令
数组里的初始内容,被顺序编译成了一系列指令:
- sipush 将一个短整型常量值推送至栈顶;
- iastore 将栈顶 int 型数值存入指定数组的指定索引位置。
具体操作:
1、 iconst_0,常量 0,入操作数栈
2、 sipush 将一个常量 1111 加载到操作数栈
3、 将栈顶 int 型数值存入数组的 0 索引位置
为了支持多种类型,从操作数栈存储到数组,有更多的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore。
数组访问
数组元素的访问,是通过第 28 ~ 30 行代码来实现的:
aload_1 将第二个引用类型本地变量推送至栈顶,这里是生成的数组;
iconst_2 将 int 型 2 推送至栈顶;
iaload 将 int 型数组指定索引的值推送至栈顶。
获取数组的长度,是由字节码指令 arraylength 来完成的
获取数组长度的指令 arraylength
字节码指令——foreach
无论是 Java 的数组,还是 List,都可以使用 foreach 语句进行遍历,虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。
public class ForDemo {
void loop(int[] arr) {
for (int i : arr) {
System.out.println(i);
}
}
void loop(List<Integer> arr) {
for (int i : arr) {
System.out.println(i);
}
}
}
数组:它将代码解释成了传统的变量方式,即 for(int i, i
List:实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。
反编译可以看到实际生成的代码:
字节码指令——注解
@KingAnnotation
public class AnnotationDemo {
@KingAnnotation
public void test(@KingAnnotation int a){
}
}
无论是类的注解,还是方法注解,都是由一个叫做 RuntimeInvisibleAnnotations 的结构来存储的,而参数的存储,是由 RuntimeInvisibleParameterAnotations 来保证的
字节码指令总结
Java 的特性非常多,比如异常的处理、finally 块的执行顺序;以及隐藏的装箱拆箱和 foreach 语法糖的底层实现。还有字节码指令,可能有几千行,看起来很吓人,但执行速度几乎都是纳秒级别的。Java 的无数框架,包括 JDK,也不会为了优化这种性能对代码进行限制。了解其原理,但不要舍本逐末,比如减少一次 Java 线程的上下文切换,就比你优化几千个装箱拆箱动作,速度来的更快一些。
五、JVM 即时编译器 JIT
1、解释执行与JIT
Java 程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行。
解释执行的方式是非常低效的,需要先把字节码翻译成机器码才能往下执行。另外,字节码是 Java 编译器做的一次初级优化,许多代码不仅满足语法分析,其实还有很大的优化空间。
同时那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码,如果按照解释执行,效率是非常低的。(这个就是Java以前被C、C++开发者吐槽慢的原因)以上的这些代码称为热点代码。所以,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化和缓存。完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。
2、热点代码
热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。
JVM 提供了一个参数“-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。
如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU占用上升。
-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小
通过 java -XX:+PrintFlagsFinal –version查询:
3、C1、C2 与 Graal 编译器
在JDK1.8中 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器。
- C1编译器
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求,C1也被称为 Client Compiler。
C1编译器几乎不会对代码进行优化
- C2编译器
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为Server Compiler。但是C2代码已超级复杂,无人能维护!所以才会开发Java编写的Graal编译器取代C2(JDK10开始)
- 分层编译
在 Java7之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。Java7及以后引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,当然我们也可以通过参数强制指定虚拟机的即时编译模式。
在 Java8 中,默认开启分层编译。
通过 java -version 命令行可以直接查看到当前系统使用的编译模式(默认分层编译)
JVM 的执行状态分为了 5 个层次:(不重要、了解即可)
第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling;
第 2 层:也称为 C1 编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数profiling 的 C1 编译;
第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
4、热点探测
在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
5、方法调用计数器
用于统计方法被调用的次数,方法调用计数器的默认阈值在 客服端模式下是 1500 次,在 服务端 模式在是 10000 次,可通过 -XX: CompileThreshold 来设定;
而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。
当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
-XX: CompileThreshold 设定阈值
通过 java -XX:+PrintFlagsFinal –version查询:
6、回边计数器
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 jit 编译的阈值,在不开启分层编译的情况下,客户端 默认为 13995,服务端 默认为 10700,可通过 -XX: OnStackReplacePercentage=N 来设置;
怎么算的呢!参考以下公式(有兴趣可了解):
回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
通过 java -XX:+PrintFlagsFinal –version查询先关参数:
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700.
回边计数器阈值 =10000×(140-33)=10700
而在分层编译的情况下,-XX:OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言
-XX: OnStackReplacePercentage=N 设定阈值
六、编译优化技术
JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码.
1、方法内联
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
例如以下方法:
最终会被优化为:
JVM 会自动识别热点方法,并对它们使用方法内联进行优化。
我们可以通过 -XX:CompileThreshold 来设置热点方法的阈值。
但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。
而方法体的大小阈值,我们也可以通过参数设置来优化:
经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:FreqInlineSize=N 来设置大小值;
不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。
代码演示
设置 VM 参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
-XX:+PrintCompilation //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来
如果循环太少,则不会触发方法内联
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
-
- 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
- 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
- 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
2、锁消除
在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。
但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。
下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。
我们把锁消除关闭---测试发现性能差别有点大
-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)
-XX:-EliminateLocks 关闭锁消除
3、标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
-XX:-EliminateAllocations 关闭标量替换
逃逸分析技术
逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。
比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,
例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。
从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。
如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。
当然逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化,另外对象如果要分配到栈上,需要将对象拆分,这种编译优化就叫做标量替换技术。
如下图中foo方法如果使用标量替换的话,那么最后执行的话就是foo1方法的效果。
逃逸分析代码示例
这段代码在调用的过程中Myboject这个对象属于不可逃逸,JVM可以做栈上分配,所以运行速度非常快!
JVM默认会做逃逸分析、会进行标量替换,会进行栈上分配。
然后关闭逃逸分析 ‐XX:‐DoEscapeAnalysis
然后关闭标量替换 -XX:‐EliminateAllocations
测试结果可见,开启逃逸分析对代码的执行性能有很大的影响!那为什么有这个影响?
逃逸分析
如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,
如果是频繁的调用此方法则可以得到很大的性能提高。
采用了逃逸分析后,满足逃逸的对象在栈上分配
没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢
代码验证
开启GC打印日志 ‐XX:+PrintGC
开启逃逸分析
可以看到没有GC日志(因为进行了栈上分配)
关闭逃逸分析
可以看到关闭了逃逸分析,JVM在频繁的进行垃圾回收(GC),正是这一块的操作导致性能有较大的差别。
七、虚拟机栈再认识
整体介绍见运行时数据区域,虚拟机栈简单介绍见虚拟机栈(JVM 后续的执行子程序有详细的见解)栈帧中的数据在编译后就已经确定了,写在了字节码文件的 code 属性中(属性表集合)
栈桢详解
当前栈帧有效:一个线程的方法调用链可能会很长,这意味着虚拟机栈会被压入很多栈帧,但在线程执行的某个时间点只有位于栈顶的栈帧才是有效的,该栈帧称为“当前栈帧”,与这个栈帧相关联的方法称为“当前方法”。
局部变量表
局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中导向性地说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、double、long 8 种数据类型和 reference ,可以使用 32 位或更小的物理内存来存放。对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32 位也可能是 64 位)64 位的数据类型只有 long 和 double 两种。
操作数栈
操作数栈(Operand Stack)也常称为操作栈,它是一个先进后出(First In Last Out,FILO)栈。 同局部变量表一样, 操作数栈的每一个元素可以是任意的Java 数据类型,包括 long 和 double。 32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在"调用其他方法的时候是通过操作数栈来进行参数传递的"。java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
数据重叠优化
虚拟机概念模型中每两个栈帧都是相互独立的,但在实际应用是我们知道一个方法调用另一个方法时,往往存在参数传递,这种做法在虚拟机实现过程中会做一些优化,具体做法如下:令两个栈帧出现一部分重叠。让下面栈帧的一部分操作数栈与上面栈帧的部分局部变量表重叠在一起,进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
动态连接
既然是执行方法,那么我们需要知道当前栈帧执行的是哪个方法,栈帧中会持有一个引用(符号引用),该引用指向某个具体方法。符号引用是一个地址位置的代号,在编译的时候我们是不知道某个方法在运行的时候是放到哪里的,这时我用代号eg: com/enjoy/pojo/User.Say:()V 指代某个类的方法,将来可以把符号引用转换成直接引用进行真实的调用。
用符号引用转化成直接引用的解析时机,把解析分为两大类
-
- 静态解析:符号引用在类加载阶段或者第一次使用的时候就直接转换成直接引用。
- 动态连接:符号引用在每次运行期间转换为直接引用,即每次运行都重新转换。
方法返回地址(完成出口)
方法退出方式有:正常退出与异常退出
理论上,执行完当前栈帧的方法,需要返回到当前方法被调用的位置,所以栈帧需要记录一些信息,用来恢复上层方法的执行状态。
正常退出,上层方法的 PC 计数器可以做为当前方法的返回地址,被保存在当前栈帧中。
异常退出,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出时会做的操作:恢复上次方法的局部变量表、操作数栈,把当前方法的返回值,压入调用者栈帧的操作数栈中,使用当前栈帧保存的返回地址。调整 PC 计数器的值,当前栈帧出栈,随后,执行 PC 计数器指向的指令。
附加信息
虚拟机规范允许实现虚拟机时增加一些额外信息,例如与调试相关的信息。一般把把动态连接、方法返回地址、其他额外信息归成一类,称为栈帧信息。
八、基于栈的字节码解释执行引擎
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。
基于栈的指令集
举个最简单的例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。
基于寄存器的指令集
如果基于寄存器,那程序可能会是这个样子:
mov eax,1
add eax,1
mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里面。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。