CPU Cache 基础解析

文末含分享内容视频连接

CPU Cache 基础

最近看了一些 CPU 缓存相关的东西,在这里作一下记录。html

Wiki 词条:CPU cache - Wikipediajava

一些基本概念

CPU 缓存出现的缘由

  1. 主存通常是 DRAM,CPU 速度比主存快不少倍,没有缓存存在时 CPU 性能很大程度取决于读取存储数据的能力
  2. 比 DRAM 快的存储介质是存在的,好比做为 CPU 高速缓存的 SRAM,只是很贵,作很大的 SRAM 不经济
  3. CPU 访问数据存在时间局部性和空间局部性,因此能够将 CPU 须要频繁访问的少许热数据放在速度快但很贵的 SRAM 中,既能大幅度改善 CPU 性能也不会让成本提高太多

Cache Line

CPU 每次访问数据时先在缓存中查找一次,找不到则去主存找,访问完数据后会将数据存入缓存,以备后用。这就产生了一个问题,CPU 在访问某个地址的时候如何知道目标数据是在缓存中存在?如何知道缓存的数据是否还有效没被修改?不能为每一个存入缓存的字节都打标记,因此 CPU 缓存会划分为固定大小的 Block 称为 Cache Line,做为存取数据的最小单位。大小都为 2 的整数幂,好比 16 字节,256 字节等。这样一个 cache line 这一整块内存能经过一个标记来记录是否在内存中,是否还有效,是否被修改等。一次存取一块数据也能充分利用总线带宽以及 CPU 访问的空间局部性。linux

Cache Write Policy

Cache 不光是在读取数据时有用,目前大部分 CPU 在写入数据时也会先写 Cache。一方面是由于新存数据极可能会被再次使用,新写数据先写 Cache 能提升缓存命中率;另外一方面 CPU 写 Cache 速度更快,从而写完以后 CPU 能够去干别的事情,能提升性能。git

CPU 写数据若是 Cache 命中了,则为了保持 Cache 和主存一致有两种策略。若是 CPU 写 Cache 每次都要更新主存,则称为 Write-Through ,由于每次写 Cache 都伴随主存更新因此性能差,实际使用的也少;写 Cache 以后并不当即写主存而是等待一段时间能积累一些改动后再更新主存的策略称为 Write-Back ,性能更好但为了保证写入的数据不丢使机制更加复杂。采用 Write Back 方式被修改的内存在从 Cache 移出(好比 Cache 不够须要腾点空间)时,若是被修改的 Cache Line 还未写入主存须要在被移出 Cache 时更新主存,为了能分辨出哪些 Cache 是被修改过哪些没有,又须要增长一个新的标志位在 Cache Line 中去标识。程序员

CPU 写数据若是 Cache 未命中,则只能直接去更新主存。但更新完主存后又有两个选择,将刚修改的数据存入 Cache 仍是不存。每次直接修改完主存都将主存被修改数据所在 Cache Line 存入 Cache 叫作 Write-Allocate 。须要注意的是 Cache 存取的最小单位是 Cache Line。即便 CPU 只写一个字节,也须要将被修改字节所在附近 Cache Line 大小的一块内存完整的读入 Cache。若是 CPU 写主存的数据超过一个 Cache Line 大小,则不用再读主存原来内容,直接将新修改数据写入 Cache。至关于彻底覆盖主存以前的数据。github

写数据时除了须要考虑上述写 Cache 策略外,还须要保持各 CPU Cache 之间的一致性。好比一个 CPU 要向某个内存地址写数据,它须要通知其它全部 CPU 本身要写这个地址,若是其它 CPU 的 Cache Line 内有缓存这个地址的话,须要将这个 Cache Line 设置为 Invalidate。这样写数据的那个 CPU 就能安全的写数据了。下一次其它 CPU 要读这个内存地址时,发现这个 Cache Line 是 Invalidate 状态,因此须要从新从内存作加载,即发生一次 Communication Miss。这种 Miss 不是 Cache 不够大,也不是 Cache 冲突,而是由于其它 CPU 写同一个 Cache Line 里数据致使了 Cache Miss。缓存一致性维护须要专门文章来写。算法

SMP 和 NUMA

SMP 词条:Symmetric multiprocessing - Wikipedia
NUMA 词条:Non-uniform memory access - Wikipediashell

简单说 SMP 就是一组 CPU 会经过一条总线共享机器内的内存、IO 等资源。由于全部东西都是共享的,因此扩展性受限。segmentfault

NUMA 则是将机器内 CPU 分为若干组,每一个组内都有独立的内存,IO资源,组与组之间不相互共享内存和 IO 等资源,组之间经过专门的互联模块链接。整体上扩展性更强。缓存

本文主要以 SMP 系统为例。CPU 以及 Cache,Memory 的关系以下:

CPU 缓存结构

Direct Mapped Cache

最简单的缓存结构就是 Direct Mapped 结构。以下图所示,每一个 Cache Line 由基本的 Valid 标志位,Tag 以及 Data 组成。当访问一个内存地址时,根据内存地址用 Hash 函数处理获得目标地址所在 Cache Line 的 Index。根据 Index 在 Cache 中找到对应 Cache Line 的 Data 数据,再从 Data 中根据内存地址的偏移量读取所需数据。由于 Hash 函数是固定的,因此每个内存地址在缓存上对应固定的一块 Cache Line。因此是 Direct Mapped。

实际中为了性能 hash 函数都很是简单,就是从内存地址读取固定的几个 bit 数据做为 Cache Line 的 Index。拿下图为例,Cache Line 大小为 4 字节,一共 32 bit 是图中的 Data 字段。4 字节一共须要 2 bit 用于寻址,因此看到 32 bit 的地址中,0 1 两个 bit 做为 Offset。2 ~ 11 bit 做为 Cache Line 的 Index 一共 1024 个,12 ~ 31 bit 做为 tag 用于区分映射到相同 Cache Line 的不一样内存 block。好比如今要读取的地址是 0x1124200F,先从地址中取 2 ~ 11 bit 获得 0x03 表示目标 Cache Line 的 Index 是 3,以后从地址中读 12 ~ 31 bit 做为 tag 是 0x11242。拿这个 Tag 跟 Index 为 3 的 Cache Line 的 Tag 作比较看是否一致,一致则表示当前 Cache Line 中包含目标地址,不一致则表示当前 Cache Line 中没有目标地址。由于 Cache 比内存小不少,因此可能出现多个不在同一 Cache Line 的内存地址被映射到同一个 Cache Line 的状况,因此须要用 Tag 作区分。最后,若是目标地址确实在 Cache Line,且 Cache Line 的 Valid 为 true,则读取 0x1124200F 地址的 0 ~ 1 bit,即找目标数据在 Cache Line 内的 Offset,获得 0x03 表示读取当前 Cache Line 中最后一个字节的数据。

上图的 Cache 是 1024 X 4 字节 一共 4 KB。但因为 Tag 和 Valid 的存在,缓存实际占用的空间还会更大。

替换策略

由于 Direct Mapped 方式下,每一个内存在 Cache 中有固定的映射位置,因此新访问的数据要被存入 Cache 时,根据数据所在内存地址计算出 Index 发现该 Index 下已经存在有效的 Cache Line,须要将这个已存在的有效 Cache Line 从 Cache 中移出。若是采用 Write-Back 策略,移出时须要判断这个数据是否有被修改,被修改了须要更新主存。

Write-Back 策略在前面有介绍,即写数据时只写缓存就当即返回,但标记缓存为 Dirty,以后在某个时间再将 Dirty 的缓存写入主存。

Two-way Set Associative Cache

咱们但愿缓存越大越好,越大的缓存常常意味着更快的执行速度。对于 Direct Mapped Cache 结构,增大缓存就是增长 Index 数量,至关因而对上面表进行纵向扩展。但除了纵向扩展以外,还能够横向扩展来增长 Cache 大小,这就是 Two-way Set Associative Cache。

基本就是以下图所示,图上省略了 Tag 和 Valid 等标识每一个 cell 就是一个 Cache Line,与 Direct Mapped Cache 不一样点在于,Two-way Set Associative Cache 里每一个 Index 对应了两个 Cache Line,每一个 Cache Line 有本身的 Tag。同一个 index 下的两个 cache line 组成 Set。在访问内存时,先根据内存地址找到目标地址所在 Set 的 index,以后并发的去验证 Set 下的两个 Cache Line 的 Tag,验证目标地址是否在 Cache Line 内,在的话就读取数据,不在则去读主存。

这里并发的验证两个 Cache Line 的 Tag 是由硬件来保证,硬件电路结构会更加复杂但查询效率与 Direct Mapped Cache 一致。

Set 内的两个 Cache Line 是具备相同 Index 的两个不一样 Cache Line。上图来自 Is Parallel Programming Hard, And, If So, What Can You Do About It?,图 C.2。以这个图为例,假设 Cache Line 大小是 256 字节,因此图上全部地址最右侧都是 00,即有 8 bit 的 Offset,从 0 到 7。由于 Cache Line 只有 16 个,因此 index 是 4 bit,从 8 ~ 11。图中看到 0x12345E00 和 0x43210E00 在 8 ~ 11 bit 位置上是相同的,都是 0xE 因此这两个内存地址被映射到 Cache 中同一个 Index 下。这两个 Cache Line 就会放在同一个 Set 内,在访问时能同时被比较 Tag。

替换策略

新数据要存入 Cache 时,根据数据所在内存地址计算出 Index 后发现该 Index 下两个 Way 的 Cache Line 都已被占用且处在有效状态。须要有办法从这两个 Cache Line 里选一个出来移除。Direct Mapped 由于一个 Index 下只有一个 Cache Line 就没这个问题。

若是是这里说的 Two-way Set Associative Cache 还比较好弄,给每一个 Way 增长一个最近访问过的标识。每次一个 Way 被访问就将最近访问位置位,并清理另外一个 Way 的最近访问位。从而在执行替换时,将不是最近访问过的那个 Way 移除。不过下面会看到 N-way Set Associative Cache 当有 N 个 Way 的时候替换策略更加复杂,通常是尽量使用最少的状态信息实现近似的 LRU。

N-way Set Associative Cache

顾名思义,就是在 Two-way Set Associative Cache 基础上继续横向扩展,在一个 Set 内加入更多更多的 Way 也即 Cache Line。这些 Cache Line 能被并发的同时验证 Tag。若是 Cache 内全部的 Cache Line 都在同一个 Set 内,即全部 Cache Line 都能同时被验证 Tag,则这种 Cache 叫作 Fully Associative Cache 。能够看出 Fully Associative Cache 性能是最强的,能省去从地址中读取 Index 查找 Set 的过程。但横向扩展的 Way 越多,结构越复杂,成本越高,越难实现大的 Cache。因此 Fully Associative Cache 虽然存在,但都很小,通常用在 TLB 上。

Cache 结构为何发展出横向扩展?

这个是我本身提出来的问题。对 Direct Mapped Cache 扩展 Cache 时就是增长更多的 index,让 cache 表变得更长。但为何会发展出 Two-way Set Associative Cache 呢?好比若是一共 16 个 Cache Line,是 16 行 Cache Line 仍是 8 行 Set 每一个 Set 两个 Cache Line 在容量和命中率上彷佛并无差异。

后来看到了 这个问题 ,明白了其中的缘由。主要是须要区分出来 Conflict Miss 和 Capacity Miss (还有一个 Communication Miss,前面说过)。当 Cache 容量足够,但因为两块不一样的内存映射到了同一个 Cache Line,致使必须将老的内存块剔除产生的 Miss 叫作 Conflict Miss,即便整个 Cache 都是空的,只有这一个 Cache Line 有效时也会出现 Miss。而因为容量不足致使的 Miss 就是 Capacity Miss。好比 cache 只有 32k,访问的数据有 200k,那访问时候必定会出现后访问的数据不断的把先访问数据从 Cache 中顶出去,致使 Cache 一直处在 Miss 状态的问题。

在 Capacity Miss 方面横向扩展和纵向扩展没有什么区别,主要区别就是 Conflict Miss。倘若轮番访问 A B 两个内存,这两个内存映射到同一个 Cache Line 上,那对于 Direct Mapped Cache,由于每块内存只有固定的一个 Cache Line 能存放,则会出现持续的 Conflict Miss,称为 cache thrashing。而 Two-way Set Associative Cache 就能将 A B 两块内存放入同一个 Set 下,就都能 Cache 住,不会出现 Conflict Miss。这就是横向扩展的好处,也是为何横向扩展即便困难,各个 CPU 都在向这个方向发展。而且横向扩展和纵向扩展并不冲突,Two-way Set Associative Cache 也能加多 Set 来进行扩展。

Cache Prefetching

词条:Cache prefetching - Wikipedia

Cache 运做时并不必定每次只加载一条 Cache Line,而是可能根据程序运行情况,发现有一些固定模式好比 for loop 的时候在加载 Cache Line 时会多加载一点,相似于经过 Batch 来作优化同样。

为何缓存存取速度比主存快

Why is SRAM better than DRAM? - Quora

False Sharing

Wiki 词条:False sharing - Wikipedia

好比像下面图这个样子:

A B 两个对象在内存上被连续的建立在一块儿,倘若这两个对象都很小,小于一个 Cache Line 大小,那他们可能会共用同一个 Cache Line。若是再有两个线程 Thread 1 和 Thread 2 会去操做这两个对象,咱们从代码角度保证 Thread 1 只会操做 A,而 Thread2 只会操做 B。那按道理这两个 Thread 访问 A B 不应有相互影响,都能并行操做。但如今由于它俩恰好在同一个 Cache Line 内,就会出现 A B 对象所在 Cache Line 在两个 CPU 上来回搬迁的问题。

好比 Thread 1 要修改对象 A,那 Thread 1 所在 CPU 1 会先获取 A 所在 Cache Line 的 Exclusive 权限,会发送 Invalidate 给其它 CPU 让其它 CPU 设置该 Cache Line 为无效。以后 Thread 2 要修改对象 B,Thread 2 所在 CPU 2 又会尝试获取 B 所在 Cache Line 的 Exclusive 权限,会发 Invalidate 给其它 CPU,包括 CPU 1。CPU 1 要是已经写完了 A,那就要把数据刷写内存,以后设置 Cache Line 无效并响应 Invalidate。没写完就得等待 CPU 1 写完 A 后再处理 Cache Line 的 Invalidate 问题。以后 CPU 2 再去操做 Cache Line 更新 B 对象。再后来 Thread 1 要去更新 A 对象的话又要去把 A B 所在 Cache Line 在 CPU 2 上设置无效。也就是说这块 Cache Line 失去了 Cache 功能,会在两个 CPU 上来回搬迁,会常常性的执行刷写内存,读取内存操做,致使两个原本看上去没有关系的操做实际上有相互干扰。

想观测到这个现象最简单的是让 A B 是同一个类的不一样 Field,而不是两个独立对象,好比:

class SomeClass{
    volatile long valueA;
    volatile long valueB;
}

这个对象在内存上布局以下:

看到这个 Object 只有 32 字节,在我机器上一个 Cache Line 是 64 字节 (mac 上执行 sysctl machdep.cpu.cache.linesize,Linux 上执行 getconf LEVEL1_DCACHE_LINESIZE 来查看),因此 A B 都能放在同一个 Cache Line 上,以后能够建立出来两个 Thread 去分别操做同一个 SomeClass 对象的 valueA 和 valueB Field,记录一下时间,再跟下面解决方案里说的方式作对比,看看 False Sharing 的现象。

解决办法

解决这个问题的办法也很容易,若是是上面例子的话,就是让被操做的 valueA 和 valueB 隔得远一点。好比能够这么声明:

class SomeClassPadding {
    volatile long valueA;
    public long p01, p02, p03, p04, p05, p06, p07, p08;
    volatile long valueB;
}

对象内存布局就变成了:

由于我确认我机器的 Cache Line 是 64 字节,因此加了 8 个 long。若是 Padding 少一些,好比 6 个,那 valueA 在 Offset 16,第六个 Padding 在 Offset 64,valueB 刚好是 Offset 64,彷佛已经足够将 valueB 放到下一个 Cache Line 了但实际仍是有问题。由于对象不必定恰好分配在 Cache Line 开头,好比 Cache Line 刚好从 valueA 所在 Offset 16 开始,到 Offset 80 结束,若是只有 6 个 long 作 Padding,那 valueA 和 valueB 仍是在同一个 Cache Line 上。因此 Padding 至少须要和 Cache Line 同样长。

还有要注意看到 Padding 得声明为 public,否则 JVM 发现这一堆 Padding 不可能被访问到可能就直接优化掉了。

在我机器上测试,Padding 以后性能提高了大概 4 5 倍的样子。若是上面 SomeClasPadding 去掉 volatile 声明,则提高大概 1.5 倍的样子,之因此有这个差距是由于没有 volatile 的话线程操做 valueA 和 valueB 若是 Cache Line 不在当前 CPU Cache 中,它并不要求等待 Cache Line 加载进来后再作写入,而是能够把写入操做放在一个叫 Store Buffer 的地方以提升性能,具体能够关注咱们的下一篇分享内容。等 Cache Line 加载后再对它作修改,至关因而将一段时间的写入操做积累了一下一口气写入。而有了 volatile 后则要求每次写入真的得等 Cache Line 加载后再写,从而放大了等待 Cache Line 加载的时间,更容易观察到 False Sharing 问题。

另外,Padding 固然是有代价的。一个是让对象变得更大,占用内存,再有是 Padding 了一堆无用数据还得加载到 Cache 里,白白占用了 Cache 空间。

须要注意的是本身手工 Padding 方法可能被虚拟机作重排,即 Padding 原本想加到 valueA 和 valueB 之间,但可能被重排到 valueB 以后,致使实际没有什么用。好比:

class SomeClassPadding {
    volatile long valueA;
    public int p01, p02, p03, p04, p05, p06, p07, p08;
    volatile long valueB;
}

实际的内存布局是下图这样,即 Padding 都跑到 valueB 后面去了。另外按说 Cache Line 是 64 字节的话用 int 作 Padding 至少要 16 个,我这里只是为了说明手工 Padding 的问题,因此少写了一些。

内存布局实际会依赖虚拟机而不一样,因此上面这种 Padding 方式是不可靠的。即便变量真声明为 long 也不能保证全部虚拟机都按照相同方式作排列。最靠谱的手工 Padding 方式是用 Class 的层级结构作 Padding,由于 JVM 要求父类的成员必定要排在子类成员以前,因此级联的 Class 结构能保证 Padding 的可靠性。好比:

class SomeClassValueA {
    volatile long valueA;
}
class SomeClassPaddings extends SomeClassValueA{
    public int p01, p02, p03, p04, p05, p06, p07, p08, p09, p10, p11;
}
class SomeClassValueB extends SomeClassPaddings{
    volatile long valueB;
}
class SomeClass extends SomeClassValueB{   
}

内存结构就变成:

这里为了演示用 int 作 Padding,又不想让图片太长,因此只写了 11 个 int,但实际至少须要 16 个 int 即凑够 64 字节才行。通常 Padding 都用 long 作,不会用 int,能够少写不少变量。

这个顺序是 JVM 规范保证的,因此全部虚拟机都会按照这个方式排列,因此是可靠的。

另外一个方法是用 @Contentded 注解,java 8 后开始支持,java 11 后 Contendedsun.misc 搬到了 jdk.internal.misc。它做用就是自动帮你作 Padding,它保证在任意 JVM 上都能有 Padding 效果,就不用咱们再去构造 Class 级联结构了。好比上面例子中用 @Contended 就是:

class SomeClassContended {
    volatile long valueA;
    @Contended
    volatile long valueB;
}

它的内存结构在 HotSpot 64 下是:

它是按 128 作 padding 的,它并没管我机器的 Cache Line 是多少,另外它是在 valueB 先后都作 Padding。这也是更推荐的方式。通常来讲都尽力用 @Contended 注解了,除非为了兼容 Java 8 如下 JVM 或者为了性能,为了 Object 大小等缘由,才可能会去手工作 Padding。

上面内存结构是经过 JOL 插件 来查看的,它里面用的OpenJDK: jol 工具。

False Sharing 测试的话能够参考 JMH 的例子 写本身的测试。

实际 Padding 例子,能够看看 Netty 4.1.48 下的 InternalThreadLocalMap

JVM 上这个问题常见吗

上面 False Sharing 的例子是咱们故意构造而获得的,因此很容易复现,很容易观察到。但实际开发中让同一个对象里不一样 Field 被多个线程同时访问的状况并很少。却是有这种例子好比 ConcurrentHashMap 里用于计算元素总量的 CounterCell 类。不过这种场景并非不少,而通常状况下,拿上面的 SomeClass 来举例,它更可能被声明为:

class SomeClass{
    volatile long value;
}

SomeClass a = new SomeClass();
SomeClass b = new SomeClass();

以后 Thread 1 和 Thread 2 分别去操做 ab 两个对象。这种场景下,按说确实有 False Sharing 问题,但由于 a, b 对象都分配在 JVM 堆上,它俩得刚巧在堆上被连续建立出来,且在后续一系列 GC 中都一直能刚好挨在一块儿,才能持续的存在 False Sharing 问题。这么看来 False Sharing 彷佛很难出现。好比咱们测试时,让每一个 Thread 都像上面这样 new SomeClass() 以后都操做本身 new 出来的 SomeClass 对象,咱们会发现不管怎么测试,性能都和没有 False Sharing 时的性能一致,也即没有 False Sharing 问题。下面会说为何这里没有 False Sharing。

更进一步,即便是同一个 Class 内的不一样 Field,若是不是普通变量而是引用,好比这样:

class SomeClass {
    ObjectA valueA;
    ObjectB valueB;
}

两个 Thread 分别操做这两个引用,False Sharing 问题要求这两个引用刚好指向 JVM 堆上两个相邻对象,且两个对象得足够小,保证两个对象内被操做的值离得足够近,能放在同一个 Cache Line 上。想一想每一个对象都有 Object Header 也即天生就有至少 16 字节的 Padding 在,这也让被操做的值更不容易刚好在同一个 Cache Line 上。

因此 False Sharing 问题在 JVM 上并不会特别常见。

TLAB,PLAB 可能会加剧 False Sharing 问题

按说 False Sharing 问题不会很常见,不过 TLAB 和 PLAB 机制可能会增大它出现的概率。TLAB 全称 Thread Local Allocation Buffers,我并无找到一个特别好的介绍,这个 Blog 马马虎虎能看看: Introduction to Thread Local Allocation Buffers (TLAB) - DZone Java

如下图为例,Java 分配内存一般一开始在 Eden 区分配,一个指针用来区分分配过的区域和还未分配的区域。每次分配内存都须要去移动这个指针来分配。若是全部线程分配内存时候都去操做这个指针,势必会产生不少竞争,各个线程都想去移动这个指针,而 TLAB 的存在便是说每次线程分配内存的时候不是申请多少就分配多少,而是每次分配稍大的区域,以下图虚线,以后内存分配尽力在线程本身的这块内存区域上进行,从而减小对 ptr 指针的竞争。若是线程的 TLAB 用完了,或者分配的对象太大,才会去争抢 ptr。

与 TLAB 相似的还一个叫作 PLAB 的,Promotion Local Allocation Buffers,用在一组 GC 线程并发的将新生代对象晋升到老代时使用。也是每一个 GC 线程会提早分配一块区域,每次晋升对象的时候将对象拷贝至线程本身的这块分配好的区域上,从而减少竞争。

若是是标记-整理算法,按说对象并发的 Sweep 到新位置时也会是用上面这种方法进行。不过这个我没有找到说明的地方。

这么一来以前说两个线程分别 new SomeClass(),每一个线程只操做本身 new 出来的 SomeClass 对象不会引发 False Sharing 的缘由就清楚了,由于每一个线程会把 SomeClass 分配在本身的 TLAB 上,通常 TLAB 大于 Cache Line 因此不会引发 False Sharing 问题。JVM 上容易引发 False Sharing 问题的点也清楚了,即一个线程连续分配了两个对象,这两个对象后来被分配给不一样的线程,而且被它们频繁更新,这两个对象在两个不一样线程上就容易出现 False Sharing 问题,即便经历数轮 GC,它俩可能在内存上仍是可能在一块儿,因此说 TLAB 和 PLAB 会增大 False Sharing 出现的几率。

怎么证明这一点呢?不太容易,但指导思想就是让同一个线程连续 new 对象,再让其它线程来访问这些对象。只要对象不会很大,由于 TLAB 的关系,这些对象中有两个在同一个 Cache Line 上的概率会很大。好比我在 JMH 测试时这么写:

public static class SomeClassValue {
    volatile int value;
}
@State(Scope.Group)
public static class SomeClass {
    SomeClassValue[] val = new SomeClassValue[2];
    public SomeClass() {
        this.val[0] = new SomeClassValue();
        this.val[1] = new SomeClassValue();
    }
}
@Benchmark
@Group("share")
public void testA(SomeClass someClass) {
    someClass.c[0].value++;
}

@Benchmark
@Group("share")
public void testB(SomeClass someClass) {
    someClass.c[1].value++;
}

看到线程会共享 SomeClass 对象,但会分别访问 SomeClass 中不一样的 SomeClassValue。这两个 value 可能会被放在同一个 Cache Line 上而被观测到执行性能降低。

JVM 上 False Sharing 严重吗

正常来讲 False Sharing 并不常见,想测出它也不容易,可能根据机器型号不一样,JVM 版本不一样,运行情况甚至运行时机不一样而不必定何时出现,可是一旦出如今系统的 Hot Spot 上,数倍的性能损失是很严重的。False Sharing 可能产生严重问题的场景是:

  • 某个 Class 的对象被连续的建立;
  • 建立的对象被分发给多个不一样的线程去读取、写入,每一个线程原本能够独享一个对象;
  • 对象内被线程操做的 Field 被声明为 volatile;

好比能够拿 Netty 4.1.48 下的 InternalThreadLocalMap 做为例子感觉一下。这个 Thread Local Map 原本是每一个 EventLoop 一个的,各个 EventLoop 不相互干扰。可是访问 Thread Local 对象自己是 Hot Spot,访问的不少,若是一旦出现 False Sharing 就会致使性能大幅度降低。EventLoop 是 Netty 启动时在一个 for loop 中一口气被建立出来的,因此一组 InternalThreadLocalMap 中有几个刚巧紧挨着被建立出来是彻底可能的。因此 InternalThreadLocalMap 用 Padding 作了一下保护。固然我理解即便 EventLoop 不是连续被建立出来也该去保护一下 InternalThreadLocalMap 以防刚好多个 Map 对象被放到同一个 Cache Line 上去。

参考

相关文章
相关标签/搜索