Java高并发底层原理(六)—— 多核 CPU 如何保持缓存一致

📅 2026/7/7 18:31:45
Java高并发底层原理(六)—— 多核 CPU 如何保持缓存一致
第6章 多核 CPU 如何保持缓存一致CAS 能够在不使用 Java 锁的情况下完成原子更新但它仍然需要硬件保证“比较”和“写入”不会被其他 CPU Core 同时破坏。问题在于现代 CPU 不会让每个 Core 都直接访问主内存。为了缩小 CPU 与内存之间的速度差距数据通常会先进入 Cache同一份数据也可能同时出现在多个 Core 的缓存中。当一个 Core 修改自己的缓存副本时其他 Core 如何知道旧副本已经失效两个 Core 同时准备修改同一份数据时硬件如何决定谁先获得写入权限这些问题由缓存一致性机制处理。1. 同一份数据为什么会有多个副本假设 Heap 中存在一个Counter对象classCounter{intcount0;}Thread A 运行在 Core 1Thread B 运行在 Core 2。两个线程都读取count时处理器可能把包含count的数据分别加载到两个 Core 的 Cache 中。┌──────────────────────┐ ┌──────────────────────┐ │ Core 1 │ │ Core 2 │ ├──────────────────────┤ ├──────────────────────┤ │ L1 Cache │ │ L1 Cache │ │ count 0 │ │ count 0 │ └──────────────────────┘ └──────────────────────┘ ╲ ╱ ╲ ╱ ╲ ╱ └──────────┬───────────┘ ↓ ┌────────────────────────┐ │ Main Memory │ ├────────────────────────┤ │ count 0 │ └────────────────────────┘从 Java 程序的角度看count只有一份从硬件执行过程看它可能存在多个缓存副本。Cache 的作用就是让 Core 尽量在附近读取数据而不是每次都等待主内存。不同处理器的缓存结构并不完全相同。常见设计是每个 Core 拥有私有的 L1、L2 Cache多个 Core 共享更大的末级缓存但具体层级和共享方式由硬件决定。无论结构如何变化只要同一份数据能够同时被多个 Core 缓存就需要解决副本一致性问题。2. CPU 以 Cache Line 为单位读取数据CPU Cache 通常不会只加载一个 Java 字段而是一次加载一整块连续数据这个基本单位称为Cache Line。常见桌面和服务器处理器的 Cache Line 为 64 字节但具体大小取决于硬件平台。假设程序只读取一个 4 字节的intCPU 仍然会把它所在的整条 Cache Line 加载到缓存中┌─────────────────────────────────────────────────────────────┐ │ Cache Line │ ├──────────────┬──────────────┬──────────────┬────────────────┤ │ count │ status │ version │ other data │ │ 4 bytes │ 4 bytes │ 8 bytes │ ... │ └──────────────┴──────────────┴──────────────┴────────────────┘这样设计是为了利用局部性。程序访问某个位置后通常还会继续访问附近的数据一次读取一整块往往比每次只读取几个字节更高效。缓存一致性协议跟踪的通常也是 Cache Line而不是单独的 Java 字段。Core 只修改其中一个字段时其他 Core 中对应的整条 Cache Line 都可能失效。3. 修改 Cache 不等于立即写回主内存现代 CPU 通常使用写回缓存策略。Core 修改数据时可以先修改自己的 Cache Line并把它标记为已修改稍后再写回主内存。这样可以把多次连续写入合并减少访问主内存的次数。例如Core 1 连续把某个值从0修改到1、2、3没有必要每次都等待主内存完成写入。只要硬件能够保证其他 Core 不再使用过期副本修改就可以先保留在 Cache 中。因此线程能否看到最新值不能只理解成“数据是否已经写回主内存”。更准确地说需要关注两个问题其他 Core 中的旧副本是否已经失效其他 Core 下一次读取时能否取得最新数据。缓存一致性机制负责协调这些缓存副本而不是要求所有读写都绕过 Cache 直接访问主内存。4. 缓存一致性协议要解决什么问题假设 Core 1 和 Core 2 都缓存了同一条 Cache Line。只要两个 Core 都只读取两个副本可以同时存在一旦某个 Core 准备修改硬件就必须保证其他 Core 不能继续把旧副本当作有效数据。缓存一致性机制主要解决两个问题同一条 Cache Line 不能同时被多个 Core 以可修改状态持有一个 Core 完成修改后其他 Core 不能继续使用旧副本。写入过程可以简化为Core 想要写入 ↓ 请求独占权限ownership ↓ 将其他 Core 的副本失效 ↓ 修改自己的 Cache Line ↓ 写回主内存可选这里的ownership是 Cache Line 的硬件修改权限不是 Java 对象锁。synchronized保护的是一段业务代码缓存一致性协议协调的是多个 Core 对同一条 Cache Line 的读写两者处于不同层次。5. MESI 的四种状态MESI 是一种经典的缓存一致性协议模型名称来自四种状态的首字母状态含义Modified当前 Core 独占并修改过这条 Cache Line内容可能与主内存不同Exclusive当前 Core 独占这条 Cache Line内容与主内存一致Shared多个 Core 可以持有相同的只读副本Invalid当前副本已经失效不能继续读取真实处理器可能使用 MESIF、MOESI 或其他扩展协议并加入更多优化状态。本章使用 MESI 只是为了建立一个清晰模型重点不是记住每一种状态转换而是理解共享读取、独占修改和副本失效。6. 多个 Core 读取同一份数据假设 Core 1 首先读取count并且其他 Core 中没有对应副本Core 1 可能以 Exclusive 状态持有这条 Cache LineCore 1: Exclusive Core 2: Invalid随后 Core 2 也读取count。两个 Core 都只读取不会破坏数据因此它们可以同时持有 Shared 状态的副本Core 1: Shared Core 2: Shared┌──────────────────────┐ ┌──────────────────────┐ │ Core 1 │ │ Core 2 │ ├──────────────────────┤ ├──────────────────────┤ │ State: Shared │ │ State: Shared │ │ count 0 │ │ count 0 │ └──────────────────────┘ └──────────────────────┘Shared 状态允许多个 Core 并发读取。只要没有 Core 发起写入这些副本都可以继续使用。7. 一个 Core 修改数据时会发生什么如果 Core 1 准备把count从0修改为1它不能直接在 Shared 状态下写入因为 Core 2 仍然持有有效副本。Core 1 必须先取得独占修改权限并让 Core 2 的副本失效。┌──────┬──────────────────────────────┬──────────────────────────────┐ │ Step │ Core 1 │ Core 2 │ ├──────┼──────────────────────────────┼──────────────────────────────┤ │ 1 │ Shared, count 0 │ Shared, count 0 │ │ 2 │ Request ownership │ │ │ 3 │ │ Invalidate local copy │ │ 4 │ Modified, count 1 │ Invalid │ └──────┴──────────────────────────────┴──────────────────────────────┘Core 2 再次读取count时会发现自己的 Cache Line 已经处于 Invalid 状态不能继续返回旧值0。它必须重新取得有效数据最终看到 Core 1 修改后的结果。在许多处理器中最新的 Cache Line 可以通过 CPU 内部的缓存一致性互连直接从 Core 1 的 Cache 传递给 Core 2不需要先写回主内存再重新读取。这些失效通知和状态转换由处理器自动完成。Java 程序不需要手动清除 CPU Cache也不能通过普通代码直接控制某条 Cache Line 的 MESI 状态。8. 两个 Core 同时修改会发生什么如果 Core 1 和 Core 2 同时准备修改处于 Shared 状态的同一条 Cache Line它们都会请求独占权限。硬件不能同时批准两个请求否则两个 Core 会各自修改自己的副本再次产生不一致。处理器会通过内部互连结构对请求进行协调只有一个 Core 能先取得这条 Cache Line 的修改权限。另一个 Core 的副本会失效它必须等待或重新取得最新状态后再尝试。Core 1 requests ownership ─┐ ├──→ Hardware arbitration Core 2 requests ownership ─┘ ↓ One request succeeds ↓ Other copy becomes Invalid多个 Core 频繁修改同一条 Cache Line 时修改权限会不断在 Core 之间转移。Cache Line 反复失效、重新获取常被形容为在不同 Core 之间“来回弹跳”。即使程序只修改一个整数硬件仍然需要协调一整条 Cache Line。9. CAS 为什么需要硬件支持CAS 必须原子地完成“比较当前值”和“写入新值”。假设 Core 1 执行CAS(expected 0, newValue 1)处理器需要保证在比较当前值是否为0并决定是否写入1的过程中其他 Core 不能同时完成冲突写入。否则比较成功后再写入的结果仍然可能覆盖其他线程的修改。CAS 的硬件过程可以概括为请求修改权限 ↓ 比较当前值与期望值 ↓ 当前值等于期望值时写入新值 ↓ 返回成功或者失败不同 CPU 架构使用的实现方式并不完全相同。有些架构提供原子读改写指令有些架构通过一组配套指令完成条件更新。具体指令可以不同但都必须保证比较和写入作为一个原子动作完成。因此CAS 并不是完全不需要互斥。它没有使用 Java Monitor也不会先把整个方法锁住但硬件仍然需要协调目标内存位置的修改权限。更准确的说法是CAS 不使用线程级互斥锁而是依赖 CPU 提供的原子读改写能力。10. 为什么 CAS 竞争也有成本CAS 不会像synchronized那样先让一个线程独占整段临界区但多个 Core 同时更新同一个值时仍然会争夺对应 Cache Line 的修改权限。假设十个线程不断对同一个AtomicInteger自增。每一轮只有一个线程能够基于某个旧值更新成功其他线程的 CAS 会失败然后重新读取并重试。与此同时Cache Line 的所有权也可能在多个 Core 之间反复转移。CAS 的主要成本包括失败后的重复读取和计算持续占用 CPU 执行重试Cache Line 在多个 Core 之间频繁转移其他 Core 中对应副本反复失效。这也是 CAS 在低竞争场景中通常表现较好而在高竞争场景中可能出现明显性能下降的原因。它避免的是线程阻塞不是竞争本身。11. 缓存一致性不等于线程可见性缓存一致性协议能够保证同一条 Cache Line 的多个副本不会长期保持相互冲突的有效状态但这并不等于 Java 程序中的所有读写都会按照源码顺序立刻被其他线程观察到。处理器可能使用 Store Buffer 暂存写入编译器和 JVM 也可能在不破坏单线程语义的前提下调整指令顺序。例如data42;readytrue;另一个线程执行if(ready){System.out.println(data);}程序希望另一个线程看到ready true时data已经等于42。缓存一致性协议能够处理data和ready各自的缓存副本但它并不单独规定这两个写操作必须以什么顺序对其他线程可见。因此需要区分三个概念Cache Coherence同一内存位置的多个缓存副本如何保持一致Memory Ordering多个内存操作以什么顺序完成并对外可见Thread Safety所有合法执行顺序是否都能得到正确业务结果。缓存一致性是 Java 并发的硬件基础之一但不是完整答案。Java 还需要在 JVM 和语言层定义统一的内存规则。12. 什么是伪共享Cache Line 是缓存一致性协议维护的基本单位因此两个完全不同的变量只要位于同一条 Cache Line 中也可能互相影响。这种现象称为伪共享False Sharing。假设 Thread A 只修改leftThread B 只修改rightclassPairCounter{longleft;longright;}从业务逻辑看两个线程没有修改同一个字段但如果left和right位于同一条 Cache LineCore 1 修改left时会取得整条 Cache Line 的修改权限使 Core 2 中包含right的副本失效。Core 2 随后修改right又会让 Core 1 的副本失效。┌──────────────────────────────────────────────────────────────┐ │ Cache Line │ ├────────────────────────────┬─────────────────────────────────┤ │ left │ right │ │ written by Core 1 │ written by Core 2 │ └────────────────────────────┴─────────────────────────────────┘两个线程没有业务上的数据竞争却因为共享同一条 Cache Line 而产生硬件争用所以称为伪共享。它通常会造成 Cache Line 频繁失效、重新加载和迁移最终降低吞吐量。伪共享主要出现在高频计数器、队列游标和性能敏感的数据结构中。普通业务代码不应该仅凭字段相邻就随意增加填充因为对象布局还受到 JVM、字段重排、对象头和硬件平台影响。只有通过性能分析确认瓶颈后才需要专门处理。13. LongAdder 为什么适合高竞争计数AtomicInteger只有一个共享值。所有线程执行自增时都要对这个值进行 CASThread A ─┐ Thread B ─┼──→ AtomicInteger.value Thread C ─┤ Thread D ─┘线程较少时这种方式没有问题线程很多时大量 CAS 会集中在同一个变量上。一个线程更新成功后其他线程基于旧值执行的 CAS 会失败只能重新读取并重试。这个值所在的 Cache Line 也会在多个 Core 之间频繁转移。LongAdder不让所有线程一直竞争同一个值而是把总数拆成一个base和多个Cell┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ base │ Cell 0 │ Cell 1 │ Cell 2 │ Cell 3 │ ├──────────┼──────────┼──────────┼──────────┼──────────┤ │ 5 │ 8 │ 6 │ 7 │ 9 │ └──────────┴──────────┴──────────┴──────────┴──────────┘ ↓ sum 5 8 6 7 9刚开始没有竞争时线程会优先使用base效果与普通原子计数器类似base 0 ↓ increment base 1如果多个线程同时更新base其中一些线程 CAS 失败LongAdder就会创建Cell数组把后续更新分散到不同的 Cell 中Thread A ───→ Cell 0 Thread B ───→ Cell 2 Thread C ───→ Cell 1 Thread D ───→ Cell 3线程并不是永久绑定某个 Cell也不是一个 Core 固定对应一个 Cell。LongAdder会根据线程自身的探测值选择 Cell如果选中的 Cell 仍然竞争激烈还可以尝试其他位置并在需要时扩容 Cell 数组。每个 Cell 都有自己的数值线程通常只需要对选中的 Cell 执行 CAScell.valuecell.value1;实际更新仍然依赖 CAS只是竞争从一个共享变量分散到了多个 Cell。原来所有线程都争抢同一个位置现在不同线程更可能修改不同位置因此 CAS 失败次数和 Cache Line 争用都会减少。读取总数时LongAdder会把base和所有 Cell 相加longtotalbase;for(Cellcell:cells){totalcell.value;}使用方式如下LongAdderaddernewLongAdder();adder.increment();adder.add(10);longtotaladder.sum();这种拆分并没有减少自增次数而是把更新压力分散到多个内存位置因此LongAdder在大量线程频繁计数时通常比AtomicInteger更适合。不过调用sum()时其他线程仍然可能继续更新不同的 Cell所以汇总结果不一定对应某个严格瞬间的值。LongAdder适合请求次数、事件数量等统计场景不适合“余额是否足够”“库存是否大于零”这类需要读取后立即判断并更新的业务逻辑。本章总结现代 CPU 使用多级 Cache 缩小 Core 与主内存之间的速度差距同一份数据也可能同时存在多个缓存副本。缓存一致性协议通过跟踪 Cache Line 状态、协调修改权限和发送失效通知保证多个 Core 不会长期使用相互冲突的有效副本。本章的核心结论包括CPU 通常以 Cache Line 为单位读取和维护缓存数据修改可以先发生在 Cache 中不必每次立即写回主内存多个 Core 可以同时读取同一条 Cache Line但修改前必须取得独占权限MESI 使用 Modified、Exclusive、Shared 和 Invalid 描述缓存状态多个 Core 同时修改时硬件必须协调 Cache Line 的所有权CAS 依赖 CPU 提供的原子读改写能力CAS 避免了线程级锁但高竞争下仍然会发生重试和 Cache Line 争用缓存一致性只解决同一内存位置的副本一致不等于完整的线程可见性不同字段位于同一条 Cache Line 时可能发生伪共享LongAdder通过分散热点降低高并发计数时的 Cache Line 竞争。缓存一致性解决了多个 Core 如何围绕同一份数据协作的问题但还没有解释多个读写操作之间的可见顺序。下一章将继续分析 Java 中的可见性、指令重排和volatile。