大家好,我是。之前写FastThreadLocal的时候挖坑了。咳咳,时间有点久了,不过影响不大,今天补上。下面说一下什么是虚假共享,为什么Netty在这里去掉了这个优化?话不多说,走吧!什么是虚假分享?这个词听起来有点高级,但其实很容易理解。我们都知道CPU的执行速度远快于从内存中获取数据的速度。为了缩小这个差距,研究人员不断研究并制作了缓存,但是由于进程集成的问题,这个缓存不能作为主存的介质,所以常见的CPU缓存结构如下图所示:L1、L2、L3是CPU和主存之间的高速缓存。缓存离CPU越近,访问速度越快,容量也越小。比如我笔记本的CPU:存取速度:L1>L2>L3>主存。L1和L2是单核CPU独有的。CPU访问数据时,会先在L1上查找。如果找不到,它将转到L2,然后是L3,最后是主存储器。所以,在重新计算一条数据时,要尽量保证数据在L1,这样效率才高。从上面的结构来看,有经验的同学肯定会发现上面的结构存在共享内存多线程的问题。这里介绍一下共识协议MESI。协议的具体内容这里不再展开。这里举一个简单的例子来理解:cpu1和cpu3共同访问主存中的一段数据时,会分别获取并放入各自的高速缓冲区中。该区的数据会失效,它会让cpu1刷新到主存的变化,然后再去主存加载数据,这样数据就正确了。按照图中序号顺序阅读应该不难理解。然后是重要的一点。CPUcache的单位是cacheline,也就是说CPU并不是一个一个从主存中取数据,而是一行一行的取。这一行的大小一般是64字节,所以问题就来了。比如现在有一个长数组,大小为8,刚好满足一行的大小。现在cpu1频繁更新long[0]的值,而cpu3频繁更新long[5]的值,有点麻木了。由于cacheline的机制,每次cpu1都会把整个数组加载到cache中,每次只修改long[0]也会让这一行变脏。这时cpu3访问到的long[5]就会失效,所以cpu3需要让cpu1刷新对主存的修改,然后从主存中重新获取long[5],然后进行运算。假设此时cpu1修改了long[0],那么上面的操作还得再做一次!明明修改了不同的变量,却又相互影响。在这种情况下,它被称为虚假共享!如何避免虚假共享问题?解决方法很简单粗暴,补。将内存中可能冲突的数据分开,用什么来分开呢?使用无用的数据将其分离。在关键数据前后填充无用数据(只填上图后),使得一个cacheline中只有一个有效数据,其他都是无效数据,避免了一个cacheline数据中有多个有效数据。这样不同的CPU核心修改不同的数据不会使其他数据缓存失效,避免了伪共享的问题。所以Netty中的InternalThreadLocalMap中的奇怪代码就是这样做的。不过恕我直言,可能是我水平太低,没看到这东西是给哪个变量填的。果不其然,最新版本的一个老板把它标记为过时了。我是从github上看的,老大弃用的原因如下:简单直白的翻译:我看不到padding有什么切实的好处。唯一受保护的对象可能是BitSet,但它的修改很少用long填充,这不一定会阻止JVM在对齐间隙中匹配上述对象引用。简单的说,我觉得这个填充没什么用,就弃用了,以后的版本会点进去的。所以用Netty来展示伪共享的例子是不行的(我只是填了之前写FastThreadLocal的坑)。既然填好了,我们换一个更好的例子。用代码跑一下看看我写了个例子,来看看填充和不填充的真正差距。我用两个线程循环5000万次来修改一个对象中的两个变量a和b。这两个变量很可能在同一个缓存行中,从而创建一个错误的共享场景。在未填充的情况下,花费的毫秒数为1400。然后我们再次填充变量p1-p7以分离a和b。如您所见,结果已更改为380毫秒。这样看,还真管用!说明padding真的有效!其实Java提供了一个注解@Contended,可以在指定的字段上标注,减少伪共享的发生。你可以认为Thisannotation会让JVM自动为我们填写,而不需要我们手动填写变量。不过需要注意的是,这个注解需要在启动时加上-XX:-RestrictContended参数才能生效。跑起来看效果:果然,也提高了效率!这个注解其实在其他地方也有应用,比如ConcurrentHashMap中的CounterCell,Striped64中的Cell。不过需要注意的是,没有-XX:-RestrictContended是不会生效的!最后,到这里,你肯定已经明白什么是falsesharing了,可以使用padding来避免falsesharing的问题。但是padding代表着空间的浪费,并不是所有情况下都需要padding。可能只有相邻字段更新频繁才需要考虑伪共享,其他情况不用担心。好了,今天就到这里。
