JVM执行引擎

前端编译及优化

Javac编译过程

  • 准备过程:初始化插入式注解处理器。

  • 处理过程:

参考《编译原理》,此处略。

语法糖

类型擦除型泛型

与C#的“具现化性范型“不同,Java中的泛型只在程序源码中存在,在字节码时已替换为具体类型,无法当成真实类型使用。因此,对范型进行实例判断、创建泛型对象是不合法的行为。

裸类型 , 视为所有该泛型化实例的共同父类型。

后端编译与优化

即时编译器JIT

  • 编译模式:代码编译为本地代码

  • 解释模式:便于程序迅速启动

  • 虚拟机可指定参数设置运行模式,默认为分层模式:

    • 第0层:程序纯解释执行,解释器不开启性能监控功能(Profiling

    • 第1层:使用客户端编译器,进行简单可靠的稳定优化,不开启性能监控功能

    • 第2层:使用客户端编译器,开启方法及回边次数统计等有限性能监控功能

    • 第3层:使用客户端编译器,开启全部性能监控功能,出第2层的内容,还会

      收集分支跳转、虚方法调用版本等统计信息

    • 第4层:使用服务端编译器,会启动更多编译耗时更长的优化,且会根据监控信息进行一些不可靠的激进优化。

即时编译器编译的目标是“热点代码”方法体,即被多次调用的方法或循环体。(循环体的编译入口不同)。目前采用基于计数器的热点检测方案(还有一个主流方案是基于采样)

回边 的意思是在循环边界往回跳转。

方法调用计数器有半衰期,热度衰减动作由垃圾收集时顺便执行。

回边计数器没有衰减机制,统计值为绝对数字。

(LIR:中间代码表示,SSA:静态单分派)

虚拟机在代码编译未完成时会按照解释方式继续执行,编译动作在后台的编译线程执行。

提前编译器

可以说是抛弃一些比较耗时的优化策略,选择替代方案,应用过程内分析模拟缓存加速 作为实现方向。

即时编译器的优势在于:

  • 性能分析制导优化

  • 激进预测性优化

  • 链接时优化

编译器优化技术

方法内联

方法内联被称为优化之母,除了消除方法调用的成本之外,更重要的意义是为其他优化手段建立良好的基础。

对于虚方法,Java虚拟机引入类型继承关系分析 技术CHA,确定虚方法的接口、类关系信息,如果只有一个方法版本则进行守护内联 (假设只有这个版本)并预留继承关系发生变化时的回滚解释状态;若有多个版本,使用内联缓存方式。

逃逸分析

是目前Java虚拟机中比较前沿的优化技术,其基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸。

如果逃逸程度在线程逃逸之下(即不会被别的线程引用):

  • 栈上分配优化。使该对象在栈上分配内存,对应内存空间随栈帧出栈而销毁。

  • 标量替换优化。标量指不可再分解的数据,如基本数据类型,否则称作聚合量。对于方法逃逸之下 的对象,可将对象的成员变量拆散为基本数据类型们,而不去创建对象体,在栈上分配与读写。

  • 同步消除优化。关闭线程间同步措施。

公共子表达式消除

如果表达式 E 已经被计算过了,并且到现在 E 中所有变量的值都没有发生变化,那么 E 的此次出现就称为公共子表达式,是冗余的。

复写传播、无用代码消除

清除额外的变量、不会被执行的代码、无意义代码等。

数组检查边界消除

根据性能监控信息,相对于每次对数组索引的判断,使用隐式异常优化可减少开销,即:

虚拟机注册异常处理器Segment Fault,使用try catch机制代替if else机制。当然,由于越界需要进入中断异常处理,速度比判空慢。

Java编译期优化分析

String系列

新时代的JVM

指针压缩技术

在32位到64位的转变中,程序最大的获益是内存容量。在一个32位系统中,内存地址的宽度就是32位,这就意味着,程序最大能获取的内存空间是2^324G)字节。这个容量明显不够用了。在一个64位的机器中,理论上程序能获得的内存容量是2^64字节,这是一个十分庞大的数字。不过,这个转变也是有代价的:

  1. 运行在64位中的程序会花费更多的内存。通常64位JVM消耗的内存会比32位的大1.5倍,这是因为对象指针在64位架构下,长度会翻倍(更宽的寻址)。对于那些将要从32位平台移植到64位的应用来说,平白无故多了1/2的内存占用,这是开发者不愿意看到的。

  2. 增加了GC开销。64位对象引用需要占用更多的对空间,留给其他数据的空间将会减少,从而加快了GC的发生。

  3. 降低了CPU缓存的命中率。64位对象引用(指针)增加了,CPU能缓存的指针将会减少,从而降低了CPU缓存的效率。

可以使用lucene提供的专门用于计算堆内存占用大小的工具类:RamUsageEstimator,以便捷地计算对象占用的内存大小。

为了解决上述的问题,HotSpot引入了两个压缩优化的技术,Compressed Ordinary Object PointersCompressed Class Pointers

Compressed Ordinary Object Pointers

Ordinary Object Pointersoops 即普通对象指针。启用CompressOops后,以下对象的指针会被压缩:

  • 每个Class的属性指针(静态成员变量)

  • 每个对象的属性指针

  • 普通对象数组的每个元素指针

启动压缩后,JVM保存32位的指针,但是在64位机器中,最终还是需要一个64位的地址来访问数据。这里JVM需要做一个对指针数据编码、解码的工作。在机器码中植入压缩与解压指令来实现以下过程:

首先,每个对象的大小一定是8字节的倍数,因为JVM会在对象的末尾加上数据进行对齐填充(Padding)。

假设对象x中有3个引用,a在地址0b在地址8c在地址16。那么在x中记录引用信息的时候,可以不记录0, 8, 16…这些数值,而是可以使用0, 1, 2…(即地址右移3位,相当于除8),这一步称为encode。在访问x.c的时候,拿到的地址信息是2,这里做一次decode(即地址左移3位,相当于乘8)得到地址16,然后就可以访问到c了。这样,虽然我们使用32位来存储指针,但是我们多出了8倍的可寻址空间。所以压缩指针的方式可以访问的内存是4G * 8 = 32G

Compressed Class Pointers

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32bit和64bit,官方称它为Mark Word另外一部分是一个指向MetaspaceKlass结构(这个结构可以理解为一个Java类在虚拟机内部的表示)的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。在32位JVM中,这个指针是32位的。在64位JVM中,这个指针本来是64位的,但开启Compressed Class Pointers之后,这个指针是32位的,为了找到真正的64位地址,需要加上一个base值

由于32位地址只能访问到4G的空间,所以最大只允许4GKlass地址。这项限制也意味着,JVM需要向Metaspace分配一个连续的地址空间。当从系统申请内存时,通过调用系统接口malloc(3)mmap(3),操作系统可能返回任意一个地址值,所以在64位系统中,它并不能保证在4G的范围内。所以,我们只能用一个mmap()来申请一个区域单独用来存放Klass对象。 我们需要提前知道这个区域的大小,而且不能超过4G。显然,这种方式是不能扩展的,因为这个地址后面的内存可能是被占用的。因此Metaspace分为两个区域:

  • class part:存放Klass对象,需要一个连续的不超过4G的内存。

  • non-class part:包含其他的所有metadata。其他的metadata都是通过64位的地址进行访问的,所以它们可以被放到任意的地址上。除了Klassmetadata还包括:

  • Method metadata方法元数据。Java类文件中method_info结构在虚拟机内部的运行时表示,包括bytecode(字节码)、exception table(异常表)、constants(常量)等

  • constant pool常量池。

  • Annotations注解。

  • 方法计数器。记录方法执行的次数,用于辅助JIT的决策。

在线Java分析工具

www.javamex.com 中提供了许多面向对象的工具:比如classmexer计算对象的大小。


参考: 《深入理解JAVA虚拟机》 Java指针压缩

最后更新于

这有帮助吗?