伪共享

2.2.9 伪共享

2.2.9.1什么是伪共享

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

伪共享_第1张图片
image.png

Cache内部是按行存储的,其中每一行称为一个cache行,cache行是Cache与主内存进行数据交换的单位,cache行的大小一般为2的幂次数字节。


伪共享_第2张图片
image.png

当CPU访问某一个变量时候,首先会去看CPU Cache内是否有该变量,如果有则直接从中获取,否者就去主内存里面获取该变量,然后把该变量所在内存区域的一个Cache行大小的内存拷贝到Cache(cache行是Cache与主内存进行数据交换的单位)。由于存放到Cache行的的是内存块而不是单个变量,所以可能会把多个变量存放到了一个cache行。当多个线程同时修改一个缓存行里面的多个变量时候,由于同时只能有一个线程操作缓存行,所以相比每个变量放到一个缓存行性能会有所下降,这就是伪共享。

伪共享_第3张图片
image.png

如上图变量x,y同时被放到了CPU的一级和二级缓存,当线程1使用CPU1对变量x进行更新时候,首先会修改cpu1的一级缓存变量x所在缓存行,这时候缓存一致性协议会导致cpu2中变量x对应的缓存行失效,那么线程2写入变量x的时候就只能去二级缓存去查找,这就破坏了一级缓存,而一级缓存比二级缓存更快。更坏的情况下如果cpu只有一级缓存,那么会导致频繁的直接访问主内存。

2.2.9.2为何会出现伪共享

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

long a;
long b;
long c;
long d;

如上代码,声明了四个long变量,假设cache行的大小为32个字节,那么当cpu访问变量a时候发现该变量没有在cache命中,那么就会去主内存把变量a以及内存地址附近的b,c,d放入缓存行。也就是地址连续的多个变量才有可能会被放到一个缓存行中,当创建数组时候,数组里面的多个元素就会被放入到同一个缓存行。那么单线程下多个变量放入缓存行对性能有影响?其实正常情况下单线程访问时候由于数组元素被放入到了一个或者多个cache行对代码执行是有利的,因为数据都在缓存中,代码执行会更快,可以对比下面代码执行:

代码(1):

public class TestForContent {

    static final int LINE_NUM = 1024;
    static final int COLUM_NUM = 1024;
    public static void main(String[] args) {
        
        long [][] array = new long[LINE_NUM][COLUM_NUM];
        
        long startTime = System.currentTimeMillis();
        for(int i =0;ifor(int j=0;j2+j;
            }
        }
        long endTime = System.currentTimeMillis();
        long cacheTime = endTime - startTime;
        System.out.println("cache time:" + cacheTime);

    }
}

代码(2):

public class TestForContent2 {

    static final int LINE_NUM = 1024;
    static final int COLUM_NUM = 1024;
    public static void main(String[] args) {
        
        long [][] array = new long[LINE_NUM][COLUM_NUM];

        long startTime = System.currentTimeMillis();
        for(int i =0;ifor(int j=0;jarray[j][i] = i*2+j;
            }
        }
        long endTime = System.currentTimeMillis();
        
        System.out.println("no cache time:" + (endTime - startTime));

    }

}

笔者mac电脑上执行代码(1)多次耗时均在10ms一下,执行代码(2)多次耗时均在10ms以上。总结下来是说代码(1)比代码(2)执行的快,这是因为数组内数组元素之间内存地址是连续的,当访问数组第一个元素时候,会把第一个元素后续若干元素一块放入到cache行,这样顺序访问数组元素时候会在cache中直接命中,就不会去主内存读取,后续访问也是这样。总结下也就是当顺序访问数组里面元素时候,如果当前元素在cache没有命中,那么会从主内存一下子读取后续若干个元素到cache,也就是一次访问内存可以让后面多次直接在cache命中。而代码(2)是跳跃式访问数组元素的,而不是顺序的,这破坏了程序访问的局部性原理,并且cache是有容量控制的,cache满了会根据一定淘汰算法替换cache行,会导致从内存置换过来的cache行的元素还没等到读取就被替换掉了。

所以单个线程下顺序修改一个cache行中的多个变量,是充分利用了程序运行局部性原理,会加速程序的运行,而多线程下并发修改一个cache行中的多个变量而就会进行竞争cache行,降低程序运行性能。

2.2.9.3如何避免伪共享

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

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

假如Cache行为64个字节,那么我们在FilledLong类里面填充了6个long类型变量,每个long类型占用8个字节,加上value变量的8个字节总共56个字节,另外这里FilledLong是一个类对象,而类对象的字节码的对象头占用了8个字节,所以当new一个FilledLong对象时候实际会占用64个字节的内存,这个正好可以放入Cache的一个行。

在JDK8中提供了一个sun.misc.Contended注解,用来解决伪共享问题,上面代码可以修改为如下:

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

上面是修饰类的,当然也可以修饰变量,比如Thread类中的使用:

    /** The current seed for a ThreadLocalRandom */
    @sun.misc.Contended("tlr")
    long threadLocalRandomSeed;

    /** Probe hash value; nonzero if threadLocalRandomSeed initialized */
    @sun.misc.Contended("tlr")
    int threadLocalRandomProbe;

    /** Secondary seed isolated from public ThreadLocalRandom sequence */
    @sun.misc.Contended("tlr")
    int threadLocalRandomSecondarySeed;

2.2.9.1 总结

本节讲述了伪共享如何产生,以及如何避免,并证明多线程下访问同一个cache行的多个的变量时候才会出现伪共享,当单个线程访问一个cache行里面的多个变量时候反而对程序运行起到加速作用。这里为后面高级篇讲解LongAdder的实现提供了基础,敬请期待Java中高并发编程必备基础之并发包源码剖析 一书出版

欢迎关注微信公众号 技术原始积累


你可能感兴趣的:(并发,并发编程)