双管齐下,JVM内部优化与JVM性能调优

Posted 毛奇志(公众号:爱奇志)

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了双管齐下,JVM内部优化与JVM性能调优相关的知识,希望对你有一定的参考价值。

一、前言

本文要讲两种东西JVM优化和JVM调优,读者看到这连个字眼,一定会问,这不是同一个东西,有什么不同?这里解释:

JVM优化:JVM本身自带的编译时、运行时的优化机制,各个jdk版本会有些不同(如泛型、集合框架等),不随程序员操作而变化,程序员只要学习了解即可;

JVM调优:又称JVM性能调优,是指Java程序员在学习完成JVM的知识后,通过调节JVM相关参数,来满足自己的程序在特定情况下性能门槛、性能更佳。

JVM优化是JVM自带的优化机制,不需要程序员干涉,程序员学习了解即可;JVM调优(JVM性能调优)是程序员设置虚拟机参数(不直接使用默认参数)满足自己的需求,是程序员的一项工作技能。

JVM优化更多的是与jdk版本相关,JVM调优更多的是与程序员的操作相关。

JVM优化包括两种——编译时优化,运行时优化。所以,第一步便是要搞懂什么是编译时期,什么是运行时期?

编译时期:将.java文件转换为.class文件的过程;

运行时期:运行.class文件,显示运行结果的过程。

注意:关于编译时期的说法有不同多种,.java文件转换为.class文件是最符合大家认知的,所以本文采用这种

二、编译时优化

2.1 Javac编译器

Javac编译器编译过程:解析与填充符号表过程==>插入式注解处理器的注解处理过程==>分析与字节码生成过程。如图:

在这里插入图片描述

一步一步解释Javac编译器的每一个过程。

(1)词法、语法分析

词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以称为标记,如“int a = b + 2”这句代码包含了6个标记,不可拆分,分别为int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个标记(Token),不可再拆分。

语法分析是根据Token序列构成抽象语法树的过程,抽象语法树(Abstract Syntax Tree)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序中的一个语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。

(2)符号填充表

完成词法分析和语法分析后,下一步就是填充符号表的过程,符号表(Symbol Table)是由一组符号地址和符号信息构成的表格,可以将它想象成哈希表中K-V键值对的形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等)。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号表名进行地址分配时,符号表是地址分配的依据。

(3)注解处理器

在JDK 1.5之后,Java语言提供了对注解(Annotation)的支持,这些注解与普通的Java代码一样,是在运行期间发挥作用的。在JDK 1.6中提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,我们可以把它看做是一组编译器的插件,在这些插件里面可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是上图的回环过程。

有了编译器注解处理器的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中访问到,所以通过插入式注解处理器实现的插件功在功能上有很大的发挥空间。

(4)语义分析与字节码生成

语义分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法表示源程序是否符合逻辑。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查!

a) 标注检查

int a = 1;
boolean b = false;
char c = 2;

后续可能会出现的赋值运算:

​​int d = a + c;    //int + char ==> int  可行,因为char可以向上转型为int,变为int + int ==> int
int d = b + c;    // boolean + char ==> int 不可行,boolean无法转为int
char d = a + c;   // int + char ==> char  不可行,int + char 变为int,无法自动变小

后续代码中如果出现了如上3中赋值运算(第一段代码第三行 char c=3;)的话,那它们都能构成结构正确的语法树,但是只有第1种的写法在语义上是没有问题的,能够通过编译,其余两种在Java语言中是不合逻辑的,无法编译(是否符合语义逻辑必须在具体的语言与具体的上下文环境之中才有意义)。

b) 数据及控制流分析

数据及控制流分析是对程序上下文逻辑更近异步的验证,它可以检查出诸如程序员局部变量在使用前是否有赋值、方法的每条路径是够都有返回值、是否所有的受查异常都被正确处理了等问题。有一些校验只有在编译期或运行期才能进行!

c) 语法糖

语法糖是指在计算机语言中添加的某种语法,这种语法对语言的功能没有影响,但是更方便程序员的使用。Java中最常用的语法糖主要是泛型、变长参数、自动装箱/拆箱、条件编译等,虚拟机不支持这些语法,他们在编译阶段还原回简单的基础语法结构(泛型的擦除、变长参数封装成数组参数、Integer自动装箱拆箱变为Integer.value()等、分支不成立的代码块清除掉)。

d) 字节码生成

字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面有com.sun.tools.javac.jvm.Gen类完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化为字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。

2.2 Java语法糖

2.2.1 泛型和泛型擦除

泛型是jdk1.5 (又称为jdk5.0)引入的语法,本质上是参数化类型的应用,也就是所操作的数据类型被指定为参数,我们来看一下。

在这里插入图片描述

对于生成的Test.class文件,泛型类型被擦除了

在这里插入图片描述

注意:如果编译无法通过,这说明擦除失败,且看下面。

在这里插入图片描述

所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。

2.2.2 自动装箱、自动拆箱、遍历循环

对于自动装箱、自动拆箱、遍历循环这些语法糖,它们是Java语言中使用的最多的语法糖,如:

在这里插入图片描述

编译后生成的.class文件是:

在这里插入图片描述

2.2.3 条件编译

在这里插入图片描述

生成的.class文件
在这里插入图片描述

小结:只能使用条件为常量的if语句才能达到上述效果,编译器将会把分支中不成立的代码块消除掉,这一过程在编译阶段完成!

三、运行时优化(核心:JIT编译器/即时编译器)

第二部分讲述的是JVM编译时优化,当进入到运行时,JVM也有相应的优化机制。Java程序运行时,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务编译器称为即时编译器(Just In Time Compiler,简称为JIT编译器),这个JIT编译器是整个JVM运行时优化的核心。即时编译器编译性能的好坏、代码优化程度的高低却是衡量虚拟机的优秀与否的最关键指标之一。

第三部分所出现的虚拟机是HotSpot虚拟机(JVM虚拟机本身就是一种HotSpot虚拟机),所出现的编译器就是HotSpot虚拟机里面的JIT编译器(即时编译器)。

3.1 HotSpot虚拟机内的JIT编译器

JIT编译器问题一:为什么HotSpot虚拟机要使用编译器和解释器并存的架构?

JIT编译器问题二:为什么HotSpot虚拟机要实现两个不同JIT编译器(Client Compiler和Server Compiler)?

JIT编译器问题三:程序何时实现解释器执行?何时实现编译器执行?

JIT编译器问题四:哪些程序代码会被编译为本地代码?如何编译为本地代码?

3.1.1 编译器和解释器并存的架构

HotSpot虚拟机使用的是编译器和解释器并存的架构,即同时包含编译器和解释器。

关于JIT编译器问题一:为什么HotSpot虚拟机要使用编译器和解释器并存的架构?

这是因为,编译器和解释器各有优势。

编译:一次性转换,不保留源代码

解释:逐条转换,保留源代码

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。

在这里插入图片描述

HotSpot虚拟机内置两个JIT编译器,分别是Client Compiler 和 Server Compiler(其中在JVM第一篇文章讨论HotSpot的二种实现方式我们就谈论过这点),如图:

在这里插入图片描述

关于JIT编译器问题二:为什么HotSpot虚拟机要实现两个不同JIT编译器?

因为各有优劣,C1编译器(Client Compiler)会对字节码进行简单和可靠的优化,以达到更快的编译速度;而C2编译器(Server Compiler)会启动一些编译耗时更长的优化,以获取更好的编译质量。

HotSpot虚拟机默认采用解释器和其中一个编译器直接配合的方式工作,程序使用何种编译器,取决于虚拟机的运行模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在Client模式或Server模式。

无论采用的JIT编译器是Client Compiler还是Server Compiler(简称为C1编译器和C2编译器),解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”,用户可以使用参数“-Xint”强制虚拟机运行于“解释模式”,这时编译器完全不介于工作,全部代码都可以解释执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”,这时则优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。如图:

在这里插入图片描述

分层编译:

为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机使用分层编译进行优化(就像前言部分讲述的一样,JVM的自身优化)。分层编译根据编译器编译、优化的规模与耗时,划分为不同的编译层次,其中包括:

第0层,程序解释执行,解释器不开启性能监控功能,可触发第一层编译

第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。

第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的编译优化。

C1编译和C2编译的区别:一般是C1编译时可靠的优化,C2编译时不可靠的优化。

实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

3.1.2 编译对象和触发条件

关于JIT编译器问题三:程序何时实现解释器执行?何时实现编译器执行?

一般的代码都是使用解释器执行,当代码变为热点代码的时候就会被JIT编译器(即时编译器)执行。

问题:上面回答中的热点代码是什么?

回答:程序通过一定的计数方式,被认为是多次被执行的代码,称为热点代码。

如何被鉴定为多次?如何确定热点代码的次数阈值?方法调用计数器和回边计数器。

在运行过程中会被即时编译器编译的“热点代码”有两类,即:被多次调用的方法、被多次执行的循环体。

对于“被多次调用的方法”:由于是由方法调用触发的编译,所以编译器会以整个方法作为编译对象,这是虚拟机标志的JIT编译方式;

对于“被多次执行的循环体”:是由循环体触发的编译,此时编译器以整个方法(而不是单独的循环体)作为编译对象,被称为OSR编译器。

注意:多次执行的循环体,虽然是循环体触发的编译,但是还是会以循环体所在的整个方法作为编译对象。

现在提出一个问题,无论方法还是循环体,如何被鉴定为多次?

主要的热点探测判定方式有两种——基于采样的热点探测,基于计数器的热点探测,

由于HotSpot虚拟机使用的是基于计数器的热点探测技术,所以这里着重介绍这种,“基于采样的热点探测”略过。

基于计数器的热点探测技术,为每一个方法提供两个计数器:方法调用计数器和回边计数器,对应上面两种多次调用。

多次调用基于计数器的热点探测技术使用的具体计数器阈值
被多次调用的方法方法调用计数器默认阈值在Client模式下是1500次,在Server模式下是10000次,这是阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定
方法内被多次执行的循环体回边计数器默认阈值在Client模式下是1500次,在Server模式下是10000次,这是阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定

在确定JVM运行参数(指-XX:CompileThreshold)后,两个计数器都有确定的阈值,达到阈值后会触发JIT即时编译。

这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次,这是阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。

运行过程:当一个方法被调用,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译的版本,则将该方法的调用计数器+1,然后判断方法调用计数器和回边计数器之和是否超过方法的调用计数器的阈值(复习:默认阈值在Client模式下是1500次,在Server模式下是10000次)。如果超过阈值,那么将会向JIT编译器提交一个该方法的代码编译请求。这里给出方法调用计数器触发即时编译图:

在这里插入图片描述

方法统计半衰期:

实际上,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个执行频率,即一段时间内的方法调用的次数。为什么会这样(记录的是一段时间的方法调用次数而不是绝对调用次数)呢?因为存在一个方法调用半衰周期,即一个半衰周期的时间内,该方法的调用计数器会被减少一半,所以默认情况下方法调用计数器记录的只能是一段时间内的方法调用次数。

程序员在JVM调优的时候可以通过设置相关参数来进行调整,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法调用计数器统计方法调用的绝对次数。可以使用虚拟机参数-XX:CounterHalfLifeTime设置半衰周期的时间,单位为秒。

回边计数器:

从上面的表格就知道,回边计数器是统计被多次执行的循环体,因为程序在循环体中运行的时候,字节码遇到控制流向后跳转的指令称为“回边”,回边计数器统计的目的是为了触发OSR编译(On-Stack Replacement 栈上替换)。关于回边计数器的阈值设置:

若虚拟机运行在Client模式下,回边计数器阈值计算公式为:

方法调用计数器阈值(CompileThreshold)* OSR比率(OnStackReplacePercentage)/100

其中,CompileThreshold默认值为1500,OnStackReplacePercentage默认值是933,如果都取默认值,则Client模式虚拟机的回边计数器的阈值为13995。

若虚拟机运行在Server模式下,回边计数器阈值计算公式为:

方法调用计数器阈值(CompileThreshold)* (OSR比率(OnStackReplacePercentage)- 解释器监控比率(InterpreterProfilePercentage))/100

其中CompileThreshold默认值为10000,OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,则Server模式虚拟机的回边计数器阈值为10700

执行过程:当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已编译好的版本,如果有,它将会优先执行已编译的代码,如果没有,回边计数器+1,然后判断方法调用计数器与回边计数器之和是否超过回边计数器的阈值。当超过阈值时,将会提交一个OSR编译请求,并将回边计数器的值降低一些,以便继续在解释器中执行,等待编译器输出编译结果。整个回边计数器触发即时编译过程如图:

在这里插入图片描述

对于上面的方法计数器而言,由于半衰期的存在,方法计数器统计是一段时间的执行次数。值得注意的是,回边计数器没有技术热度衰减的过程,所以回边计数器中统计的就是循环次数。当计数器溢出的时候,它会把方法计数器的值也调整到溢出状态,这样下次再进入到该方法的时候就会执行标准编译过程。

这里从JIT编译器的热点代码引入了方法调用计数器和回边计数器,然后分别介绍了其阈值确定、计数方式、半衰周期的问题(虽然回边计数器没有半衰周期)。

3.1.3 不同的编译过程

我们现在知道,HotSpot虚拟机中有两个编译器——C1编译器(Client Compiler)和C2编译器(Server Compiler),两个编译器的编译过程是不同的。这里介绍:

(1)Client Compiler的编译过程(三段式编译过程)

第一阶段:一个平台独立的前端将字节码构造成一种高级中间代码表示。HIR使用静态单分配的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联,常量传播等优化将会在字节码被构造成HIR之前完成。

第二阶段:一个平台相关的后端从HIR中产生低级中间代码表示,而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

第三阶段:在平台相关的后端使用线性扫描算法,在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器优化。

在这里插入图片描述

(2)Server Compiler的编译过程

Server Compiler是专门面向服务端的典型应用并为服务端性能配置特别调整过的编译器,是一个充分优化过的高级编译器,可以执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提等,还会实施一些与Java语言特性密切相关的优化技术,如范围消除检查、空值检查消除等,还可以根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。

关于JIT编译器问题四:哪些程序代码会被编译为本地代码?如何编译为本地代码?

哪些代码:当向编译器提交编译请求的那些代码。
如何编译:C1编译器(Client Compiler)和C2编译器(Server Compiler)编译过程不同。
  C1:简单快速三段式编译,主要关注局部的优化,放弃全局优化。
  C2:专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器。

好了,我们分别解决了我们刚刚开始提出的四个问题

3.2 编译优化技术

这里介绍HotSpot虚拟机的即时编译器在生成代码时采用的代码优化技术,它们是:

语言无关的经典优化技术之一:公共子表达式消除。

语言相关的经典优化技术之一:数组范围检查消除。

最重要的优化技术之一:方法内联。

最前沿的优化技术之一:逃逸分析。

3.2.1 公共子表达式消除(局部公共子表达式消除+全局公共子表达式消除)

含义:如果一个表达式E已经计算过了,并且之前的计算到现在表达式E中所有变量的值都没有发生变化,那么表达式E的这次出现就成为了公共子表达式。

对于这种表达式,没有必要花时间对它再次进行计算,只需要直接用计算过的表达式结果代替表达式E就好了。

局部公共子表达式消除:如果这种优化局限于程序的基本块内,称为局部公共子表达式消除

全局公共子表达式消除:如果这种优化的范围涵盖多个基本块,称为全局公共子表达式消除

在这里插入图片描述

上图没有未做任何优化的字节码,如果使用公共子表达式消除:因为c * b出现两次,第一次出现记为E,第二次直接用就好了,如图:

在这里插入图片描述

可进一步代数化简,如图:

在这里插入图片描述

3.2.2 数组范围检查消除

对于虚拟机执行子系统来说,为保证代码安全,每次数组元素的读写都带有一次隐含的条件判定的操作(如果没有判空,数组越界会得到ArrayIndexOutOfBoundsException异常),对于拥有大量数组访问的程序代码,这种大量的条件的判定是一种很大的性能负担。为了对编译过程优化,决定在保证安全性的条件下,消除数组范围检查,那么JVM是如何聪明的消除这种检查的呢?

(1)判断循环变量取值范围(以数组越界为例)

我们的核心问题是“每次数组元素的读写都带有一次隐含的条件判定的操作带来性能影响”,解决方式:

如数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标“3”没有越界,执行的时候就无需判断了。更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。

(2)隐式异常处理(try…catch…取代if…else…)(以空指针为例)

上面我们说到“为保证代码安全,每次数组元素的读写都带有一次隐含的条件判定的操作带来性能影响”,即:

if(null!=foo)
    return foo.value;
else
    throw new NullPointerException();

这一段代码是安全的,但是问题是它太安全了,每一次都要检查if(null!=foo),造成性能影响,所以隐式异常处理的想法是使用try…catch…取代if…else…,经过隐式异常优化后,虚拟机将上面的伪代码转换为:

try{
    return foo.value;
}catch (Exception e){
    e.printStackTrace();
}

通常来说,一次异常捕获处理所用的时间比一次if判断所消耗的时间要长的多,但是我们为什么用try…catch…取代if…else…呢?

实际上,这里我们这里我们报一种侥幸的心理,虽然if判断比try…catch时间短,但是if判断是每一次运行都要执行的,try…catch只有异常发生的时候才捕获。如果foo引用大多数次数不为null(这里我们报一种侥幸的心理),只有少数情况下foo引用为空触发catch块,那么使用try…catch…效率一定要if…else…效率高。当然,如果foo引用不满足大多数次数下不为null,频繁执行catch块,当然是if…else效率高。所以,这种隐式异常处理(try…catch…取代if…else…)更像是在玩火,皮!!!(本文只是介绍虚拟机有这样的一种机制,仅介绍,读者了解即可)

3.2.3 方法内联

方法内联是一个提高JVM性能的手段。用实例来解释什么是方法内联:

在这里插入图片描述

就是说,如果虚拟机可以比较聪明的,提前知道方法调用直接的一些关联关系,就可以更早的做出优化。这里是因为foo()是一个静态方法(是一个非虚方法)。

编译器进行内联优化的时候,如果是非虚方法,直接内联即可,包括invokespecial调用的私有方法、实例构造器、父类方法和使用invokestatic调用的静态方法,还包括final方法,因为它们都是唯一的,所以可以直接内联。

对于虚方法,Java实例方法,Java虚拟机引入一种名为“类型继承关系分析(Class Hierarchy Analysis,CHA)”,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多一个的实现(invokespecial invokestatic 和final都是一个实现),某个类是否存在子类、子类是否是抽象类等信息。

当真正遇到虚方法的时候,会向CHA查询此方法在当前程序下是否有多个目标版本可供选择(有final关键字就一定只有一个版本,没有就不一定),如果查询结果只有一个版本,就可以直接内联,不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联(Guarded Inlining)。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。

如果向CHA查询出的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次方法调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者的不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

总体来说,内联优化本来就是一个激进优化,这是虚拟机常见的优化方式。

3.2.4 逃逸分析(方法逃逸+线程逃逸)

逃逸分析是JVM的一种优化技术,但是它(逃逸分析)并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。

逃逸分析定义(方法逃逸+线程逃逸):

如果一个对象在方法中被定义之后,它可能被外部方法所引用,称为方法逃逸,如作为调用参数传递到其他方法中。

如果一个对象在类中被定义后,它可能被外部线程访问到,称为线程逃逸,如赋值给类变量或可以在其他线程中访问的实例变量.

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化。如:

(1)栈上分配:JVM中,对于引用类型变量,在Java堆内存中分配新建对象的内存空间,然后在栈内存中,压入指向对象的引用。

虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果可以确定一个对象不会逃逸出方法之外,可以让这个对象直接在栈中分配内存,对象所占用的内存空间随着方法调用帧栈出栈而销毁。这一点是可行的,因为,一般应用中,不会逃逸的局部对象所占的比例是比较大的,如果能在栈中分配,这些局部对象就会随着方法结束而自动销毁,Java堆垃圾收集系统压力会小很多,从而提高JVM性能。

(2)同步消除:我们知道,多线程情况下的线程同步(无论是synchronize还是lock机制),本质上牺牲并发性能换取安全读写,我么希望既保证安全读写又能维持高性能并发,现在我们使用逃逸分析来实现。如果逃逸分析能够确定一个变量不会逃逸出自身定义线程,无法被其他线程访问,那这个变量的读写就不会有线程竞争(即只有自己线程可以读写这个变量),则对这个变量实施的同步措施就可以消除掉,以获取高的并发性能。

(3)标量替换:

标量和聚合量

标量是指一个数据不能再分解为更小的数据表示了,如int long reference类型都满足这一条件,不能被分解了,都可以称为标量;

聚合量是指一个数据还可以分解为更小的数据表示,如Java对象是典型的聚合量。

标量替换:如果将一个Java对象拆散,根据程序访问情况,将其访问的成员变量恢复为原始类型来访问,这就是标量替换。

如果逃逸分析证明一个对象不会被外界访问,并且这个对象可以被拆散的话,那程序真正执行的时候就可以不创建这个对象,而修改为直接创建若干个被这个方法所使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为JVM进一步优化手段创建条件。

实际上,真正的应用程序,尤其是大型程序反而发现实施逃逸分析可能出现性能不稳定的情况,或者因分析过程耗时但却无法有效判别出非逃逸对象而导致性能下降,所以很长的一段时间内,即使是Server Compiler,也默认不开始逃逸分析。

如果一定要使用逃逸分析,程序员可以自行设置:

参数作用
-XX:+DoEscapeAnalysis手动开启逃逸分析
-XX:+PrintEscapeAnalysis查看分析结果
-XX:+EliminateAllocations开启标量替换
-XX:+EliminateLocks开启同步消除
-XX:+PrintEliminateAllocations查看标量的替换情况

四、JVM调优(JVM性能调优)

其实JVM性能调优基本上都是设置年轻代和老年代的大小,这部分内容可以放在第一篇文章“JVM自动内存管理”下面。但是,这里为了比对“JVM优化”和“JVM调优”两个概念,而且第一篇文章比较长了,所以“JVM调优”就放到第三篇来了。

本文开始是就说过,JVM优化更多的是与jdk版本相关,JVM调优更多的是与程序员操作相关。本文第四部分,我们讲述JVM调优。我们这里不使用命令行了,直接使用可视化工具分析。

4.1 jconsole.exe

Jconsole (Java Monitoring and Management Console),一种基于JMX的可视化监视、管理工具。在jdk根目录/bin下,如图:

在这里插入图片描述

4.1.1 内存监控(jstat命令)——手把手教你如何看图

先启动mypackage.Test类,然后jconsole连接这个类,如图:

在这里插入图片描述

记住我们设置的虚拟机参数:-XX:+PrintGCDetails -Xms100M -Xmx100M -XX:+UseSerialGC

-XX:+UseSerialGC:表示使用Serial+Serial Old,记住,下面要用到。

一点一滴看懂上面jconsole图:

时间:表示下面的具体数据的更新的时间

已用:表示实际使用,与实际程序相关联,程序开辟了64K*1000=640000KB,与图中相差无几。

已提交:由虚拟机参数-Xms100M,102400KB,与图中相差无几

最大值:由虚拟机参数-Xmx100M,102400KB,与图中相差无几

Copy:表示年轻代使用复制算法,其实对应的就是我们这里使用的Serial收集器,其内部就是复制算法;

MarkSweepCompact:Mark译为标记,Sweep译为清除,Compact译为紧凑,整个表示老年代使用标记-清除算法,其实对应的就是我们这里使用的Serial Old收集器,其内部就是标记-清除算法。

右上角“执行GC”按钮,学习者可以手动触发GC操作.

右下角六个柱状体,分为堆内存和非堆内存,进一步分别表示Eden区、Survivor区、Tenured Gen老年代、MetaSpace元数据、Code Cache、Compressed Class Space,图中用箭头上表示.

4.1.2 线程监控(jstack命令)——手把手教你如何看图

线程长时间等待的原因有:等待外部资源(数据库连接、网络资源、设备资源)、死循环、锁等待(活锁和死锁)。下面程序演示死循环、锁等待

程序如下:

package mypackage;
 
import java.io.BufferedReader;
import java.io.InputStreamReader;
 
public class Test {
    public static void createBusyThread() {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) ;  //该线程永久运行
            }
        }, "testBusyThread");
        thread.start();
        System.out.println("testBusyThread running");
    }
 
    public static void createLockThread(final Object lock) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (lock) {
                    try {
                        lock.wait();  // 进入无参wait() 必须要notify() notifyAll()来唤醒
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                }
            }
        }, "testLockThread");
        thread.start();
        System.out.println("testLockThread running");
    }
 
    public static void main(String[] args) throws Exception {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(System.in));
        bufferedReader.readLine();
        createBusyThread(); //新建一个繁忙线程,永久运行  演示死循环
        bufferedReader.readLine();
        Object object = new Object();
        createLockThread(object); //新建一个锁线程,永远被锁住  演示锁等待
    }
}

这个程序来自《深入了解Java虚拟机》一书,很多人都看到过,但是不一定都能看懂,这里一步步解释,这个程序总共有三个线程——main线程(用来作为程序启动入口,用来演示等待控制台输入)、testBusyThread线程(用来演示死循环while(true))、testLockThread线程(用来演示阻塞线程无参wait(),只能notify()/notifyAll()唤醒),程序启动,jconsole.exe建立连接:

第一阶段:首先是main线程,main线程需要控制台输入System.in,使用字符缓冲读入,如果没有输入,则程序在main线程这里阻塞,后面两个线程连“新建并启动”的机会都没有,如图:

在这里插入图片描述

第二阶段,笔者在控制台任意输入,然后回车(这里输入数字12,实际上可以是任意的),程序然后新建testBusyThread线程并启动。

在这里插入图片描述

第三阶段,在控制台回车,程序继续运行,新建testLockThread并运行。

在这里插入图片描述

4.1.3 线程监控之线程死锁——手把手教你如何看图

package mypackage;
 
public class Test {
    static class SynAddRunnable implements Runnable {
        int a, b;
 
        public SynAddRunnable(int a, int b) {
            this.a = a;
            this.b = b;
        }
 
        //死锁的原因是Integer常量池,-128~127之间Integer不会新建新对象,实参1 2 在 -128~127之间,
        //所以100次循环(即200个线程)中只有两个对象   new Integer(1)    new Integer(2)
        //要执行一次run()方法,必须同时掌握这两个仅有的对象,
        //即任何一个线程,要么同时掌握  new Integer(1)    new Integer(2)两个对象,要么不掌握任何一个对象 
        //如果仅掌握一个对象(即另外一个对象掌握在其他线程手里),马上就会死锁,200个线程太容易出现这种情况而死锁了
        @Override
        public void run() {
            synchronized (Integer.valueOf(a)) {
                synchronized (Integer.valueOf(b)) {
                    System.out.println(a + b);
                }
            }
        }
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(new SynAddRunnable(1, 2)).start();
            new Thread(new SynAddRunnable(2, 1)).start();
        }
    }
}

解释:死锁的原因是Integer常量池,-128~127之间Integer不会新建新对象,实参1 2 在 -128~127之间, 所以100次循环(即200个线程)中只有两个对象 new Integer(1) new Integer(2) ,要执行一次run()方法,必须同时掌握这两个仅有的对象, 即任何一个线程,要么同时掌握 new Integer(1) new Integer(2)两个对象,要么不掌握任何一个对象。如果仅掌握一个对象(即另外一个对象掌握在其他线程手里),马上就会死锁,200个线程太容易出现这种情况而死锁了

在这里插入图片描述

4.2 jvisualvm.exe

我们用上面的例子,简单的介绍一下,只是工具不同——jvisualvm.exe。

4.2.1 内存监控——使用jvisualvm看图

在这里插入图片描述

4.2.2 线程监控——使用jvisualvm看图

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

4.2.3 线程监控之线程死锁——使用jvisualvm看图

在这里插入图片描述

4.3 JVM性能调优(虚拟机参数调优+Java代码优化经验)

4.3.1 JVM性能调优概要

看懂了上面的图表并了解了相关虚拟机参数含义后,我们要根据实际程序的需要来设置虚拟机参数,提高JVM性能(即默认的参数值无法满足我们的要求)。

JVM性能调优的目标:使用较小的内存占用来获得较高的吞吐量(计算型)或者较低的延迟(交互型)。

从性能角度上讲,JVM调优三个目标:较小的内存、吞吐量、延迟。

要想达到这个目标,在给定的物理内存的容量下(即内存时不可变),要想达到预期目的(高吞吐、低延迟),我们要对Java堆分配策略进行优化,合理规划Java堆容量、年轻代、老年代比例,使自动内存分配和回收高效进行,这里关注内存回收,即GC操作。

从GC角度上来讲,JVM调优三个目标:第一条,GC的时间足够的小;第二条,GC的次数足够的少;第三条,发生Full GC的周期足够的长。

第一条要求GC整个垃圾收集过程,消耗的时间尽量小就必须要一个更小的堆,

第二条要求GC整个垃圾收集过程,次数尽量少,必须保证一个更大的堆,

注意,第一条要求和第二条要求是互斥的,不能同时满足,我们要把握一个适度适中的原则,一个相对大小的堆。

第三条要求老年代的空间比例尽量大一些,这样Full GC的次数就会比较少,周期比较长,要平均相隔比较长的一段时间才有一个Full GC,即Full GC一定不要太频繁。

即第一条和第二条要求是一个大小适中的堆,第三条要求这个堆中老年代的空间容量比例尽量高一些。

注意,上面三条,笔者都使用“尽量”这个词,表示在调节jvm参数的时候,一定要把握适度适中这个原则,绝对不能过激。

注意:在控制台打印gc日志,年轻代收集用GC表示,老年代收集用Full GC表示,即查看控制台的gc日志时,GC表示年轻代回收,Full GC表示老年代回收。

4.3.2 JVM调优技巧

物理内存一定的情况下,且Xmx总空间一定的情况下,如何配置新生代和老年代的大小(新生代中Eden:Survivor默认是8:1,一般不改动,JVM调优集中在新生

以上是关于双管齐下,JVM内部优化与JVM性能调优的主要内容,如果未能解决你的问题,请参考以下文章

双管齐下,JVM内部优化与JVM性能调优

双管齐下,JVM内部优化与JVM性能调优

JVM 性能调优实战之:一次系统性能瓶颈的寻找过程

JVM性能调优

JVM性能调优

JVM调优--03---性能优化步骤常用的jvm图形化界面