CPU伪共享

158 阅读6分钟

CPU伪共享

本篇参考了这位博主的内容,原文看参考

CPU缓存架构

首先要明白为什么会有缓存架构,我们知道CPU的运算是很快的,那么为了降低CPU与内存之间的速度差,所以有了CPU缓存。主流的CPU缓存架构是三级缓存。L1, L2, L3。缓存大小逐次变大速度逐次降低。如图:

img

缓存行

CPU缓存是由缓存行组成的,相当于缓存行是CPU缓存的最小单元,一个缓存行一般是64个字节。CPU读取数据以缓存行为单位进行读取,就算你的数据只占了1个字节那他读取的时候也会读连续的64个字节。

MESI协议(缓存一致性协议)

缓存一致性协议用于管理多个 CPU cache 之间数据的一致性。这个协议比较复杂,这里不多介绍,只要知道他是为了保证缓存一致性而存在的一种协议就行。参考链接

缓存失效

在Java中一个long型变量占用的字节数是8个,上面说到缓存行的大小一般是64,那么一个缓存行就可以存入8个long型变量。CPU读取缓存的最小单位又是缓存行,那么相当于如果这个缓存行存了几个变量就会被加载几个变量而不是说我需要缓存行中的一个就只加载一个。 那么问题就来了,我如果修改了缓存行中的其中一个变量时,那么在MESI协议下该缓存行就由共享(Shared)变为了无效(Invalid)。这是因为在多核系统中各个核心有自己的缓存,他们通过缓存一致性来保证数据一致性。当其他核心读取同一个缓存行是发现状态是invalid,这意味着数据已经过时了,这时为了保证缓存一致性,这些核心需要重新读取缓存行,从主内存中获取最新的数据然后刷到自己的缓存中。这就是缓存失效的的过程。

伪共享

好了,到这里我们知道一个缓存行中可能会存在多个数据,当核心A读取了缓存行中的a变量时,B核心又读取了该缓存行中的b变量,那么在A核心没有将数据写回该缓存行中时该缓存行都是无效的,那么本来使用缓存是为了提高效率现在反而被拖慢了,这就是伪共享。

如何解决伪共享

我们上面知道了因为缓存行的大小为64,因为里面存了多个变量,由于一个核心读取了其中的一个变量在未写回数据行时导致了缓存行失效invalid,那我们只要在变量后面添加一个padding保证他的大小为64就可以解决这个问题。当然缓存行的大小由硬件决定我们修改不了。之所以设置为64也是综合考虑袭下来的结果。

先看一个因为有伪共享而影响性能的例子:

/**
 * 伪共享演示
 *
 */
public class FalseSharingDemo {
​
    public static void main(String[] args) throws InterruptedException {
 testPointer(new Pointer());
    }
​
    private static void testPointer(Pointer pointer) throws InterruptedException {
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.a++;
            }
        }, "A");
​
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 100000000; i++) {
                pointer.b++;
            }
        },"B");
​
        t1.start();
        t2.start();
        t1.join();
        t2.join();
​
        System.out.println(System.currentTimeMillis() - start);
        System.out.println(pointer.a + "@" + Thread.currentThread().getName());
        System.out.println(pointer.b + "@" + Thread.currentThread().getName());
    }
}
​
class Pointer {
    //在一个缓存行中,如果有一个线程在读取a时,会顺带把b带出 
    volatile long a;  //需要volatile,保证线程间可见并避免重排序
//    放开下面这行,解决伪共享的问题,提高了性能
//  long p1, p2, p3, p4, p5, p6, p7;
    volatile long b;   //需要volatile,保证线程间可见并避免重排序
}

这段代码看似没有什么问题,但性能不是最优!

程序输出:

3370 //基本在3000ms多的耗时

100000000@main

100000000@main

如上面的pointer类改成:

class Pointer {
    //在一个缓存行中,先会存储a
    volatile long a;  //需要volatile,保证线程间可见并避免重排序
//    放开下面这行,解决伪共享的问题,提高了性能
    long p1, p2, p3, p4, p5, p6, p7;
    volatile long b;   //需要volatile,保证线程间可见并避免重排序
}

再运行上面的程序,输出:

1284 //基本在1000ms多的耗时,性能提高了2倍

100000000@main

100000000@main

使用消除了伪共享结构的类

如上面的程序不直接使用long类型,我们自动以一个long类型:MyLong:

class Pointer2 {
    MyLong a = new MyLong();
    MyLong b = new MyLong();
}
​
class MyLong {
    volatile long value;
    long p1, p2, p3, p4, p5, p6, p7;
}

然后在简单的改下:

private static void testPointer(Pointer2 pointer)

pointer.a++ 改成 pointer.a.value++

pointer.b++ 改成 pointer.b.value++

再次执行程序,输出:

1285 //与不消除伪共享前,性能提高2倍

com.javastack.mtc.cacheline.MyLong@1de0aca6@main

com.javastack.mtc.cacheline.MyLong@255316f2@main

使用@sun.misc.Contended 注解(java8)

修改 MyLong 如下:

@sun.misc.Contended
class MyLong {
    volatile long value;
}
​
或者:
class Pointer {
    volatile long a;
    @Contended
    volatile long b;
}

@Contended注解使用方法

@Contended 注解会增加目标实例大小,要谨慎使用。默认情况下,除了 JDK 内部的类,JVM 会忽略该注解。要应用代码支持的话,需要在jvm启动参数上设置 -XX:-RestrictContended=false,它默认为 true(意味仅限 JDK 内部的类使用)。当然,也有个 –XX: EnableContented 的配置参数,来控制开启和关闭该注解的功能,默认是 true,如果改为 false,可以减少 Thread 和 ConcurrentHashMap 类的大小。参考《Java性能权威指南》。

jdk中的ConcurrentHashMap类

@sun.misc.Contended static final class CounterCell {
volatile long value;
CounterCell(long x) { value = x; }
}

img

总结

  1. CPU具有多级缓存,越接近CPU的缓存越小也越快;
  2. CPU缓存中的数据是以缓存行为单位处理的;
  3. CPU缓存行能带来免费加载数据的好处,所以处理数组性能非常高;
  4. CPU缓存行也带来了弊端,多线程处理不相干的变量时会相互影响,也就是伪共享;
  5. 避免伪共享的主要思路就是让不相干的变量不要出现在同一个缓存行中;
  6. 一是每两个变量之间加七个 long 类型;
  7. 二是创建自己的 long 类型,而不是用原生的;
  8. 三是使用 java8 提供的注解;