JVM性能调优的基础知识 | JVM内部优化与运行时优化
目录
JVM内部的优化逻辑
JVM的执行引擎
解释执行器
即时编译器
JVM采用哪种方式?
即时编译器类型
JVM的分层编译5大级别:
分层编译级别:
热点代码:
如何找到热点代码?
java两大计数器:
OSR 编译(不重要,别纠结)
Code Cache
Code Cache的优化
Code Cache的查看
JDK9中的分段代码缓存:
AOT和Graal VM
Graal VM
重新认知JVM
运行时优化
方法内联
为什么会出现方法内联呢?
内联条件
逃逸分析
什么是“对象逃逸”?
什么是逃逸分析?
基于逃逸分析的优化
标量替换
栈上分配案例:
同步锁消除
什么条件下会触发逃逸分析?
TLAB(Thread Local Allocation Buffer)
TLAB分配的对象可以共享吗?
JVM内部的优化逻辑
JVM的执行引擎
javac编译器将Person.java源码文件编译成class文件[前期编译],交给JVM运行,因为JVM只能识别class字节码文件。同时在不同的操作系统上安装对应版本的JDK,里面包含了各自屏蔽操作系统底层细节的JVM,这样使得同一份class文件就能运行在不同的操作系统平台之上。这也是Write Once,Run Anywhere的原因所在。
最终JVM需要把字节码指令转换为机器码,可以理解为是0101这样的机器语言,这样才能运行在不同的机器上,那么由字节码转变为机器码是谁来做的呢?即谁来执行这些字节码指令的呢?这就是执行引擎
解释执行器
Interpreter解释器逐条把字节码翻译成机器码并执行,跨平台的保证。
刚开始执行引擎只采用了解释执行的,但是后来发现某些方法或者代码块被调用执行的特别频繁时,就会把这些代码认定为“热点代码”。
即时编译器
Just-In-Time compliation(JIT),即时编译器先将字节码编译成对应平台的可执行文件,运行速度快。即时编译器会把这些热点代码编译成与本地平台关联的机器码,并且进行各层次的优化,保存到内存中。
JVM采用哪种方式?
JVM采取的是混合模式,也就是解释+编译的方式,对于大部分不常用的代码,不需要浪费时间将其编译成机器码,只需要用到的时候再以解释的方式运行;对于小部分的热点代码,可以采取编译的方式,追求更高的运行效率。
即时编译器类型
(1)HotSpot虚拟机里面内置了两个JIT:C1和C2
C1也称为Client Compiler,适用于执行时间短或者对启动性能有要求的程序
C2也称为Server Compiler,适用于执行时间长或者对峰值性能有要求的程序
(2)Java7开始,HotSpot会使用分层编译的方式
分层编译也就是会结合C1的启动性能优势和C2的峰值性能优势,热点方法会先被C1编译,然后热点方法中的热点会被C2再次编译
-XX:+TieredCompilation开启参数
JVM的分层编译5大级别:
0.解释执行
1.简单的C1编译:仅仅使用我们的C1做一些简单的优化,不会开启Profiling
2.受限的C1编译代码:只会执行我们的方法调用次数以及循环的回边次数(多次执行的循环体)Profiling的C1编译
3.完全C1编译代码:我们Profiling里面所有的代码。也会被C1执行
4.C2编译代码:这个才是优化的级别。
级别越高,我们的应用启动越慢,优化下来开销会越高,同样的,我们的峰值性能也会越高
通常C2 代码的执行效率要比 C1 代码的高出 30% 以上
分层编译级别:
Java 虚拟机内置了 profiling。
profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据。这里所收集的数据我们称之为程序的 profile。
如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。
那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。
在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。(简单的C1编译)
由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。
在 C1 忙碌的情况下,Java 虚拟机在解释执行过程中对程序进行 profiling,而后直接由 4 层的 C2 编译。
在 C2 忙碌的情况下,方法会被 2 层的 C1 编译,然后再被 3 层的 C1 编译,以减少方法在 3 层的执行时间。
Java 8 默认开启了分层编译。-XX:+TieredCompilation开启参数
不管是开启还是关闭分层编译,原本用来选择即时编译器的参数 -client 和 -server 都是无效的。当关闭分层编译的情况下,Java 虚拟机将直接采用 C2。
如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数 -XX:TieredStopAtLevel=1。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。
热点代码:
在运行过程中会被即时编译的“热点代码” 有两类,即:
-
被多次调用的方法
-
被多次执行的循环体
对于第一种,编译器会将整个方法作为编译对象,这也是标准的JIT 编译方式。对于第二种是由循环体出发的,但是编译器依然会以整个方法(而不是单独的循环体)作为编译对象,因为发生在方法执行过程中,称为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。
如何找到热点代码?
判断一段代码是否是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种,分别如下:
-
基于采样的热点探测(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的 reduce,容易受到线程阻塞或其他外因扰乱。
-
基于计数器的热点探测(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。
HotSpot 使用的是第二种——基于计数器的热点探测,并且有两类计数器:方法调用计数器(Invocation Counter )和回边计数器(Back Edge Counter )。
这两个计数器都有一个确定的阈值,超过后便会触发 JIT 编译。
java两大计数器:
(1)首先是方法调用计数器 。Client 模式下默认阈值是 1500 次,在 Server 模式下是 10000次,这个阈值可以通过 -XX:CompileThreadhold 来人为设定。如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内的方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那么这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就成为此方法的统计的半衰周期( Counter Half Life Time)。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。整个 JIT 编译的交互过程如下图。
(2)第二个回边计数器 ,作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”( Back Edge )。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。
关于这个计数器的阈值, HotSpot 提供了 -XX:BackEdgeThreshold 供用户设置,但是当前的虚拟机实际上使用了 -XX:OnStackReplacePercentage 来简介调整阈值,计算公式如下:
-
在 Client 模式下, 公式为 方法调用计数器阈值(CompileThreshold)X OSR 比率(OnStackReplacePercentage)/ 100 。其中 OSR 比率默认为 933,那么,回边计数器的阈值为 13995。
-
在 Server 模式下,公式为 方法调用计数器阈值(Compile Threashold)X (OSR 比率(OnStackReplacePercentage) - 解释器监控比率(InterpreterProfilePercent))/100。 其中 onStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那么 Server 模式虚拟机回边计数器阈值为 10700 。
与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。为什么 Java 虚拟机需要维护两个不同的计数器呢?
OSR 编译(不重要,别纠结)
实际上,除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。
OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。也就是说,我只要遇到回边指令,我就可以触发执行切换。
在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。
该倍数的计算方法为:
(OnStackReplacePercentage - InterpreterProfilePercentage)/100
其中 -XX:InterpreterProfilePercentage 的默认值为 33,当使用 C1 时 -XX:OnStackReplacePercentage 为 933,当使用 C2 时为 140。
也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。
在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。
OSR 编译在正常的应用程序中并不多见。它只在基准测试时比较常见,因此并不需要过多了解。
那么这些即时编译器编译后的代码放哪呢?
Code Cache
JVM生成的native code存放的内存空间称之为Code Cache;JIT编译、JNI等都会编译代码到native code,其中JIT生成的native code占用了Code Cache的绝大部分空间,他是属于非堆内存的。
简而言之,JVM Code Cache (代码缓存)是JVM存储编译成本机代码的字节码的区域。我们将可执行本机代码的每个块称为 nmethod
。 nmethod
可能是一个完整的或内联的Java方法。
即时( JIT )编译器是代码缓存区的最大消费者。这就是为什么一些开发人员将此内存称为JIT代码缓存。
Code Cache的优化
代码缓存的大小是固定的。一旦它满了,JVM就不会编译任何额外的代码,因为JIT编译器现在处于关闭状态。此外,我们将收到“ CodeCache is full… The compiler has been disabled
”警告消息。因此,我们的应用程序的性能最终会下降。为了避免这种情况,我们可以使用以下大小选项调整代码缓存:
-
InitialCodeCacheSize –初始代码缓存大小,默认为160K
-
ReservedCodeCacheSize –默认最大大小为48MB
-
CodeCacheExpansionSize –代码缓存的扩展大小,32KB或64KB
增加ReservedCodeCacheSize可能是一个解决方案,但这通常只是一个临时解决办法。
幸运的是,JVM提供了一个 UseCodeCache 刷新选项来控制代码缓存区域的刷新。其默认值为false。当我们启用它时,它会在满足以下条件时释放占用的区域:
-
代码缓存已满;如果该区域的大小超过某个阈值,则会刷新该区域
-
自上次清理以来已过了特定的时间间隔
-
预编译代码不够热。对于每个编译的方法,JVM都会跟踪一个特殊的热度计数器。如果此计数器的值小于计算的阈值,JVM将释放这段预编译代码
Code Cache的查看
为了监控Code Cache(代码缓存)的使用情况,我们需要跟踪当前正在使用的内存的大小。
要获取有关代码缓存使用情况的信息,我们可以指定 –XX:+PrintCodeCache
JVM选项。运行应用程序后,我们将看到类似的输出:
或者直接设置 -XX:ReservedCodeCacheSize=3000k
,然后重启
让我们看看这些值的含义:
-
输出中的大小显示内存的最大大小,与 ReservedCodeCacheSize 相同
-
used
是当前正在使用的内存的实际大小 -
max_used
是已使用的最大尺寸 -
free
是尚未占用的剩余内存
JDK9中的分段代码缓存:
从Java9开始,JVM将代码缓存分为三个不同的段,每个段都包含特定类型的编译代码。更具体地说,有三个部分:
-XX:nonNMethoddeHeapSize
-XX:ProfiledCodeHeapSize
-XX:nonprofiedCodeHeapSize
这种新结构以不同的方式处理各种类型的编译代码,从而提高了整体性能。
例如,将短命编译代码与长寿命代码分离可以提高方法清理器的性能——主要是因为它需要扫描更小的内存区域。
AOT和Graal VM
在Java9中,引入了AOT(Ahead-Of-Time)编译器
即时编译器是在程序运行过程中,将字节码翻译成机器码。而AOT是在程序运行之前,将字节码转换为机器码
优势
:这样不需要在运行过程中消耗计算机资源来进行即时编译
劣势
:AOT 编译无法得知程序运行时的信息,因此也无法进行基于类层次分析的完全虚方法内联,或者基于程序 profile 的投机性优化(并非硬性限制,我们可以通过限制运行范围,或者利用上一次运行的程序 profile 来绕开这两个限制)
Graal VM
官网
: GraalVM | OracleGraalVM core features include:
GraalVM Native Image, available as an early access feature –– allows scripted applications to be compiled ahead of time into a native machine-code binary
GraalVM Compiler –– generates compiled code to run applications on a JVM, standalone, or embedded in another system
Polyglot Capabilities –– supports Java, Scala, Kotlin, JavaScript, and Node.js
Language Implementation Framework –– enables implementing any language for the GraalVM environment
LLVM Runtime–– permits native code to run in a managed environment in GraalVM Enterprise
在Java10中,新的JIT编译器Graal被引入
它是一个以Java为主要编程语言,面向字节码的编译器。跟C++实现的C1和C2相比,模块化更加明显,也更加容易维护。
Graal既可以作为动态编译器,在运行时编译热点方法;也可以作为静态编译器,实现AOT编译。
除此之外,它还移除了编程语言之间的边界,并且支持通过即时编译技术,将混杂了不同的编程语言的代码编译到同一段二进制码之中,从而实现不同语言之间的无缝切换。
重新认知JVM
JVM Architecture: Getting Started with the G1 Garbage Collector
运行时优化
方法内联
方法内联,是指 JVM在运行时将调用次数达到一定阈值的方法调用替换为方法体本身 ,从而消除调用成本,并为接下来进一步的代码性能优化提供基础,是JVM的一个重要优化手段之一。
注:
C++的inline属于编译后内联,但是java是运行时内联
简单通俗的讲就是把方法内部调用的其它方法的逻辑,嵌入到自身的方法中去,变成自身的一部分,之后不再调用该方法,从而节省调用函数带来的额外开支。
为什么会出现方法内联呢?
之所以出现方法内联是因为(方法调用)函数调用除了执行自身逻辑的开销外,还有一些不为人知的额外开销。 这部分额外的开销主要来自方法栈帧的生成、参数字段的压入、栈帧的弹出、还有指令执行地址的跳转 。比如有下面这样代码:
public static void function_A(int a, int b){//do somethingfunction_B(a,b);}public static void function_B(int c, int d){//do something}public static void main(String[] args){function_A(1,2);}
则代码的执行过程如下:
所以如果java中方法调用嵌套过多或者方法过多,这种额外的开销就越多。
试想一下想get/set这种方法调用:
public int getI() {return i;}public void setI(int i) {this.i = i;}
很可能自身执行逻辑的开销还比不上为了调用这个方法的额外开锁。如果类似的方法被频繁的调用,则真正相对执行效率就会很低,虽然这类方法的执行时间很短。这也是为什么jvm会在热点代码中执行方法内联的原因,这样的话就可以省去调用调用函数带来的额外开支。这里举个内联的可能形式:
public int add(int a, int b , int c, int d){return add(a, b) + add(c, d);}public int add(int a, int b){return a + b;}
内联之后:
public int add(int a, int b , int c, int d){return a + b + c + d;}
内联条件
一个方法如果满足以下条件就很可能被jvm内联。
-
热点代码。 如果一个方法的执行频率很高就表示优化的潜在价值就越大。那代码执行多少次才能确定为热点代码?这是根据编译器的编译模式来决定的。如果是客户端编译模式则次数是1500,服务端编译模式是10000。次数的大小可以通过-XX:CompileThreshold来调整。
-
方法体不能太大。jvm中被内联的方法会编译成机器码放在code cache中。如果方法体太大,则能缓存热点方法就少,反而会影响性能。热点方法小于325字节的时候,非热点代码35字节以下才会使用这种方式
-
如果希望方法被内联, 尽量用private、static、final修饰 ,这样jvm可以直接内联。如果是public、protected修饰方法jvm则需要进行类型判断,因为这些方法可以被子类继承和覆盖,jvm需要判断内联究竟内联是父类还是其中某个子类的方法。
所以了解jvm方法内联机制之后,会有助于我们工作中写出能让jvm更容易优化的代码,有助于提升程序的性能。
逃逸分析
什么是“对象逃逸”?
对象逃逸的本质是对象指针的逃逸。
在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针(或对象)的逃逸(Escape)。
什么是逃逸分析?
逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。 逃逸分析(Escape Analysis)算是目前Java虚拟机中比较前沿的优化技术了。
注意:逃逸分析不是直接的优化手段,而是代码分析手段。
对象逃逸案例:
Xpublic User doSomething1() {User user1 = new User ();user1 .setId(1);user1 .setDesc("xxxxxxxx");// ......return user1 ;
}
对象未逃逸:
public void doSomething2() {User user2 = new User ();user2 .setId(2);user2 .setDesc("xxxxxxxx");// ......
}
基于逃逸分析的优化
当判断出对象不发生逃逸时,编译器可以使用逃逸分析的结果作一些代码优化
-
栈上分配:将堆分配转化为栈分配。如果某个对象在子程序中被分配,并且指向该对象的指针永远不会逃逸,该对象就可以在分配在栈上,而不是在堆上。在的垃圾收集的语言中,这种优化可以降低垃圾收集器运行的频率。
-
同步消除:如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。
-
分离对象或标量替换。如果某个对象的访问方式不要求该对象是一个连续的内存结构,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
标量替换
标量:不可被进一步分解的量,而Java中基本数据类型就是标量(比如int,long等基本数据类型) 。
聚合量: 标量的对立就是可以被进一步分解的量,称之为聚合量。 在Java中对象就是可以被进一步分解的聚合量。
标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
栈上分配案例:
虚拟机参数:
-XX:+PrintGC -Xms5M -Xmn5M -XX:+DoEscapeAnalysis
-XX:+DoEscapeAnalysis表示开启逃逸分析,JDK8是默认开启的
-XX:+PrintGC 表示打印GC信息
-Xms5M -Xmn5M 设置JVM内存大小是5M
public static void main(String[] args){for(int i = 0; i < 5_000_000; i++){createObject();}
}public static void createObject(){new Object();
}
运行结果是没有GC。
把虚拟机参数改成 -XX:+PrintGC -Xms5M -Xmn5M -XX:-DoEscapeAnalysis。关闭逃逸分析得到结果的部分截图是,说明了进行了GC,并且次数还不少。
这说明了JVM在逃逸分析之后,将对象分配在了方法createObject()方法栈上。方法栈上的对象在方法执行完之后,栈桢弹出,对象就会自动回收。这样的话就不需要等内存满时再触发内存回收。这样的好处是程序内存回收效率高,并且GC频率也会减少,程序的性能就提高了。
同步锁消除
如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步 。
虚拟机配置参数:-XX:+PrintGC -Xms500M -Xmn500M -XX:+DoEscapeAnalysis。配置500M是保证不触发GC。
public static void main(String[] args){long start = System.currentTimeMillis();for(int i = 0; i < 5_000_000; i++){createObject();}System.out.println("cost = " + (System.currentTimeMillis() - start) + "ms");}public static void createObject(){synchronized (new Object()){}}
运行结果
cost = 6ms
把逃逸分析关掉:-XX:+PrintGC -Xms500M -Xmn500M -XX:-DoEscapeAnalysis
运行结果
cost = 270ms
说明了逃逸分析把锁消除了,并在性能上得到了很大的提升。这里说明一下Java的逃逸分析是方法级别的,因为JIT ( just in time )即时编译器的即时编译是方法级别。
什么条件下会触发逃逸分析?
对象会先尝试栈上分配,如果不能成功分配,那么就去TLAB,如果还不行,就判定当前的垃圾收集器悲观策略,可不可以直接进入老年代,最后才会进入Eden。
Java的逃逸分析只发在JIT的即时编译中,因为在启动前已经通过各种条件判断出来是否满足逃逸,通过上面的流程图也可以得知对象分配不一定在堆上,所以可知满足逃逸的条件如下,只要满足以下任何一种都会判断为逃逸。
一、对象被赋值给堆中对象的字段和类的静态变量。
二、对象被传进了不确定的代码中去运行。
对象逃逸的范围有:全局逃逸、参数逃逸、没有逃逸;
TLAB(Thread Local Allocation Buffer)
即线程本地分配缓存区,这是一个线程专用的内存分配区域。
由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS+失败重试的方式保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。
每个线程会从Eden分配一大块空间,例如说100KB,作为自己的TLAB。这个start是TLAB的起始地址,end是TLAB的末尾,然后top是当前的分配指针。显然start <= top < end。
当一个Java线程在自己的TLAB中分配到尽头之后,再要分配就会出发一次“TLAB refill”,也就是说之前自己的TLAB就“不管了”(所有权交回给共享的Eden),然后重新从Eden里分配一块空间作为新的TLAB。所谓“不管了”并不是说就让旧TLAB里的对象直接死掉,而是把那块空间的控制权归还给普通的Eden,里面的对象该怎样还是怎样。通常情况下,在TLAB中分配多次才会填满TLAB、触发TLAB refill,这样使用TLAB分配就比直接从共享部分的Eden分配要均摊(amortized)了同步开销,于是提高了性能。其实很多关注多线程性能的malloc库实现也会使用类似的做法,例如TCMalloc。
到触发GC的时候,无论是minor GC还是full GC,要收集Eden的时候里面的空间无论是属于某个线程的TLAB还是不属于任何TLAB都一视同仁,把Eden当作一个整体来收集里面的对象——把活的对象拷贝到survivor space(或者直接晋升到Old Gen)。在GC结束之后,每个Java线程又会重新从Eden分配自己的TLAB。周而复始。
TLAB分配的对象可以共享吗?
答:只要是Heap上的对象,所有线程都是可以共享的,就看你有没有本事访问到了。在GC的时候只从root sets来扫描对象,而不管你到底在哪个TLAB中。