JVM编译优化

JVM 编译优化

字节码是如何运行的

解释:解释执行优势在于没有编译的等待时间,性能相对差一些
编译:编译执行运行效率会高很多,一般认为比解释执行快一个数量级,但是带来了额外的开销

即时编译器(Just-In-Time)

C1 编译器:

  • 是一个简单快速的编译器
  • 主要关注局部性的优化
  • 适用于执行时间较短或对启动性能有要求的程序,例如 GUI 应用对界面启动速度就有一定要求
  • 也被称为 Client Compiler

C2 编译器:

  • 是为长期运行的服务器端应用程序做性能调优的编译器
  • 适用于执行时间较长或对峰值性能有要求的程序
  • 也被称为是 Server Compiler

分层编译

  • 0:解释执行
  • 1:简单 C1 编译:会用 C1 编译器进行一些简单的优化,不开启 Profiling
  • 2:受限的 C1 编译:仅执行带方法调用次数以及循环回边执行次数 Profiling 的 C1 编译
  • 3:完全 C1 编译:会执行带有所有 Profiling 的 C1 代码
  • 4:C2 编译:使用 C2 编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化,级别越高,应用启动越慢,优化的开销越高,峰值性能也越高

只想开启 C2:-XX:-TieredCompilation (禁用中间编译层(123层) )
只想开启 C1:-XX:+TieredCompilation -XX:TieredStopAtLevel=1

如何判断热点代码

基于采样的热点探测

这种探测方法的实现原理是 JVM 周期性的检查各个线程的虚拟机栈的栈顶,如果发现某个方法经常出现在栈顶,那么就认为这个方法是“热点方法”,这种探测方法的优点是实现简单、高效,还可以很容易的获取方法调用关系(将堆栈展开即可),缺点是难以精确的确认一个方法的热度,例如由于线程阻塞造成某个方法长时间处于栈顶,方法可能被误判为热点方法

基于计数器的热点探测

这种探测方法的实现原理是虚拟机为每个方法(甚至是代码块)建立计数器,统计各个方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种探测方法的缺点是实现起来较为麻烦,需要为每个方法建立并维护计数器,并且不能直接获取到方法的调用关系,但优点是统计的结果更加精准和严谨

一般来说,计数器记录的不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然达不到被判断为“热点方法”的阈值,虚拟机会将这个方法的调用计数器的数值减半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为次方法统计的半衰周期(Counter Half Life Time)

若计数器记录的是方法被调用的绝对次数,只要程序的运行时间足够长,绝大部分方法最终都会被判定为“热点方法”,并被编译成本地代码

  • 方法调用计数器(Invocation Counter)

image.png

用于统计方法被调用的次数,在不开启分层编译的情况下,在 C1 编译器下的默认阈值是 1500 次,在 C2 模式下是 10000 次,也可用 -XX:CompileThreshold=X 指定阈值

  • 回边计数器(Back Edge Counter)

image.png

用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),在不开启分层编译的情况下,C1 编译器下的默认阈值 13995,C2 默认为 10700,可使用 -XX:OnStackReplacePercentage=X 指定阈值
建立回边计数器的主要目的是为了触发 OSR(OnStackReplacement),所以 OSR 就是在运行中替换正在运行的函数的栈帧的技术

方法内联

1
2
3
4
5
6
7
8
9
10
11
12
private int add4(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}

private int add2(int x1, int x2) {
return x1 + x2;
}

// 内联后
private int add4(int x1, int x2, int x3, int x4) {
return x1 + x2 + x3 + x4;
}

使用条件:

  • 方法体足够小
    • 热点方法:如果方法体小于 325 字节会尝试内联,可用 -XX:FreqInlineSize 修改大小
    • 非热点方法:如果方法体小于 35 字节,会尝试内联,可用 -XX:MaxInlineSize 修改大小
  • 被调用方法运行时的实现被可以唯一确定
    • private、static 及 final 方法,JIT 可以唯一确定具体的实现代码
    • public 的实例方法,指向的实现可能是自身、父类、子类的代码当且仅当 JIT 能够唯一确定方法的具体实现时,才有可能完成内联

注意:

  • 尽量让方法体小一些
  • 尽量使用 private、static、final 关键字修改方法,避免因为多态,需要对方法做额外的检查
  • 一些情况下,可通过 JVM 参数修改阈值,从而让更多方法内联

可能带来的问题:

  • CodeCache 的溢出,导致 JVM 退化成解释执行模式
内联相关JVM参数
参数名 默认 说明
-XX:+Printinlining - 打印内联详情,该参数需和 -XX:+UnlockDiagnosticVMOptions 配合使用
-XX:+UnlockDiagnosticVMOptions - 打印 JVM 诊断相关的信息
-XX:MaxInlineSize=n 35 如果非热点方法的字节码超过该值,则无法内联,单位字节
-XX:FreqInlineSize=n 325 如果热点方法的字节码超过该值,则无法内联,单位字节
-XX:InlineSamllCode=n 1000 目标编译后生成的机器码代销大于该值则无法内联,单位字节
-XX:MaxInlineLevel=n 9 内联方法的最大调用帧数(嵌套调用的最大内联深度)
-XX:MaxTrivialSize=n 6 如果方法的字节码少于该值,则直接内联,单位字节
-XX:MinlnliningThreshold=n 250 如果目标方法的调用次数低于该值,则不去内联
-XX:LiveNodeCountInliningCutoff=n 40000 编译过程中最大活动节点数(IR节点)的上限,仅对 C2 编译器有效
-XX:InlineFrequencyCount=n 100 如果方法的调用点(call site)的执行次数超过该值,则触发内联
-XX:MaxRecursivelnlineLevel=n 1 递归调用大于这么多次就不内联
-XX:+InlineSynchronizedMethods 开启 是否允许内联同步方法

逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化,可以通过 -XX:+DoEscapeAnalysis 开启逃逸分析(JDK8 默认开启)

逃逸分析场景:

  • 全局变量赋值逃逸
  • 方法返回值逃逸
  • 实例引用逃逸
  • 线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量
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
27
28
29
30
public class EscapeTest {

public static SomeClass someClass;

// 全局变量赋值逃逸
public void globalVariablePointerEscape() {
someClass = new SomeClass();
}

// 方法返回值逃逸
public SomeClass methodPointerEscape() {
return new SomeClass();
}
public void someMethod() {
EscapeTest escapeTest = new EscapeTest();
SomeClass someClass = escapeTest.methodPointerEscape();
}

// 实例引用逃逸
public void instancePassPointerEscape() {
this.methodPointerEscape().printClassName(this);
}
}

class SomeClass {

public void printClassName(EscapeTest escapeTest) {
System.out.println(escapeTest.getClass().getName());
}
}

逃逸状态标记:

  • 全局级别逃逸:一个对象可能从方法或者当前线程中逃逸
    • 对象被作为方法的返回值
    • 对象作为静态字段或者成员变量
    • 如果重写了某个类的 finalize() 方法,那么这个类的对象都会被标记为全局逃逸状态并且一定会放在堆内存中
  • 参数级别逃逸
    • 对象被作为参数传递给一个方法,但是在这个方法之外无法访问/对其他线程不可见
  • 无逃逸
    • 一个对象不会逃逸
栈上分配

我们都知道 Java 的对象是在堆上分配的,而堆是对所有对象可见的。同时,JVM 需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。如果逃逸分析能够证明某些新建的对象不逃逸,那么 JVM 完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。

这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。不过 Hotspot 虚拟机,并没有进行实际的栈上分配,而是使用了标量替换这一技术。所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的基本类型。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 的对象。编译器会在方法内将未逃逸的聚合量分解成多个标量,以此来减少堆上分配

标量替换

标量:不能被进一步分解的量,如基本数据类型、对象引用
聚合量:可以被进一步分解的量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Data
@AllArgsConstructor
class Person {

int age;
int weight;
}

public class Example {

public static void example() {
Person person = new Person(1, 10);
addAgeAndWeight(person.age(), person.weight());
}
}

经过逃逸分析,person 对象未逃逸出 example() 的调用,因此可以对聚合量 person 进行分解,得到两个标量 age 和 weight,进行标量替换后的伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Data
@AllArgsConstructor
class Person {

int age;
int weight;
}

public class Example {

public static void example() {
int age = 1;
int weight = 10;
addAgeAndWeight(age, weight);
}
}
同步消除

如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义。因为线程并不能获得该锁对象。在这种情况下,即时编译器会消除对该不逃逸锁对象的加锁、解锁操作。实际上,编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。不过,基于逃逸分析的锁消除实际上并不多见