张三和李四在同一间办公室,各管一摊订单。公司给每人发了一张“协作登记纸”的复印件:左边是张三的专栏,右边是李四的专栏。规矩是——任何人动笔改了自己的专栏,整张纸立刻变成“最新版”,所有人的复印件全部作废,必须去一楼档案室重新复印。于是张三添一笔,李四手里的纸作废;李四改一下,张三的纸又作废。一整天下来,两人上下楼跑了几百趟,真正写的字却没几个,而他们从头到尾都没看过对方的专栏。 这个故事就是“伪共享”(False Sharing)的缩影。计算机里,CPU 每个核心都有自己的缓存(办公桌),主存是档案室(内存),数据以“缓存行”为单位在两者间搬运,常见规格是 64 字节。当两个核心修改同一缓存行上的不同变量时,任何一方写入都会让另一方整行作废、被迫重取——不碰对方的数据,却被“同纸效应”反复拖累。AI 系统里,推理网关的 QPS 计数、特征服务的命中统计、分布式训练的同步标志,都可能踩中这个坑。

岗位关联

性能优化是 AI 工程落地的硬功夫。理解缓存行失效与伪共享,你写的并发代码才能扛住高吞吐压力,让模型服务和特征管道真正跑满硬件,而不是把算力浪费在看不见的“复印跑腿”上。

目录
  1. 可视化演示
  2. 伪共享产生机制
  3. MESI一致性协议
  4. 性能影响测量
  5. Padding对齐优化
  6. @Contended注解
  7. LongAdder场景分析
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示多核CPU缓存一致性问题:多核共享缓存行、MESI协议失效机制、缓存行填充消除伪共享,三模式交互演示。

本节配有交互动效(进入视口后自动加载,约 3KB)。

多核缓存行共享 4个核心共享同一L1缓存行 数据访问冲突导致缓存行频繁同步 伪共享性能下降的根源

伪共享产生机制

回到张三和李四的办公室。那张协作登记纸有个硬性规格:整整 64 厘米宽,谁也别想只换自己那半栏——要换就得整张纸一起换。这个规格对应到计算机里就是缓存行:CPU 缓存与主存之间搬运数据的最小单位,主流处理器普遍是 64 字节。缓存行就像一张“标准纸”,不管你要的数据只有 4 字节,内存也得按整行给你搬运。

假如程序里有两个变量 x 和 y,x 被线程 A 频繁修改,y 被线程 B 频繁修改,而它们恰好被分配在相邻的内存位置上,落进了同一条缓存行。于是两个核心各自持有这整行 64 字节的副本。线程 A 每写一次 x,就会触发一次“写失效”,把 B 核手里的整行副本作废;B 核下一次访问 y 时发现副本没了,只好回主存重新把整行拉回来。A 再写,B 再作废、再拉……循环往复。x 与 y 之间根本没有数据依赖,这被称为“虚假的共享”,英文直译就是 False Sharing——伪共享。

伪共享发生的数学条件非常朴素:两个变量的内存地址落在同一条 64 字节缓存行里。

伪共享最迷惑人的地方,就是结果从来不会出错。张三和李四的笔记各记各的,内容从没被对方篡改过;只是每动一笔,对方就得重新复印。这种“看不见的跑腿”不会让程序崩溃,只会让它在多核下莫名变慢——这正是它比数据竞争更隐蔽的原因。

⌊addr(x)/64⌋=⌊addr(y)/64⌋\displaystyle \lfloor \text{addr}(x) / 64 \rfloor = \lfloor \text{addr}(y) / 64 \rfloor
公式说明:当左边等于右边时,x 与 y 共享同一条缓存行,伪共享的温床就出现了。注意这并不代表程序有数据竞争——两个变量逻辑上完全独立,一切错误都只发生在性能层面。
  1. 变量地址每个变量在内存中有唯一的起始字节地址,它决定了变量“住在哪一格”。
    addr(x), addr(y)\text{addr}(x),\ \text{addr}(y)
  2. 行号换算地址除以 64,得到变量所在的缓存行编号。
    addr(x)/64\text{addr}(x) / 64
  3. 向下取整地址并不总在行首,取整后才能得到准确的“行号”。
    ⌊⋅⌋\lfloor \cdot \rfloor
  4. 行号相等判断两变量行号相同,说明住在同一间“屋子”,伪共享条件成立。
    ⌊addr(x)/64⌋=⌊addr(y)/64⌋\lfloor \text{addr}(x)/64 \rfloor = \lfloor \text{addr}(y)/64 \rfloor

MESI一致性协议

复印室需要规则。谁宣布“我改了”?谁有资格改?为了让大家手里的纸不打架,CPU 请来了一位叫“缓存一致性协议”的检查员,最常见的型号叫 MESI。它给每张纸盖上四种印章之一,规定谁能改、改了之后别人怎么办。

M 表示“修改过”(Modified):我这张纸是唯一最新版,档案室里的母版还是旧货;E 表示“独占”(Exclusive):只有我手上有这张纸,且和档案室一致;S 表示“共享”(Shared):好几个人都有副本,大家都和档案室一致;I 表示“失效”(Invalid):我这张已经作废,要用必须重新复印。

总线就像办公室的广播喇叭。任何核想写某一行,都得先在喇叭里喊一声“我要写这一行!”——这叫总线嗅探,其他核听到后立刻把自己的副本盖上 I 印章。没了对手之后,写核独占该行,状态从 S 或 E 变成 M。伪共享的乒乓就此产生:两个核轮流喊“我要写”、轮流让对方作废,M 和 I 之间来回横跳,每次横跳都伴随一次主存往返。

检查员 MESI 绝不是坏人,它保证所有人读到一致的数据;只是在伪共享场景里,它被“一张纸两个主人”的结构坑了——每次尽职的失效广播,都变成纯浪费。张三写左栏,李四的纸作废;李四写右栏,张三的纸作废。乒乓就这样打起来了。

I→read missE,E→local writeM\displaystyle I \xrightarrow{\text{read miss}} E,\quad E \xrightarrow{\text{local write}} M
S→local writeM,S→peer writeI\displaystyle S \xrightarrow{\text{local write}} M,\quad S \xrightarrow{\text{peer write}} I
公式说明:上下两行展示 MESI 最核心的四条转换路径。伪共享主要靠最后一条“对端写失效”伤人——别人写,你的副本作废。
  1. 读未命中的独占进入本地缓存没有该行,广播后也没人持有,取来的行直接盖 E 章。
    I→EI \to E
  2. 独占后本地写既然没人抢,直接下笔,行标记为修改过的 M。
    E→ME \to M
  3. 共享时强行写先广播写失效,把其他核的副本作废,再把自己提升为 M。
    S→MS \to M
  4. 对端写引发失效这是伪共享的物理根源——别人动笔,你的纸就作废。
    S→IS \to I

性能影响测量

想量化“跑腿浪费”,就得记账。张三今天写了 1 万笔,李四就被迫作废近 1 万次;每次作废,李四都要去档案室重抄整张 64 厘米的纸。把账加起来,就是伪共享的真实代价。

这笔账有三个科目。第一是缓存未命中率:本该在 L1 命中的访问变成了主存访问,延迟从 1 纳秒级跳到几十上百纳秒,相差一到两个数量级;第二是内存带宽:每次失效都要搬运整行 64 字节,多核同时乒乓时,总线被无意义的复制占满,真正需要的搬运反而排队;第三是核间同步开销:写失效广播和总线嗅探消耗大量一致性流量,核心越多、广播越贵,性能损失随核数近似线性放大。

假设系统有 K 个核心共享这条缓存行,其中一个核写了 N 次,那么其余 K-1 个核每人至少重取 N 次,每次 64 字节。伪共享的“带宽账单”可以写成:

测量时,Linux 下可以用 perf stat -e cache-misses 观察未命中事件暴涨,Java 下可以用 JMH 写基准测试对比“同缓存行”与“填充后”两种布局。跑出来的数字往往让人吃惊:8 线程场景下,伪共享版本可能比优化版慢一个数量级。办公室里那个上下楼跑断腿的“实习生”,就这样被记在了性能账单上。

Bfs=Nwrite×(K−1)×64 B\displaystyle B_{fs} = N_{\text{write}} \times (K - 1) \times 64\text{ B}
公式说明:写入次数乘以受波及的核数,再乘以每次重传的 64 字节,就是被伪共享白白烧掉的总带宽。核数越多,账单越贵。
  1. 写入次数热点变量被写的总次数,写一次就广播一次失效。
    NwriteN_{\text{write}}
  2. 其余核数每次失效会波及 K-1 个持有该行副本的核心。
    K−1K - 1
  3. 重传粒度每次重建一行都要搬运 64 字节起步。
    64 B64\text{ B}
  4. 求积得带宽三数相乘就是被伪共享烧掉的总流量,核越多放大越明显。
    Nwrite×(K−1)×64N_{\text{write}} \times (K - 1) \times 64

Padding对齐优化

最直接的解法来自生活常识:既然两人共用一张纸会互相牵连,那就一人一张纸。在 Java 里,我们手动在热点变量旁边填上“空位”,让它和邻居彻底分开——这就是 Padding(填充)。

经典写法是给 long 变量后面补 7 个无用的 long 字段:8 字节变量 + 56 字节填充 = 64 字节,正好占满一条缓存行。这样下一个字段的起点天然落进下一行,两个热变量不再同居。著名的无锁队列框架 Disruptor 就靠这招起步。需要提醒的是,Java 对象字段的实际布局并不完全由书写顺序决定,对象头、对齐、字段重排都会影响偏移,动手前最好用 JOL 工具确认。

填充不是免费的午餐:对象变大、堆占用上升、GC 压力增加,而且字段排列必须手工维护,多写一个字段就可能破坏隔离。它像给张三李四各配了一张独立办公桌——舒服,但办公室面积也得翻倍。

Spad=(64−Sfield) mod 64=(64−8) mod 64=56 B\displaystyle S_{\text{pad}} = (64 - S_{\text{field}}) \bmod 64 = (64 - 8) \bmod 64 = 56\text{ B}
公式说明:用缓存行大小减去热点变量自身宽度,取模得到需要补充的填充字节数。8 字节的 long 补上 56 字节空位后,正好凑满一行。
  1. 缓存行尺寸填充的目标是让热点变量占满或对齐一整行,一行 64 字节。
    Lline=64 BL_{\text{line}} = 64\text{ B}
  2. 热点变量宽度Java 中 long 类型占 8 字节,是计数器的常见载体。
    Sfield=8 BS_{\text{field}} = 8\text{ B}
  3. 取模求差值行大小减去变量占用,算出需要手动补的空白字节数。
    (64−8) mod 64=56(64 - 8) \bmod 64 = 56
  4. 填充生效变量与 56 字节空白连成完整一行,后续字段被推到下一行。
    Spad=56 BS_{\text{pad}} = 56\text{ B}

@Contended注解

手工填充就像自己量桌子尺寸,麻烦且容易出错。JVM 提供了一张官方“隔离工位卡”——@Contended 注解,只要挂上它,JVM 会自动在字段周围布置隔离带。

JDK 8 里注解的全名是 @sun.misc.Contended;JDK 9 之后因模块化移到了 jdk.internal.vm.annotation.Contended。默认情况下,JVM 会在被注解的字段前后各插入 128 字节填充,等于在两边各留出一条完整缓存行的“真空走廊”,保证没有任何相邻字段能和它共享一行。JDK 内部如 Thread、ForkJoinPool 的计数器都用了这套机制,LongAdder 的 Cell 也靠它隔离。

不过官方把这张卡默认发给“内部员工”:普通用户类直接使用会报错或不生效,需要加 JVM 参数 -XX:-RestrictContended 解除限制;JDK 9+ 还需配合 --add-exports 导出内部包。观察效果可以用 JOL 查看对象内存布局,字段之间那 128 字节的空白一览无余。

有了这张“隔离工位卡”,程序员再也不用操心填充量该写多少——JVM 负责把工位隔好,就像行政部直接给张三李四各配了一间独立办公室。

Siso=2×64=128 B\displaystyle S_{\text{iso}} = 2 \times 64 = 128\text{ B}
公式说明:@Contended 在字段前后各留一条 64 字节缓存行的空位,合计 128 字节,形成双向物理隔离。
  1. 单行宽度一条缓存行 64 字节,是隔离的最小单位。
    Lline=64 BL_{\text{line}} = 64\text{ B}
  2. 双向隔离注解在字段前后各留一行,形成两段隔离带。
    2×Lline2 \times L_{\text{line}}
  3. 总填充量两个方向合计 128 字节,任何邻居都无法与它同处一行。
    Siso=128 BS_{\text{iso}} = 128\text{ B}

LongAdder场景分析

计数器是伪共享最经典的翻车现场。想象全公司只有一本总账(AtomicLong),所有人都要排队在账本上写一笔;账本只有一页纸,每笔修改都要广播“我改了”,其他人瞬间作废重抄。人越多,排队和重抄的时间越长。

高并发 QPS 统计、请求成功率、失败次数,这些场景都是多个线程反复累加同一个数。用 AtomicLong 时,CAS 循环在缓存行上激烈碰撞:每个核抢到写权都会作废别人的行副本,抢不到的核只能自旋重试,白白烧掉 CPU 和带宽。这就是并发计数器的痛点——真正的性能瓶颈往往不是 CAS 指令本身,而是被伪共享放大的缓存失效。

LongAdder 的解法是分账本:内部维护一个 base 和一个 Cell 数组,每个线程往自己的 Cell 里累加,最后 sum() 把各 Cell 汇总。关键是每个 Cell 都标了 @Contended,独占一条缓存行,线程与线程之间物理隔离,谁也作废不了谁。JDK 官方的基准测试显示,高竞争 8 线程下 LongAdder 的吞吐可以达到 AtomicLong 的数倍甚至一个数量级;但低竞争或单线程时,LongAdder 要维护 Cell 数组,反而不如 AtomicLong 轻快。

从“一本总账大家抢”变成“一人一本分账、下班再汇总”,计数器领域的伪共享就这样被拆掉了。记住选型口诀:高竞争用 LongAdder,低竞争用 AtomicLong,没竞争用普通 long。

Scell=64 B=Lline\displaystyle S_{\text{cell}} = 64\text{ B} = L_{\text{line}}
公式说明:LongAdder 的每个计数单元 Cell 经 @Contended 处理后恰好独占一条缓存行,从布局上彻底消除线程间的伪共享。
  1. 分片单元LongAdder 把累加压力摊到多个 Cell 上,每个 Cell 持有一个 long 值。
    Scell=8 B (值)S_{\text{cell}} = 8\text{ B}\ (\text{值})
  2. 缓存行对齐@Contended 让每个 Cell 独占一条 64 字节缓存行。
    Scell=64 BS_{\text{cell}} = 64\text{ B}
  3. 消除伪共享不同线程写不同 Cell,互不作废,乒乓消失。
    Lline=64 BL_{\text{line}} = 64\text{ B}

AI 应用场景

场景1:推理网关的 QPS 与错误计数

阶段:模型服务部署与在线监控

解决的问题:在线推理网关通常有几十个 worker 线程并发处理请求,每个线程都要累加总 QPS、错误数、时延分位数等指标。若这些计数器字段被安排在相邻内存,就会发生“同行”伪共享:累加线程频繁互相作废缓存行,网关整体吞吐明显下降。改用 LongAdder 或对计数器对象做 @Contended 隔离,能让累加和读取互不干扰。

具体案例:某推荐系统推理网关在压测中发现 cache-misses 事件数是理论值的 30 倍,用 JOL 定位到 QPS 计数器与错误计数器共用缓存行后,将计数器字段做 128 字节隔离,压测吞吐从 8.2 万 QPS 提升到 11.6 万 QPS。

场景2:特征服务的热键命中统计

阶段:特征工程与特征存储

解决的问题:特征服务常按特征键分区,每个分区由一个线程负责。如果一个对象里同时放了多个分区的统计字段(命中数、未命中数),各个 worker 的写入就会在一条缓存行上互相踩踏。Padding 让每个分区字段独占一行后,命中统计不再触发跨核失效。

具体案例:某广告特征服务有 16 个分区线程各自维护 Long 型计数器数组,数组相邻元素恰好同行,p99 时延偶发从 2ms 跳到 15ms;按 64 字节对齐重新排布数组后,p99 稳定在 3ms 以内。

场景3:分布式训练同步屏障

阶段:分布式训练参数同步

解决的问题:AllReduce 前各 rank 需要等待所有 worker 就绪,常用自旋锁加共享标志位。若每个 worker 的“就绪标志”挤在同一缓存行,最后一个 worker 的写入会让其他 worker 自旋等待的副本全部失效,自旋变成疯狂的缓存行乒乓。将每个 rank 的标记用填充隔离到独立缓存行后,等待阶段 CPU 占用大幅下降。

具体案例:一个自定义分布式训练脚本中,8 卡同步等待时 CPU 占用高达 90%,其中相当部分来自同步标志的伪共享;隔离后等待期间 CPU 占用降到 30%。

Python 演示

python54 行
import threading ROUNDS = 10000          # 每线程写入次数MISS_COST_NS = 100      # 模拟一次缓存失效重载的开销(纳秒)LINE = 64               # 缓存行大小(字节)  class FalseSharingSim:    """简化版伪共享模拟器, 追踪两个核心各自缓存行的状态"""     def __init__(self, layout):        self.layout = layout            # {'x': 字节偏移, 'y': 字节偏移}        self.state = {0: {}, 1: {}}     # 核ID -> {行号: 状态}        self.misses = {0: 0, 1: 0}        self.lock = threading.Lock()     def line_of(self, var):        return self.layout[var] // LINE     def write(self, core, var):        with self.lock:            line = self.line_of(var)            if self.state[core].get(line) != 'M':                other = 1 - core                if self.state[other].get(line) in ('S', 'E', 'M'):                    self.state[other][line] = 'I'                    self.misses[other] += 1                self.state[core][line] = 'M'     def worker(self, core, var):        for _ in range(ROUNDS):            self.write(core, var)     def run(self):        t1 = threading.Thread(target=self.worker, args=(0, 'x'))        t2 = threading.Thread(target=self.worker, args=(1, 'y'))        t1.start()        t2.start()        t1.join()        t2.join()     def report(self, tag):        total = (self.misses[0] + self.misses[1]) * MISS_COST_NS / 1000.0        print(tag + ': 缓存失效 ' + str(self.misses[0] + self.misses[1]) +              ' 次, 模拟重载开销 ' + str(round(total, 1)) + ' 微秒')  if __name__ == '__main__':    shared = FalseSharingSim({'x': 0, 'y': 8})    # x 与 y 挤在同一条缓存行    shared.run()    shared.report('伪共享(同缓存行)')    padded = FalseSharingSim({'x': 0, 'y': 64})   # 填充后 x 与 y 各占一行    padded.run()    padded.report('填充后(独立缓存行)')
目的:用两个线程模拟两个“核心”各自修改自己的变量,在“同缓存行”与“填充隔离”两种布局下统计写失效次数,直观看到伪共享带来约两万次缓存失效,而填充后为 0 次。
  1. return self.layout[var] // LINE变量地址除以 64,算出它落在第几条缓存行,相当于算出“两人是否共用一张纸”。
  2. if self.state[core].get(line) != 'M'只有当自己持有独占修改态(M)时才能直接下笔,否则要先争夺独占权。
  3. self.state[other][line] = 'I'写失效:把自己的写广播给对端,把对端副本作废,对方的纸被盖上“作废”章。
  4. self.misses[other] += 1记一笔账:对端下次访问必须回“档案室”重新复印,这笔开销就是伪共享的代价。
  5. threading.Thread(target=self.worker, args=(0, 'x'))用两个线程模拟两个核并行修改各自的变量 x 与 y。

常见误区

误区线程 A 改 x、线程 B 改 y,最后程序变慢了,这一定是“数据竞争”导致的吧?我是不是该加锁?

正解伪共享不会导致任何逻辑错误。x、y 各自独立修改,最终值完全正确,它只是一道“性能伤口”。真正的数据竞争是同一变量同时读写且缺少同步导致的“值错乱”。用比喻说:张三李四的笔记内容从不出错,只是整天被迫重复复印。加锁不仅不能治伪共享,反而会让并发度更低。

为何常见:很多人把“并发性能问题”和“并发正确性问题”混为一谈,因为两者都发生在多线程修改共享数据时。

误区我把共享字段加上 volatile,保证可见性,伪共享应该就没有了。

正解volatile 只保证可见性和有序性,它完全不隔离缓存行。相反,volatile 每次写都会立即广播并让其他副本失效,在伪共享场景下会让乒乓更频繁。要解决伪共享,只能从“布局”下手:让热点变量各自独占缓存行,比如 Padding 或 @Contended。

为何常见:初学者容易把所有并发问题都归因于“可见性不足”,于是把 volatile 当成万能药。

误区既然伪共享是因为共用了缓存行,那我给所有字段都加上大量填充,肯定更安全。

正解过度填充会显著增加对象体积,堆内存占用上升、GC 压力变大,同时破坏空间局部性——本来一条缓存行能装下的热数据被拆散,反而可能更慢。填充的目标是“把确实会写冲突的冷热字段隔离”,而不是无脑加空位。像给每个人配一整层楼,纯属浪费。

为何常见:看到填充能提效就走向极端,忽略了内存与 GC 成本。

误区LongAdder 分片计数,肯定全程碾压 AtomicLong,无脑换用就行。

正解低并发或单线程场景下,LongAdder 每次要维护 base、定位 Cell 甚至创建 Cell 数组,额外开销比一次 CAS 更大;只有竞争激烈的多线程场景才有明显优势。选型要看竞争程度:高竞争用 LongAdder,低竞争用 AtomicLong。就像排队买票时多开几个窗口很快,但只有一个顾客时,开 20 个窗口反而是浪费。

为何常见:只看到“高并发提升数倍”的 Benchmark 数据,就忽略了适用条件。

面试题

Q1. 现代主流 CPU 的缓存行通常是多少字节?

单选题 · easy

  • A.8 字节
  • B.32 字节
  • C.64 字节← 正确答案
  • D.4 KB

答案:C。主流 x86/ARM 处理器的 L1/L2 缓存行都是 64 字节,这是缓存与主存之间搬运数据的最小单位。就像办公室“协作纸”的标准规格是 64 厘米宽,数据再小也要按整行搬运。

Q2. 什么是伪共享(False Sharing)?

单选题 · easy

  • A.多个线程同时读同一个变量,读到旧值
  • B.不同核心修改同一缓存行中各自独立的变量,导致整行频繁失效← 正确答案
  • C.多个变量共享一个锁对象
  • D.内存不足时变量被换出到磁盘

答案:B。伪共享的关键词是“虚假的共享”:变量之间没有数据依赖,只是物理上挤在同一条缓存行,一写全撕。它不产生错误结果,只产生性能损失,与数据竞争有本质区别。

Q3. 若线程 A 频繁修改 x、线程 B 频繁修改 y,要发生伪共享,x 与 y 必须满足什么条件?

单选题 · mid

  • A.x 和 y 类型相同且都是 long
  • B.地址落在同一条缓存行,且至少一方被写← 正确答案
  • C.x、y 都被 volatile 修饰
  • D.x、y 位于不同的缓存行

答案:B。伪共享需要“同处一行 + 至少一方写”。只有读没有写不会触发写失效。volatile 不是必要条件,会导致失效的恰恰是“写”。张三李四必须共用同一张纸,且至少一人动笔,才会互相作废。

Q4. MESI 协议中,核 B 持有某缓存行的 S 状态副本,此时核 A 对该行执行写入,核 B 的副本状态会变成什么?

单选题 · mid

  • A.M(修改)
  • B.E(独占)
  • C.S(共享)
  • D.I(无效)← 正确答案

答案:D。核 A 写之前会广播失效消息,核 B 听到后把自己的副本标记为 I。这正是伪共享伤人的机制:张三写左栏,李四手里的整张纸被盖上“作废”章,下次访问必须重新复印。

Q5. LongAdder 在结构上是如何规避伪共享的?

多选题 · mid

  • A.使用 base 加 Cell 数组进行分片计数← 正确答案
  • B.Cell 使用 @Contended 隔离缓存行← 正确答案
  • C.数组中相邻 Cell 共享同一条缓存行
  • D.每个 Cell 独占一条缓存行← 正确答案

答案:ABD。LongAdder 把计数摊到 base 和多个 Cell 上,每个 Cell 都标注 @Contended,独占一条 64 字节缓存行。相邻 Cell 共享缓存行(C)恰恰是它要避免的,会让分片失效。

Q6. JDK 8 中,@sun.misc.Contended 默认会在被注解字段的前后各插入多少字节填充?

单选题 · hard

  • A.32 字节
  • B.64 字节
  • C.128 字节← 正确答案
  • D.256 字节

答案:C。默认前后各 128 字节,等于两侧各留一条 64 字节缓存行的“真空走廊”,保证任何相邻字段都无法与它共享一行。这个 128 = 2 × 64 的设计,就是给热点字段配了两道隔离带。

Q7. 在 JDK 8 中,如果想在自己的类里使用 @Contended,需要添加哪个 JVM 参数?

单选题 · easy

  • A.-XX:-RestrictContended← 正确答案
  • B.-XX:+UseConcMarkSweepGC
  • C.-Xmx4g
  • D.-XX:-UseCompressedOops

答案:A。@Contended 默认只对 JDK 内部类放行,用户类需要 -XX:-RestrictContended 解除限制。就像“隔离工位卡”默认只发给内部员工,外部人员需先通过门禁审批。

Q8. 下列哪些措施可以缓解或消除伪共享?

多选题 · mid

  • A.在热点变量后添加 padding 填充← 正确答案
  • B.用 LongAdder 分片计数← 正确答案
  • C.给所有共享字段加上 volatile
  • D.调整字段排列顺序,隔离冷热字段← 正确答案

答案:ABD。填充和调整字段布局都能让热点变量独占缓存行;LongAdder 通过分片加 @Contended 化解竞争。volatile 只保证可见性,不隔离缓存行,反而可能加剧写失效,因此 C 错。

Q9. 关于 MESI 缓存一致性协议,下列说法正确的有?

多选题 · hard

  • A.本地读未命中且其他核无副本时,新载入的行进入 E 状态← 正确答案
  • B.E 状态本地写后进入 M 状态← 正确答案
  • C.S 状态被其他核写后进入 I 状态← 正确答案
  • D.M 状态被其他核读取时无需任何总线操作

答案:ABC。读未命中且无人持有副本时独享为 E;独占后本地写升级为 M;共享副本被对端写会作废为 I。而 M 状态被其他核读时,需要写回主存或缓存间转发,存在明显总线开销,所以 D 错。

Q10. 8 线程基准测试中,各写同一缓存行不同变量的伪共享版本,相比填充后版本通常表现如何?

单选题 · hard

  • A.两者耗时完全相同
  • B.伪共享版本更快
  • C.伪共享版本慢数倍甚至一个数量级← 正确答案
  • D.只取决于操作系统版本

答案:C。按带宽公式 B = N_write × (K-1) × 64B,8 线程每次写会波及 7 个核的副本,失效和重取呈线性放大,实践中常慢 5~10 倍。操作系统版本对缓存行大小有影响,但不是决定性因素。