HotSpot 虚拟机对象探秘

对象的创建

一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。 Java 的对象可以分为基本数据类型和普通对象。 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。 对于基本数据类型来说(byte、short、int、long、float、double、char),有两种情况。 之前说过,每个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。 注意,像 int[] 数组这样的内容,是在堆上分配的。数组并不是基本数据类型。

创建对象的方式

 

  1. 使用new关键字
  2. 使用Class类的newInstance方法
  3. 使用Constructor类的newInstance方法 
  4. 使用clone方法 
  5. 使用反序列化 

使用new关键字

通过这种方式,我们可以调用任意的构造函数(无参的和带参数的)。

CreateInstance newInstance = new CreateInstance();
//字节码
0 new #2 <com/fhj/jvm/CreateInstance>
3 dup
4 invokespecial #3 <com/fhj/jvm/CreateInstance.<init> : ()V>

使用Class类的newInstance方法

我们可以通过这个newInstance方法调用无参且公开的构造函数创建对象。

CreateInstance classInstance1 = (CreateInstance) Class.forName("com.fhj.jvm.CreateInstance").newInstance();
//class.newInstance()新建对象
CreateInstance classInstance2 = CreateInstance.class.newInstance();
//字节码
42 invokestatic #15 <java/lang/Class.forName : (Ljava/lang/String;)Ljava/lang/Class;>
45 invokevirtual #16 <java/lang/Class.newInstance : ()Ljava/lang/Object;>

86 invokevirtual #16 <java/lang/Class.newInstance : ()Ljava/lang/Object;>

使用Constructor类的newInstance方法

我们可以通过这个newInstance方法调用有参数的和私有的构造函数。事实上Class的newInstance方法内部调用Constructor的newInstance方法。

Constructor<CreateInstance> constructor = CreateInstance.class.getConstructor();
CreateInstance constructorInstance = constructor.newInstance();
//字节码
131 invokevirtual #18 <java/lang/Class.getConstructor : ([Ljava/lang/Class;)Ljava/lang/reflect/Constructor;>

使用clone方法 

用clone方法创建对象并不会调用任何构造函数。要使用clone方法,我们需要在需要clone的类中实现Cloneable接口,否则会出现java.lang.CloneNotSupportedException异常,由于Object类中clone方法是protected 修饰的,所以我们必须在需要克隆的类中重写克隆方法。

CreateInstance cloneInstance = (CreateInstance) newInstance.clone();
//字节码
185 invokevirtual #21 <com/fhj/jvm/CreateInstance.clone : ()Ljava/lang/Object;>

使用反序列化

当我们使用反序列化一个对象的时候,JVM会给我们创建一个对象。但是,反序列化的时候JVM并不会去调用类的构造函数(前边的1,2,3方式都会去调用构造函数)来创建对象,而是通过之前序列化对象的字节序列来创建的。

序列化对象必须实现Serializable这个接口,否则会出现java.io.NotSerializableException异常。把对象转为字节序列的过程称为对象的序列化;把字节序列恢复为对象的过程称为对象的反序列化。


// 反序列新建对象
// Serialization
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data.obj"));
out.writeObject(cloneInstance);
out.close();
//Deserialization
ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj"));
CreateInstance serializableInstance = (CreateInstance) in.readObject();
in.close();
//字节码 
277 invokevirtual #33 <java/io/ObjectInputStream.readObject : ()Ljava/lang/Object;>

对象序列化通常有两种用途:

1)将对象的字节序列永久的保存到硬盘上

例如web服务器把某些对象保存到硬盘让他们离开内存空间,节约内存,当需要的时候再从硬盘上取回到内存中使用

2)在网络上传递字节序列

当两个进程进行远程通讯的时候,发送方将java对象转换成字节序列发送(序列化),接受方再把这些字节序列转换成java对象(反序列化)

总结

我们从上面的字节码片段可以看到,除了第1个方法,其他4个方法全都转变为invokevirtual(创建对象的直接方法),第一个方法转变为两个调用,new和invokespecial(构造函数调用)。

demo:

public class CreateInstanceTest {

    public static void main(String[] args) throws Exception {
        //new新建对象
        CreateInstance newInstance = new CreateInstance();
        System.out.println(newInstance + ", hashcode : " + newInstance.hashCode());
        //class.newInstance()新建对象
        CreateInstance classInstance1 = (CreateInstance) Class.forName("com.fhj.jvm.CreateInstance").newInstance();
        System.out.println(classInstance1 + ", hashcode : " + classInstance1.hashCode());
        //class.newInstance()新建对象
        CreateInstance classInstance2 = CreateInstance.class.newInstance();
        System.out.println(classInstance2 + ", hashcode : " + classInstance2.hashCode());
        //constructor.newInstance()新建对象
        Constructor<CreateInstance> constructor = CreateInstance.class.getConstructor();
        CreateInstance constructorInstance = constructor.newInstance();
        System.out.println(constructorInstance + ", hashcode : " + constructorInstance.hashCode());
        //clone()新建对象
        CreateInstance cloneInstance = (CreateInstance) newInstance.clone();
        System.out.println(cloneInstance + ", hashcode : " + cloneInstance.hashCode());
        // 反序列新建对象
        // Serialization
        ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("data.obj"));
        out.writeObject(cloneInstance);
        out.close();
        //Deserialization
        ObjectInputStream in = new ObjectInputStream(new FileInputStream("data.obj"));
        CreateInstance serializableInstance = (CreateInstance) in.readObject();
        in.close();
        System.out.println(serializableInstance + ", hashcode : " + serializableInstance.hashCode());
    }
}

CreateInstance类

public class CreateInstance implements Cloneable, Serializable {

    private int age = 18;

    private String name;

    private CreateInstanceTest createInstanceTest;

    public CreateInstance() {
        this.name = "jack";
        this.createInstanceTest = new CreateInstanceTest();
    }

    @Override
    public Object clone() {
        Object obj = null;
        try {
            obj = super.clone();
        } catch (CloneNotSupportedException e) {
            e.printStackTrace();
        }
        return obj;
    }
}

 

创建对象的过程

  1. 当Java虚拟机遇到一条字节码 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
  2. 在类加载检查通过后,虚拟机将为新生对象分配内存。内存大小在类加载完成后便可完全确定,为对象分配空间,实际上就是吧一块确定大小的内存块从堆中划分出来。
    1. 如果堆内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,所分配内存就仅仅是把指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
    2. 如果内存不是规整的,虚拟机就需要维护一个列表,记录那些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种方式称为“空闲列表”。
    3. 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。因此,当使用Serial、ParNew等带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存了。说理论上是因为在CMS的实现里面,为了能在多数情况下分配的更快,设计了一个叫做Linear Allocation Buffer 的分配缓冲区,通过空闲列表拿到一大块分配缓冲区之后,在它里面仍然可以使用指针碰撞方式来分配。
  3.  除如何划分可用空间外,还需要考虑:对象创建在虚拟机中是非常频繁的行为,在并发情况下并不是线程安全的,可能出现以下情况,正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存。针对以上情况,有两个方案可以处理:  
    1. 对分配内存空间的动作进行同步处理,实际上虚拟机采用CAS配上失败重试的方式保证更新操作的原子性;
    2. 把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),哪个线程要分配内存,就在那个线程的本地缓冲区分配,只有本地缓冲区用完了,分配新的缓冲区时才需要同步锁定。 
  4. 内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

  5. 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是某个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用Object::hashCode()方法时才计算)、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用了偏向锁等,对象头会有不同的设置方式。
  6. 到这一步,从虚拟机的视角看,一个新的对象已经产生了。但是从Java程序的视角看来,对象创建才刚刚开始,构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial 指令所决定,Java 编译器会在遇到new关键字的地方同时生成这两条字节码指令, 但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

对象的内存布局

在HotSpot虚拟机里,对象在堆内存中的存储布局划分为三个部分:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充
    • alignment(外部对齐):比如 8 字节的数据类型 long,在内存中的起始地址必须是 8 字节的整数倍。
    • padding(内部填充):在对象体内一个字段所占据空间的末尾,如果有空白,需要使用 padding 来补齐,因为下一个字段的起始位置必须是 4/8 字节(32bit/64bit)的整数倍。
    • 其实这两者都是一个道理,让对象内外的位置都对齐。

对象头(Header)

在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。

  • 标记字段用以存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称为“Mark Word”。对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用来存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容自行了解。 
  • 类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是那个类的实例。但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,即查找对象的元数据信息并不一定要经过对象本身。此外,如果是数组对象,那在对象头中还会有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。 

在 64 位 JVM 中,对象头占据的空间是 12-byte(=96bit=64+32),但是以 8 字节对齐,所以一个空类的实例至少占用 16 字节。

在 32 位 JVM 中,对象头占 8 个字节,以 4 的倍数对齐(32=4*8)。所以 new 出来很多简单对象,甚至是 new Object(),都会占用不少内容。

通常在 32 位 JVM,以及内存小于 -Xmx32G 的 64 位 JVM 上(默认开启指针压缩),一个引用占的内存默认是 4 个字节。

因此,64 位 JVM 一般需要多消耗 30%~50% 的堆内存。

压缩指针

在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。因此,每一个 Integer 对象的额外内存开销至少是 400%。这也是为什么 Java 要引入基本类型的原因之一。

为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。

这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

压缩指针的原理:

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1 号车,依次类推。

原本的内存寻址用的是车位号。比如说我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找 3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车。

这样一来,32 位压缩指针最多可以标记 2 的 32 次方辆车,对应着 2 的 33 次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号 *2 的寻址系统。

上述模型有一个前提,就是每辆车都从偶数号车位停起。这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)。

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。

在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB 的地址空间(超过 32GB 则会关闭压缩指针)。

在对压缩指针解引用时,我们需要将其左移 3 位,再加上一个固定偏移量,便可以得到能够寻址 32GB 地址空间的伪 64 位指针了。

此外,我们可以通过配置刚刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。

举例来说,如果规定每辆车都需要从偶数车位号停起,那么对于占据两个车位的小房车来说刚刚好,而对于需要三个车位的大房车来说,也仅是浪费一个车位。

但是如果规定需要从 4 的倍数号车位停起,那么小房车则会浪费两个车位,而大房车至多可能浪费三个车位。

当然,就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐。此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。

字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

实例数据(Instance Data)

是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle=mode, 默认mode是1)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),所以相同宽度的字段总是被分配到一起存放,在满足这个前提条件下,父类的变量出现在子类之前。如果+XX:CompactFields参数值为true(默认为true),那子类中较窄的变量也允许插入到父类变量的空隙中,以节省空间。

对齐填充

只是起了占位符的作用。因为HotSpot的自动内存管理要求对象起始地址必须为8字节的整数倍,即任何对象的大小都必须是8字节的整数倍。对象头已经设计为8字节的倍数,所以如果对象实例数据没有对齐的话,需要通过对齐填充来补全。

图示

让我们来看看下面的示例对象:

class X { // 8 字节-指向 class 定义的引用
   int a; // 4 字节
   byte b; // 1 字节
   Integer c = new Integer(); // 4 字节的引用
}

我们可能会认为,一个 X 类的实例占用 17 字节的空间。但是由于需要对齐(padding),JVM 分配的内存是 8 字节的整数倍,所以占用的空间不是 17 字节,而是 24 字节。

当然,运行 JOL 的示例之后,会发现 JVM 会依次先排列 parent-class 的 fields,然后到本 class 的字段时,也是先排列 8 字节的,排完了 8 字节的再排 4 字节的 field,以此类推。当然,还会 “加塞子”,尽量不浪费空间。

Java 内置的序列化,也会基于这个布局,带来的坑就是加字段后就不兼容了。只加方法不固定 serialVersionUID 也出问题。所以有点经验的都不喜欢用内置序列化,例如自定义类型存到 Redis 时。

对象的内存占用

一个 Java 对象占用的内存

  • JVM 具体实现可以用任意形式来存储内部数据,可以是大端字节序或者小端字节序(Big/Little Endian),还可以增加任意数量的补齐、或者开销,尽管原生数据类型(primitives)的行为必须符合规范。例如:JVM 或者本地编译器可以决定是否将 boolean[] 存储为 64bit 的内存块中,类似于 BitSet。JVM 厂商可以不告诉你这些细节,只要程序运行结果一致即可。
  • JVM 可以在栈(stack)空间分配一些临时对象。
  • 编译器可能用常量来替换某些变量或方法调用。
  • 编译器可能会深入地进行优化,比如对方法和循环生成多个编译版本,针对某些情况调用其中的一个。

当然,硬件平台和操作系统还会有多级缓存,例如 CPU 内置的 L1/L2/L3、SRAM 缓存、DRAM 缓存、普通内存,以及磁盘上的虚拟内存。

用户数据可能在多个层级的缓存中出现。这么多复杂的情况、决定了我们只能对内存占用情况进行大致的估测。

对象内存占用的测量方法

一般情况下,可以使用 Instrumentation.getObjectSize() 方法来估算一个对象占用的内存空间。

使用Instrumentation类计算Java对象大小的过程如下:

  • 创建一个有premain方法的agent 类,JVM在调用agent类的premain方法时会传入一个Instrumentation 对象,调用Instrumentation的getObjectSize方法
  • 定义META-INF/MANIFEST.MF文件
  • 把agent类打成一个jar包
  • 启动我们的应用程序,使用JVM参数指定agent jar的路径。

举例详细介绍使用Instrumentation计算对象大小的过程

1、创建agent 类:

public class MyAgent {
    private static volatile Instrumentation globalInstr;
    public static void premain(String args, Instrumentation inst) {
        globalInstr = inst;
    }
    public static long getObjectSize(Object obj) {
        if (globalInstr == null)
            throw new IllegalStateException("Agent not initted");
        return globalInstr.getObjectSize(obj);
    }
}

2、定义META-INF/MANIFEST.MF文件,文件路径必须是 resources/META-INF/MANIFEST.MF

Premain-Class: com.fhj.jvm.MyAgent

3、打成jar包,打开resources目录,执行以下命令:

jar cvfm myagent.jar META-INF/MANIFEST.MF

会在当前目录生成  myagent.jar。

4、启动我们的应用程序,使用JVM参数指定agent jar的路径。

-javaagent:"D:\tmp\myagent.jar"

这里新建一个测试类:

public static void main(String[] args) {
  System.out.println(MyAgent.getObjectSize(new Object()));
}

执行结果:

 

想要查看对象的实际内存布局(layout)、占用(footprint)、以及引用(reference),可以使用 OpenJDK 提供的 JOL 工具(Java Object Layout)。

JOL(Java Object Layout)是分析 JVM 中内存布局的小工具,通过 Unsafe、JVMTI,以及 Serviceability Agent(SA)来解码实际的对象布局、占用和引用。所以 JOL 比起基于 heap dump,或者基于规范的其他工具来得准确。

举例介绍使用JOL查看对象的实际内存

1、增加依赖

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>

2、使用方法

System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());

执行结果

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)                           00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)                           e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

OFFSET 是每个部分的开始字节偏移量,SIZE 是该部分的占用的字节数,TYPE 是类型,DESCRIPTION 是每个部分的描述信息,VALUE 是每个部分的值。

最后的 Space losses 指的是空间浪费,该对象最后填充了4个字节,即浪费的内存空间为4个字节。

包装类型、数组和字符串

包装类型比原生数据类型消耗的内存要多:

  • Integer:占用 16 字节(8+4=12+补齐),因为 int 部分占 4 个字节。所以使用 Integer 比原生类型 int 要多消耗 300% 的内存。
  • Long:一般占用 16 个字节(8+8=16),当然,对象的实际大小由底层平台的内存对齐确定,具体由特定 CPU 平台的 JVM 实现决定。看起来一个 long 类型的对象,比起原生类型 long 多占用了 8 个字节(也多消耗了 100%)。相比之下,Integer 有 4 字节的补齐,很可能是因为 JVM 强制进行了 8 字节的边界对齐。

其他容器类型占用的空间也不小。

多维数组

在进行数值或科学计算时,开发人员经常会使用 int[dim1][dim2] 这种构造方式。

在二维数组 int[dim1][dim2] 中,每个嵌套的数组 int[dim2] 都是一个单独的 Object,会额外占用 16 字节的空间。某些情况下,这种开销是一种浪费。当数组维度更大时,这种开销特别明显。

例如,int[128][2] 实例占用 3600 字节。而 int[256] 实例则只占用 1040 字节。里面的有效存储空间是一样的,3600 比起 1040 多了 246% 的额外开销。在极端情况下,byte[256][1],额外开销的比例是 19 倍!而在 C/C++ 中,同样的语法却不增加额外的存储开销。

String:String 对象的空间随着内部字符数组的增长而增长。当然,String 类的对象有 24 个字节的额外开销。

对于 10 字符以内的非空 String,增加的开销比起有效载荷(每个字符 2 字节 + 4 个字节的 length),多占用了 100% 到 400% 的内存。

字段重排列

字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1),但都会遵循如下两个规则。

  • 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。
  • 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
class A {
  long l;
  int i;
}
 
class B extends A {
  long l;
  int i;
}

定义了两个类 A 和 B,其中 B 继承 A。A 和 B 各自定义了一个 long 类型的实例字段和一个 int 类型的实例字段。分别打印了 B 类在启用压缩指针和未启用压缩指针时,各个字段的偏移量。

# 启用压缩指针时,B 类的字段分布
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4    int A.i                                       0
     16     8   long A.l                                       0
     24     8   long B.l                                       0
     32     4    int B.i                                       0
     36     4        (loss due to the next object alignment)

当启用压缩指针时,可以看到 Java 虚拟机将 A 类的 int 字段放置于 long 字段之前,以填充因为 long 字段对齐造成的 4 字节缺口。由于对象整体大小需要对齐至 8N,因此对象的最后会有 4 字节的空白填充。

# 关闭压缩指针时,B 类的字段分布
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4        (object header)
     16     8   long A.l
     24     4    int A.i
     28     4        (alignment/padding gap)                  
     32     8   long B.l
     40     4    int B.i
     44     4        (loss due to the next object alignment)

当关闭压缩指针时,B 类字段的起始位置需对齐至 8N。这么一来,B 类字段的前后各有 4 字节的空白。那么可不可以将 B 类的 int 字段移至前面的空白中,从而节省这 8 字节呢?

应该是可以的,并且修改过后的 Java 虚拟机也没有跑崩。Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(false sharing)问题。这个注释也会影响到字段的排列。

虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。

然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。

Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。

对象的访问定位

Java程序通过栈上的reference数据来操作堆上的具体对象。由于reference 类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种

  • 使用句柄
  • 使用直接指针

句柄访问:

Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。使用句柄访问的好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不会修改。

 

 直接指针:

Java堆中对象的内存布局必须考虑如何放置访问类型数据的相关信息,reference中存储的就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。使用直接指针的好处是速度快,它节省了一次指针定位的时间开销,由于对象访问很频繁,所以能节省很多成本。

 HotSpot主要使用直接指针方式进行对象访问,但也有例外情况,如果使用了Shenandoah收集器的话也会有一次额外的转发,但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也是十分常见的。

执行引擎

执行引擎

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。如果想让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的翻译者。

Java代码编译和执行的过程

 大部分程序代码转换为物理机的目标代码或是虚拟机能执行的指令集之前,都需要经过上图中的各个步骤。

 解释器和JIT编译器

解释器:当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码指令翻译为对应平台的本地机器指令执行。当一条字节码指令被解释执行完成后,接着在根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。

JIT(Just In Time Compiler)编译器:就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

 

 解释器

在Java发展历史中,一共有两套解释执行器,即古老的字节码解释器和现在普遍使用的模板解释器。

字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率低下。

模板解释器将每一条字节码和一个模板函数相关联。模板函数中直接产生这个字节码执行时的机器码,从而提高了解释器的性能。在HotSpot VM中,解释器主要有interpreter模块和Code模块构成。interpreter模块实现了解释器的核心功能,Code模块用于管理HotSpot VM在运行时生成的本地机器指令。

JIT编译器

基于解释器执行是比较低效的,所以JVM平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译为与本地平台相关的机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率提升。HotSpot VM采用解释器和即时编译器并存的架构。虽然解释器是低效的,但是当程序启动后,解释器可以马上发挥作用,省去编译时间,立即执行。而即时编译器需要吧代码编译为本地代码,需要一定的执行时间。但是编译为本地代码后,执行效率高。像JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。它执行性能非常高效,但程序在启动时需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于看中启动时间的应用场景而言,就需要采用解释器和编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去很多时间。随着时间推移,编译器发挥作用,把越多的代码编译成本地代码,获得更高的执行效率。

编译类型:

  • 前端编译:把.java文件转变为.class文件的过程。Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
  • JIT编译:把字节码转变为机器码的过程。HotSpot VM的c1 c2编译器。
  • 静态提前编译器(AOT编译器,Ahead Of Time Compiler):直接吧.java文件编译为本地机器代码的过程。GNU Compiler for the Java(GCJ)、Excelsior JET。

 是否启动JIT需要根据代码被调用执行的频率而定。那些被编译为本地代码的字节码被称为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以提升执行性能。

热点探测

 一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以称为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换,或简称为OSR(On Stack Replacement)编译。

一个方法要被调用多少次,或者一个循环体需要执行多少次才可以达到这个标准,必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。

目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。

采用基于计数器的热点探测,HotSpot VM 将会为每个方法都建立2个不同类型的计数器,分别为方法调用计数器和回边计数器。方法调用计数器用于统计方法的调用次数,回边计数器则用于统计循环体执行的循环次数。

 方法调用计数器

 方法调用计数器用于统计方法被调用的次数,默认阈值在Client模式下是1500次,server模式下是10000次。超过这个阈值,就会触发JIT编译。这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。

当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

 

 

 热度衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就被称为此方法统计的半衰周期。

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:-UserCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外可以使用-XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

回边计数器

统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器统计的目的就是为了触发OSR编译。

 

举例说明

public static void foo(Object obj) {
  int sum = 0;
  for (int i = 0; i < 200; i++) {
    sum += i;
  }
}

上面这段代码将被编译为下面的字节码。其中,偏移量为 18 的字节码将往回跳至偏移量为 7 的字节码中。在解释执行时,每当运行一次该指令,Java 虚拟机便会将该方法的循环回边计数器加 1。

public static void foo(java.lang.Object);
  Code:
     0: iconst_0
     1: istore_1
     2: iconst_0
     3: istore_2
     4: goto 14
     7: iload_1
     8: iload_2
     9: iadd
    10: istore_1
    11: iinc 2, 1
    14: iload_2
    15: sipush 200
    18: if_icmplt 7
    21: return

在即时编译过程中,我们会识别循环的头部和尾部。在上面这段字节码中,循环的头部是偏移量为 14 的字节码,尾部为偏移量为 11 的字节码。循环尾部到循环头部的控制流边就是真正意义上的循环回边。也就是说,将在这个位置插入增加循环回边计数器的代码。

实际上,Java 虚拟机并不会对这些计数器进行同步操作,因此收集而来的执行次数也并非精确值。不管如何,即时编译的触发并不需要非常精确的数值。只要该数值足够大,就能说明对应的方法包含热点代码。

具体来说,在不启用分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数 -XX:CompileThreshold 指定的阈值时(使用 C1 时,该值为 1500;使用 C2 时,该值为 10000),便会触发即时编译。

当启用分层编译时,Java 虚拟机将不再采用由参数 -XX:CompileThreshold 指定的阈值(该参数失效),而是使用另一套阈值系统。在这套系统中,阈值的大小是动态调整的。所谓的动态调整其实并不复杂:在比较阈值时,Java 虚拟机会将阈值与某个系数 s 相乘。该系数与当前待编译的方法数目成正相关,与编译线程的数目成负相关。

系数的计算方法为:
s = queue_size_X / (TierXLoadFeedback * compiler_count_X) + 1
 
其中 X 是执行层次,可取 3 或者 4;
queue_size_X 是执行层次为 X 的待编译方法的数目;
TierXLoadFeedback 是预设好的参数,其中 Tier3LoadFeedback 为 5,Tier4LoadFeedback 为 3;
compiler_count_X 是层次 X 的编译线程数目。

在 64 位 Java 虚拟机中,默认情况下编译线程的总数目是根据处理器数量来调整的(对应参数 -XX:+CICompilerCountPerCPU,默认为 true;当通过参数 -XX:+CICompilerCount=N 强制设定总编译线程数目时,CICompilerCountPerCPU 将被设置为 false)。

Java 虚拟机会将这些编译线程按照 1:2 的比例分配给 C1 和 C2(至少各为 1 个)。举个例子,对于一个四核机器来说,总的编译线程数目为 3,其中包含一个 C1 编译线程和两个 C2 编译线程。

对于四核及以上的机器,总的编译线程的数目为:
n = log2(N) * log2(log2(N)) * 3 / 2
其中 N 为 CPU 核心数目。

当启用分层编译时,即时编译具体的触发条件如下。

当方法调用次数大于由参数 -XX:TierXInvocationThreshold 指定的阈值乘以系数,或者当方法调用次数大于由参数 -XX:TierXMINInvocationThreshold 指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数 -XX:TierXCompileThreshold 指定的阈值乘以系数时,便会触发 X 层即时编译。
 
触发条件为:
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s  && i + b > TierXCompileThreshold * s)

其中 i 为调用次数,b 为循环回边次数。

OSR 编译

可以看到,决定一个方法是否为热点代码的因素有两个:方法的调用次数、循环回边的执行次数。即时编译便是根据这两个计数器的和来触发的。实际上,除了以方法为单位的即时编译之外,Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译。循环回边计数器便是用来触发这种类型的编译的。

OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。事实上,去优化(deoptimization)采用的技术也可以称之为 OSR。

在不启用分层编译的情况下,触发 OSR 编译的阈值是由参数 -XX:CompileThreshold 指定的阈值的倍数。

该倍数的计算方法为:

(OnStackReplacePercentage - InterpreterProfilePercentage)/100
 
其中 -XX:InterpreterProfilePercentage 的默认值为 33,当使用 C1 时 -XX:OnStackReplacePercentage 为 933,当使用 C2 时为 140。

也就是说,默认情况下,C1 的 OSR 编译的阈值为 13500,而 C2 的为 10700。

在启用分层编译的情况下,触发 OSR 编译的阈值则是由参数 -XX:TierXBackEdgeThreshold 指定的阈值乘以系数。

OSR 编译在正常的应用程序中并不多见。它只在基准测试时比较常见,因此并不需要过多了解。

设置程序执行方式

 默认情况下,HotSpot VM 采用解释器和编译器并存的架构,当然也可以通过命令显式地为Java虚拟机指定在运行时完全采用解释器执行,还是完全采用即时编译器执行。

-Xint:完全采用解释器模式执行程序;

-Xcomp:完全采用即时编译器模式执行程序。如果即时编译出现问题,解释器会介入执行。

-Xmixed:采用解释器和即时编译器的混合模式执行程序。

 HotSpot VM中JIT分类

在HotSpot VM中内嵌有两个JIT编译器,分别为Clien Compiler和Server Compiler,采用的是分层编译的模式,但大多数情况下我们简称为C1编译器和C2编译器。我们可以通过如下命令显式指定Java虚拟机在运行时使用哪种即时编译器

-client:指定Java虚拟机运行在Client模式下,并使用C1编译器。C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。

-server:指定Java虚拟机运行在Server模式下,并使用C2编译器。C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

分层编译策略:程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控,C2编译会根据性能监控细腻系进行激进优化。不过在Java 7版本之后,一旦开发人员在程序中显式指定命令“-server”时,默认将会开启分层编译策略,由C1和C2相互协作共同来执行编译任务。

JIT 编译方式有两种:一种是编译方法,另一种是编译循环。分层编译将 JVM 的执行状态分为了五个层次:

  • 字节码的解释执行;
  • 执行不带 profiling 的 C1 代码;
  • 执行仅带方法调用次数,以及循环执行次数 profiling 的 C1 代码;
  • 执行带所有 profiling 的 C1 代码;
  • 执行 C2 代码。

其中,profiling 指的是运行时的程序执行状态数据,比如循环调用的次数、方法调用的次数、分支跳转次数、类型转换次数等。JDK 中的 hprof 工具就是一种 profiler。

在不启用分层编译的情况下,当方法的调用次数和循环回边的次数总和,超过由参数 -XX:CompileThreshold 指定的阈值时,便会触发即时编译;当启用分层编译时,这个参数将会失效,会采用动态调整的方式进行。

常见的优化方法有以下几种:

  • 公共子表达式消除
  • 数组范围检查消除
  • 方法内联
  • 逃逸分析

C1和C2编译器不同的优化策略

C1编译器主要有方法内联,去虚拟化、冗余消除。

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现类进行内联
  • 冗余消除:在运行期间吧一些不会执行的代码折叠掉

C2编译器的优化主要是在全局层面,逃逸分析是优化基础。基于逃逸分析在C2上有如下几种优化:

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配在栈而不是堆
  • 同步消除:清除同步操作,通常只synchronized

方法内联

在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。方法内联不仅可以消除调用本身带来的性能开销,还可以进一步触发更多的优化。因此,它可以算是编译优化里最为重要的一环。以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作仅剩字段访问。

在 C2 中,方法内联是在解析字节码的过程中完成的。每当碰到方法调用字节码时,C2 将决定是否需要内联该方法调用。如果需要内联,则开始解析目标方法的字节码。内联后的代码和调用方法的代码,会组成新的机器码,存放在 CodeCache 区域里。同 C2 一样,Graal 也会在解析字节码的过程中进行方法调用的内联。

方法内联条件

方法内联能够触发更多的优化。通常而言,内联越多,生成代码的执行效率越高。然而,对于即时编译器来说,内联越多,编译时间也就越长,而程序达到峰值性能的时刻也将被推迟。此外,内联越多也将导致生成的机器码越长。在 Java 虚拟机里,编译生成的机器码会被部署到 Code Cache 之中。这个 Code Cache 是有大小限制的(由 Java 虚拟机参数 -XX:ReservedCodeCacheSize 控制)。这就意味着,生成的机器码越长,越容易填满 Code Cache,从而出现 Code Cache 已满,即时编译已被关闭的警告信息(CodeCache is full. Compiler has been disabled)。JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。因此,即时编译器不会无限制地进行方法内联。下面列举即时编译器的部分内联规则。

首先,由 -XX:CompileCommand 中的 inline 指令指定的方法,以及由 @ForceInline 注解的方法(仅限于 JDK 内部方法),会被强制内联。 而由 -XX:CompileCommand 中的 dontinline 指令或 exclude 指令(表示不编译)指定的方法,以及由 @DontInline 注解的方法(仅限于 JDK 内部方法),则始终不会被内联。其次,如果调用字节码对应的符号引用未被解析、目标方法所在的类未被初始化,或者目标方法是 native 方法,都将导致方法调用无法内联。再次,C2 不支持内联超过 9 层的调用(可以通过虚拟机参数 -XX:MaxInlineLevel 调整),以及 1 层的直接递归调用(可以通过虚拟机参数 -XX:MaxRecursiveInlineLevel 调整)。太高的话,CodeCache 区域会被挤爆。相似的,编译后的代码超过一定大小也不会再内联,这个参数由 -XX:InlineSmallCode 进行调整。如果方法 a 调用了方法 b,而方法 b 调用了方法 c,那么我们称 b 为 a 的 1 层调用,而 c 为 a 的 2 层调用。

方法内联的过程是非常智能的,内联后的代码,会按照一定规则进行再次优化。最终的机器码,在保证逻辑正确的前提下,可能和我们推理的完全不一样。在非常小的概率下,JIT 会出现 Bug,这时候可以关闭问题方法的内联,或者直接关闭 JIT 的优化,保持解释执行。

-XX:CompileCommand=exclude,com/xiaojie/Test,test

上面的参数,表示 com.xiaojie.Test 的 test 方法将不会进行 JIT 编译,一直解释执行。

最后,即时编译器将根据方法调用指令所在的程序路径的热度,目标方法的调用次数及大小来决定方法调用能否被内联。有非常多的参数,被用来控制对内联方法的选择,总体来说,即时编译器中的内联算法更青睐于小方法。这和我们在日常中的编码要求是一致的:代码块精简,逻辑清晰的代码,更容易获得优化的空间。

去虚拟化

方法内联中举的例子都是静态方法调用,即时编译器可以轻易地确定唯一的目标方法。然而,对于需要动态绑定的虚方法调用来说,即时编译器则需要先对虚方法调用进行去虚化(devirtualize),即转换为一个或多个直接调用,然后才能进行方法内联。

即时编译器的去虚化方式可分为完全去虚化以及条件去虚化(guarded devirtualization)。

  • 完全去虚化是通过类型推导或者类层次分析(class hierarchy analysis),识别虚方法调用的唯一目标方法,从而将其转换为直接调用的一种优化手段。它的关键在于证明虚方法调用的目标方法是唯一的。
  • 条件去虚化则是将虚方法调用转换为若干个类型测试以及直接调用的一种优化手段。它的关键在于找出需要进行比较的类型。

在介绍具体的去虚化方式之前,我们先来看一段代码。这里我定义了一个抽象类 BinaryOp,其中包含一个抽象方法 apply。BinaryOp 类有两个子类 Add 和 Sub,均实现了 apply 方法。

abstract class BinaryOp {
  public abstract int apply(int a, int b);
}
 
class Add extends BinaryOp {
  public int apply(int a, int b) {
    return a + b;
  }
}
 
class Sub extends BinaryOp {
  public int apply(int a, int b) {
    return a - b;
  }
}

下面我便用这个例子来逐一讲解这几种去虚化方式。

基于类型推导的完全去虚化

基于类型推导的完全去虚化将通过数据流分析推导出调用者的动态类型,从而确定具体的目标方法。

基于类层次分析的完全去虚化

基于类层次分析的完全去虚化通过分析 Java 虚拟机中所有已被加载的类,判断某个抽象方法或者接口方法是否仅有一个实现。如果是,那么对这些方法的调用将只能调用至该具体实现中。

public static int foo() {
  BinaryOp op = new Add();
  return op.apply(2, 1);
}
 
public static int bar(BinaryOp op) {
  op = (Add) op;
  return op.apply(2, 1);
}

举个例子,上面这段代码中的 foo 方法和 bar 方法均会调用 apply 方法,且调用者的声明类型皆为 BinaryOp。这意味着 Java 编译器会将其编译为 invokevirtual 指令,调用 BinaryOp.apply 方法。

在上面的例子中,假设在编译 foo、bar 或 notInlined 方法时,Java 虚拟机仅加载了 Add。那么,BinaryOp.apply 方法只有 Add.apply 这么一个具体实现。因此,当即时编译器碰到对 BinaryOp.apply 的调用时,便可直接内联 Add.apply 的内容。

那么问题来了,即时编译器如何保证在今后的执行过程中,BinaryOp.apply 方法还是只有 Add.apply 这么一个具体实现呢?

事实上,它无法保证。因为 Java 虚拟机有可能在上述编译完成之后加载 Sub 类,从而引入另一个 BinaryOp.apply 方法的具体实现 Sub.apply。

Java 虚拟机的做法是为当前编译结果注册若干个假设(assumption),假定某抽象类只有一个子类,或者某抽象方法只有一个具体实现,又或者某类没有子类等。之后,每当新的类被加载,Java 虚拟机便会重新验证这些假设。如果某个假设不再成立,那么 Java 虚拟机便会对其所属的编译结果进行去优化。

  public static int test(BinaryOp op) {
    return op.apply(2, 1);
  }

以上面这段代码中的 test 方法为例。假设即时编译的时候,如果类层次分析得出 BinaryOp 类只有 Add 一个子类的结论,那么即时编译器可以注册一个假设,假定抽象方法 BinaryOp.apply 有且仅有 Add.apply 这个具体实现。

基于这个假设,原虚方法调用便可直接被去虚化为对 Add.apply 方法的调用。如果在之后的运行过程中,Java 虚拟机又加载了 Sub 类,那么该假设失效,Java 虚拟机需要触发 test 方法编译结果的去优化。

  public static int test(Add op) {
    return op.apply(2, 1); // 仍需添加假设
  }

事实上,即便调用者的声明类型为 Add,即时编译器仍需为之添加假设。这是因为 Java 虚拟机不能保证没有重写了 apply 方法的 Add 类的子类。

为了保证这里 apply 方法的语义,即时编译器需要假设 Add 类没有子类。当然,通过将 Add 类标注为 final,可以避开这个问题。

可以看到,即时编译器并不要求目标方法使用 final 修饰符。只要目标方法事实上是 final 的(effective final),便可以进行相应的去虚化以及内联。

不过,如果使用了 final 修饰符,即时编译器便可以不用生成对应的假设。这将使编译结果更加精简,并减少类加载时所需验证的内容。

条件去虚化

条件去虚化通过向代码中添加若干个类型比较,将虚方法调用转换为若干个直接调用。

具体的原理非常简单,是将调用者的动态类型,依次与 Java 虚拟机所收集的类型 Profile 中记录的类型相比较。如果匹配,则直接调用该记录类型所对应的目标方法。

  public static int test(BinaryOp op) {
    return op.apply(2, 1);
  }

我们继续使用前面的例子。假设编译时类型 Profile 记录了调用者的两个类型 Sub 和 Add,那么即时编译器可以据此进行条件去虚化,依次比较调用者的动态类型是否为 Sub 或者 Add,并内联相应的方法。其伪代码如下所示:

  public static int test(BinaryOp op) {
    if (op.getClass() == Sub.class) {
      return 2 - 1; // inlined Sub.apply
    } else if (op.getClass() == Add.class) {
      return 2 + 1; // inlined Add.apply
    } else {
      ... // 当匹配不到类型 Profile 中的类型怎么办?
    }
  }

如果遍历完类型 Profile 中的所有记录,仍旧匹配不到调用者的动态类型,那么即时编译器有两种选择。

第一,如果类型 Profile 是完整的,也就是说,所有出现过的动态类型都被记录至类型 Profile 之中,那么即时编译器可以让程序进行去优化,重新收集类型 Profile。

第二,如果类型 Profile 是不完整的,也就是说,某些出现过的动态类型并没有记录至类型 Profile 之中,那么重新收集并没有多大作用。此时,即时编译器可以让程序进行原本的虚调用,通过内联缓存进行调用,或者通过方法表进行动态绑定。在 C2 中,如果类型 Profile 是不完整的,即时编译器压根不会进行条件去虚化,而是直接使用内联缓存或者方法表。

逃逸分析

逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”。

在 Java 虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(对象被赋值给堆中对象的字段和类的静态变量),二是对象是否被传入未知代码中。前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。关于后者,由于 Java 虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。

举个例子,以下代码中,虽然 map 是一个局部变量,但是它通过 return 语句返回,其他外部方法可能会使用它,这就是方法逃逸。另外,如果被其他线程引用或者赋值,则成为线程逃逸。

public Map fig(){
    Map map = new HashMap();
    ...
    return map;
}

用完 Map 之后就直接销毁了,我们就可以说 map 对象没有逃逸。

public void fig(){
    Map map = new HashMap();
    ...
}

通常来说,即时编译器里的逃逸分析是放在方法内联之后的,以便消除这些“未知代码”入口。

使用 -XX:+DoEscapeAnalysis 参数可以开启逃逸分析,逃逸分析现在是 JVM 的默认行为,这个参数可以忽略。

基于逃逸分析的优化(逃逸分析的好处)

即时编译器可以根据逃逸分析的结果进行诸如锁消除(同步省略)、栈上分配以及标量替换的优化。

锁消除(同步省略)

我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。

实际上,传统编译器仅需证明锁对象不逃逸出线程,便可以进行锁消除。由于 Java 虚拟机即时编译的限制,上述条件被强化为证明锁对象不逃逸出当前编译的方法。

例如:synchronized (new Object()) {} 会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的 happens-before 规则。synchronized (escapedObject) {} 则不然。由于其他线程可能会对逃逸了的对象escapedObject进行加锁操作,从而构造了两个线程之间的 happens-before 关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令。

栈上分配

不过,基于逃逸分析的锁消除实际上并不多见。一般来说,开发人员不会直接对方法中新构造的对象进行加锁。事实上,逃逸分析的结果更多被用于将新建对象操作转换成栈上分配或者标量替换。我们知道,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。不过,由于实现起来需要更改大量假设了“对象只能堆分配”的代码,因此 HotSpot 虚拟机并没有采用栈上分配,而是使用了标量替换这么一项技术。

标量替换

所谓的标量,就是仅能存储一个值的变量,比如 Java 代码中的局部变量。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是 Java 对象。标量替换这项优化技术,可以看成将原本对对象的字段的访问,替换为一个个局部变量的访问。由于对象没有被实际分配,因此和栈上分配一样,它同样可以减轻垃圾回收的压力。与栈上分配相比,它对字段的内存连续性不做要求,而且,这些字段甚至可以直接在寄存器中维护,无须浪费任何内存空间。

总结

一般来说,JIT编译出来的机器码执行性能比解释器高。C2编译器启动时长比C1编译器慢,系统稳定执行后,C2编译器执行速度远远快于C1编译器。

自JDK10起,HotSpot又加入一个全新的即时编译器:Graal编译器。编译效果和C2编译器差不多。目前,带有“实验状态”标签,需要使用开关参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler去激活,才可以使用。

JDK9引入AOT编译器(静态提前编译器,Ahead Of Time Compiler),JDK9 引入了实验性AOT编译工具jaotc。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放在生成的动态共享库之中。所谓AOT编译,是与即时编译相对立的概念。即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。这样的好处是Java虚拟机加载已经预编译成二进制库,可以直接运行。不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验。缺点就是破坏了java“一次编译,处处运行”,必须为每个不同硬件、OS编译对应的发行包。降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知。目前还在继续优化中,最初只支持Linux x64 java base。

posted @ 2021-11-24 23:01  xiaojiesir  阅读(198)  评论(0编辑  收藏  举报