聊聊CPU缓存、伪共享与缓存行填充

前言

2019年的最后一篇技术文了(明天写总结),找个细碎的话题随便聊聊吧。本文的知识点在之前的文章中隐蔽地出现过。

CPU缓存简介

CPU高速缓存(cache)是现代中央处理器的必备部件。它位于CPU package内部,在金字塔形存储体系中,距离计算单元的距离是各存储部件中第二近的,存取速度也是第二快的,仅次于寄存器。

CPU在读内存时,会先尝试从缓存读取,如果缓存未命中,再从内存读取目标数据与其相邻的数据,并放入缓存。CPU在写内存时,也会首先写缓存,然后再批量将缓存数据写回内存。根据局部性原理:

时间局部性(temporal locality):如果一条数据正在被访问,那么在不久的将来它很可能再次被访问。
空间局部性(spatial locality):如果一条数据正在被访问,那么在不久的将来与它相邻的数据有可能也被访问。

所以缓存是加速CPU访存的利器。

不同的处理器微架构可以有不同的缓存方案。Intel/AMD的桌面级处理器采用的缓存都是3级的,分别称为L1、L2和L3缓存。从I家的Skylake PPT上抄一张图来描述缓存层级结构吧。

可见,L1和L2缓存是每个核心都有的,并且L1缓存还分为数据(D)缓存和指令(I)缓存,L3缓存则是由所有核心共享的。MC和DDR则分别表示内存控制器与内存模组。

以笔者自用MBP中的Intel Core i9-9880H处理器(8核16线程)为例,用MacCPUID软件查看缓存信息如下,其中的Size和Max Threads属性都是与上文的框图相符的。

Assoc(associative)属性是缓存与内存之间的组相连映射关系,这不是本文要讨论的,不再赘述。Line Size属性则表示一个缓存行的大小,目前基本都为64个字节。缓存行是CPU缓存组织的最小单位,它比较重要,请务必牢记。

伪共享与缓存行填充

用Java来举例,考虑如下代码(availableProcessors除以2是为了减去超线程的影响)。

class VolatileLong {
  volatile long value;
  // long padding1, padding2, padding3, padding4, padding5;
}

class VolatileLongThread extends Thread {
  private VolatileLong[] shares;
  private int index;

  VolatileLongThread(VolatileLong[] shares, int index) {
    this.shares = shares;
    this.index = index;
  }

  @Override
  public void run() {
    for (int i = 0; i < 1000000; i++) {
      shares[index].value++;
    }
  }
}

public class FalseSharingExample {
  public static void main(String[] args) throws Exception {
    int processorsNum = Runtime.getRuntime().availableProcessors() / 2;
    System.out.println("Processor num: " + processorsNum);

    for (int i = 0; i < 10; i++) {
      VolatileLong[] shares = new VolatileLong[processorsNum];
      for (int j = 0; j < processorsNum; j++) {
        shares[j] = new VolatileLong();
      }

      Thread[] threads = new Thread[processorsNum];
      for (int j = 0; j < processorsNum; j++) {
        threads[j] = new VolatileLongThread(shares, j);
      }

      for (Thread t : threads) {
        t.start();
      }

      long start = System.nanoTime();
      for (Thread t : threads) {
        t.join();
      }
      long end = System.nanoTime();
      System.out.println((i + 1) + " loop costs " + (end - start) + " ns");
    }
  }
}

执行结果如下。

Processor num: 8
1 loop costs 132287965 ns
2 loop costs 142371998 ns
3 loop costs 115655744 ns
4 loop costs 120651836 ns
5 loop costs 122757797 ns
6 loop costs 121669394 ns
7 loop costs 110498861 ns
8 loop costs 123886526 ns
9 loop costs 123184849 ns
10 loop costs 120447527 ns

然后我们把注释掉的那行代码取消注释,重新运行,结果如下。

Processor num: 8
1 loop costs 62166652 ns
2 loop costs 62525585 ns
3 loop costs 58595315 ns
4 loop costs 41346343 ns
5 loop costs 9786560 ns
6 loop costs 9768927 ns
7 loop costs 7786844 ns
8 loop costs 46012633 ns
9 loop costs 47246131 ns
10 loop costs 52160379 ns

可见,执行效率有了明显的提升,最高可以达到之前的12倍多。到底是为什么呢?上文已经说过,CPU缓存行是64B大的。在64位JVM下,对取消注释之前的VolatileLong对象而言,它占用的空间大小为(关闭OOP压缩,即-XX:-UseCompressedOops):

  • 对象头mark word,8B;
  • 对象头klass word,8B;
  • volatile long value,8B。一共为24B。

也就是说,一个缓存行可以存储不止一个VolatileLong对象实例。而我们使用数组来维护VolatileLong,故它们在内存中是连续存储的。由于该类内的value成员已经使用volatile关键字来修饰,故CPU要保证它的修改对所有线程都立即可见,value的自增值会直接回写到内存,并将对应的缓存行置为失效(即MESI一致性协议中的I状态)。

发现什么问题了吗?在这种情况下,如果两个CPU核心对位于同一缓存行内的VolatileLong.value进行操作,那么它们会对这一行产生竞争,该缓存就会频繁失效,失去了作为缓存的作用,这就是伪共享(false sharing)。英文维基给出的定义如下:

In computer science, false sharing is a performance-degrading usage pattern that can arise in systems with distributed, coherent caches at the size of the smallest resource block managed by the caching mechanism. When a system participant attempts to periodically access data that will never be altered by another party, but those data share a cache block with data that are altered, the caching protocol may force the first participant to reload the whole unit despite a lack of logical necessity. The caching system is unaware of activity within this block and forces the first participant to bear the caching system overhead required by true shared access of a resource.

伪共享的产生如下图所示。

伪共享在SMP系统中是隐形的性能杀手。解决伪共享问题的最常用方法就是缓存行填充(cache line padding)。如果我们在上述类中加上padding1~padding5五个long字段,一个VolatileLong对象实例就会占用24 + 5 * 8 = 64B,刚好是一个缓存行的大小,消灭了竞争,效率自然就提高了。

当然,由于padding1~padding5并没有实际的含义,所以缓存行的利用率会降低,只有原来的3 / 8,这就是典型的用空间换时间的思路。

伪共享与缓存行填充的例子在之前已经提到过,看官可以参见《再谈JVM里的记忆集合》《浅谈Java的伪随机数发生器和线性同余法》两篇文章。在后者还出现了缓存行填充的另一种方式,即JDK8中新增的@sun.misc.Contended注解。为了稳妥,它支持对注解字段进行分组,并会在被注解的对象或字段前后都各加上128B的padding,详情请参见这里

The End

写得实在是有些潦草。民那晚安。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,236评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,867评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,715评论 0 340
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,899评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,895评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,733评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,085评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,722评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,025评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,696评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,816评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,447评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,057评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,254评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,204评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,561评论 2 343