xiaomitu

伪共享

什么是伪共享

​ 为了解决计算机系统中主存与CPU之间的运行速度差问题,会在CPU与主存之间添加一级或者多级高速缓冲存储器(Cache),这个Cache一般集中于CPU内部当中,所以也叫CPU Cache,图中是两级Cache结构

image

​ 在cache中,其中的每一行称为一个cache行,cache行是cache与主存进行数据交换的单位,cache行一般为2的幂次数字节。

image

​ 当CPU访问某一个变量的时候,首先会查看CPU cache中是否有该变量,如果有,则直接从缓存中拿,否则就去缓存中获取该变量。然后把该变量所在的内存区域的一个大cache行大小复制到caceh中,由于存放到cache行的是内存块而不是单个变量。所以可能会把多个变量放到一个cache行中。当多个线程同时修改一个缓存行里面的多个变量时同时只能有一个线程操作缓存行,所以相比每个变量放到一个缓存行,性能过会有所下降,这就是伪共享,如图: !image

​ 在该图中,变量X和变量Y同时被放到CPU的一级缓存和二级缓存,当线程1使用CPU1对变量X进行更新的时候,首先会修改CPU1的一级缓存X所在的缓存行,这时候在缓存一致性的协议下,CPU2中变量X对应的缓存行失效。那么线程2在写入变量X的时候就只能去二级缓存里找,这就破坏了一级缓存,而一级缓存更新比二级块,这也说明了多个线程不能同时去修改自己所使用的CPU中相同缓存行里面的数量,更坏的情况是,如果CPU中只有一级缓存,则会频繁的访问主存。

为何会出现伪共享

​ 伪共享的产生是因为多个变量同时的被放入到一个缓存行中,并且过个线程同时去写入内存缓存行中不同的变量。那么为什么多个变量会被放到一个缓存行中呢?其实是因为缓存与内存交换的单位就是缓存行。当CPU要访问的变量没有在缓存中找到的时候,根据程序运行的局部性原理,会把该变量所在内存中大小为缓存行的内存存入缓存行。

​ 当单线程下多个变量被放到同一个缓存行对性能影响吗?其实在正常情况下单线程访问时将数组元素放到一个或者多个缓存行对代码执行是有利的。因为数据都在缓存中,代码执行会更快,比较以下代码:

public class TestForContent {
    static final int LINE_NUM=1024;
    static final int COLUMN_NUM=1024;

    public static void main(String[] args) {
        long[][] array=new long[LINE_NUM][COLUMN_NUM];
        long startTime=System.currentTimeMillis();
        for (int i = 0; i < LINE_NUM; i++) {
            for (int j = 0; j < COLUMN_NUM; j++) {
                array[i][j]=i*2+j;
            }
        }

        long endTime=System.currentTimeMillis();
        System.out.println("cache time:"+(endTime-startTime));
    }
}

执行结果

cache time:9

Process finished with exit code 0
public class TestForContent {
    static final int LINE_NUM=1024;
    static final int COLUMN_NUM=1024;

    public static void main(String[] args) {
        long[][] array=new long[LINE_NUM][COLUMN_NUM];
        long startTime=System.currentTimeMillis();
        for (int i = 0; i < COLUMN_NUM; i++) {
            for (int j = 0; j < LINE_NUM; j++) {
                array[j][i]=i*2+j;
            }
        }

        long endTime=System.currentTimeMillis();
        System.out.println("not cache time:"+(endTime-startTime));
    }
}

执行结果

not cache time:14

Process finished with exit code 0

​ 经过多次测试,有缓存所需的时间要少于没有缓存执行的时间。这是因为数组内元素地址是连续的,当访问数组第一个元素的时候,就会把第一个元素后的若干元素一块放入缓存行内,这样顺序访问数字元素就会在缓存中直接命中,因为就交少了去主存中取数据的时间,后续访问也是这样,一次内存访问可以让后面访问直接在缓存命中。

​ 而没有缓存代码则是跳跃式访问数组元素,不是顺序的,这破坏了程序访问的局部性原则,并且缓存是有限容量的,当缓存满后会根据一定的淘汰算法替换缓存行,这会导致从内置过来的缓存行还没等到被读取就被替换掉了。

​ 所以在单线程下顺序修改一个缓存行中的多个变量,会充分利用程序运行的局部性原理,从而加快程序的运行,而在多线程下修改一个缓存中的多个变量时会竞争缓存行,从而降低运行性能。

如何避免伪共享

​ 在JDK8以前一般都是通过字节填充 方式来避免该问题,也就是创建变量的时候使用填充字段填充该变量所在的缓存行,这样就避免了将多个变量放到同一个缓冲行中,例如如下代码:

public final static class FilledLong{
    public volatile long value=0L;
    public long p1,p2,p3,p4,p5,p6;
}

​ 假如缓存行为64字节,那么我们在FilledLong类里面填充了6个long类型的变量,每个long类型占用8字节,加上value变量总共56字节,另外,这里FilledLong是一个类对象,而类对象的字节码的对象头占用8个字节,因此一个FilledLong对象就会占用64个字节,就刚好是放入到一个缓存行中。

JDK8提供了一个sum.mis.Contended注解,用来解决伪共享问题,将上面的代码修改如下。

@sum.mis.Contended
public final static class FilledLong{
    public volatile long value=0L;
} 

在这里注解可以修饰类,当然注解也可用于修饰变量,例如:

@sum.mis.Contented("tlr")
long threadLongLoalRandomeSeed;

​ 在默认情况下,@Contended注解只用于Java核心类,比如rt下包的类,如果用户下的类需要使用这个注解,则需要添加JVM参数:-XX:-RestrictContented。填充的字节默认宽度为128字节,如果需要手动修改则可以设置:-XX:CntendedPaddingWidth参数。

小结

​ 该部分主要讲了伪共享是怎么产生的,以及如何避免,在单线程下访问一个缓存行里面的多个变量反而会对程序运行起加速作用,但是多线程同时访问同一个缓存行里面的多个变量才会出现伪共享。

相关文章: