Java虚拟机JVM的编译期优化(早期、晚期)和几种语法糖

编译期(早期)优化和几种语法糖

一、编译期概述

Java语言的“编译期”并不特指将xxx.java编译为xxx.class。Java有三个编译器

  • JIT编译器:将字节码编译为机器码(将热点代码进行编译,提高效率)
  • AOT编译器:将xxx.java直接编译为机器码
  • 前端编译器:将xxx.java编译为xxx.class文件

我们经常使用的javac编译器就属于前端编译器

二、前端编译期步骤过程

我们使用javac将一个xxx.java文件编译为xxx.class文件主要经历三个步骤:

  • 解析与填充符号表过程
  • 插入式注解处理器处理过程
  • 分析与字节码生成过程

在这里插入图片描述

解析与填充符号表

词法与语法分析

词法分析是将源代码的字符流转换为标记(Token)集合。标记是编译过程的最小元素,关键字、变量名、字面量和运算符都可以成为标记。

例:int a = b + 2;这句代码就包含了6个标记(int、a、=、b、+、2)

  • 词法分析由com.sun.tools.javac.parser.Scanner类实现

  • 语法分析是根据Token序列构成抽象语法树的过程

抽象语法树是一种描述程序代码结构的树形表示方式,树中每一个节点都代表着程序代码中的一个语法结构。

填充符号表

符号表是一组符号地址和符号信息构成的表格(具体实现可以为哈希表、有序符号表、树状符号表和栈结构符号表等)。

符号表在编译的不同阶段都要使用。

  • 在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码
  • 在目标代码生成阶段,对符号名进行地质分配时的依据
注解处理器

插入式注解处理器可以看做为编译器的插件,在插件里可以读取、修改、添加抽象语法树中的任意元素。如果修改之后则需要重新进行词法和语法检查。每一次循环称为一个Round

语义分析与字节码生成

语法树可以保证一个结构正确的源程序的抽象(即拼写不存在问题),但无法保证程序符合逻辑。语义分析的主要任务就是对结构上正确的源程序进行上下文有关性质的审查。

例:进行类型审查

//定义三个变量
int a = 1;
boolean b = false;
char c = 2;
//进行三个赋值运算
int d = a + c;
int d = b + c;
char d = a + c;

只有第一种运算是正确的。其余两种是错误的。他们拼写都没有问题,但是明显是有错误的。语法检查就是要根据程序上下文判断出类似的错误并拒绝编译

语义分析主要分为两个步骤:

  • 标注检查
  • 数据及控制流分析

标注检查

标注检查包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。

在标注检查中,还有一个重要的动作称为常量折叠:

int a = 1 + 2;在标注检查阶段会折叠为int a = 3; 所以在运行期间不会再计算1 + 2的值

数据及控制流分析

主要检查局部变量在使用前是否有赋值、方法的每条路径都有返回值、所有的受查异常都被正确处理等问题。编译期的数据及控制流分析与运行期的分析目的基本一样,但有一些检验项只能在编译器或运行期进行

例:final修饰的局部变量只在编译期有效,在运行时final属性就被擦除

解语法糖

语法糖就是Java的一些友好的语法(如可变长参数、泛型、自动装箱拆箱等)在编译期间就被还原成简单的基础结构语法

字节码生成

将前面各个阶段生成的信息转化为字节码保存到磁盘上,并进行少量的代码添加和转换工作,如<clinit>和<init>方法就是此时被添加到语法树中

三、语法糖

泛型与类型擦除

泛型有两种方式

  • 真实泛型:不同的泛型就是不同的数据类型
  • 伪泛型:不同的泛型归根到底是同一种数据类型

例:Java中List<Integer>与List<String>在编译阶段就被擦除还原为原生类型进行强制类型转换的代码

Map<String,String> map = new HashMap<String,String>();
map.put("hello","张三");
System.out.println(map.get("hello"));
//===等价于
Map<String,String> map = new HashMap<String,String>();
map.put("hello","张三");
System.out.println((String)map.get("hello"));      

Java泛型的代码被擦除,但是元数据信息仍被保留,这就是通过反射能够获取Java泛型信息的原因

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

自动拆装箱就是在编译阶段对代码自动调用拆装箱方法

来个例题:

Integer a1 = 2;
Integer b1 = 2;
Integer c1 = 4;
Integer a = 128;
Integer b =128;
Integer c = 256;
System.out.println(a1==b1);
System.out.println(a1.equals(b1));
System.out.println(c1==(a1+b1));
System.out.println(a==b);
System.out.println(a.equals(b));
System.out.println(c==(a+b));
  • 对于-128~127的数字会放在IntegerCache,其余的会创建一个新的Integer对象
  • 当进行==运算而没有其他算术运算时,不会自动进行拆装箱操作

遍历循环则还原成为普通的迭代器,这也是为什么被遍历的类需要实现Iterable接口的原因

编译期(晚期)优化

Java程序是通过解释器进行解释执行的,如果当某个方法或者代码块运行特别频繁,就会把这些“热点代码”进行编译,提高代码的执行效率。完成这个任务的编译器被称为即时编译器(JIT编译器 just in time compiler)

解释器与编译器

解释器:当程序需要快速启动和执行时候,解释器可以省去编译的时间,立即执行

编译器:随着时间的推移,编译器把越来越多的代码编译为本地代码后,系统的执行效率就变的更高

HotSpot虚拟机内置即时编译器

  • Client Compiler:简称C1编译器
  • Server Compiler:简称C2编译器

编译器与解释器搭配方式

  • 混合模式:mixed Mode
  • 解释模式:Interpreted Mode,使用-Xint参数强制虚拟机运行于“解释模式”
  • 编译模式:Compiled Mode,使用-Xcomp参数强制虚拟机运行于“编译模式”

HotSpot虚拟机的分层编译

要编译出优化程度更高的代码,花费的时间就越长,而且解释器需要不停为编译器收集监控信息,对解释执行速度也有影响。为了寻求平衡,使用了分层编译

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

编译对象

可以被即时编译器编译的“热点代码”有两类:

  • 被多次调用的方法
  • 被多次执行的循环体:但是仍会编译整个方法,所以被称为栈上替换(OSR)

触发条件

  • 基于采样的热点探测:虚拟机周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。
    • 优点:简单高效,容易获取方法调用关系(栈的情况)
    • 缺点:难以精确地确认一个方法的热度,容易受线程阻塞或别的外界因素干扰
  • 基于计数器的热点探测:虚拟机为每个方法(或代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定阈值就认为这个方法是“热点方法”
    • 优点:结果更加精确严谨
    • 缺点:统计起来较为麻烦,需要为每个方法建立并维护计数器,不能直接获取到方法的调用关系

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

方法调用计数器

统计方法被调用的次数,可以通过-XX:CompileThreshould来设定次数

当一个方法被调用时先检查是否存在JIT编译过的版本。如果有直接使用,如果没有则将此方法的调用计数器值加1。如果超过阈值则提交一个编译请求,默认情况下执行引擎不会等待编译请求完成,而是继续进入解释器以解释方式执行,知道编译完成

在这里插入图片描述

默认情况下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率。如果超过一定时间限度,方法调用次数仍然不足以触发编译请求的阈值,调用计数器就会被减少一半,这个过程称为方法调用计数器的热度衰减,这段时间称为此方法统计的半衰周期

回边计数器

用于统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令就称为“回边”

当解释器遇到要执行的代码片段会检查是否有编译好的版本,如果有的话,优先执行已编译的代码,否则就把回边计数器值加1,然后判断方法调用计数器的值与回边计数器的值两者之和是否超过回边计数器的阈值

与方法计数器不同,回边计数器没有热度衰减的过程,因此就竖起统计的就是该方法循环执行的绝对次数。当提交OSR编译请求,就会把会变计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果

在这里插入图片描述

编译过程

Client Compiler是一个简单快速的三段式编译器,主要关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段

**Client Compiler的第一个阶段:**将字节码构造成一种高级中间码表示(HIR),HIR使用静态单分配的形式来代表代码值

HIR优点:可以使一些在HIR的构造过程之中和之后的优化动作更容易实现。在此之前,编译器会在字节码上完成一部分基础优化如方法内联(在运行时超过一定阈值的方法调用替换为方法本身)、常量传播(将常量直接替换为值)等

**Client Compiler的第二个阶段:**一个平台无关的后端从HIR中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化如控制检查消除、范围检查消除等

**Client Compiler的第三个阶段:**在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化

在这里插入图片描述

Server Compiler专门面向服务端的典型应用,优化性能较高,可以完成经典的优化动作和Java密切相关的优化技术

编译优化技术

JDK几乎把对代码的所有优化措施都集中在了即时编译器之中

公共子表达式消除

如果一个表达式E已经被计算过,并且从先前的计算到现在E中所有变量值都没有发生变化,那么E的这次出现就成为公共子表达式,可以用之前计算的结果直接替换E。如果这种优化仅限于程序的基本块内,称为局部公共子表达式消除。如果涵盖了多个基本块,那就称为全局公共子表达式消除

例:

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

(c*b)出现两次,所以进行公共子表达式消除优化后为

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

数组边界检查消除

Java的数组访问并不像C、C++一样直接对裸指针操作。Java会自动进行数组越界判断。Java会在编译期检查数组是否越界,如果没有越界,执行时就不用判断了

方法内联

将其他方法直接替换掉方法调用。消除了方法调用的成本并可以为其他优化手段建立良好的基础

分为非虚方法内联和虚方法内联

非虚方法在编译期就已经可以确定调用关系进行内联

虚方法则需要在运行时才知道调用关系(多态),这时就需要一些技术才可以进行内联。Java引入类型继承关系分析技术

逃逸分析

逃逸分析并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法内部被定义后,它可能被外部方法引用。例如作为调用参数传递到其他方法中,这种行为称为方法逃逸。甚至可能被外部线程访问到,这种行为称为线程逃逸。

如果一个对象不会逃逸到方法或线程之外,可以对这个变量进行一些高效的优化:

  • 栈上分配:Java堆中创建对象,进行垃圾回收和整理内存都会耗费时间,如果确定一个对象不会逃逸出方法之外,直接将对象在栈上分配内存,对象占用的内存随着栈帧出栈而销毁。减少GC的工作压力
  • 同步消除:线程同步是一个相对耗时的过程,如果分析一个变量不会逃逸出线程,则可以对变量实施的同步措施进行消除
  • 标量替换:标量是指一个数据已经无法再分解成更小的数据表示,Java中的原始数据类型都可看做标量。如果可以继续分解,那他就被称为聚合量,Java对象就属于聚合量。如果一个对象不会被外部访问,并且这个对象可以拆散的话,那么可能真正执行时候不创建这个对象,直接创建若干个被这个方法使用到的成员变量来代替。(栈上存储的数据,很大机会被虚拟机分配至物理机的高速寄存器中存储)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值