线程本地缓存?CPU缓存!

张开发
2026/4/15 23:15:31 15 分钟阅读

分享文章

线程本地缓存?CPU缓存!
——彻底揭开Java可见性问题的硬件真相前言一个流传甚广的误解在Java并发编程的学习中几乎每个人都会遇到这样的描述“每个线程有自己的本地内存线程对共享变量的操作在本地内存中进行而不是直接在主内存中。”这个说法不能说完全错误但它带来了一个长期的困惑线程真的有“本地内存”吗如果你去查JVM的内存结构会发现线程私有的区域有三个程序计数器、虚拟机栈、本地方法栈。但它们跟可见性问题中的“本地内存”似乎又不是一回事。今天我们从硬件层面彻底搞清楚那个所谓的“线程本地内存”到底是什么一、先上结论线程没有本地内存在物理硬件层面不存在“线程的本地内存”这个东西。线程是一个软件概念是操作系统调度的最小执行单元。它本身不拥有任何硬件资源。那么那个被反复提到的“本地内存”到底指什么答案是当前执行该线程的那个CPU核心的私有缓存。常见说法抽象层硬件真相物理层主内存RAM内存条线程本地内存 / 工作内存CPU核心的L1/L2缓存本地内存失效CPU缓存行被标记为无效写回主内存缓存脏数据冲刷到RAM重新加载到本地内存从RAM重新加载到CPU缓存Java内存模型JMM中的“工作内存”是对CPU缓存行为的抽象封装。二、硬件视角你的电脑到底长什么样以一台典型的8核电脑为例┌─────────────────────────────────────────────────────────┐ │ RAM主内存 │ │ 所有线程共享的数据老家 │ └───────────┬───────────┬───────────┬─────────────────────┘ │ │ │ ┌───────▼──────┐ ┌───▼────────┐ ┌▼──────────────────┐ │ CPU核心0 │ │ CPU核心1 │ │ ... CPU核心7 │ │ ┌─────────┐ │ │ ┌────────┐ │ │ │ │ │L1/L2缓存│ │ │ │L1/L2缓存│ │ │ │ │ └─────────┘ │ │ └────────┘ │ │ │ └─────────────┘ └────────────┘ └────────────────────┘关键事实每个CPU核心有自己的私有L1/L2缓存8核 8套独立的缓存系统线程被调度到哪个核心就用哪个核心的缓存这就是真相所谓的“线程本地内存”其实是“CPU核心缓存”。线程只是临时租用而已。三、一个完整的故事变量在线程间传递的旅程场景线程A和线程B访问同一个共享变量count第一步线程A运行在CPU核心0线程A要读取count的值CPU核心0的缓存中没有这个变量冷启动从RAM加载count0到核心0的L1缓存线程A对count进行1操作缓存中的值变为1关键这个1还停留在核心0的缓存中没有写回RAM第二步线程B运行在CPU核心1线程B也要读取count的值CPU核心1的缓存中也没有这个变量从RAM加载count——读到的还是0线程B基于0进行计算产生错误结果这就是可见性问题的硬件根源多个CPU核心各自持有同一份数据的缓存副本一个核心的修改对另一个核心不可见。四、线程切换的细节同一个CPU核心呢有人可能会问如果线程B被调度到同一个CPU核心0呢答案是仍然可能出问题只是原因不同。线程A执行完后count1留在核心0的缓存中线程B被调度到核心0可能直接命中缓存中的旧值但如果线程B被调度到核心0时该核心的缓存已经被其他线程的数据覆盖或冲刷情况会更复杂本质结论可见性问题的根源不在于“不同的CPU核心”而在于存在多个可能不一致的缓存副本而主内存只有一个。五、Java的解决方案volatile与内存屏障理解了硬件原理volatile的作用就非常清晰了。不加volatile的情况privatestaticbooleanrunningtrue;// 普通变量线程A修改running false新值停留在CPU核心A的缓存中线程B读取running从自己的缓存或RAM读到的还是true无限循环线程B永远看不到修改加上volatile之后privatestaticvolatilebooleanrunningtrue;写入volatile变量时CPU会执行一个内存屏障指令强制做两件事冲刷Flush将当前CPU核心缓存中该变量的新值立即写回RAM失效Invalidate通过缓存一致性协议如MESI通知其他所有CPU核心你们缓存中的这个变量副本已经过期了必须标记为无效其他线程再读取时发现缓存中的副本已失效被迫从RAM重新加载从而看到最新值。六、一图胜千言完整的数据流向┌─────────────────────────────────────────────────────────────────┐ │ 主内存RAM │ │ 共享变量 count 最终的真相 │ │ ▲ │ │ 写回冲刷 │ 重新加载失效后 │ │ │ │ │ ┌────────────────────┴────────────────────┐ │ │ │ │ │ │ ┌────▼────┐ ┌─────▼────┐ │ │ │ CPU核心0 │ │ CPU核心1 │ │ │ │ 缓存 │◄───── 缓存一致性协议 ────────►│ 缓存 │ │ │ │ count1 │ MESI通知失效 │ count0 │ │ │ └────┬────┘ └─────┬────┘ │ │ │ │ │ │ 线程A运行 线程B运行 │ │ 修改 count1 看到 count0 │ │ 修改在自己缓存中 错误旧值 │ │ │ └─────────────────────────────────────────────────────────────────┘加上volatile后核心0写入时通知核心1的缓存失效核心1被迫重新从RAM加载。七、与JVM私有组件的彻底区分现在可以清楚地回答一个经典困惑程序计数器、虚拟机栈、本地方法栈跟“本地内存”有关系吗组件类型与可见性的关系说明程序计数器JVM物理区域无关只记录字节码行号不涉及共享数据虚拟机栈JVM物理区域无关局部变量是线程私有的其他线程看不到本地方法栈JVM物理区域无关同上服务于native方法JMM“本地内存”抽象概念核心相关硬件上对应CPU缓存是可见性问题的根源一句话总结JVM的私有组件是逻辑隔离软件层面CPU缓存是物理隔离硬件层面。前者不产生可见性问题后者才是。八、实践意义理解硬件才能写好并发代码这个认知转变带来的实际好处1. 理解为什么volatile不能保证原子性volatileintcount0;count;// 不是原子的count 读取 → 修改 → 写入三步操作即使在写入时使用了内存屏障读取和修改之间仍可能被其他线程插入2. 理解synchronized的可见性保证synchronized在退出同步块时也会强制将工作内存中的修改冲刷到主内存所以它既能保证原子性也能保证可见性。3. 理解 happens-before 规则的硬件基础volatile变量规则对volatile的写入 happens-before 后续对它的读取 → 由内存屏障实现监视器锁规则解锁 happens-before 后续加锁 → 由内存屏障缓存冲刷实现九、最终总结一张表终结所有困惑你的疑惑答案线程有本地内存吗没有。线程是软件概念不拥有硬件资源。那“本地内存”指什么当前执行线程的CPU核心的私有缓存JMM的抽象叫法。8核电脑有几个“本地内存”8个每个核心一套L1/L2缓存。线程切换会带走本地内存吗不会。缓存属于CPU核心线程离开就失去了对该缓存的“使用权”。JVM的栈/PC跟它有关吗无关。那是软件层面的线程私有区域不涉及可见性问题。怎么保证可见性volatile、synchronized、Lock—— 它们都会触发内存屏障。写在最后“线程本地缓存不是CPU缓存”这个认知转变看似只是一个名词纠正但它代表着你对并发编程的理解从JVM规范层面下沉到了硬件架构层面。当你再看到“线程本地内存失效”时脑海中浮现的不再是一个模糊的软件概念而是一个清晰的物理画面某个CPU核心的缓存行被标记为无效另一个核心的修改通过内存屏障被推送到RAM当前核心被迫放弃自己的过期副本重新从主内存加载。这才是并发编程的真相。延伸阅读CPU缓存一致性协议MESI协议内存屏障Store Barrier、Load Barrier、Full BarrierJSR-133Java内存模型与线程规范修订如果这篇文章帮你彻底搞懂了“本地内存”欢迎点赞、收藏、转发让更多同学看到硬核的真相。

更多文章