False-Sharing

在阅读VIVO的博客高性能无锁队列 Disruptor 核心原理分析及其在i主题业务中的应用的时候,了解到 Disruptor 高性能的核心原因:

  1. 空间预分配
  2. 避免伪共享
  3. 无锁

其中 伪共享 的概念之前没有了解过,故特地了解学习了下,主要涉及到一些基础的概念:

  1. CPU的分级缓存机制
  2. volatile的内存可见性
  3. Java long类型/Long类型的字节大小

众所周知,CPU在读取内存中的数据时,并不是读取的直接内存,而是从L1/L2/L3缓存中读取数据,而读取缓存也并非按1字节的读取,而是按照缓存行(通常64字节)一块一块的读取,以此来提高读取效率。

周所也周知,现代计算机都是多核CPU在运行,线程都会被分配CPU来执行,所以线程内的变量数据是需要读取到CPU Cache中才能够对CPU可见的,为了解决内存在CPU1中修改后CPU2不可见(脏读)的问题,在Java中有设计变量修饰符volatile来修饰变量,以此来实现数据在多个CPU之间不会产生脏读问题,内存在任何一个CPU上发生修改后,在其他CPU上均不可用而丢弃重新从内存中获取。

当然,也正是因为以上设计,带来了一些预期外的结果(不是问题或bug),如下代码所示:

对于下面的代码:

public class FalseSharing {
    public static void main(String[] args) throws InterruptedException {
        int num = 100000000;
        Pointer pointer1 = new Pointer();
        long start = System.currentTimeMillis();
        Thread t1 = new Thread(() -> {
            for(int i = 0; i < num; i++){
                pointer1.x++;
            }
        });
        Thread t2 = new Thread(() -> {
            for(int i = 0; i < num; i++){
                pointer1.y++;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("pointer1=" + (System.currentTimeMillis() - start));
    }
}

class Pointer {
    volatile long x;
    volatile long y;
}

上述代码在我的电脑上执行,需要3.5秒+,多次执行亦然如此,而我们只需稍作调整,改成如下代码,则只需要0.5秒左右即可执行完毕,这是为何?

public class FalseSharing {

    public static void main(String[] args) throws InterruptedException {
        int num = 100000000;
        Pointer2 pointer2 = new Pointer2();
        long start2 = System.currentTimeMillis();
        Thread t3 = new Thread(() -> {
            for(int i = 0; i < num; i++){
                pointer2.x++;
            }
        });
        Thread t4 = new Thread(() -> {
            for(int i = 0; i < num; i++){
                pointer2.y++;
            }
        });
        t3.start();
        t4.start();
        t3.join();
        t4.join();
        System.out.println("pointer2=" + (System.currentTimeMillis() - start2));
    }
}

class Pointer2 {
    volatile long x;
    // long p1, p2, p3, p4, p5, p6, p7;
    Long z1, z2; long z3;
    volatile long y;
}

以上现象对应的机制,正是因为缓存行的设计。

在上面的代码中,包含了线程t1/t2(t3和t4等同)两组线程,分别对x和y进行累加操作,看似是两个线程(CPU在分别对两块内存中的变量执行累加),但是因为CPU是以缓存行的方式读取内存,Pointer1中的x和y在内存中时相邻的两块内存,Java的基本数据类型long类型,占用8字节,所以x和y(加起来16字节)被放到同一个缓存行了,当CPU1对x做了修改后,CPU2读取到y时发现,对应缓存行已经失效了,所以不得不重新从内存中重新读取数据,从而导致了效率降低。

而我们下面给出的代码,Pointer2的x和y变量间,被塞了两组变量:

  1. long p1, p2, p3, p4, p5, p6, p7; --long类型,占用8个字节, 8*7=56
  2. Long z1, z2; long z3; --包装Long类型,根据计算机不同有所不同,在我电脑上占用24字节, 24+24+8=56
    两组变量任何一组都可以解决伪共享的问题,之所以塞两组变量,都是为了验证缓存行的存在和解决方案。

方案一直接使用7个基本数据类型,占用了56个字节,加上x变量自身,刚好占用一个完整的缓存行64字节,y只能在另一个缓存行了。
方案二使用了2组包装数据类型,1组基本数据类型,加起来依旧占用的是56个字节,依旧能将一个缓存行占满,解决缓存行失效问题。

通过对这块的学习,对基础知识又做了一个巩固,造火箭的知识又增加了。🤡

博文参考:

  1. 美团:高性能队列——Disruptor
  2. VIVO:高性能无锁队列 Disruptor 核心原理分析及其在i主题业务中的应用

实操代码:FalseSharing