引言
在上一文CPU高速缓存行之伪共享中,我们探讨了高速缓存行的伪共享对Java程序带来的问题以及坎坷的解决之路。幸运的是,最终由官方给出了sun.misc.Contended注解结束了这场悲剧。在上文中可以发现,对于Java对象的内存布局还是比较重要的,而且Contended注解不但能够用在类上,还能用在具体的字段上,而且还能进行分组,本文将详细了解该注解。当然在这之前,有必要对Java对象的内存布局等相关知识有一定的了解。
Java对象内存布局
对于Java对象的内存布局,其实在Java内存模型JMM之六深入理解synchronized(1)一文中已经有一个大概的了解,一个Java对象在内存中的布局分为三块区域:对象头,实例数据,对齐填充。可见静态成员不参与实例对象的内存布局。
对象头
- Mark Word:包含一系列的标记位,比如轻量级锁的标记位,偏向锁标记位等等。在32位系统占4字节,在64位系统中占8字节;
- Class Pointer:用来指向对象对应的Class对象(其对应的元数据对象)的内存地址。在32位系统占4字节,在64位系统中占8字节;
- Length:如果是数组对象,还有一个保存数组长度的空间,占4个字节;
实例数据
- double (8) 和 long (8)
- int (4) 和 float (4)
- short (2) 和 char (2)
- boolean (1) 和 byte (1)
- reference (4/8),32位操作系统占4个字节,64位操作系统占8个字节。
对齐填充
Java对象占用的空间是按8字节对齐的,即所有Java对象占用字节数必须是8的倍数。例如,一个包含两个属性的对象:int和byte,这个对象在32位系统上需要占用8+4+1=13个字节,这时就需要加上大小为3字节的填充进行8字节对齐,最终占用大小为16个字节。
指针压缩
别以为了解了以上的知识就够了,对于64位系统,由于其寻址范围更宽,所以相同的程序在64位系统上运行将会占用更多的内存,为了解决这种浪费,从JDK 1.6 update14开始,64位的JVM正式支持了 -XX:+UseCompressedOops 这个可以压缩指针,起到节约内存占用的新参数。当然32位虚拟机是不支持指针压缩的。到了JDK1.8,64位的系统已经默认开启了指针压缩。有了指针压缩,对于Java对象的内存布局又的重新看待了。
当然,指针压缩也不是对所有的指针都会压缩,对一些特殊类型的指针,例如指向PermGen的Class对象指针、本地变量、堆栈元素、入参、返回值和null指针不会被压缩。一般来说它主要针对以下指针进行压缩:
- 每个Class的属性指针(静态成员变量);
- 每个对象的属性指针;
- 普通对象数组的每个元素指针。
所以,开启指针压缩之后,对象头中的Class Pointer被从8字节压缩成4字节。由此得出,开启指针压缩后,Java对象的对象头大小为:8(Mark Word)+4(Class Pointer)=12字节;数组对象对象头:8+4+4(数组长度)=16字节。开启指针压缩之后,基本类型的包装类型占用内存的大小等于对象头大小加上底层基础数据类型的大小。以下是开启指针压缩之后,相关的实例数据占用的内存大小:
- Double 和 Long:12(对象头)+8+4(对齐填充)=24
- Integer 和 Float:12+4=16
- Short 和 Character:12+2+2(对齐填充)=16
- Boolean 和 Byte: 12+1+3(对齐填充)=16
- reference:64位系统开启指针压缩后为4字节。
- 数组new int[3] 或者 new Integer[3]:16(对象头)+3*4+4(对齐填充)= 32
- 数组new Object[3] : 16(对象头)+3*4+4(对象填充)=32,引用类型8字节被压缩至4字节,所以是3*4而不是3*8
- String 类型:在JDK1.7及以上版本中,java.lang.String中包含2个属性,一个用于存放字符串数据的char[], 一个int类型的hashcode,所以内存大小:12(String对象头)+4(hashcode)+16(char[]对象头)+ n*2(char数组长度*2) + 填充
有了这些基础就能以此类推计算出复合类型的内存布局大小。
sun.misc.Contended详解
在上一文中,我们知道 sun.misc.Contended注解既可以用于类也可以用于成员属性。而且用于成员属性的时候还可以进行分组。其实对于该注解还有深层次的理解,例如https://shipilev.net/talks/jvmls-July2013-contended.pdf。Contended注解在进行缓存行填充的时候是按缓存行大小的2倍在被填充的内存数据的前后分别进行填充的。
一、Contended修饰类
@Contended public static class ContendedTest2 { private Object plainField1; private Object plainField2; private Object plainField3; private Object plainField4; }
这样的注解将使整个字段块的两端都被填充:(以下是使用 –XX:+PrintFieldLayout的输出)(翻译注:注意前面的@140表示字段在类中的地址偏移)
TestContended$ContendedTest2: field layout Entire class is marked contended @140 --- instance fields start --- @140 "plainField1" Ljava.lang.Object; @144 "plainField2" Ljava.lang.Object; @148 "plainField3" Ljava.lang.Object; @152 "plainField4" Ljava.lang.Object; @288 --- instance fields end --- @288 --- instance ends ---我来利用上面的Java对象内存布局分析一下这个结果(注意这是64位的JDK8,默认是开启指针压缩的): 对象头占用12字节,接着是2倍缓存行的大小即128字节(缓存行大小是64字节),所以第一个字段的偏移地址是12+128=140.接下来是分别占用4个字节的四个字段plainField1,plainField2,plainField3,plainField4,到最后一个字段plainField4结束,指针移动到156,后面又是一个128字节的填充,所以实例结束的位置是:156+128+4(对齐填充)=288.
所以,上例中的Contended注解导致ContendedTest2对象的内存结构如下:
12(对象头)+128(缓存行填充)+4(plainField1)+4(plainField2)+4(plainField3)+4(plainField4)+128(换存行填充)+4(对齐填充)=288
|对象头12字节|---|128字节的缓存行填充|---|plainField1:140~144|---|plainField2:144~148|---|plainField3:148~152|---|plainField4:152~156|---|128字节的缓存行填充|---|4字节的对齐填充|
其实也就是整个对象的所有字段作为一个整体,在前后进行缓存行填充,各个字段相互之间还是连续在一起的。
二、Contended修饰某个成员属性
public static class ContendedTest1 { @Contended private Object contendedField1; private Object plainField1; private Object plainField2; private Object plainField3; private Object plainField4; } //--------------- TestContended$ContendedTest1: field layout @ 12 --- instance fields start --- @ 12 "plainField1" Ljava.lang.Object; @ 16 "plainField2" Ljava.lang.Object; @ 20 "plainField3" Ljava.lang.Object; @ 24 "plainField4" Ljava.lang.Object; @156 "contendedField1" Ljava.lang.Object; (contended, group = 0) @288 --- instance fields end --- @288 --- instance ends ---由该示例可以得出, 被@sun.misc.Contended注解的变量会加到对象的最后面,没有被修饰的成员属性之间不会有任何填充,所以整个对象布局为:
12(对象头)+4(plainField1)+4(plainField2)+4(plainField3)+4(plainField4)+128(前置缓存行填充)+4(contendedField1)+128(后置缓存行填充)= 288
三、Contended修饰多个成员属性(没有指定Group,等同与指定了不同的group)
public static class ContendedTest4 { @Contended private Object contendedField1; @Contended private Object contendedField2; private Object plainField3; private Object plainField4; } //------------- TestContended$ContendedTest4: field layout @ 12 --- instance fields start --- @ 12 "plainField3" Ljava.lang.Object; @ 16 "plainField4" Ljava.lang.Object; @148 "contendedField1" Ljava.lang.Object; (contended, group = 0) @280 "contendedField2" Ljava.lang.Object; (contended, group = 0) @416 --- instance fields end --- @416 --- instance ends ---由此例得出,被 sun.misc.Contended注解修饰(没有指定group名字)的多个成员属性之间也是被缓存行填充的,该示例的对象布局为:
12(对象头)+4(plainField3)+4(plainField4)+128(缓存行前置填充)+4(contendedField1)+128(缓存行填充)+4(contendedField2)+128(缓存行后置填充)+4(对齐填充)=416
在该例中,被 Contended修饰的两个属性,都没有指定goup,这等效与各自都属于不同goup的匿名组。
四、Contended修饰多个成员属性 (指定相同的goup)
public class ContendedTest { byte a; @sun.misc.Contended("a") long b; @sun.misc.Contended("a") long c; int d; private static Unsafe UNSAFE; static { try { Field f = Unsafe.class.getDeclaredField("theUnsafe"); f.setAccessible(true); UNSAFE = (Unsafe) f.get(null); } catch (NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } } public static void main(String[] args) throws NoSuchFieldException { System.out.println("offset-a: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("a")));16 System.out.println("offset-b: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("b")));152 System.out.println("offset-c: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("c")));160 System.out.println("offset-d: " + UNSAFE.objectFieldOffset(ContendedTest.class.getDeclaredField("d")));12 } }在上例中, sun.misc.Contended修饰b,c两个属性,并且同属于一个组a(@sun.misc.Contended("a")),Contended注解括号中的group参数到底有何意义?首先了看看这个Java对象的内存布局,根据两个原则,1. 被Contended注解的变量会加到对象的最后面,2. 封装对象的时候为了高效率,对象字段声明的顺序会被重排序成占用大的排前面,占用内存小的字段放后面,所以得出该对象的内存布局为:
12(对象头)+4(int成员d)+1(byte成员a)+128(缓存行填充)+8(long型b)+8(long型c)+128(缓存行填充)+7(对齐填充)=296
由此得出,Contended修饰的同一个组的成员属性是作为一个整体进行缓存行填充的,在它们之间还是连续在一起的。那么这有什么用呢? 这只有当被分到同一组的变量之间不存在访问冲突的时候这有做才有意义,所以这适用于这几个字段是被同一个线程同时进行更新操作的,这时候就可以将它们放在一起来优化它们的内存占用,当然这种情况也可以使用Contended来注解它们使它们被分开来,只是这样做显然没有放在一起要好。
另外还有一个结论,静态成员属性不参与对象的内存布局。
五、Contended修饰多个成员属性 (不同组)
public static class ContendedTest5 { @Contended("updater1") private Object contendedField1; @Contended("updater1") private Object contendedField2; @Contended("updater2") private Object contendedField3; private Object plainField5; private Object plainField6; } //---------------------------- TestContended$ContendedTest5: field layout @ 12 --- instance fields start --- @ 12 "plainField5" Ljava.lang.Object; @ 16 "plainField6" Ljava.lang.Object; @148 "contendedField1" Ljava.lang.Object; (contended, group = 12) @152 "contendedField2" Ljava.lang.Object; (contended, group = 12) @284 "contendedField3" Ljava.lang.Object; (contended, group = 15) @416 --- instance fields end --- @416 --- instance ends ---
这样的复杂组合,其实完全可以根据前面的四种情况,轻易的得出结论,那就是相同组的成员作为一个整体进行缓存行填充,相互之间连续在一起,不同组之间依然进行缓存行填充(当然两个组之间有一个128字节的填充就足够了,没有必要两个128字节),得出内存布局为:
12(对象头)+4(plainField5)+4(plainField6)+128(缓存行填充)+4(contendedField1)+4(contendedField2)+128(缓存行填充)+4(contendedField3)+128(缓存行填充)=416
参考文献
https://www.jianshu.com/p/91e398d5d17c
https://www.cnblogs.com/Genesisx/p/7502288.html
https://www.cnblogs.com/Binhua-Liu/p/5623089.html
https://shipilev.net/talks/jvmls-July2013-contended.pdf
http://mail.openjdk.java.net/pipermail/hotspot-dev/2012-November/007309.html