面试官问JVM时,这些问题你一定得知道:执行引擎、逃逸分析、标量替换、锁消除

1,092 阅读11分钟

前言

关于JVM 的知识点总结了一个图谱,分享给大家:

JVM.jpg

执行引擎是什么?

执行引擎是JVM运行java程序的一套子系统。

执行引擎执行的就是字节码文件。

语言的深度:Java->C++ -> 硬编码(010101 CPU指令)

一、两种解释器

1.1、字节码解释器

将Java字节码-> 解释成C++代码 -> 运行硬编码

早期的时候只有字节码解释器,但是由于字节码解释器性能比较低,才出现了模板解释器。 因为需要将java代码先转化成C++代码,再将C++代码转换成硬编码执行,比价耗费时间。而C++代码可以直接转化成硬编码,所以效率比较好。

new #2 <com/jihu/test/jvm/Test> 3 dup 4 invokespecial #3 <com/jihu/test/jvm/Test.<init>> 7 astore_1 8 getstatic #4 <java/lang/System.out> 11 aload_1 12 invokevirtual #5 <com/jihu/test/jvm/Test.add> 15 invokevirtual #6 <java/io/PrintStream.println> 18 return

上面的字节码,如果是用字节码解释器通用框架如下:

while (true) { chat code = X; switch (code) { case NEW: ... break'; CASE DUP: ... break; } }

会根据switch,将字节码转化成C++代码,CPU执行C++编译后的硬编码。

1.2、模板解释器

Java字节码 -> 硬编码

看一个C程序:

1、申请一块内存,可读可写可执行 2、将处理new字节码的硬编码拿过来(硬编码怎么拿到) 3、将处理new字节码的硬编码写入申请的内存 4、申请一个函数指针,用这块函数指针指向这块内存 5、调用的时候,直接通过这个函数指针调用就可以了

image.png

如果是字节码解释器,需要先将字节码解析成C++,然后再解释成硬编码执行; 如果是模板解释器,因为开启了及时编译,所以字节码已经被解释成了硬编码,直接执行即可。下一次执行的时候,模板解释器会直接执行硬编码,所以说模板解释器效率比字节码解释器高。

二、三种运行模式

1、-Xint 纯字节码解释器 2、-Xcomp 纯模板解释器: 会先将代码编译成硬编码,之后再运行程序 3、-Xmixed 混合模式:字节码解释器 + 模板解释器

我们使用java -version来查看JVM的运行模式:

D:Tool_WorkspaceIDEAJDK-Test-01srccomjihutestbyte_code>java -version java version "1.8.0_201" Java(TM) SE Runtime Environment (build 1.8.0_201-b09) Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode) 64位机下,JVM只有server模式,32位机可以设置为client模式运行。

我们可以看到,此时运行模式是mixed,即混合模式。

java -Xint -version D:Tool_WorkspaceIDEAJDK-Test-01srccomjihutestbyte_code>java -Xint -version java version "1.8.0_201" Java(TM) SE Runtime Environment (build 1.8.0_201-b09) Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, interpreted mode) 此时为字节码解释器模式

java -Xcomp -version D:Tool_WorkspaceIDEAJDK-Test-01srccomjihutestbyte_code>java -Xcomp -version java version "1.8.0_201" Java(TM) SE Runtime Environment (build 1.8.0_201-b09) Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, compiled mode) 此时为编译模式

2.1、比较三种运行模式的性能

image.png

从我们的测试的值,性能从高到低是:模板解释器、混合模式解释器、字节码解释器。

我们思考一下,为什么此时JVM默认还是使用混合模式呢?而不是纯模板解释器呢?

纯模板解释器 会先将代码编译成硬编码,之后再运行程序。而如果一个程序比较大,那么在运行初期,并不会执行,但是会耗费较多时间。

所以说,混合模式还是模板模式哪个效率更高,和程序的大小有关。如果程序很小,纯模板解释器效率更好。反之,混合模式更好。

注意: 模板解释器运行的是编译好的硬编码。

现在有两个问题?

1、谁来编译?

2、编译后生成的热点代码(热点代码就是编译好的硬编码)保存在哪里?

编译优化,找找感觉。

正常的(非裸函数)C++函数生成的硬编码都有堆栈操作。

三、及时编译器

字节码解释器和及时编译器没有关系!字节码解释器是解释执行,和编译器没有关系。

只有模板解释器才和及时编译器有关系。模板解释器运行的是热点代码,即硬编码,是及时编译器编译后产生的。

3.1、C1编译器、

c1编译器是client模式下的及时编译器。

1、触发的条件相对C2比较宽松:需要收集的数据较少。

2、编译的优化比较浅:基本运算在编译的时候运算掉了。比如final修饰的拼接字符串。

3、c1编译器编译生成的代码执行效率较c2低。

3.2、C2编译器

C2编译器是server模式下的及时编译器。

1、触发的条件比较严格,一般来说,是程序运行了一段时间以后才会触发;

2、优化比较深;

3、编译生成的代码执行效率较C1更高。

3.3、混合编译

现在一般是混合编译。

程序运行初期触发c1编译器

程序运行一段时间后触发c2编译器

及时编译器生成的代码就是给模板解释器用的。

四、触发条件

及时编译的最小单位不是一个方法,而是一个代码块(for、while循环)。

Client编译器模式下,N默认的值1500 Server编译器模式下,N默认的值10000

4.1、热度衰减

10000次就会触发及时编译

比如调用一个new方法,之前已经调用了7000次了。然后接下来的一段时间中没有继续调用,热度会2倍速往下掉3500。

如果没有热度衰减,我们只需要调用3001次就会触发及时编译,但是现在热度调到3500,需要调用6501次才能触发及时编译。

4.2、分享阿里早年的一个故障

热机切冷机故障

冷机:刚运行程序不久;

热机:运行程序一段时间了;

比如现在有10台节点,随着业务的增长,需要加新的节点。而我们添加节点的时候,一般都是添加相同配置的节点。

因为我们知道之前的10台机器每天的压力是多少,所以我们打算将这些压力往新的节点上分摊一下,即修改负载均衡。但是现在的问题是,发现一切冷机之后,冷机会立马挂掉。

因为热机上对于热点代码已经进行了缓存,所以热机抗的并发更大。所以一旦将压力切到新的冷机上,冷机的并发能力不如热机,而且冷机刚启动,一遍运行一遍还在做及时编译。所以说CPU扛不住。

正确的做法是,将一部分流量先切到冷机上,一段时间后出发及时编译后再切全部的流量。

五、热点代码缓存区

热点代码存储在方法区。

参数名是CodeCache。

热点代码缓存区也是调优中的一种,server编译器模式下缓存大小起始于 2496k。 client编译器模式下热点代码缓存大小起始于 160k。

调优参数:

InitialCodeCacheSize ReservedCodeCacheSize

一般将这两个值调成一样大。

热点代码会根据LRU自动清理,有点像Redis的热点数据缓存。

六、及时编译时如何运行的

GC,及时编译等都是通过VM_THREAD执行的。

VM_THREAD就像一个队列一样,有线程不断的将任务放到这个队列中。

1、将这个及时编译任务放入到队列;

2、VM_THREAD从这个队列中读取任务并运行。

VM_THREAD是异步运行的。

那么执行及时编译的线程有多少呢?如何调优?

可以通过命令:

java -XX:+PrintFlagsFinal

然后找到CICompilerCount参数,发现是3

image.png

问题:如何理解java是半编译半解释型语言

1、javac编译,java运行;

2、字节码解释器解释执行,模板解释器编译执行。

思路理解:

硬编码在JVM角度叫热点代码

热点代码存在哪里? 热点代码存在缓冲区

热点代码是如何生成的?

及时编译器

及时编译器的触发条件

热度衰减

阿里的热机切冷机故障

字节码解释器需要将java字节码一句句的先解释成C++,然后由C++再转化成硬编码执行,整个过程比较耗时; 而模板解释器会直接将java字节码解释成硬编码执行。但是如果是非常大的程序,整个解释的过程是比较长的,所以说只有程勋运行一段时间后,才会使用模板解释器。

七、逃逸分析

7.1、逃逸

逃逸是一种现象,逃逸指的是对象逃逸。

逃逸可以这么理解,对象的作用域不仅仅是当前线程。对象逃逸到方法外或者线程外。或者说,非局部变量就是逃逸。

如果对象的作用域是局部变量,就不会逃逸。

共享变量、返回值、参数都不会逃逸

package com.jihu.test.escape; public class EscapeAnalysis { public static Object globaVariableObject; public Object instanceObject; // 静态变量,外部线程可见,发生逃逸 public void globaVariableEscape() { globaVariableObject = new Object(); } // 返回实例,外部线程可见,发生逃逸 public void instanceObjectEscape() { instanceObject = new Object(); } // 返回实例,外部线程可见,发生逃逸 public Object returnObject() { return new Object(); } // 仅创建线程可见,对象未逃逸 public void noEscape() { synchronized (new Object()) { System.out.println("hello"); } } // 仅创建线程可见,对象无逃逸 public void noEscape2() { Object obj = new Object(); } public static void main(String[] args) { EscapeAnalysis escapeAnalysis = new EscapeAnalysis(); escapeAnalysis.globaVariableEscape(); } }

7.2、分析

分析是一种手段。

为什么要对对象的逃逸进行分析?

基于逃逸分析,JVM开发了三种优化技术。

为什么呢?因为如果对象发生了逃逸,那情况就会变的非常复杂,优化无法实施。

7.2.1、栈上分配

逃逸分析如果是开启的,栈上分配就是存在的。

JDK8中,逃逸分许默认开启。

我们传统的观念是对象是在堆中创建的。

而栈上分配指的是,对象在虚拟机栈上分配。

jdk 8中栈上分配存在吗?如何证明?

我们此时new一个对象一百万次,然后看堆中是否有一百万个,如果没有,就证明存在栈上分配(加一个限制条件,不发生gc)。

package com.jihu.test.runengine; public class StackAlloc { public static void main(String[] args) { long start = System.currentTimeMillis(); for (int i = 0; i < 1000000; i++) { alloc(); } long end = System.currentTimeMillis(); System.out.println((end - start) + "ms"); while (true); } public static void alloc() { StackAlloc stackAlloc = new StackAlloc(); } }

我们使用HSDB来分析,启动命令:

java -classpath C:\Program Files\Java\jdk1.8.0_201\lib\sa-jdi.jar sun.jvm.hotspot.HSDB

然后看我们刚才测试类的进程号:

image.png

连接HSDB:

image.png

然后选择:

image.png

image.png

开启逃逸分析,耗时6ms.可以看到,我们刚才创建的对象StackAlloc在堆中大概有十一万多个。所以说栈上分配是存在的。

我们再来看栈上的对象:

关闭逃逸分析:

package com.jihu.test.runengine; public class StackAlloc { -XX:+DoEscapeAnalysis 开启逃逸分析(JDK1.8默认开启) -XX:-DoEscapeAnalysis 关闭逃逸分析 -XX:+printEscapeAnalysis 查看逃逸分析结果

关闭逃逸分析后的运行时间是19ms. 而且从HSDB中我们也能看到有一百万个对象。

image.png

7.2.2、标量替换

标量:标量不可能再分,java中的基本数据类型就是标量

聚合量:可再分,对象就是聚合量

package com.jihu.test.runengine; public class ScalarReplace { public static void main(String[] args) { } public static void test() { Position position = new Position(1, 2, 3); System.out.println(position.x); // 替换成: System.out.println(1); System.out.println(position.y); // 替换成: System.out.println(2); System.out.println(position.z); // 替换成: System.out.println(3); } } class Position { int x; int y; int z; public Position(int i, int i1, int i2) { this.x = x; this.y = y; this.z = z; } }

这里,positon.x,positon.y,positon.z 结果都是int,都是标量。

这里的position是一个局部变量,不会发生逃逸,所以当前这个position对象的x, y, z属性是不可能被修改的。JVM在做逃逸分析的时候发现了这种情况,就会进行标量替换,将positon.x,positon.y,positon.z替换成表象1, 2,3. 这样传输效率就会更高。如果这里不做标量替换的话, 就需要根据position对象的属性去常量池中找值。

7.3.3、锁消除

我们加锁的目的是为了保证同步,而下面的代码中加锁是没有意义的,因为这里new出来的对象是一个方法内的局部变量,其他线程是不可见的。

// 仅创建线程可见,对象未逃逸 public void noEscape() { synchronized (new Object()) { System.out.println("hello"); } }

JVM分析的时候如果发现了这种情况,就会优化代码,优化后的代码如下:

public void noEscape() { System.out.println("hello"); }

问题:如何判断栈上分配对象的阈值是多少

java中的对象是8字节对齐的。

总结

我这边整理了一份JVM相关资料文档、Spring系列全家桶、Java的系统化资料:(包括Java核心知识点、面试专题和20年最新的互联网真题、电子书等)有需要的朋友可以关注公众号【程序媛小琬】即可获取。