JVM之指针压缩
阅读原文时间:2023年08月15日阅读:2

做java开发的同学一般都比较熟悉JVM,那么关于指针压缩这块内容是不是也了解呢,不熟悉的小伙伴往下看吧。

首先说明,本文涉及的JDK版本是1.8,JVM虚拟机是64位的HotSpot实现为准。

java对象结构

了解指针压缩前,需要先搞懂java的实例对象在JVM虚拟机中内存结构是什么样的。

java对象由三部分构成:

  1. 对象头

对象头里也有三部分构成。

  • Markword

存储对象的hashCode、垃圾回收对象的年龄以及锁信息等。

  • 类型指针

对象指向的类信息地址即元数据指针,比如User对象指针指向User.class的JVM内存地址。注意:jdk1.8以后元数据是存在Metaspace里的,jdk1.8之前是在方法区里

  • 数组长度

只有对象是数组的情况下,才有这部分数据,若对象不是数组,则没有这部分,不分配空间。

  1. 对象体

对象里的非静态属性占用的空间(包括父类的所有属性,不区分修饰类型),不包括方法,注意:是非静态属性,属于对象的属性,静态属性是属于类的不在对象上分配空间。如果属性是基本数据类型,则直接存对象本身,如果是引用类型,则存的是对象的指针。

  1. 对齐填充

如果对象头+对象体大小不是8字节的倍数,则通过该部分进行补齐,比如对象头+对象体大小只有30字节,则需要补齐到32字节,这里的对齐填充就是2字节。默认情况下,JVM中对象是以8字节对齐的,若对象头加上对象体是8的倍数时,则不存在字节对齐,否则会填充补齐到8的倍数。

对象结构如下图所示(默认情况下)。

压缩指针的由来

计算机操作系统分32位和64位,这里的位在计算机里是用0和1来表示的,用32个(或64个)二进制0和1的组合来表示内存地址。以32位为例,在普通的内存中,对象的大小最小是以1字节来计算的,通过0和1的排列组合,能够表示寻址的内存空间最大就是2^32个,换算成内存空间就是2 ^ 32 / 1024 / 1024 / 1024 = 4G,也就是说32位的操作系统最大能寻址的内存空间只有4G,同理,64位的操作系统(查阅资料显示其实没有用到64位,最多只用到了48位,这个可自行查阅资料,反正肯定比32位大的多)2 ^ 48 / 1024 / 1024 / 1024 / 1024 = 256TB,这样内存就足够大了,但是目前还没有厂商能生产出这么大的内存。

4G对于现在的java应用系统来说,内存已经算小的了,那我们就会想到使用64位的系统,这样内存就可以更大了,但是当我们准备将32位系统切换到64位系统,起初我们可能会期望系统性能会立马得到提升,但现实情况可能并不是这样的,为什么呢?

(1)32位系统对象指针是4字节,64位系统对象指针是8字节(1位表示1bit,8个bit表示1字节),这样64位系统中的对象引用占用的内存空间是32位系统中的两倍大小,因此间接的导致了在64位系统中更多的内存消耗以及更频繁的GC发生,GC占用的CPU时间越多,那么我们的应用程序占用CPU的时间就越少,响应会变慢,吞吐量会降低。

(2)对象的引用变大了,那么CPU可缓存的对象相对就少了,降低了CPU缓存命中率,增加了对内存的访问,CPU对CPU缓存的访问速度可比对内存的访问速度快太多了,所以大量的对内存访问,会降低CPU的执行效率,增加了执行时间,从而影响性能。

既然32位系统内存不够,64位内存够但又影响性能,那有没有折中方案来解决这两个问题呢,于是聪明的JVM开发者想到了利用压缩指针,在64位的操作系统中利用32位的对象指针引用获得超过4G的内存寻址空间。

如何压缩指针

由于在JVM里,对象都是以8字节对齐的即对象的大小都是8的倍数,所以不管用32位还是64位的二进制表示,末尾3位始终都是0。既然JVM已经知道了这些对象的内存地址后三位始终是0,那么这些无意义的0就没必要在堆中继续存储。相反,我们可以利用存储0的这3位bit存储一些有意义的信息,这样我们就多出3位bit的寻址空间,也就是说如果我们继续使用32位来存储指针,只不过后三位原本用来存储0的bit现在被我们用来存放有意义的地址空间信息,当寻址的时候,JVM将这32位的对象引用左移3位即可(后三位补0)。我们原本32位的内存寻址空间一下变成了35位,可寻址的内存空间变为2 ^ 35 / 1024 / 1024 / 1024 = 32G,也就是说在64位系统JVM的内存可扩大到32G了,基本上可满足大部分应用的使用了。

所以在64位系统下,通过压缩指针我们可以继续使用32位来处理(引用指针由8字节可降低到4字节),存储的时候右移3位,寻址的时候左移3位,如下图所示。

这样一来,JVM虽然额外的执行了一些位运算但是极大的提高了寻址空间,并且将对象引用占用内存大小降低了一半,节省了大量空间,况且这些位运算对于CPU来说是非常容易且轻量的操作,可谓是一举两得。

如何进一步扩大寻址空间

前边提到我们在Java虚拟机堆中对象起始地址均需要对其至8的倍数,不过这个数值我们可以通过JVM参数-XX:ObjectAlignmentInBytes 来改变(默认值为8)。当然这个数值的必须是2的次幂,数值范围需要在8 - 256之间。

正是因为对象地址对齐至8的倍数,才会多出3位bit让我们存储额外的地址信息,进而将4G的寻址空间提升至32G。

同样的道理,如果我们将ObjectAlignmentInBytes的数值设置为16呢?

对象地址均对齐至16的倍数,那么就会多出4位bit让我们存储额外的地址信息。寻址空间变为2 ^ 36 / 1024 / 1024 / 1024 = 64G。

通过以上规律,我们就能知道,在64位系统中开启压缩指针的情况,寻址范围的计算公式:4G * ObjectAlignmentInBytes = 寻址范围。

但是并不建议大家贸然这样做,因为增大了ObjectAlignmentInBytes虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。

JVM压缩指针参数

可以通过以下命令查看java命令默认的启动参数:

java -XX:+PrintCommandLineFlags -version

通过下面这个命令,可以看到所有JVM参数的默认值。

java -XX:+PrintFlagsFinal -version

关于压缩指针的两个参数:

UseCompressedClassPointers:压缩类指针

UseCompressedOops:压缩普通对象指针

Oops是Ordinary object pointers的缩写,这两个参数默认是开启的,即-XX:+UseCompressedClassPointers,-XX:+UseCompressedOops,也可手动设置,如下所示

-XX:+UseCompressedClassPointers //开启压缩类指针
-XX:-UseCompressedClassPointers //关闭压缩类指针
-XX:+UseCompressedOops  //开启压缩普通对象指针
-XX:-UseCompressedOops  //关闭压缩普通对象指针

32位HotSpot VM是不支持UseCompressedOops参数的,只有64位HotSpot VM才支持。

Oracle JDK从6 update 23开始在64位系统上会默认开启压缩指针。

实例

maven依赖

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

常用类及方法

查看对象内部信息: ClassLayout.parseInstance(obj).toPrintable()

查看对象外部信息:GraphLayout.parseInstance(obj).toPrintable()

查看对象占用空间总大小:GraphLayout.parseInstance(obj).totalSize()

查看类内部信息:ClassLayout.parseClass(Object.class).toPrintable()

JOL使用

public class ObjectLayOut1 {
    public static void main(String[] args) {
        Goods goods = new Goods();
        goods.setAge((short) 10);
        goods.setNo(123456);
        goods.setId(111L);
        goods.setGoodsName("方便面");
        goods.setFlag(true);
        goods.setB((byte)1);
        goods.setPrice(1.5d);
        goods.setProduceTime(LocalDateTime.now());
        goods.setType('A');
        goods.setWeight(0.065f);
        System.out.println(ClassLayout.parseInstance(goods).toPrintable());
    }

}

@Setter
class Goods {
    private byte b;
    private char type;
    private short age;
    private int no;
    private float weight;
    private double price;
    private long id;
    private boolean flag;
    private String goodsName;
    private LocalDateTime produceTime;
}

输出:

com.star95.study.jvm.Goods object internals:
OFF  SZ                      TYPE DESCRIPTION               VALUE
  0   8                           (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                           (object header: class)    0xf800c143
 12   4                       int Goods.no                  123456
 16   8                    double Goods.price               1.5
 24   8                      long Goods.id                  111
 32   4                     float Goods.weight              0.065
 36   2                      char Goods.type                A
 38   2                     short Goods.age                 10
 40   1                      byte Goods.b                   1
 41   1                   boolean Goods.flag                true
 42   2                           (alignment/padding gap)
 44   4          java.lang.String Goods.goodsName           (object)
 48   4   java.time.LocalDateTime Goods.produceTime         (object)
 52   4                           (object alignment gap)
Instance size: 56 bytes
Space losses: 2 bytes internal + 4 bytes external = 6 bytes total

OFF:表示偏移量

SZ:占用字节大小

TYPE DESCRIPTION:类型描述

VALUE:取值,基本类型存的就是实际值,引用类型存的是指针

以以上代码配合以下jvm参数设置,看压缩的不同结果

一、-XX:+UseCompressedOops -XX:+UseCompressedClassPointers

类指针压缩和普通对象压缩都开启,这个也是jdk1.8默认的,不加这两个参数,压缩也是生效的。

输出:

这里类指针和引用类型的属性都压缩到以4字节保存了,对象占用56个字节,对象大小计算公式:

对象大小=对象头+实例数据+(填充数据)

二、-XX:+UseCompressedOops -XX:-UseCompressedClassPointers

开启普通对象压缩,关闭类指针压缩。

输出:

类指针占8个字节,普通对象指针是4个字节,对象占用56个字节,这里我们也可以看到保存到的顺序改变了,这就是重排序,JVM会根据类型及对齐字节大小合理优化。

三、-XX:-UseCompressedOops -XX:-UseCompressedClassPointers

类指针和普通对象指针压缩都关闭。

输出:

类指针和普通对象指针都占8字节,压缩均关闭的情况下,我们看到对象大小变成了64字节。

四、-XX:-UseCompressedOops -XX:+UseCompressedClassPointers

关闭普通对象压缩,开启类指针压缩。

输出:

我们看到,类指针占8字节,对象指针占8字节,底部还有一个警告,意思是说类指针压缩依赖普通对象压缩,这种相当于关闭了类指针压缩和普通对象压缩。

  • 开启 UseCompressedOops 时,UseCompressedClassPointers 会默认自动开启,可手工设置UseCompressedClassPointers的开启或关闭。
  • 关闭 UseCompressedOops 时,UseCompressedClassPointers 也会默认自动关闭,无法设置UseCompressedClassPointers,设置了也不会生效。

结合以上代码例子,我们通过设置jvm堆内存大小,看看是否影响类指针压缩和普通对象压缩。

设置jvm参数:

-Xmx31g -Xms31g -XX:+UseCompressedOops -XX:+UseCompressedClassPointers

类指针和对象指针都被压缩到4字节了。

接着我们把堆内存加到32g试试。

-Xmx32g -Xms32g -XX:+UseCompressedOops -XX:+UseCompressedClassPointers

堆内存加到32g,虽然类指针压缩和对象压缩都开启了,但是输出的还是各占8字节,说明压缩失效了,而且最后JVM还会发出警告,意思是64位的虚拟机,对开启压缩来说堆内存太大了。

为什么呢?

我们来算一下,既然指针压缩到了4byte,也就是32bit,同样按照之前算的,用排列组合的方式可以识别2^32个对象,也就是4G个对象,刚才同样也说了,在Java中,非简单对象都是必须以8byte对齐。

因此,其能够识别的最大内存就是4G*8byte=32GB,所以内存过大,压缩自动失效了。

这也是为什么很多Java服务在运行中,官方都建议单个运行实例的内存设置不要超过32GB的根本原因。典型的如Elasticsearch,很多资料都说设置JVM大小不要超过32G,但是很少有提到为什么。

总结

  • 通过指针压缩,利用对齐填充特性,通过映射方式达到了内存地址扩展的效果
  • 指针压缩能够节省内存空间,同时提高了程序的寻址效率
  • 堆内存设置最好不要超过32GB,这时指针压缩将会失效,造成空间的浪费
  • 指针压缩不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段指针(包括引用类型的数组指针)
  • -XX:ObjectAlignmentInBytes,默认是 8,也就是 8 字节对齐