伪共享(False Sharing)

目录

一、计算机的基本结构

二、缓存行

三、伪共享

四、如何避免伪共享


缓存系统中是以缓存行(cache line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。

一、计算机的基本结构

下图是计算的基本结构。L1、L2、L3分别表示一级缓存、二级缓存、三级缓存,越靠近CPU的缓存,速度越快,容量也越小。所以L1缓存很小但很快,并且紧靠着在使用它的CPU内核;L2大一些,也慢一些,并且仍然只能被一个单独的CPU核使用;L3更大、更慢,并且被单个插槽上的所有CPU核共享;最后是主存,由全部插槽上的所有CPU核共享。

当CPU执行运算的时候,它先去L1查找所需的数据、再去L2、然后是L3,如果最后这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。所以如果你在做一些很频繁的事,你要尽量确保数据在L1缓存中。另外,线程之间共享一份数据的时候,需要一个线程把数据写回主存,而另一个线程访问主存中相应的数据。

下面是从CPU访问不同层级数据的时间概念:

从CPU到 大约需要的CPU周期 大约需要的时间
主存   约60-80ns
QPI 总线传输(between sockets, not drawn)   约20ns
L3 cache 约40-45 cycles 约15ns
L2 cache 约10 cycles 约3ns
L1 cache 约3-4 cycles 约1ns
寄存器 1cycle  

二、缓存行

Cache是由很多个cache line组成的。每个cache line通常是64字节,并且它有效地引用主内存中的一块儿地址。一个Java的long类型变量是8字节,因此在一个缓存行中可以存8个long类型的变量。

CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个cache line。

在访问一个long数组的时候,如果数组中的一个值被加载到缓存中,它会自动加载另外7个。因此能非常快的遍历这个数组。事实上,可以非常快速的遍历在连续内存块中分配的任意数据结构。

示例:

package com.thread.falsesharing;

/**
 * @Author: 98050
 * @Time: 2018-12-19 23:25
 * @Feature: cache line特性
 */
public class CacheLineEffect {

    private static long[][] result;

    public static void main(String[] args) {
        int row =1024 * 1024;
        int col = 8;
        result = new long[row][];
        for (int i = 0; i < row; i++) {
            result[i] = new long[col];
            for (int j = 0; j < col; j++) {
                result[i][j] = i+j;
            }
        }

        long start = System.currentTimeMillis();
        for (int i = 0; i < row; i++) {
            for (int j = 0; j < col; j++) {
                result[i][j] = 0;
            }
        }
        System.out.println("使用cache line特性,循环时间:" + (System.currentTimeMillis() - start));

        long start2 = System.currentTimeMillis();
        for (int i = 0; i < col; i++) {
            for (int j = 0; j < row; j++) {
                result[j][i] = 1;
            }
        }
        System.out.println("没有使用cache line特性,循环时间:" + (System.currentTimeMillis() - start2));

    }
}

结果:

三、伪共享

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

示例:

package com.thread.falsesharing;

/**
 * @Author: 98050
 * @Time: 2018-12-20 12:06
 * @Feature: 伪共享
 */
public class FalseSharing implements Runnable {

    /**
     * 线程数
     */
    public static int NUM_THREADS = 4;
    /**
     * 迭代次数
     */
    public final static long ITERATIONS = 500L * 1000L * 1000L;
    private final int arrayIndex;
    private static VolatileLong[] longs;
    public static long SUM_TIME = 0L;

    public FalseSharing(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i){
            longs[arrayIndex].value = i;
        }
    }

    public final static class VolatileLong {
        public volatile long value = 0L;
        public long p1, p2, p3, p4, p5, p6; //缓存行填充
    }

    private static void runTest() throws InterruptedException {
        Thread[] thread = new Thread[NUM_THREADS];
        for (int i = 0; i < thread.length; i++) {
            thread[i] = new Thread(new FalseSharing(i));
        }

        for (Thread t : thread){
            t.start();
        }
        for (Thread t : thread){
            t.join();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread.sleep(10000);
        for (int i = 0; i < 10; i++) {
            System.out.println(i);
            if (args.length == 1){
                NUM_THREADS = Integer.parseInt(args[0]);
            }
            longs = new VolatileLong[NUM_THREADS];
            for (int j = 0; j < longs.length; j++) {
                longs[j] = new VolatileLong();
            }
            final long start = System.nanoTime();
            runTest();
            final long end = System.nanoTime();
            SUM_TIME += end - start;
        }
        System.out.println("平均耗时:" + SUM_TIME / 10);
    }
}

四个线程修改一数组不同元素的内容。元素的类型是 VolatileLong,只有一个长整型成员 value 和 6 个没用到的长整型成员。value 设为 volatile 是为了让 value 的修改对所有线程都可见。程序分两种情况执行,第一种情况为不屏蔽缓存行填充,第二种情况为屏蔽缓存行填充。为了"保证"数据的相对可靠性,程序取 10 次执行的平均时间。执行情况如下:

屏蔽缓存行
不屏蔽缓存行

两个逻辑一模一样的程序,前者的耗时大概是后者的 2倍。那么这个时候,我们再用伪共享(False Sharing)的理论来分析一下,前者 longs 数组的 4 个元素,由于 VolatileLong 只有 1 个长整型成员,所以一个数组单元就是16个字节(long数据类型8个字节+类对象的字节码的对象头8个字节),进而整个数组都将被加载至同一缓存行(16*4字节),但有4个线程同时操作这条缓存行,于是伪共享就悄悄地发生了。

伪共享在多核编程中很容易发生,而且非常隐蔽。例如, ArrayBlockingQueue 中有三个成员变量:

  • takeIndex:需要被取走的元素下标
  • putIndex:可被元素插入的位置的下标
  • count:队列中元素的数量

这三个变量很容易放到一个缓存行中,但是之间修改没有太多的关联。所以每次修改,都会使之前缓存的数据失效,从而不能完全达到共享的效果。

ArrayBlockingQueue伪共享

如上图所示,当生产者线程put一个元素到ArrayBlockingQueue时,putIndex会修改,从而导致消费者线程的缓存中的缓存行无效,需要从主存中重新读取。线程越多,核越多,对性能产生的负面效果就越大。

四、如何避免伪共享

缓存行填充

一条缓存行有 64 字节,而 Java 程序的对象头固定占 8 字节(32位系统)或 12 字节( 64 位系统默认开启压缩, 不开压缩为 16 字节),所以只需要填 6 个无用的长整型补上6*8=48字节,让不同的 VolatileLong 对象处于不同的缓存行,就避免了伪共享( 64 位系统超过缓存行的 64 字节也无所谓,只要保证不同线程不操作同一缓存行就可以)。

Java8中已经提供了官方的解决方案,Java8中新增了一个注解:@sun.misc.Contended。加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。

猜你喜欢

转载自blog.csdn.net/lyj2018gyq/article/details/85109869