《深入理解Java虚拟机》读书笔记(十)–晚期(运行期)优化(下)

《深入理解Java虚拟机》读书笔记(十)--晚期(运行期)优化(下)

一、公共子表达式消除

  如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的结果代替E就行了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果优化的范围涵盖了多个基本块,就称为全局公共子表达式消除
  对于以下代码:

int d = (c * b) * 12 + a + (a + b * c);
只听到从山间传来架构君的声音:
井上辘轳床上转。有谁来对上联或下联?

  对于即时编译器来说,检测到"c*b"与"b*c"是一样的表达式,而且在计算期间b与c的值是不变的。因此这条表达式就可能被编译为:

此代码由Java架构师必看网-架构君整理
int d = E * 12 + a + (a + E);

  这时,编译器还可能进行另一种优化,把表达式变为:

ind d = E * 13 + a * 2;

二、数组边界检查消除

  在Java中访问数组元素的时候,系统将会自动进行上下界的范围检查:对于数组array,在访问array[i]的时候,需要检查i必须满足i>=0 && i<array.length这个条件,否则将抛出一个运行时异常:ArrayIndexOutOfBoundsException。虽然这对开发人员来说是一件好事,但是对于虚拟机的执行子系统来说,每次数组读写都有一次隐含的条件判定,对于有大量数组访问的程序代码,也是一种性能负担。
  为了安全,数组边界检查还必须得做,但是不一定必须在运行期间一次不漏地检查。比如数组访问发生在循环之中时,并且使用循环变量来进行数组访问,如果编译器只是通过数据流分析就可以判定循环变量的取值不会溢出,那么在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。

三、隐式异常处理

  其实安全检查不止有数组边界检查,空指针访问会得到NullPointException,除数为0会得到ArthmeticException等。在C/C++程序中出现类似的问题,一不小心就会出现各种状况,不注意还会导致程序直接崩溃退出。但这些安全检查也成为了一种隐式开销,导致相同的程序,Java要比C/C++做更多的事情,如果处理不好,就可能成为一个Java语言比C/C++更慢的因素。
  前面提到的将部分数组边界检查提前到编译器就是为了更好的处理这种开销,除此之外还有另外一种避免思路–隐式异常处理,Java中空指针检查和算术运算中除数为0的检查都采用了这个思路。
  比如程序要访问一个对象的某个属性,以Java伪代码来表示虚拟机访问的过程如下:

此代码由Java架构师必看网-架构君整理
if(obj != null){ return obj.value; }else{ throw new NullPointException(); }

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

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

  虚拟机会注册一个Segment Fault信号的异常处理器(上述伪代码中的uncommon_trap),当obj不为空的时候,对value的访问就不会额外消耗一次对obj的判空开销。代价就是当obj真的为空的时候,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程要从用户态转到内核态处理,结束后再返回用户态,速度远比一次判空检查慢。如果obj极少情况为空,那么隐式异常优化就是值得的,反之就会起反作用。虚拟机会根据运行期收集到的Profile信息自动选择自动方案。

四、方法内联

  由于Java方法的多态选择,对于一个虚方法,编译器如果要做内联,是无法确定应该使用哪个方法版本的。(关于方法的静态分派和动态分派可参考虚拟机字节码执行引擎(上)
  Java对象的方法默认就是虚方法,Java间接鼓励程序员使用虚方法完成程序逻辑。为了解决虚方法的内联问题,JVM设计团队首先引入了一种名为”类型继承关系分析(CHA)“的技术,它用于确定在目前加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
  编译器在内联时,如果是非虚方法,直接内联就可以了。如果遇到虚方法,则会向CHA查询此方法在当前程序下是否有多个目标版本,如果只有一个版本,那也可以进行内联,这种内联属于激进优化,需要预留一个”逃生门“,称为守护内联。如果状态不变,这个内联优化的代码就可以一直用下去,但如果加载了导致继承关系发生变化的新类,就需要抛弃已经编译的代码,退回到解释状态执行,或者重新编译。
  如果向CHA查询的结果是目标方法有多个版本,则编译器还会进行最后一次努力,使用内联缓存来完成方法内联。其工作原理大致是:内联缓存初始为空,当第一次调用发生后,缓存记录下方法接受者的版本信息,并且每次进行方法调用时都比较方法接收者版本,如果每次接收者版本都一样,那这个内联可以一直保持。如果方法接收者不一致了,就说明程序真正使用了虚方法的多态性,这时才会取消内联,查找虚方法表进行方法分派。

五、逃逸分析

  逃逸分析的基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,比如作为调用参数传递到其他方法或者返回给调用者,称为方法逃逸。甚至可能被其它线程访问到,比如赋值给类变量或可以在其它线程中访问的实例变量,称为线程逃逸
  如果一个对象不会逃逸到方法或线程之外,就代表别的方法或线程无法通过任何途径访问到这个对象,就可能为这个变量进行一些高效的优化:

  • 栈上分配:如果确定一个对象不会逃逸,那么让这个对象直接分配在栈帧中,这样对象所占用的内存空间就可以随栈帧出栈而销毁,降低了GC的压力。
  • 同步消除:如果一个变量不会逃逸出线程,无法被其它线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施就可以消除掉。
  • 标量替换:标量指一个数据无法再分解成更小的数据来表示,比如原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,可以称为标量。如果一个数据可以继续分解,就称为聚合量,比如Java中的对象。如果把一个Java对象拆散,根据程序访问情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还能为后续进一步的优化手段创建条件。

  并不能保证逃逸分析的性能收益一定高于它的消耗,如果分析完成后发现没有几个不逃逸的对象,这个分析消耗就白费了。用户可以使用参数-XX:+DoEscapeAnalysis开启逃逸分析,开启之后可以通过参数:-XX:+PrintEscapeAnalysis来查看分析结果。有了逃逸分析之后,可以使用参数-XX:+EliminateAllocations来开启标量替换,使用+XX:+EliminateLocks开启同步消除,使用参数:+XX:+PrintEliminateAllocations查看标量的替换情况。

六、Java与C/C++的编译器对比

  Java刚出现时,主要靠解释器执行,执行速度缓慢的帽子扣的实实的,但随着即时编译技术的发展,已经有了很大的改善。不过Java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能会由于下列原因导致输出的本地代码有一些劣势:

  • 即时编译器占用的是用户程序的运行时间,具有很大的时间压力,能提供的优化手段也严重受制于编译成本。
  • Java语言是动态的类型安全语言,虚拟机会频繁地进行动态检查,如前面提到的检查空指针、数组访问时检查下标、类型转换时检查继承关系等。尽管编译器会努力优化,但总体上仍然要消耗不少的运行时间。
  • Java中虚方法的使用频率远远高于C/C++语言,这意味着运行时对方法接收者进行多态选择的频率更高,也就有着更高的优化难度(比如前文提到的方法内联)。
  • Java是可以动态扩展的语言,运行时加载新的类可能会改变程序类型的继承关系,使得很多全局优化难以进行,许多的全局优化都只能以激进优化的方式来完成。
  • Java中的对象分配和垃圾收集的效率都较于C/C++中的栈上分配(程序代码可控)和手动释放内存更低。

  但是也有一些优化是Java的即时编译器能做,而C/C++的静态优化编译器不好做的。比如,在C/C++中,别名分析的难度要远高于Java,这点Java又得益于它的类型安全。另外,由于C/C++的编译器所有优化都在编译期完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测分支频率预测裁剪未被选择的分支等等。

本文来源黄智霖-blog,由架构君转载发布,观点不代表Java架构师必看的立场,转载请标明来源出处:https://javajgs.com/archives/31645
2

发表评论