并发编程-伪共享

并发编程-伪共享

在学习什么是伪共享的前提下,我们先来了解一下计算机系统中的一些知识点
  • CPU Cache(CPU 缓存)

    为了解决计算机系统中主内存和CPU之间运行速度的差距问题,在CPU和主内存之间添加了一级或者多级高速缓冲存储器(Cache),目前主流的大多数CPU都带有三级缓存(L1/L2/L3)。它们被集成到CPU内部,简称CPU Cache。(我个人的理解:就是我只和那些和我差不多优秀的人在一起玩,CPU和L1进行数据交互,L1和L2数据交互,L3和主内存数据交互,这样就能减少由于CPU和主内存的巨大运行速率落差带来的性能影响)

    存储器存储的空间大小:主内存>L3>L2>L1>寄存器

    存储器速度快慢排序:寄存器>L1>L2>L3>主内存

  • Cache Line(缓存行)

    CPU Cache中的数据是按行存储的,每一行为一个Cache Line(缓存行)。它是CPU Cache和主内存进行数据交换通信的单位,Cache Line 的大小一般为2的幂次数字节。目前主流的CPU的Cache Line都是64Bytes。

  • CPU读取存储器数据的过程(存储器包含寄存器/L1/L2/L3/主内存)

    1. CPU读取寄存器中X的值(假设寄存器中存在X),只需要一步,直接读取即可。
    2. CPU读取L1 Cache的某个值,需要1-3步(可能需要更多步),把cache line锁住,把某个数据读取,然后解锁,如果没锁住就等待。
    3. CPU读取L2 Cache的某个值,先会到L1 Cache中获取,L1中不存在,如果数据在L2中,L2加锁,把L2中的数据复制到L1中,再执行读取L1中复制的数据,然后再解锁。
    4. CPU读取L3 Cache也是和L2的一样,只不过先从L3复制到L2,从L2复制到L1,从L1到CPU。
    5. CPU读取主内存:通知内存控制器占用bus总线带宽,通知内存加锁,发起内存读请求,内存响应数据给L3,再从L2->L1,再从L1->CPU,然后解锁总线锁定,解锁内存。
什么是伪共享?

​ CPU访问X变量,先从Cache中获取,如果有直接返回,否则从主内存中获取,然后把X所在内存区域的一个缓存行大小的内存复制到Cache中(根据空间局部性原理)。但是由于cache line中的数据可能有多个变量值。当多个线程同时修改同一cache line中的多个变量时,由于只有一个线程能操作cache line,其他线程会等待,这就造成了性能的下降,这就是伪共享。

并发编程-伪共享_第1张图片

​ 如上图:变量x,y在同一个cache line中,被同时放到了CPU的L1、L2、L3中,thread1对变量x进行更新时,会先修改CPU1的L1中的变量x所在的cache line,在缓存一致性协议(MESI协议)下,CPU2中变量x对应的cache line会失效,会破坏CPU2中的缓存,CPU2发出读取变量x的指令并且通知CPU1将修改后的数据同步到主内存,然后CPU2再去同步新的变量x的值。

伪共享导致性能下降的案例
public class Test {
    static final int LINE_NUM = 1024 * (1 << 4);
    static final int COLUMN_NAM = 1024 * (1 << 4);

    public static void main(String[] args) {
        // 先从左到右,再从上到下插入值
        testOne();
        // 先从上到下,再从左到右插入值
        testTwo();
    }

    public static void testOne() {
        long[][] arr = new long[LINE_NUM][COLUMN_NAM];
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < LINE_NUM; i++) {
            for (int j = 0; j < COLUMN_NAM; j++) {
                arr[i][j] = i * 2 + j;
            }
        }
        long endTime = System.currentTimeMillis();
        System.out.println("testOne cost time: " + (endTime - startTime));
    }

    public static void testTwo() {
        long[][] arr = new long[LINE_NUM][COLUMN_NAM];
        long startTime = System.currentTimeMillis();
        for (int i = 0; i < LINE_NUM; i++) {
            for (int j = 0; j < COLUMN_NAM; j++) {
                arr[j][i] = i * 2 + j;
            }
        }
        long endTime = System.currentTimeMillis();
        System.out.println("testTwo cost time: " + (endTime - startTime));
    }
}

控制台输出如下:

testOne cost time: 306
testTwo cost time: 6517

分析前提:地址连续的多个变量才有可能会被放到一个cache line中。

分析:testOne比testTwo方法执行的要快,这是因为testOne方法中数组元素的内存地址是连续的,当访问数组第一个元素时,会把该元素后的若干元素一块放到cache line中,这样顺序访问数组元素会直接在cache中直接命中,因此不再会从主内存中读取。而testTwo方法中是跳跃式访问数组元素的,不是顺序的,破坏了程序的空间局部性原则。因为cache是有容量大小的,cache中缓存的数据会被不断刷新替换掉成新的数据,存在可能还没被读取就被替换掉了的可能。

如何避免伪共享?
  • JDK8以前:

    • 字节填充:在创建一个变量时使用字节填充该变量所在的cache line,这样就能避免多个变量放在同一cache line导致的缓存失效问题。

      public class FilledLong {
          public volatile long value = 0L;
          public long l1,l2,l3,l4,l5,l6;
      }
      

通常cache line为64字节,我们知道java中long类型为8个字节,在我们定义的long类型value变量后,填充6个long类型的变量,再加上创建对象的对象头所占用的8字节,所以创建一个FilledLong对象实际会占用64字节,刚好可以放入一个cache line中,就可以解决伪共享问题。

  • JDK8之后:

    • sun.misc.Contended注解:可以解决伪共享

      @Contended
      public class FilledLong {
          public volatile long value = 0L;
      }
      

      该注解也可以用来修饰变量,在Thread类中就有体现:

      这些变量是在使用ThreadLocalRandom时体现的,这里不再过多描述。

          // The following three initially uninitialized fields are exclusively
          // managed by class java.util.concurrent.ThreadLocalRandom. These
          // fields are used to build the high-performance PRNGs in the
          // concurrent code, and we can not risk accidental false sharing.
          // Hence, the fields are isolated with @Contended.
      
          /** 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;
      

    **注意点:**默认是情况下,@Contended注解只能用于Java核心类,比如rt包下的类。

    如果在用户类路径下使用该注解,需要添加JVM参数:-XX:-RestrictContended。默认填充的宽度:128,要自定义宽度可以设置:-XX:ContendedPaddingWidth参数

你可能感兴趣的:(JUC,java,多线程,并发编程)