写点什么

并发程序的隐藏杀手——假共享(False Sharing)

  • 2022 年 8 月 04 日
  • 本文字数:5553 字

    阅读完需:约 18 分钟

并发程序的隐藏杀手——假共享(False Sharing)

前言

前段时间在各种社交平台“雪糕刺客”这个词比较火,简单的来说就是雪糕的价格非常高!其实在并发程序当中也有一个刺客,如果在写并发程序的时候不注意不小心,这个刺客很可能会拖累我们的并发程序,让我们并发程序执行的效率变低,让并发程序付出很大的代价,这和“雪糕刺客”当中的“刺客”的含义是一致的。这个并发程序当中的刺客就是——假共享(False Sharing)。

假共享(False Sharing)

缓存行

当 CPU 从更慢级别的缓存读取数据的时候(三级 Cache 会从内存当中读取数据,二级缓存会从三级缓存当中读取数据,一级缓存会从二级缓存当中读取数据,缓存级别越低执行速度越快),CPU 并不是一个字节一个字节的读取的,而是一次会读取一块数据,然后将这个数据缓存到 CPU 当中,而这一块数据就叫做缓存行

有一种缓存行的大小就是 64 字节,那么我们为什么会做这种优化呢?这是因为局部性原理,所谓局部性原理简单说来就是,当时使用一个数据的时候,它附近的数据在未来的一段时间你也很可能用到,比如说我们遍历数组,我们通常从前往后进行遍历,比如我们数组当中的数据大小是 8 个字节,如果我们的缓存行是 64 个字节的话,那么一个缓存行就可以缓存 8 个数据,那么我们在遍历第一个数据的时候将这 8 个数据加载进入缓存行,那么我们在遍历未来 7 个数据的时候都不需要再从内存当中拿数据,直接从缓存当中拿就行,这就可以节约程序执行的时间。

假共享

当两个线程在 CPU 上两个不同的核心上执行代码的时候,如果这两个线程使用了同一个缓存行 C,而且对这个缓存行当中两个不同的变量进行写操作,比如线程 A 对变量 a 进行写操作,线程 B 对变量 b 进行写操作。而由于缓存一致性(Cache coherence)协议的存在,如果其中 A 线程对缓存行 C 中变量 a 进行了写操作的话,为了保证各个 CPU 核心的数据一致(也就是说两个 CPU 核心看到了 a 的值是一样的,因为 a 的值已经发生变化了,需要让另外的 CPU 核心知道,不然另外的 CPU 核心使用的就是旧的值,那么程序结果就不对了),其他核心的这个缓存行就会失效,如果他还想使用这个缓存行的话就需要重新三级 Cache 加载,如果数据不存在三级 Cache 当中的话,就会从内存当中加载,而这个重新加载的过程就会很拖累程序的执行效率,而事实上线程 A 写的是变量 a,线程 B 写的是变量 b,他们并没有真正的有共享的数据,只是他们需要的数据在同一个缓存行当中,因此称这种现象叫做假共享(False Sharing)

上面我们谈到了,当缓存行失效的时候会从三级 Cache 或者内存当中加载,而多个不同的 CPU 核心是共享三级 Cache 的(上图当中已经显示出来了),其中一个 CPU 核心更新了数据,会把数据刷新到三级 Cache 或者内存当中,因此这个时候其他的 CPU 核心去加载数据的时候就是新值了。

上面谈到的关于 CPU 的缓存一致性(Cache coherence)的内容还是比较少的,如果你想深入了解缓存一致性(Cache coherence)和缓存一致性协议可以仔细去看这篇文章。

我们再来举一个更加具体的例子:

假设在内存当中,变量 a 和变量 b 都占四个字节,而且他们的内存地址是连续且相邻的,现在有两个线程 A 和 B,线程 A 要不断的对变量 a 进行+1 操作,线程 B 需要不断的对变量进行+1 操作,现在这个两个数据所在的缓存行已经被缓存到三级缓存了

  • 线程 A 从三级缓存当中将数据加载到二级缓存和一级缓存然后在 CPU- Core0 当中执行代码,线程 B 从三级缓存将数据加载到二级缓存和一级缓存然后在 CPU- Core1 当中执行代码。

  • 线程 A 不断的执行 a += 1,因为线程 B 缓存的缓存行当中包含数据 a,线程 A 在修改 a 的值之后,就会在总线上发送消息,让其他处理器当中含有变量 a 的缓存行失效,在处理器将缓存行失效之后,就会在总线上发送消息,表示缓存行已经失效,线程 A 所在的 CPU- Core0 收到消息之后将更新后的数据刷新到三级 Cache。

  • 这个时候线程 B 所在的 CPU-Core1 当中含有 a 的缓存行已经失效,因为变量 b 和变量 a 在同一个缓存行,现在线程 B 想对变量 b 进行加一操作,但是在一级和二级缓存当中已经没有了,它需要三级缓存当中加载这个缓存行,如果三级缓存当中没有就需要去内存当中加载。

  • 仔细分析上面的过程你就会发现线程 B 并没有对变量 a 有什么操作,但是它需要的缓存行就失效了,虽然和线程 B 共享需要同一个内容的缓存行,但是他们之间并没有真正共享数据,所以这种现象叫做假共享。

Java 代码复现假共享

复现假共享

下面是两个线程不断对两个变量执行++操作的代码:

class Data {  public volatile long a;  public volatile long b;} public class FalseSharing {  public static void main(String[] args) throws InterruptedException {    Data data = new Data();    long start = System.currentTimeMillis();    Thread A = new Thread(() -> {      for (int i = 0;  i < 500_000_000; i++) {        data.a += 1;      }    }, "A");     Thread B = new Thread(() -> {      for (int i = 0;  i < 500_000_000; i++) {        data.b += 1;      }    }, "B");    A.start();    B.start();    A.join();    B.join();    long end = System.currentTimeMillis();    System.out.println("花费时间为:" + (end - start));    System.out.println(data.a);    System.out.println(data.b);  }}
复制代码

上面的代码比较简单,这里就不进行说明了,上面的代码在我的笔记本上的执行时间大约是 17 秒

上面的代码变量 a 和变量 b 在内存当中的位置是相邻的,他们在被 CPU 加载之后会在同一个缓存行当中,因此会存在假共享的问题,程序的执行时间会变长。

下面的代码是优化过后的代码,在变量 a 前面和后面分别加入 56 个字节的数据,再加上 a 的 8 个字节(long 类型是 8 个字节),这样 a 前后加上 a 的数据有 64 个字节,而现在主流的缓存行是 64 个字节,够一个缓存行的大小,因为数据 a 和数据 b 就不会在同一个缓存行当中,因此就不会存在假共享的问题了。而下面的代码在我笔记本当中执行的时间大约为 5 秒。这就足以看出假共享会对程序的执行带来多大影响了。

class Data {  public volatile long a1, a2, a3, a4, a5, a6, a7;  public volatile long a;  public volatile long b1, b2, b3, b4, b5, b6, b7;  public volatile long b;} public class FalseSharing {  public static void main(String[] args) throws InterruptedException {    Data data = new Data();    long start = System.currentTimeMillis();    Thread A = new Thread(() -> {      for (int i = 0;  i < 500_000_000; i++) {        data.a += 1;      }    }, "A");     Thread B = new Thread(() -> {      for (int i = 0;  i < 500_000_000; i++) {        data.b += 1;      }    }, "B");    A.start();    B.start();    A.join();    B.join();    long end = System.currentTimeMillis();    System.out.println("花费时间为:" + (end - start));    System.out.println(data.a);    System.out.println(data.b);  }}
复制代码

JDK 解决假共享

为了解决假共享的问题,JDK 为我们提供了一个注解 @Contened 解决假共享的问题。

import sun.misc.Contended; class Data {//  public volatile long a1, a2, a3, a4, a5, a6, a7;  @Contended  public volatile long a;//  public volatile long b1, b2, b3, b4, b5, b6, b7;  @Contended  public volatile long b;} public class FalseSharing {  public static void main(String[] args) throws InterruptedException {    Data data = new Data();     long start = System.currentTimeMillis();    Thread A = new Thread(() -> {      for (long i = 0;  i < 500_000_000; i++) {        data.a += 1;      }    }, "A");     Thread B = new Thread(() -> {      for (long i = 0;  i < 500_000_000; i++) {        data.b += 1;      }    }, "B");    A.start();    B.start();    A.join();    B.join();    long end = System.currentTimeMillis();    System.out.println("花费时间为:" + (end - start));    System.out.println(data.a);    System.out.println(data.b);  }} 
复制代码

上面代码的执行时间也是 5 秒左右,和之前我们自己在变量的左右两边插入变量的效果是一样的,但是 JDK 提供的这个接口和我们自己实现的还是有所区别的。(注意:上面的代码是在 JDK1.8 下执行的,如果要想 @Contended 注解生效,你还需要在 JVM 参数上加入-XX:-RestrictContended,这样上面的代码才能生效否则是不能够生效的)

  • 在我们自己解决假共享的代码当中,是在变量 a 的左右两边加入 56 个字节的其他变量,让他和变量 b 不在同一个缓存行当中。

  • 在 JDK 给我们提供的注解 @Contended,是在被加注解的字段的右边加入一定数量的空字节,默认加入 128 空字节,那么变量 a 和变量 b 之间的内存地址大一点,最终不在同一个缓存行当中。这个字节数量可以使用 JVM 参数-XX:ContendedPaddingWidth=64,进行控制,比如这个是 64 个字节。

  • 除此之外 @Contended 注解还能够将变量进行分组:

class Data {  @Contended("a")  public volatile long a;    @Contended("bc")  public volatile long b;  @Contended("bc")  public volatile long c;}
复制代码

在解析注解的时候会让同一组的变量在内存当中的位置相邻,不同的组之间会有一定数量的空字节,配置方式还是跟上面一样,默认每组之间空字节的数量为 128。

比如上面的变量在内存当中的逻辑布局详细布局如下:


OFFSET SIZE TYPE DESCRIPTION VALUE 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 8 4 (object header) 20 0a 06 00 (00100000 00001010 00000110 00000000) (395808) 12 132 (alignment/padding gap) 144 8 long Data.a 0 152 128 (alignment/padding gap) 280 8 long Data.b 0 288 8 long Data.c 0 296 128 (loss due to the next object alignment)Instance size: 424 bytesSpace losses: 260 bytes internal + 128 bytes external = 388 bytes total
复制代码

上面的内容是通过下面代码打印的,你只要在 pom 文件当中引入包 jol 即可:

import org.openjdk.jol.info.ClassLayout;import sun.misc.Contended;  class Data {  @Contended("a")  public volatile long a;    @Contended("bc")  public volatile long b;  @Contended("bc")  public volatile long c;} public class FalseSharing {  public static void main(String[] args) throws InterruptedException {    Data data = new Data();     System.out.println(ClassLayout.parseInstance(data).toPrintable());  }}
复制代码

从更低层次 C 语言看假共享

前面我们是使用 Java 语言去验证假共享,在本小节当中我们通过一个 C 语言的多线程程序(使用 pthread)去验证假共享。(下面的代码在类 Unix 系统都可以执行)

#include <stdio.h>#include <pthread.h>#include <time.h> #define CHOOSE // 这里定义了 CHOOSE 如果不想定义CHOOSE 则将这一行注释掉即可 // 定义一个全局变量int data[1000]; void* add(void* flag) {  // 这个函数的作用就是不断的往 data 当中的某个数据进行加一操作  int idx = *((int *)flag);  for (long i = 0; i < 10000000000; ++i) {    data[idx]++;  }} int main() {  pthread_t a, b;#ifdef CHOOSE // 如果定义了 CHOOSE 则执行下面的代码 让两个线程操作的变量隔得远一点 让他们不在同一个缓存行当中  int flag_a = 0; int flag_b = 100; printf("远离\n");#else // 如果没有定义 让他们隔得近一点 也就是说让他们在同一个缓存行当中  int flag_a = 0; int flag_b = 1; printf("临近\n");#endif pthread_create(&a, NULL, add, &flag_a); // 创建线程a 执行函数 add 传递参数 flag_a 并且启动  pthread_create(&b, NULL, add, &flag_b); // 创建线程b 执行函数 add 传递参数 flag_b 并且启动  long start = time(NULL); pthread_join(a, NULL); // 主线程等待线程a执行完成  pthread_join(b, NULL); // 主线程等待线程b执行完成  long end = time(NULL); printf("data[0] = %d\t data[1] = %d\n", data[0], data[1]);  printf("cost time = %ld\n", (end - start));  return 0;}
复制代码

上面代码的输出结果如下图所示:

我们首先来解释一下上面 time 命令的输出:

  • readl:这个表示真实世界当中的墙钟时间,就是表示这个程序执行所花费的时间,这个秒单位和我们平常说的秒是一样的。

  • user:这个表示程序在用户态执行的 CPU 时间,CPU 时间和真实时间是不一样的,这里需要注意区分,这里的秒和我们平常的秒是不一样的。

  • sys:这个表示程序在内核态执行所花费的 CPU 时间。

从上面程序的输出结果我们可以很明显的看出来当操作的两个整型变量相隔距离远的时候,也就是不在同一个缓存行的时候,程序执行的速度是比数据隔得近在同一个缓存行的时候快得多,这也从侧面显示了假共享很大程度的降低了程序执行的效率。

总结

在本篇文章当中主要讨论了以下内容:

  • 当多个线程操作同一个缓存行当中的多个不同的变量时,虽然他们事实上没有对数据进行共享,但是他们对同一个缓存行当中的数据进行修改,而由于缓存一致性协议的存在会导致程序执行的效率降低,这种现象叫做假共享

  • 在 Java 程序当中我们如果想让多个变量不在同一个缓存行当中的话,我们可以在变量的旁边通过增加其他变量的方式让多个不同的变量不在同一个缓存行。

  • JDK 也为我们提供了 Contended 注解可以在字段的后面通过增加空字节的方式让多个数据不在同一个缓存行,而且你需要在 JVM 参数当中加入-XX:-RestrictContended,同时你可以通过 JVM 参数-XX:ContendedPaddingWidth=64 调整空字节的数目。JDK8 之后注解 Contended 在 JDK 当中的位置有所变化,大家可以查询一下。

  • 我们也是用了 C 语言的 API 去测试了假共享,事实上在 Java 虚拟机当中底层的线程也是通过调用 pthread_create 进行创建的。

用户头像

不定期更新Java开发工具及Java面试干货技巧 2021.12.12 加入

Java后端工程师,十年大厂经验。具有扎实的Java、JEE基础知识。熟悉Spring、SpringMVC、Struts MyBatisHibernate等JEE常用框架。

评论

发布
暂无评论
并发程序的隐藏杀手——假共享(False Sharing)_编程_了不起的程序猿_InfoQ写作社区