编译期优化

概述

java技术下谈编译器而没有上下文语境的话,其实是一句很含糊的比偶奥术,因为它可能是指一个前端编译器(叫编译器的前端更准确一些)把.java文件转变成.class文件的过程;也可能是指java虚拟机的即使编译器(常称JIT编译器)运行期吧字节码转换成本地机器码的过程;还看是指使用静态的提前编译器(AOT ahead Of Time Compoler)直接吧程序变异成与目标指令相关的二进制代码的过程

  • 前端编译器:JDK的javac,Eclipase JDT中的增量式编译器
  • 即时编译器:hotSpot虚拟机的C1,C2编译器Graal编译器
  • 提前编译器:JDK的jaotc,GNU Compiler for the java (GCJ),Excelsior JET

在三类过程中最符合程序员任职的应该是第一类

前端编译

泛型擦除

泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数这种特殊的参数,这种参数类型能够用在类,接口方法的创建中,分别构成泛型类,泛型接口和泛型方法。泛型让程序员能够针对泛型的数据类编写相同的算法,这极大增强了变成语言的类型系统抽象能力

java与C#的泛型

java选择的泛型实现方式叫做类型擦除式泛型,而C#选择的泛型实现是具现化式泛型。比如C#的List<int>List<string>就是俩个不同的类型,它们由系统在运行期生成。有着自己独立的虚拟机方法表和类型数据。而java语言的泛型不同。,它只在源码程序中存在在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type)并且在相应的地方插入了强制转换类型的代码。对于运行期的java语言来说,ArrayList<int>ArrayList<String>是 同一个类型

比如在java中无法对泛型进行实例判断,无法使用泛型创建对象,无法使用泛型创建数组

java类型擦除式泛型无论是在使用的效果上还是运行的效率上,几乎是全面落后C#的具现化式泛型,而它的唯一优势在于实现这种泛型的影响范围上:只需要对javac编译器上做出改进即可

自动装箱拆箱与遍历循环

这些是java语言里最多被使用的语法糖

条件编译

许多程序设计语言都提供改了条件编译的途径,在java语言中并没有使用预处理器,因为java语言天然的编译方式,无序使用到预处理器。那么java语言是否有办法实现条件编译呢?

比如

public static void main(String[] args) {
        if(true){
            System.out.println("1");
        }else{
            System.out.println("2");
        }
    }

被编译成

public static void main(String[] args) {
        System.out.println("1");
    }

其他

除了泛型,自动装箱,自动拆箱,循环遍历,变长参数和条件编译之外,java语言还有不少其他的语法糖,如内部类,枚举类,断言语句,数值字面量,对枚举和字符串的switch支持,lambda白倒是(lambda不是单纯的语法糖,但前端编译器做了大量的转换工作)

后端编译与优化

如果我们把字节码看做是程序语言的一种中间表示形式的话,那编译器无论在何时,在何种状态下,把CLass文件转换成与本地基础设施,(硬件指令级操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。

解释器与编译器

景观表示所有java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用java虚拟机,如hotSpot,OpenJ9等,都同时包含解释器与编译器,解释器与编译器俩这各具优势,当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,编译器逐渐发挥作用,吧越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,繁殖可以使用编译运行来提升效率。同时,解释器还可以作为编译器激进优化后的逃生门。让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后,类型集成结构出现变化,出现罕见陷阱,时可以通过逆优化回退到解释执行状态继续执行,因此在整个java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作

提前编译器

现在提前编译产品和对齐的研究有着俩条明显的分支,一条分支是做与传统C,C++编译器类似,在程序运行之前吧程序代码编译成机器码的静态翻译工作;另外一条分支是把原本及时编译器在运行时需要做的编译工作提前做好并保留下来,下次运行到这些代码(譬如公共代码在背同一台机器其他java进程使用)时直接把它加载进来使用

我们先来说第一条,这是传统的提前编译应用形式,它在java中存在的价值直指及时编译的最大弱点:即时编译要占用程序云心改时间和运算资源。即时现在先进的即时编译器已经足够快;即使现在的即时编译器架构有了分层编译的支持,可以先用快速但低质量的即时编译器为高质量的即时编译器争取出更多编译的时间,但是,无论如何,即时编译消耗的时间都是原本用于程序运行的时间,消耗的资源都是原本可用与程序运行的资源,

关于第二条,本质是给即时编译做缓存加速,去改善java程序的启动时间,以及需要一段时间预热后才能达到最高性能的问题。这种提前编译被称为动态提前编译或者索性就大大方方的直接叫即时编译缓存。。在目前的java技术体系里,这条路径的提前编译以及完完全全被主流的商用JDK支持。

提前编译的代码输出质量一定比即时编译更高吗?提前编译因为没有执行时间和资源限制的压力,能够毫无顾忌地使用中负载优化手段,当然是一个极大的优势,但是即时编译难道就没有阈值竞争的强项了吗?当然是有的,

即时编译的优势

性能分析导致优化

Hotspot的即时编译器在解释器或者客户端运行过程中,会不断收集性能监控个信息,譬如某个程序抽象类通常会是什么实际类型,条件判断通常会走哪条分支,方法调用通常回旋哪个版本,循环通常进行多少次等,这些数据一般在静态分析时是无法得到的,或者不可能存在唯一的解,最多只能依照一些启发性的条件去进行猜测。但在动态运行时却能看出它们具有非常明显的偏好性。如果一条分支的某一条路径执行特别频繁,而其他路径鲜有问津,那就可以把热代码集中放到一起,集中优化和分配更好的资源(分支预测,寄存器,缓存等)给它。

激进预测性优化

这也已经成为即时编译优化措施的基础。静态优化无论如何都必须保证优化后所有的程序外部可见(不仅仅是执行结果)与优化前是等效的,不然优化之后会导致程序报错或者结果不对。然而对于提前编译来说,即时编译的策略可以不用敢这样的保守,如果性能监控信息能够支持它做出一些正确的可能性很大但无法保证绝对正确的预测判断,就已经可以大胆地按照高概率的假设进行优化,万一真的走到罕见的分支上,大不了回退到低级编译器甚至解释器上去执行,并不会出现无法挽救的后果。只要出错的概率足够低,这样的优化往往能够大幅度降低目标程序的复杂度,输出运行速度非常高的代码。譬如在java语言中,默认方法都是虚方法调用

虚调用是相对于实调用而言,它的本质是动态联编。在发生函数调用的时候,如果函数的入口地址是在编译阶段静态确定的,就是是实调用。反之,如果函数的入口地址要在运行时通过查询虚函数表的方式获得,就是虚调用

方法内联指的是:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段

一句话解释就是方法A调用方法B,并不会直接调用方法B而是将方法B的逻辑复制到A中执行。

Java中调用方法其实就是入栈出栈的过程。

例如Java中存在方法A,现在程序需要执行方法A,那么程序需要转移到方法A的内存地址中执行方法A的逻辑,执行完成之后再返回原来执行的位置。因此就需要保存当前程序执行的位置,以及执行完方法A后的地址。因此就会产生时间和空间的开销。

方法内联是JVM增加的一个优化方案,将需要执行的方法复制到当前方法中执行,避免程序的切换,减少时间和空间复制的开销。

部分程序员或者一些老旧的教材会说虚方法是不能内联的,但如果java虚拟机真的遇到虚方法就去查虚表而不做内联的话,java技术可能因为性能问题被淘汰了 。

实际上虚拟机会通过类继承等一系列激进的猜测去做虚拟化,以保证绝大部分有内联价值的虚方法都可以顺利内联。内联是最基础的一项优化措施,本章稍后还会对专门的java虚拟机具体如何做虚方法内联进行详细讲解。

链接时优化

动态链接

栈帧中保存了一个引用,相当于C语言中的指针;

该引用指向该方法在运行时常量池中的位置;

运行时常量池的符号引用(指向堆),完成将符号引用转化为直接引用。

java语言天生就是动态链接的,一个个class文件运行期被夹在到虚拟机内存中,然后在即时编译器里产生优化后的本地代码,这件事情在java程序员里看起来毫无违和。但如果类似的场景出现在使用提前编译的语言和程序上,譬如C,C++的程序要调用某个动态链接哭的代码在编译时是完全独立的,俩者各自编译,优化自己的代码。这些代码的作者编译时间,以及编译器甚至很可能都是不同的,当出现跨链接库边的调用时,那些理论上应该要做的优化,譬如做调用方法的内联,就会执行起来非常的困难

逃逸分析

逃逸分析的基本原理是分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法锁引用,例如调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部的线程访问到,这种称为线程逃逸;从不逃逸到方法逃逸,到线程逃逸,称为对象由低到高的逃逸程度

栈上分配

由于复杂度原因,Hotspot目前还没有做这项优化,其他部分虚拟机做了

在java虚拟机中,java堆上分配创建对象的内存,或者回收整理内存都要消耗大量资源。如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配将会是不错的主义,对象内存会随着栈帧出栈而销毁,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸

标量替换

若一个数据已经无法分解成更小的数据来表示了,java虚拟机的原始数据类型不能再进行一步分解了,那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量,java中的对象就是典型的聚合量。如果把一个java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,并且这个对象可以被拆散, 那么程序真正执行的时候可以不用创建这个对象,而改为直接创建若干个被这个额方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机的告诉寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以是做栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求不高 ,它不允许对象套溢出方法范围内

同步消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以安全地消除掉。

公共子表达式消除

这是一项非常经典的,普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为共子表达式。对于这种表达式,没有必要花时间再对它进行重新计算,只需要直接用前面计算过的表达式结果替代E。如果这种优化咸鱼程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就成为全局龚功子表达式消除

假设存在如下代码

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

如果这段代码进入虚拟机即时优化后,它将进行如下优化,检测到c*bb*c是一样的表达式而且在计算中B与C的值是不变的

表达式可能被视为

int d= E * 13 + a + a;

这时候编译器还可能进行袋鼠简化,在E本来就有乘法运算的情况下把表达式变为

int d=E*13+a+a;

这样就可以节省一些时间了

数组边界检查消除

java语言是一门动态安全语言,对于数组访问也不像C,C++那样实际上就是裸指针操作,如果有一个数组foo[],在java原因中访问数组元素foo[i]的时候系统将会进行上下界的范围检查是否越界,否则将抛出运行时异常ArrayIndexOutOfBoundsExcepition这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的调教判断操作,对于有大量数组访问的程序代码,这必定是一种负担。

无论如何为了安全数组的边界检查是肯定要做的,但数组边界检查是不是必须在运行期间一次不漏地进行则是可以商量的事情。例如数组下标是一个常量,只要在编译器根据数据流分析来确定goo.length的值,并判断下标3有没有越界,执行的时候就无需判断了。更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在[0,foo.length]中,那么循环中就可以把整个数组的上下界检查给消除掉,这可以节省很多次条件判断的操作

将这个数组边界检查的例子放在更高的角度来看,大量的安全检查令编写Java程序比编写C/C++程序容易的多,如数组越界会得到ArrayIndexOutOfBoundsException异常,空指针访问会得到NullPointException,除数为零会得到ArithmeticException等,在C/C++程序中出现类似的问题,一不小心就会出现Segment Fault信号或者Window编程中常见的“xxx内存不能为Read/Write”之类的提示,处理不好程序就直接崩溃退出了。但这些安全检查也导致了相同的程序,Java要比C/C++做更多的事情(各种检查判断),这些事情就成为一种隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提到编译器完成的思路之外,另外还有一种避免思路——隐式异常处理,Java中空指针检查和算术运算中除数为零的检查都采用了这种思路。举个例子,例如程序中访问一个对象(假设对象叫foo)的某个属性(假设属性叫value),那以Java伪代码来表示虚拟机访问foo.value的过程如下。

if(foo != null) {
    return foo.value;
} else {
    throw new NullPointException();
}

在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。

try{
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

虚拟机会注册一个Segment Fault信号的异常处理(伪代码中的uncommon_trap()),这样当foo不为空的时候,对value的访问是不会额外消耗一次对foo判空的开销的。代价就是当foo真的为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空的话,这样的优化反而会让程序更慢,还好HotSpot虚拟机足够“聪明”,它会根据运行期收集到的Profile信息自动选择最优方案。

Last modification:January 10, 2023
如果觉得我的文章对你有用,请随意赞赏