这个问题来自一位最近面试的字节跳动朋友。最终,他也顺利拿到了字节的offer。这个问题我想很多人可能都不知道,所以我想单独说一下。好的,让我们进入正题。什么是虚假分享?首先大家知道,随着CPU和内存的发展速度差异,CPU的速度要比内存快很多。它们之间的性能差异。在这种情况下,它非常简单。添加缓存必然会导致缓存一致性的问题。因此引入了缓存一致性协议。(不知道的建议自行百度,这里就不展开了)CPU缓存,顾名思义,离CPU越近,缓存速度越快,容量越小,而且成本越高,缓存一般可分为L1和L2、L3三级缓存,按性能划分:L1>L2>L3。实际上,数据在缓存中是按行存储的,称为缓存行。缓存行一般为2字节的整数次方,一般来说范围在32-256字节之间,最常见的缓存行大小为64字节。所以按照这种存储方式,缓存中的数据并不是存储在单个变量中,而是多个变量会放在一行中。我们常说的一个例子就是数组和链表。数组的内存地址是连续的。当我们读取数组中的元素时,CPU也会将数组中后续的元素加载到缓存中,以提高效率,而链表则没有,也就是说内存地址连续的变量可能放在一个cacheline中。当多个线程并发修改一个cacheline中的多个变量时,同一时间只有一个线程可以操作cacheline,这会导致性能下降。这个问题称为错误共享。为什么只能有一个线程运行?我们举一个实际的例子来说明这种情况:假设缓存中有两个变量x和y,并且它们已经同时在不同的三级缓存中。此时,两个线程A和B同时修改了位于Core1和Core2中的变量x和y。如果线程A修改了Core1缓存中的x变量,由于缓存一致性协议,Core2中缓存x变量的对应缓存行将失效,他将被迫从主存中重新加载该变量。在这种情况下,频繁访问主存基本上会使缓存失效,从而导致性能下降。这就是虚假分享的问题。如何避免?现在你知道什么是虚假分享了,如何避免呢?改变行存储方式?想都别想。剩下的可行方法是填充。如果这一行只有我的数据不是很好吗?确实如此。通常有两种解决方案。字节填充在JDK8之前,可以通过填充字节来避免虚假共享,如下代码所示:自定义填充一般来说,一个cacheline有64个字节,我们知道一个long就是8个字节。填充5个long后,一共是48个字节。在Java中,对象头在32位系统下占8个字节,64位系统下占16个字节,所以填充5个long类型可以占满64个字节,也就是一个cacheline。@Contented注解JDK8及以后的Java版本提供了sun.misc.Contended注解,可以通过@Contented注解解决伪共享问题。注解方法使用@Contented注解后,会增加128字节的padding,需要开启-XX:-RestrictContended选项才能生效。因此,通过以上两种方法,你会发现对象头的大小和缓存行的大小都与操作系统中的位数有关。JDK注解帮你解决这个问题,所以建议尽量使用注解来实现。虽然解决了伪共享的问题,但是这种填充方式也浪费了缓存资源。明明只有8B的大小,却使用了64B的缓存空间,造成了缓存资源的浪费。而且我们知道缓存小而贵,时间和空间的选择要自己斟酌考虑。在实践中,Java为原子变量提供了多个操作类,例如AtomicLong和AtomicInteger。变量是通过CAS更新的,但是如果失败了,就会无限自旋,造成CPU资源的浪费。为了解决高并发下的这个缺点,在JDK8中新增了一个LongAdder类,它的使用是解决falsesharing的一个实际应用。LongAdder继承自Striped64,内部维护了一个Cell数组。核心思想是拆分单个变量的竞争。如果多线程下一个Cell竞争失败,就去其他Cell用CAS重试。Striped64成员变量解决伪共享的真正核心在于Cell数组。如您所见,Cell数组使用了Contented注释。上面我们提到数组的内存地址都是连续的,所以数组中的元素往往会放在一个cacheline中,这样会造成falsesharing的问题,影响性能。这里用Contented填充,避免了伪共享的问题,使得数组中的元素不再共享一个cacheline。解决虚假分享。今天就到此为止。我是艾小娴。我还没有想出我的口号,但我们下次再见。本文转载自微信公众号“爱小仙”,可通过以下二维码关注。转载本文请联系艾小仙公众号。
