JVM-HotSpot虚拟机-编译原理、JIT、编译优化--一篇全了解

Java编译原理

什么是字节码、机器码、本地代码?

字节码是指平常所了解的 .class 文件,Java 代码通过 javac 命令编译成字节码

机器码和本地代码都是指机器可以直接识别运行的代码,也就是机器指令

字节码是不能直接运行的,需要经过 JVM 解释或编译成机器码才能运行

此时你要问了,为什么 Java 不直接编译成机器码,这样不是更快吗?

1. 机器码是与平台相关的,也就是操作系统相关,不同操作系统能识别的机器码不同,如果编译成机器码那岂不是和 C、C++差不多了,不能跨平台,Java 就没有那响亮的口号 “一次编译,到处运行”;

2.之所以不一次性全部编译,是因为有一些代码只运行一次,没必要编译,直接解释运行就可以。而那些“热点”代码,反复解释执行肯定很慢,JVM 在运行程序的过程中不断优化,用JIT编译器编译那些热点代码,让他们不用每次都逐句解释执行;

3.还有一方面的原因是后文讲解的解释器与编译器共存的原因。

编译步骤

java源代码(符合语言规范)-->javac-->.class(二进制文件)-->jvm-->机器语言(不同平台不同种类)

C语言和C++语言的编译过程是把源代码编译生成机器语言。这样机器可以直接执行,所有没有“一次编译,到处执行”特点,而Java是通过JVM把字节码翻译成机器语言,而不同平台安装不同版本的JVM即可编译成对应具有对应平台特性的机器语言。

JAVA编译步骤:

http://3a97dfb8.wiz03.com/share/resources/6f423e8b-e9f4-4f32-b7eb-fd8ff23e417c/index_files/8165da49-a54f-41be-8f10-886e0b92f6d1.jpg

根据完成任务不同,可以将编译器的组成部分划分为前端(Front End)与后端(Back End)。

前端编译

前端编译主要指与源语言有关但与目标机无关的部分,包括词法分析、语法分析、语义分析与中间代码生成。

  • 我们所熟知的javac的编译就是前端编译()。除了这种以外,我们使用的很多IDE,如eclipse,idea等,都内置了前端编译器。主要功能就是把.java代码转换成.class代码。
  • 解析与填充符号表过程
  1. 语法、词法分析
  2. 填充符号表
  • 插入式注解处理器的注解处理过程
  • 分析与字节码生成过程
  1. 标注检查
  2. 数据及控制流分析
  3. 解语法糖
  4. 字节码生成

1、词法分析

  1. 定义:词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写的最小元素,而标记是编译过程的最小元素。如:关键字、变量名、字面量、运算符都能成为标记。
  2. 解析类:com.sun.tools.javac.parser.Scanner

读取源代码,一个字节一个字节的读取,找出其中我们定义好的关键字(如java中的if else for等关键字,识别哪些if是合法的关键字,哪些不是),这就是词法分析器进行词法分析的过程,其结果是从源代码中找出规范化的Token流。

2、语法分析

  1. 定义:语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(AST)是一种用来描述程序代码中的一个语法结构,如包、类型、修饰符、运算符、接口、返回值等。
  2. 解析类:com.sun.tools.javac.parser.JavacParser

通过语法分析器对词法分析后Token流进行语法分析,这一步检查这些关键字组合再一次是否符合java语言规范(如在if后面是不是紧跟着一个布尔判断表达式),词法分析的结果是形成一个符合java语言规范的抽象语法树。

3、填充符号表

(1)定义:符号表(Symbol Table)是一组符号地址与符号信息构成的表格。

(2)符号表中记录的信息在编译的不同阶段都要用到,如:语义分析时,符号表中的内容用于语义检查(名字与原先的说明是否一致)与生成中间代码;在目标代码生成阶段,对地址名进行地址分配就是根据符号表的记录。

(3)解析类:com.sun.tools.javac.comp.Enter

4、注解处理器

(1)JDK1.5,java语言提供了注解的支持,但当时只能在运行期发挥作用。

(2)JDK 1.6,提供了插入式注解处理器的标准API在编译期间对注解进行处理。这些注解处理器能在处理注解期

间对语法树进行修改,所以需要回到解析以及填充符号表的过程,这称为一个Round。

(3)处理类:com.sun.tools.javac.processing.JavacProcessingEnvironment

5、语义分析:分析对结构正确的源程序进行上下文有关性质的审查:如类型审查

通过语义分析器进行语义分析。语音分析主要是将一些难懂的、复杂的语法转化成更加简单的语法,结果形成最简单的语法(如将foreach转换成for循环、注解等),最后形成一个注解过后的抽象语法树,这个语法树更为接近目标语言的语法规则。

(1)标注检查

检查内容:变量使用前是否被声明、变量与赋值之间的数据类型是否能匹配等

常量折叠:常量相加变为一个常量

例子:int a = 1 + 2; => int a = 3;

解析类:com.sun.tools.javac.comp.Attr、com.sun.tools.javac.comp.Check

(2)数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证

验证内容:局部变量在使用前是否赋值、方法的每条路径是否有返回值、是否所有的受检异常被正确处理。

例子:final 只在编译期间保证变量的不变性

解析类:com.sun.tools.javac.comp.Flow

(3)解语法糖

语法糖:JVM不支持的语法,但为了让程序员编程简单而添加的高级语法,所以编译过程需要将高级语法还原

为简单的基础语法结构。

例子:增强for循环 => 迭代器循环

解析类:com.sun.tools.javac.comp.TransTypes 、com.sun.tools.javac.comp.Lower

6、中间代码生成

通过字节码生产器将经过注解的抽象语法树转化成符合jvm规范的字节码。
该中间表示有两个重要的性质:

  1. 易于生成;
  2. 能够轻松地翻译为目标机器上的语言。

在Java中,javac执行的结果就是得到一个字节码,而这个字节码其实就是一种中间代码。

著名的解语法糖操作,也是在javac中完成的。

  1. 前面各个步骤的信息(语法树、符号表)转化为字节码写入磁盘
  2. 少量的添加和转换工作
  • 如:字符串加法 => StringBuilder的append方法;
  • 如:类构造器和实例构造器的生成(顺序为父类的构造器先执行)
  1. 关联类:com.sun.tools.javac.jvm.Gen、com.sun.tools.javac.jvm.ClassWriter

7、常见语法糖的奥秘

(1)泛型与类型擦除

Java的泛型基于类型擦除,在编译期间就把泛型变为原来的裸类型。

List<String> list = new ArrayList<>();

list.add("hello");

list.add("world");

System.out.println(list.get(0));

============================>

List list = new ArrayList();

list.add("hello");

list.add("world");

System.out.println((String)list.get(0));

(2)自动装箱、拆箱与遍历循环

List<Integer> list2 = Arrays.asList(1,2,3,4,5,6);

int sum = 0;

for (int i : list2)

sum += i;

============================>

List list2 = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2),

Integer.valueOf(3), Integer.valueOf(4), Integer.valueOf(5), Integer.valueOf(6) });

int sum = 0;

for (Iterator localIterator = list2.iterator(); localIterator.hasNext(); ) {

int i = ((Integer)localIterator.next()).intValue();

sum += i;

}

// 自动装箱 int -> Integer

1 -> Integer.valueOf(1)

// 自动拆箱 Integer -> int

Integer::intValue()

// 增强for循环

for(int i : list)

->

for(Iterator localIterator = list.iterator(); localIterator.hasNext(); ){

int i = localIterator.next();

}

// 自动装箱以及自动拆箱的陷阱

Integer a = 1;

Integer b = 2;

Integer c = 3;

Integer d = 3;

Integer e = 321;

Integer f = 321;

Long g = 3L;

System.out.println(c == d); // true

System.out.println(e == f); // false,==不遇到算术运算符不自动拆箱(即两个Integer比较)

System.out.println(c == (a + b)); // true

System.out.println(c.equals(a + b)); // true

System.out.println(g == (a + b)); // true

System.out.println(g.equals(a + b)); // false

注意:equals方法不处理数据转换,==方法不遇到算术运算符不会自动拆箱。

Integer a = Integer.valueOf(1);

Integer b = Integer.valueOf(2);

Integer c = Integer.valueOf(3);

Integer d = Integer.valueOf(3);

Integer e = Integer.valueOf(321);

Integer f = Integer.valueOf(321);

Long g = Long.valueOf(3L);

System.out.println(c == d);

System.out.println(e == f);

System.out.println(c.intValue() == a.intValue() + b.intValue());

System.out.println(c.equals(Integer.valueOf(a.intValue() + b.intValue())));

System.out.println(g.longValue() == a.intValue() + b.intValue());

System.out.println(g.equals(Integer.valueOf(a.intValue() + b.intValue()))); // Integer 与 Long比较

(3)条件编译:com.sun.tools.javac.comp.Lower完成

if (true){

System.out.println("true");

}else{

System.out.println("false");

}

===============================>

System.out.println("true");

后端编译

后端编译主要指与目标机有关的部分,包括代码优化和目标代码生成等。

这部分编译主要是将.class文件翻译成机器指令的编译过程。

JIT(just in time)

简介

  • 概述:JIT编译期能在JVM发现热点代码时,将这些热点代码编译成与本地平台相关的机器码,并进行各个层次
  • 的优化,从而提高热点代码的执行效率。
  • 热点代码:某个方法或代码块运行频繁。
  • JIT编译器(Just In Time Compiler):即时编译器。
  • 目的:提高热点代码的执行效率。

JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。经过解释执行,其执行速度必然会比可执行的二进制字节码程序慢很多。这是传统的JVM的解释器(Interpreter)的功能。为解决效率问题,引入了 JIT 技术。

JAVA程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

HotSpot虚拟机中内置了两个JIT编译器:Client Complier(编译速度)和Server Complier(编译质量),分别用在客户端和服务端,目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。

当 JVM 执行代码时,它并不立即开始编译代码。首先,如果这段代码本身在将来只会被执行一次,那么从本质上看,编译就是在浪费精力。因为将代码翻译成 java 字节码相对于编译这段代码并执行代码来说,要快很多。第二个原因是最优化,当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化。

OpenJDK HotSpot VM有两个不同的编译器,每个都有它自己的编译临界值:

  1. 客户端或C1编译器,它的编译临界值比较低,只是1500,这有助于减少启动时间。
  2. 服务端或C2编译器,它的编译临界值比较高,达到了10000,这有助于针对性能关键的方法生成高度优化的代码,这些方法由应用的关键执行路径来判定是否属于性能关键方法。

在机器上,执行java -version命令就可以看到自己安装的JDK中JIT是哪种模式:
http://3a97dfb8.wiz03.com/share/resources/6f423e8b-e9f4-4f32-b7eb-fd8ff23e417c/index_files/c943c5ee-41bc-4ff1-a209-c9a884ca8e8c.png

无论是Client Complier还是Server Complier,解释器与编译器的搭配使用方式都是混合模式,即上图中的mixed mode

HotSpot虚拟机要使用解释器与编译器并存的架构?

并存原因

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如HotSpot),都同时包含解释器和编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中_内存资源限制较大_(如部分嵌入式系统中),可以使用解释器执行节约内存,反之可以使用_编译执行来提升效率_。此外,如果编译后出现“罕见陷阱”,可以通过逆优化退回到解释执行。

并存的优势

  • 程序需要迅速启动和执行时,解释器先发挥作用,省去编译时间,且随时间推移,编译器将热点代码编译本
  • 地代码,提高执行效率。
  • 当运行环境内存资源限制较大(嵌入式)时,使用解释执行节约内存,反之使用编译执行提升效率。
  • 解释器能作为编译器激进优化的逃生门,如:编译优化出现问题,能退化为解释器状态执行 。

Hotspot虚拟机内置的JIT编译器

  • C1编译器(Client Compiler):更高的编译速度
  • C2编译器(Server Compiler,Opto编译器):更好的编译质量

JVM的运行模式

  • 混合模式(Mixed Mode):使用解释器 + 其中一个JIT编译器(-client / -server 指定使用哪个)
  • 解释模式(Interpreted Mode):只使用解释器(-Xint 强制JVM使用解释模式)
  • 编译模式(Compiled Mode):只使用编译器(-Xcomp JVM优先使用编译模式,解释模式作为备用)

编译层次:

  • 第0层:程序解释执行,解释器不开启性能监控功能,可触发第1层编译
  • 第1层:C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控逻辑
  • 第2层:C2编译,同1层优化,但启动了一些编译耗时较长的优化,甚至根据性能监控信息进行不可靠的激进
  • 优化

编译的时间开销

解释器的执行,抽象的看是这样的:
输入的代码 -> [ 解释器 解释执行 ] -> 执行结果
而要JIT编译然后再执行的话,抽象的看则是:
输入的代码 -> [ 编译器 编译 ] -> 编译后的代码 -> [ 执行 ] -> 执行结果

说JIT比解释快,其实说的是“执行编译后的代码”比“解释器解释执行”要快,并不是说“编译”这个动作比“解释”这个动作快。
JIT编译再怎么快,至少也比解释执行一次略慢一些,而要得到最后的执行结果还得再经过一个“执行编译后的代码”的过程。
所以,对“只执行一次”的代码而言,解释执行其实总是比JIT编译执行要快。

怎么算是“只执行一次的代码”呢?粗略说,下面两个条件同时满足时就是严格的“只执行一次”

只被调用一次,例如类的构造器(class initializer,())

没有循环

对只执行一次的代码做JIT编译再执行,可以说是得不偿失。
对只执行少量次数的代码,JIT编译带来的执行速度的提升也未必能抵消掉最初编译带来的开销。

编译的空间开销

对一般的Java方法而言,编译后代码的大小相对于字节码的大小,膨胀比达到10x是很正常的。同上面说的时间开销一样,这里的空间开销也是,只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致“代码爆炸”。

这也就解释了为什么有些JVM会选择不总是做JIT编译,而是选择用解释器+JIT编译器的混合执行引擎。

热点检测

编译对象(热点代码)

  • 被多次调用的方法:整个方法为编译对象
  • 被多次执行的循环体(一个方法中):整个方法为编译对象

循环体编译优化发生在方法执行过程中,称为栈上替换(On Stack Replacement,简称OSR编译,机方法栈帧还在栈上,方法就被替换了)

热点代码探测判定

  • 基于采样的热点探测:周期检查各个线程的栈顶,发现某个方法经常出现栈顶,即热点方法。
  1. 实现简单,高效
  2. 容易获取方法调用关系(堆栈中展开即可)
  3. 但很难准备确定一个方法的热度
  • 基于计数器的热点探测(Hotspot JVM采用):为每个方法(代码块)建立计数器,统计方法的执行次数,次数超过阈值就认定为热点方法。
  1. 实现复杂,每个方法维护一个计数器
  2. 不能直接获取方法调用关系
  3. 但统计准确和严谨

基于计数器的热点探测分类

  1. 方法调用计数器:统计方法调用次数
  • 阈值修改:-XX:CompileThreshold
  • 设置半衰周期(周期内没有达到阈值将减半):-XX:CounterHalfLifeTime (单位 s)
  • 关闭热度衰减:-XX:-UseCounterDecay
  1. 回边计数器:统计一个方法中的循环体代码执行次数,字节码中遇到控制流向后跳转的执行称为”回边“。
  • 阈值修改:-XX:BackEdgeThreshold
  • 间接调整阈值: -XX:OnStackReplacePercentage
  1. Client模式下:方法调用计数器阈值 * OSR比率 / 100
  2. Server模式下:方法调用计数器阈值 * (OSR比率 - 解释器监控比率<默认33>) / 100

处理逻辑

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。

  1. 方法计数器。顾名思义,就是记录一个方法被调用次数的计数器。

执行过程如下:判断是否已存在编译版本,如已存在,则执行编译版本;否则,方法计数器+1,判断两个计数器之和(注意:是方法计数器和回边计数器的和)是否超过方法计数器阈值,超过则向编译器提交编译请求,然后和不超过阈值情况下的处理方式一样,仍旧解释执行该方法。
PS该计数器并不是绝对次数,而是相对的执行次数,即在一段时间内的执行次数,当超过一定的时间限度,若还是没有达到阈值,那么它的计数器会减少一半,此过程被称为热度衰减。
http://3a97dfb8.wiz03.com/share/resources/6f423e8b-e9f4-4f32-b7eb-fd8ff23e417c/index_files/b65943e1-5a39-41df-9d60-d411880c64b1.png

  1. 回边计数器。用于统计方法中循环体代码的执行次数,字节码中遇到控制流向后跳转的指令称为“回边”。建立该计数器的目的就是为触发OSR(On StackReplacement)编译,即栈上替换。

和方法计数器执行过程不同的是:当两个计数器之和超过阈值的时候,它向编译器提交OSR编译,并且调整回边计数器值,然后仍旧以解释方式执行下去。
PS该计数器是绝对次数,没有热度衰减。
http://3a97dfb8.wiz03.com/share/resources/6f423e8b-e9f4-4f32-b7eb-fd8ff23e417c/index_files/ef8acf53-816c-47d4-9ea4-4379bf31cd9d.png

JIT编译过程

什么是编译和解释?

编译器:把源程序的每一条语句都编译成机器语言,并保存成二进制文件,这样运行时计算机可以直接以机器语言来运行此程序,速度很快;

解释器:只在执行程序时,才一条一条的解释成机器语言给计算机来执行,所以运行速度是不如编译后的程序运行的快的;

通过javac命令将 Java 程序的源代码编译成 Java 字节码,即我们常说的 class 文件。这是我们通常意义上理解的编译。

字节码并不是机器语言,要想让机器能够执行,还需要把字节码翻译成机器指令。这个过程是Java 虚拟机做的,这个过程也叫编译。是更深层次的编译。(实际上就是解释,引入 JIT 之后也存在编译)

此时又有疑惑了,Java 不是解释执行的吗?

没错,Java 需要将字节码逐条翻译成对应的机器指令并且执行,这就是传统的 JVM 的解释器的功能,正是由于解释器逐条翻译并执行这个过程的效率低,引入了 JIT 即时编译技术。

必须指出的是,不管是解释执行,还是编译执行,最终执行的代码单元都是可直接在真实机器上运行的机器码,或称为本地代码

http://3a97dfb8.wiz03.com/share/resources/6f423e8b-e9f4-4f32-b7eb-fd8ff23e417c/index_files/f7ef74d4-29a3-4cd9-9fda-d2e3bc7116ac.jpg

分层编译

  • 通过引进分层编译,OpenJDK HotSpot VM 用户可以通过使用服务端编译器改进启动时间获得好处。
  • 当使用客户端编译时,代码在启动期间通过客户端编译器予以优化,生成比解释型代码更好的性能优化信息。编译的代码存在在一个称为“代码缓存”的缓存里。代码缓存有固定的大小,如果满了,JVM将停止方法编译。
  • 分层编译可以针对每一层设定它自己的临界值,比如-XX:Tier3MinInvocationThreshold, -XX:Tier3CompileThreshold, -XX:Tier3BackEdgeThreshold。第三层最低调用临界值为100。而未分层的C1的临界值为1500,与之对比你会发现会非常频繁地发生分层编译,针对客户端编译的方法生成了更多的性能分析信息。于是用于分层编译的代码缓存必须要比用于不分层的代码缓存大得多,所以在OpenJDK中用于分层编译的代码缓存默认大小为240MB,而用于不分层的代码缓存大小默认只有48MB。

Hot Spot 编译

  • 当 JVM 执行代码时,它并不立即开始编译代码。这主要有两个原因:
    1、JIT编译器可以权衡代码的执行频率,在java代码编译为字节码文件后,JIT可以将运行频率高的字节码(热点代码)直接编译成及器指令并保存供下一次使用以提高性能。
    2、当 JVM 执行某一方法或遍历循环的次数越多,就会更加了解代码结构,那么 JVM 在编译代码的时候就做出相应的优化,减少解释器执行字节码文件动态查询的时间。
    例子:equals() 这个方法存在于每一个 Java Object 中而且经常被覆写。当解释器遇到 b = obj1.equals(obj2) 这样一句代码,它则会查询 obj1 的类型从而得知到底运行哪一个 equals() 方法。

后台执行编译优化过程(-XX:BackgroudCompilation设置来禁止后台编译)

(1)一个平台独立的前端将字节码构造成一种高级中间代码(HIR,High Level Intermediate Representation)表示。HIR使用静态单分配的形式代表代码值,使得HIR的构造过程中和之后进行优化动作更容易实现。在此之前会进行基础优化,如:方法内联、常量传播等。

(2)一个平台相关的后端从HIR中产生低级中间代码(LIR)表示,而在此之前进行HIR上的优化,如:空值检查、范围检查消除等

(3)平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,产生机器代码。

初级调优:客户模式或服务器模式

  • JIT编译器在运行程序的时候有两种编译模式,在运行时会使用其中一种以达到最优性能。
    1、Server模式:
    启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升。使用的是一个代号为 C2 的编译器。
    2、Client模式:
    使用的是一个代号为 C1 的轻量级编译器。
    3、C2 比 C1 编译器编译的相对彻底,服务起来之后,性能更高。通过 java -version 命令行可以直接查看当前系统使用的编译模式。

中级编译器调优

  • 优化代码缓存
    当 JVM 编译代码时,它会将汇编指令集保存在代码缓存。代码缓存具有固定的大小,并且一旦它被填满,JVM 则不能再编译更多的代码。
    1、通过 –XX:ReservedCodeCacheSize=Nflag(N 就是之前提到的默认大小)来最大化代码缓存大小。代码缓存的管理类似于 JVM 中的内存管理:有一个初始大小(用-XX:InitialCodeCacheSize=N 来声明)。代码缓存的大小从初始大小开始,随着缓存被填满而逐渐扩大。重定义代码缓存的大小并不会真正影响性能。
    2、代码缓存并不是无限的,但是为代码缓存设置一个很大的值并不会对应用程序本身造成影响,应用程序并不会内存溢出,这些额外的内存预定会被操作系统所接受的。
  • 编译阈值
    在 JVM 中,编译是基于两个计数器的:一个是方法被调用的次数,另一个是方法中循环被回弹执行的次数。当 JVM 执行一个 Java 方法,它会检查这两个计数器的总和以决定这个方法是否有资格被编译。
    1、当方法里有一个很长的循环或者是一个永远都不会退出并提供了所有逻辑的程序,JVM会在每执行完一次循环,分支计数器都会自增和自检。如果分支计数器计数超出其自身阈值,那么这个循环将具有被编译资格。
    2、标准编译是被-XX:CompileThreshold=Nflag 的值所触发。Client 编译器模式下,N 默认的值 1500,而 Server 编译器模式下,N 默认的值则是 10000。改变 CompileThreshold 标志的值将会使编译器相对正常情况下提前(或推迟)编译代码。
  • 检查编译过程
    1、启用PrintCompilation,每次一个方法(或循环)被编译,JVM 都会打印出刚刚编译过的相关信息。
    2、用 jstat 命令检查编译,Jstat 有两个选项可以提供编译器信息。其中,-compile 选项提供总共有多少方法被编译的总结信息(下面 6006 是要被检查的程序的进程 ID):
  • 进程详情
  • % jstat -compiler 6006
  • CompiledFailedInvalid TimeFailedTypeFailedMethod
  • 206 0 0 1.97 0

高级编译器调优

当一个方法拥有编译资格时,它就会排队并等待编译。这个队列是由一个或很多个后台线程组成。编译是一个异步的过程。并且这些队列并不会严格的遵守先进先出原则:哪一个方法的调用计数器计数更高,哪一个就拥有优先权,这种优先权顺序保证最重要的代码被优先编译。

编译优化

JIT除了具有缓存的功能外,还会对代码做各种优化,例如:逃逸分析、锁消除、锁膨胀、方法内联、数据边界检查、空值检查消除、类型检测消除、公共子表达式消除等。

优化技术概述

// 优化前

static class B{

int value;

final int get(){

return value;

}

}

public void foo(){

y = b.get();

// ...do stuff

z = b.get();

sum = y + z;

}

// 内联优化后

public void foo(){

y = b.value;

// ...do stuff

z = b.value;

sum = y + z;

}

// 冗余访问消除或公共子表达式消除后

public void foo(){

y = b.value;

// ...do stuff

z = y;

sum = y + z;

}

// 复写传播后

public void foo(){

y = b.value;

// ...do stuff

y = y;

sum = y + y;

}

// 无用代码消除后

public void foo(){

y = b.value;

// ...do stuff

sum = y + y;

}

逃逸分析

基本行为:分析对象的作用域

  • 方法逃逸:当一个对象在方法里面被定义后,它可能被外部方法所引用。如:作为调用参数传递到其他方法中。
  • 线程逃逸:当一个对象在方法里面被定义后,它可能被外部线程访问到。如:类变量或可被访问到实例变量

根据逃逸分析证明一个对象不会逃逸到方法或线程中,则进行高效的优化

  • 栈上分配:JVM中,对象一般在堆中分配,堆是线程共享的,进行垃圾回收和整理内存都是消耗时间的。所有确定一个对象不会逃逸时,让对象从栈上分配内存可以缓解垃圾回收的压力。
  • 同步消除:如果确定一个变量不会逃逸出线程,则消除掉变量的同步措施。
  • 标量替换:聚合量 => 拆开 => 成员变量恢复为原始变量 => 标量。
  1. 标量是一个数据已经无法再分解成更小的数据,JVM中的原始数据类型(int、long等数值类型以及reference类型等)。反之为聚合量,如Java对象。
  2. 如果对象不会逃逸,则不创建该对象。方法执行时直接创建若干个相关的变量来替代。并且对象拆分后,对象的成员变量在栈上分配和读写,为进一步优化提供条件。

逃逸分析(Escape Analysis)是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,这种行为被称为方法逃逸。甚至还可能被外部线程访问,这种行为被称为线程逃逸。

         public static StringBuffer craeteStringBuffer(String s1, String

             StringBuffer sb = new StringBuffer();

             sb.append(s1);

             sb.append(s2);

             return sb;

         }

StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,称其逃逸到了方法外部

上述代码如果想要StringBuffer sb不逃出方法,可以这样写:

         public static String createStringBuffer(String s1, String s2) {

             StringBuffer sb = new StringBuffer();

             sb.append(s1);

             sb.append(s2);

             return sb.toString();

         }

使用逃逸分析,编译器可以对代码做如下优化:

同步省略。

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

将堆分配转化为栈分配。

如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

分离对象或标量替换。

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

在Java代码运行时,通过JVM参数可指定是否开启逃逸分析, -XX:+DoEscapeAnalysis : 表示开启逃逸分析 -XX:-DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis

对象的栈上内存分配

在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。

public static void main(String[] args) {

    long a1 = System.currentTimeMillis();

    for (int i = 0; i < 1000000; i++) {

        alloc();

    }

    // 查看执行时间

    long a2 = System.currentTimeMillis();

    System.out.println("cost " + (a2 - a1) + " ms");

    // 为了方便查看堆内存中对象个数,线程sleep

             try {

                 Thread.sleep(100000);

             } catch (InterruptedException e1) {

                 e1.printStackTrace();

             }

         }

         private static void alloc() {

             User user = new User();

         }

         static class User {

         }

其实代码内容很简单,就是使用for循环,在代码中创建100万个User对象。

我们在alloc方法中定义了User对象,但是并没有在方法外部引用他。也就是说,这个对象并不会逃逸到alloc外部。经过JIT的逃逸分析之后,就可以对其内存分配进行优化。

我们指定以下JVM参数并运行:

-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

在程序打印出 cost XX ms 后,代码运行结束之前,我们使用[jmap]命令,来查看下当前堆内存中有多少个User对象:        

➜  ~ jps

         2809 StackAllocTest

         2810 Jps

         ➜  ~ jmap -histo 2809

          num     #instances         #bytes  class name

         ----------------------------------------------

            1:           524       87282184  [I

            2:       1000000       16000000  StackAllocTest$User

            3:          6806        2093136  [B

            4:          8006        1320872  [C

            5:          4188         100512  java.lang.String

            6:           581          66304  java.lang.Class

从上面的jmap执行结果中我们可以看到,堆中共创建了100万个StackAllocTest$User实例。

在关闭逃避分析的情况下(-XX:-DoEscapeAnalysis),虽然在alloc方法中创建的User对象并没有逃逸到方法外部,但是还是被分配在堆内存中。也就说,如果没有JIT编译器优化,没有逃逸分析技术,正常情况下就应该是这样的。即所有对象都分配到堆内存中。

接下来,我们开启逃逸分析,再来执行下以上代码。再来看看堆内存中有多少个User对象        

➜  ~ jps

         709

         2858 Launcher

         2859 StackAllocTest

         2860 Jps

         ➜  ~ jmap -histo 2859

          num     #instances         #bytes  class name

         ----------------------------------------------

            1:           524      101944280  [I

            2:          6806        2093136  [B

            3:         83619        1337904  StackAllocTest$User

            4:          8006        1320872  [C

            5:          4188         100512  java.lang.String

            6:           581          66304  java.lang.Class

从以上打印结果中可以发现,开启了逃逸分析之后(-XX:+DoEscapeAnalysis),在堆内存中只有8万多个StackAllocTest$User对象。也就是说在经过JIT优化之后,堆内存中分配的对象数量,从100万降到了8万。

除了以上通过jmap验证对象个数的方法以外,还可以尝试将堆内存调小,然后执行以上代码,根据GC的次数来分析,也能发现,开启了逃逸分析之后,在运行期间,GC次数会明显减少。正是因为很多堆上分配被优化成了栈上分配,所以GC次数有了明显的减少。

锁消除

通过对运行上下文的扫描,去除不可能存在共享资源竞争的锁,通过锁消除,可以节省毫无意义的请求锁时间。

package com.winwill.lock;

         public class TestLockEliminate {

             public static String getString(String s1, String s2) {

                 StringBuffer sb = new StringBuffer();

                 sb.append(s1);

                 sb.append(s2);

                 return sb.toString();

             }

             public static void main(String[] args) {

                 long tsStart = System.currentTimeMillis();

                 for (int i = 0; i < 1000000; i++) {

                     getString("TestLockEliminate ", "Suffix");

                 }

                 System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");

             }

         }

getString()方法中的StringBuffer数以函数内部的局部变量,作用于方法内部,不可能逃逸出该方法,因此他就不可能被多个线程同时访问,也就没有资源的竞争,但是StringBuffer的append操作却需要执行同步操作:

@Override

         public synchronized StringBuffer append(String str) {

             toStringCache = null;

             super.append(str);

             return this;

         }

逃逸分析和锁消除分别可以使用参数-XX:+DoEscapeAnalysis和-XX:+EliminateLocks(锁消除必须在-server模式下)开启。使用如下参数运行上面的程序:

-XX:+DoEscapeAnalysis -XX:-EliminateLocks

结果:

一共耗费:233 ms

使用如下命令运行程序:

-XX:+DoEscapeAnalysis -XX:+EliminateLocks

结果:

一共耗费:192 ms

由上面的例子可以看出,关闭了锁消除之后,StringBuffer每次append都会进行锁的申请,浪费了不必要的时间,开启锁消除之后性能得到了提高。

公共子表达式消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。 对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。

// 未优化前

int d = (c * b) * 12 + a + (a + b + c);

// 公共子表达式消除后

int E = c * b;

int d = E * 12 + a + (E + a);

// 代数化简后

int d = 13 * E + 2 * a;

方法内联

该方法是针对Client而言的,方法调用本身是有代价的,要从常量池找到方法地址,然后保存当前栈帧状态,压入新栈帧启动调用过程,调用完弹出,并恢复调用者栈帧。而在运行期,如果方法很频繁的执行,就会运行期把方法内联到调用者方法内部,减少频繁调用的开销。-XX:+PringInlining来查看方法内联信息,-XX:MaxInlineSize=35控制编译后文件大小。

  1. 优点
  • 去除调用方法的成本(如建立栈帧)
  • 为其他优化建立基础
  1. 涉及技术:用于解决多态特性。
  • 类型继承关系分析(CHA,Class Hierarchy Analysis)技术:用于确定目前的加载类中,某个接口是否有多个实现,某个类是否有子类和子类是否抽象等。
  • 内联缓存:在未发生方法调用前,内联缓存为空,第一次调用后,缓存下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果每次调用方法接收者版本一样,那么内联缓存可以继续用。但发现不一样时,即发生了虚函数的多态特性时,取消内联,查找虚方法表进行方法分派。
// 优化前

public static void foo(Object obj){

if(obj != null){

Sout("do something");

}

}

public static void testInline(String[] args){

Object obj = null;

foo(obj);

}

// 优化后

public static void testInline(String[] args){

Object obj = null;

if(obj != null){

Sout("do something");

}

}

数据边界检查

jvm在数组访问过程中会检查访问的下标是否越界,这本来是一个为了安全性提供的功能,但是像下边这段代码,在数组访问过程中,每次都去校验,性能损耗也是很大的。jvm在运行期做了优化,只用校验用来访问数组的起始下标在0到数组最大长度-1之内那么整个循环中就可以把数组的上下界检查消除掉,这可以节省很多次的条件判断操作。通过数据流分析实现。

int [] arrs = new int[10000];

for(int i = 0;i < 10000;i++){

    arrs[i] = i;

}

Java即时编译与C/C++编译对比

Java的劣势:

  1. JIT即时编译器运行占用用户运行时间
  2. Java语言时动态的类型安全语言,JVM频繁进行动态检查,如:实例方法访问时检查空指针、数组元素访问检查上下界、类型转换时检查继承关系
  3. Java使用虚方法频率高于C/C++,即多态选择频率大于C/C++。
  4. Java是可以动态拓展的语言,运行时加载新的类可能改变程序类型的继承关系,即编译器需要时刻注意类型变化并在运行时撤销或重新进行一些优化。
  5. Java对象在堆上分配,垃圾回收比C/C++语言由用户管理开销大。

Java的优势:

  1. 开发效率高
  2. C/C++编译器属于静态优化,不能在运行期间进行优化。如:
  • 调用频率预测
  • 分支频率预测
  • 裁剪未被选择的

参考

参考资料:
链接:深入浅出 JIT 编译器

链接:什么是即时编译(JIT)!?OpenJDK HotSpot VM剖析

链接:深入分析Java的编译原理-HollisChuang's Blog

链接:对象和数组并不是都在堆上分配内存的。-HollisChuang's Blog

扩展阅读:http://www.cnblogs.com/wade-luffy/p/5925728.html

 

猜你喜欢

转载自blog.csdn.net/w372426096/article/details/81631564