早上看到一篇帖子介绍“伪共享”的内容,同时联想到JAVA虚拟机原理中介绍的关于“伪共享”的话题,记录一下自己的知识反刍。
伪共享(False Sharing)
CPU和内存之间还有个CPU缓存的概念,CPU缓存又分为L1、L2和L3三级;级别数字越小容量也越小,同时离CPU也越近访问速度会越快;L1和L2集成在CPU上,L3集成在主板上,盗个图展示如下:
CPU缓存是以缓存行(Cache line)为最小数据单位,缓存行是2的整数幂个连续字节,主流大小是64个字节。如果多个变量同属于一个缓存行,在并发环境下同时修改,因为写屏障及内存一致性协议会导致同一时间只能一个线程操作该缓存行,进而因为竞争导致性能下降,这就是“伪共享”。“伪共享”是高并发场景下一个底层细节问题。
JVM中的“伪共享”
在进行GC时需要进行可达性分析来判断实例是否需要标记回收,JVM抽象了OopMap和记忆集(remember set)两个数据结构来优化遍历GC Roots的时间成本;其中OopMap记录了普通对象指针的引用关系;记忆集则记录跨代引用关系,HotSport虚拟机中的“伪共享”就发生在记忆集上。
记忆集的一条数据记录有三种数据精度:
- 字长精度:每条记录精确到一个机器字长(处理器的寻址长度,32位或64位),字长中记录了跨代引用指针;
- 对象精度:每条记录精确到一个对象,对象中有字段保存了跨代引用指针;
- 卡页精度:每条记录精确到一个内存区域,这个内存区间用卡页来表示,卡页标识的内存区域中存在跨代引用指针(也可以理解为该区域中有且最少一个对象中的字段保存了跨代引用指针);
采用卡页精度实现的记忆集被称为“卡表”,“卡表”是最主流的记忆集实现方式。在Hotspot虚拟机中卡表的一个元素即一个卡页占用一个字节,一个缓存行中可以存储64个卡页;但是一个卡页标识了512个字节的内存区间,所以卡表的一个缓存行覆盖64*512=32KB大小的JVM内存区域;在这32KB区域内发生的跨代引用时,需要标识64个卡页中的一个卡页存在跨代引用,从而导致这个缓存行上产生“伪共享”的问题。
Hotspot虚拟机在JDK1.7后提供“+XX UseCondCardMark”参数来优化这个问题(而非解决),每次标识卡页状态时先判断该卡页是否已经被标识过存在跨代引用了,避免重复标识,进一步的降低了“伪共享”的发生频率,所以是优化而非解决。
if(card_table[this_address>>9]!=0) //2的9次幂=512
card_table[this_address>>9] = 0
高并发场景下的“伪共享”
结合概念和JVM中的“伪共享”实例,其实我们很容易想到在高并发场景下的影响。比如a和b两个变量同属一个缓存行,现有两个线下t1和t2分别要修改a和b。首先缓存行是最小数据单位,那么两个线程会存在竞争关系,缓存通过写屏障来隔离。
在复杂一点,如果t1和t2分别在两个cpu上,此时两个CPU缓存中都有一个相同的缓存行且包含a和b。在t1对a进行修改后,因为内存一致性协议,会导致t2的缓存行失效,然后必须从主存再次经过L3->L2->L1更新数据。这种高频率的反复的操作势必会拉低应用的并发性能和吞吐量,主要体现在变量的并发修改,通过锁机制我们可以保证数据的原子性和一致性,但是时间成本是可以通过对底层的理解来优化的。
如何优化“伪共享”
C和C++的内存管理特性应该需要对“伪共享”考虑的更多,JAVA语境下是一个值得掌握的并发优化细节。在JAVA中我们可以通过字节对齐和@Contended注解两种方式解决。
字节对齐(padding)
字节对齐是指在并发场景下容易产生“伪共享”的变量对象,按照缓存行的大小进行对齐。比如某个变量需要占用32个字节,然后在额外定义多个字段(比如4个long类型变量字段)来补齐整个缓存行。
字节补齐更合理的方式是在变量的前后进行补齐,且按照2倍的缓存行大小来补齐,即128个字节参考JEP-142。因为缓存行也有预加载的机制,按照前后且2倍大小补齐可以优化预加载机制的负面机制。
@Contended注解
sun.misc.Contended是jdk1.8版本中出现的注解,具体实现原理参考JEP-142。该注解可以用在字段和类上,自动帮我们进行字节补齐。1.8以上推荐使用这种方式,比我们自己实现补齐会更鲁棒,要习惯站在巨人的肩膀上。Contended注解的value可以用来定义分组,比如对象中的两个字段均用@Contended修饰,为了避免两个字段所在的缓存行临近连续,可以分别指定不同的value来隔离。
Tips: @Contended注解需要与JVM配置结合使用,-XX:-RestrictContended默认是开启的,如果注解不起作用记得检查JDK版本和JVM参数。
思维跳跃
“预加载”关键词触发联想
由预加载联想到Innodb引擎四大特性中的预读机制,分为线性预读和随机预读;线性预读发生在extent之间,由Innodb_read_ahead_threshold定义触发比例阈值;随机预读是在extent内加载剩余页,随机预读增加了不确定性,高并发场景下成本的不确定性是非常危险的,5.5以后逐渐被弃用。
“padding”关键词触发联想
由padding关键字联想到在jvm中也有对齐的使用,类的对象实例数据在内存中存储结构可以分为三部分,对象头(header)、实例数据(instance data)和填充补齐(padding)。这里的padding并不是为了缓存行的内存对齐,而是因为Hotspot虚拟机内存管理要求对象起始地址必须是8字节的整数倍,而header在设计上就满足这个要求;所以当实例数据不符合这个要求是,自动按照8字节的整数倍自动填充补齐。
参考
- [1] 真实字节二面:什么是伪共享
- [2] JEP-142: 降低特定字段的缓存竞争
- [3] JAVA8的@Contended注解