做java开发的同学一般都比较熟悉JVM,那么关于指针压缩这块内容是不是也了解呢,不熟悉的小伙伴往下看吧。
首先说明,本文涉及的JDK版本是1.8,JVM虚拟机是64位的HotSpot实现为准。
了解指针压缩前,需要先搞懂java的实例对象在JVM虚拟机中内存结构是什么样的。
java对象由三部分构成:
对象头里也有三部分构成。
存储对象的hashCode、垃圾回收对象的年龄以及锁信息等。
对象指向的类信息地址即元数据指针,比如User对象指针指向User.class的JVM内存地址。注意:jdk1.8以后元数据是存在Metaspace里的,jdk1.8之前是在方法区里
只有对象是数组的情况下,才有这部分数据,若对象不是数组,则没有这部分,不分配空间。
对象里的非静态属性占用的空间(包括父类的所有属性,不区分修饰类型),不包括方法,注意:是非静态属性,属于对象的属性,静态属性是属于类的不在对象上分配空间。如果属性是基本数据类型,则直接存对象本身,如果是引用类型,则存的是对象的指针。
如果对象头+对象体大小不是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虽然能扩大寻址范围,但是这同时也可能增加了对象之间的字节填充,导致压缩指针没有达到原本节省空间的效果。
可以通过以下命令查看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位系统上会默认开启压缩指针。
<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()
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参数设置,看压缩的不同结果
类指针压缩和普通对象压缩都开启,这个也是jdk1.8默认的,不加这两个参数,压缩也是生效的。
输出:
这里类指针和引用类型的属性都压缩到以4字节保存了,对象占用56个字节,对象大小计算公式:
对象大小=对象头+实例数据+(填充数据)
开启普通对象压缩,关闭类指针压缩。
输出:
类指针占8个字节,普通对象指针是4个字节,对象占用56个字节,这里我们也可以看到保存到的顺序改变了,这就是重排序,JVM会根据类型及对齐字节大小合理优化。
类指针和普通对象指针压缩都关闭。
输出:
类指针和普通对象指针都占8字节,压缩均关闭的情况下,我们看到对象大小变成了64字节。
关闭普通对象压缩,开启类指针压缩。
输出:
我们看到,类指针占8字节,对象指针占8字节,底部还有一个警告,意思是说类指针压缩依赖普通对象压缩,这种相当于关闭了类指针压缩和普通对象压缩。
结合以上代码例子,我们通过设置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,但是很少有提到为什么。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章