首页 / 资讯中心 / 文章详情

从count++到MESI:深入硬件层理解Java并发三大特性

从count++到MESI:深入硬件层理解Java并发三大特性 ★ FEATURED ARTICLE
1. 从一行count说起为什么并发问题总在硬件层埋雷很多人学并发编程第一反应是去翻《Java并发编程的艺术》这类书或者直接背synchronized、volatile、CAS的八股文。背完之后面试能过但一上生产环境遇到偶发的数据错乱、死循环、状态不一致还是两眼一抹黑。我自己带过几个项目线上出现过库存扣减少扣、计数器丢更新、状态标志位改了但另一个线程死活读不到的情况排查到最后根子都不在Java代码写得漂不漂亮而在对硬件层面发生了什么缺乏直觉。这篇东西就是想把这条链路从头捋一遍。核心就一句话并发编程里的原子性、可见性、有序性不是Java发明出来的概念而是对CPU、缓存、内存这套硬件行为的抽象和补救。你只有先看懂硬件在干什么才能理解为什么count这种看起来只有一行的代码在多线程下会出错才能明白volatile到底解决了什么、没解决什么才能在选择锁、原子类、还是无锁结构时做出有依据的判断。内容会从最基础的count拆起讲到CPU缓存一致性协议、内存屏障、指令重排再回到Java内存模型JMM是怎么把这些硬件规则包装成程序员能用的语义。适合已经会写多线程代码、但总觉得“知其然不知其所以然”的开发者也适合准备系统补一遍并发底层知识的人。不需要你懂电路但需要你愿意跟着把一层层抽象剥开。我尽量不堆术语每个概念都用生活化的类比先讲清楚“它在干嘛”再落到实际的代码和参数上。你看完之后至少能做到看到一段并发代码能判断它有没有原子性漏洞、可见性漏洞、有序性漏洞以及该用什么手段去补。2. 一行代码背后的三层真相原子性、可见性、有序性到底是什么2.1count不是一步而是三步先看这段再普通不过的代码public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }单线程跑increment()调一万次count就是一万毫无悬念。但只要你开两个线程各调五千次最后的结果大概率小于一万而且每次跑出来的数还不一样。这不是玄学是因为count在硬件层面根本不是“一个动作”。它至少拆成三步读把count当前的值从内存或缓存加载到CPU寄存器算在寄存器里做加一运算写把算好的新值写回内存或缓存。这三步之间随时可能被其他线程插进来。比如线程A读到count5还没来得及写回线程B也读到count5两个线程各自加一都写回6。两次自增结果只加了一次。这就是丢失更新也是原子性被破坏最经典的例子。注意很多人以为“一行代码”就等于“一个原子操作”这是并发编程里最贵的一个误解。判断原子性要看这行代码在字节码甚至机器指令层面被拆成了几步而不是看它在源码里占几行。2.2 原子性要么全做完要么当没发生原子性的定义很直白一个操作或一组操作要么全部执行完中间不被打断要么完全不执行不存在“执行了一半”的中间状态被外界看到。count不满足原子性因为它的读、算、写三步可以被穿插。那什么满足单个的int赋值count 5在32位JVM上通常是原子的因为int是32位一次总线写就能完成但long和double在32位机器上就可能被拆成两次32位写反而不是原子的——这也是为什么JMM规定long/double的非volatile读写不保证原子性。解决原子性的手段从硬件到软件有好几层硬件层CPU提供LOCK前缀指令、CMPXCHG比较并交换这类原子指令语言层Java的synchronized、ReentrantLock通过锁把临界区串行化无锁层AtomicInteger这类原子类底层靠CAS循环重试。后面会详细讲CAS为什么能保证原子性以及它的ABA问题。2.3 可见性一个线程改了另一个线程凭什么能看见再看一个更隐蔽的例子public class VisibilityDemo { private boolean flag false; public void writer() { flag true; } public void reader() { while (!flag) { // 空转等待 } System.out.println(flag 已变为 true); } }一个线程调writer()把flag设为true另一个线程在reader()里死循环等flag变真。单看逻辑没毛病但实际跑起来reader()很可能永远出不来哪怕writer()早就执行完了。原因在于flag true这个写操作可能只写进了CPU的写缓存store buffer还没刷到主内存而reader()读flag时可能一直从自己的本地缓存里读旧值压根没去主内存看。两个线程各看各的缓存谁也不知道对方改了。这就是可见性问题一个线程对共享变量的修改另一个线程不一定能立刻看到。硬件上现代CPU为了弥补内存和CPU之间的速度鸿沟加了好几级缓存L1、L2、L3。每个核心有自己的L1/L2共享L3。数据被读进来后会缓存在本地写的时候也先写本地缓存。这就导致不同核心看到的同一份数据可能不一致。解决可见性硬件靠缓存一致性协议如MESI软件靠内存屏障和volatile关键字。volatile的本质就是告诉编译器和CPU这个变量的读写不许缓存、不许重排必须直接和主内存打交道。2.4 有序性代码写的顺序不一定是执行的顺序第三个坑是有序性。编译器和CPU为了提升性能都会对指令做重排。编译器重排是在编译期调整指令顺序CPU重排是在运行时乱序执行。只要重排不改变单线程的执行结果它们就认为“没问题”。问题是单线程没问题多线程就出事了。经典的双重检查锁单例public class Singleton { private static Singleton instance; public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); // 问题在这 } } } return instance; } }instance new Singleton()这行实际分三步分配内存空间在内存上初始化对象把instance指向这块内存。编译器或CPU可能把第2步和第3步重排成先让instance指向内存再初始化对象。这时候另一个线程进来判断instance ! null直接返回了一个还没初始化完的对象用的时候各种空指针、字段是默认值。这就是有序性被破坏的后果。volatile能禁止这种重排因为它会在读写前后插入内存屏障。2.5 三者关系一张表说清问题本质典型表现硬件根源常用解决手段原子性操作被拆散、被穿插count丢更新读改写非原子指令锁、CAS、原子类可见性改了别人看不到死循环读不到新值CPU多级缓存volatile、锁、内存屏障有序性执行顺序被重排半初始化对象逸出编译器/CPU指令重排volatile、锁、内存屏障这三者不是孤立的。volatile同时管可见性和有序性但不管原子性——volatile int count做count照样丢更新。锁三者都管但代价高。理解它们的边界才能选对工具。3. 硬件层拆解CPU缓存、MESI协议与内存屏障3.1 为什么要有缓存一个速度对比就懂了CPU和主内存之间的速度差距用数字说话最直观存储层级典型访问延迟相对CPU周期CPU寄存器约0.3纳秒1个周期L1缓存约1纳秒3-4个周期L2缓存约4纳秒10-12个周期L3缓存约15纳秒40-50个周期主内存约100纳秒200-300个周期CPU执行一条指令可能只要零点几纳秒但去主内存取一次数据要等上百纳秒。如果每次读写都直接怼主内存CPU大部分时间都在干等。所以硬件设计上加了多级缓存把热点数据放在离核心最近的地方。问题也随之而来缓存是每个核心私有的。核心A改了变量X改的是自己L1里的副本核心B读X读的是自己L1里的旧副本。两边不一致程序就乱了。缓存一致性协议就是来解决这个问题的。3.2 MESI协议四种状态管住一份缓存行MESI是目前最主流的缓存一致性协议名字来自缓存行的四种状态MModified已修改这行数据被当前核心改过和主内存不一致只有我这个核心有最新值EExclusive独占这行数据和主内存一致且只有我这个核心缓存了它SShared共享这行数据和主内存一致多个核心都缓存了它IInvalid无效这行数据已失效不能用。核心之间的状态流转大致是这样核心A要写一个处于S状态的缓存行得先发消息让其他缓存了这行的核心把它置为I自己升到M。核心B要读一个处于M状态的行得等A把数据刷回主内存或者直接从A那里拿然后双方都变成S。这套机制保证了最终一致但注意是“最终”不是“立刻”。从A写完到B看到中间有消息传递的延迟。而且MESI只保证缓存行级别的一致不保证指令执行顺序也不保证写操作何时对其他核心可见。这就是为什么还需要内存屏障。实操心得MESI的粒度是缓存行通常是64字节。这意味着如果你有两个变量恰好落在同一个缓存行里一个核心改变量A会导致另一个核心缓存里的变量B也失效——这就是伪共享False Sharing。高并发计数器场景下伪共享能让性能掉好几倍。解决办法是用Contended注解或手动填充把热点变量隔到不同缓存行。3.3 写缓存与失效队列可见性延迟的真正来源光有MESI还不够。如果每次写都要等其他核心确认失效CPU会慢得没法看。所以硬件又加了两个缓冲区写缓存Store Buffer核心写数据时先写进这里不等缓存行失效确认完成就继续执行后面的指令失效队列Invalidate Queue核心收到失效消息后先放进队列回一个ACK等有空再真正处理。这两个缓冲区的存在让写操作和失效处理都变成了异步的。后果就是核心A写了变量XX可能还在A的写缓存里没进缓存行核心B收到了失效消息但还没处理读X时读到的还是旧值。可见性问题在硬件层就是这么来的。要强制把写缓存刷出去、把失效队列处理掉就需要内存屏障。3.4 内存屏障四种屏障各管什么内存屏障Memory Barrier/Fence是CPU提供的指令用来约束读写操作的顺序和可见性。常见的有四种屏障类型作用典型指令x86LoadLoad禁止前面的读和后面的读重排通常隐含StoreStore禁止前面的写和后面的写重排通常隐含LoadStore禁止前面的读和后面的写重排通常隐含StoreLoad禁止前面的写和后面的读重排并刷新写缓存mfence/lock前缀x86架构比较强只对StoreLoad重排敏感所以很多屏障在x86上退化成空操作或轻量指令。但在ARM这类弱内存模型架构上四种屏障都得显式加。这也是为什么同一段并发代码在x86服务器上跑得好好的换到ARM机器上就出问题——内存模型强弱不同。Java的volatile写会在前面插StoreStore、后面插StoreLoadvolatile读会在前面插LoadLoad、后面插LoadStore。这些屏障最终映射到具体CPU指令保证volatile语义。3.5 指令重排CPU为什么要“自作主张”CPU乱序执行是为了填满流水线。一条指令在等数据的时候后面的指令如果和它没依赖就可以先执行。编译器重排则是为了优化寄存器使用和减少内存访问。重排遵守一个底线不改变单线程的语义。也就是在单线程视角下重排后的结果和顺序执行一样。但多线程视角下其他线程可能观察到中间状态就出问题了。举个生活化的例子你让助理先“把文件从柜子里拿出来”再“把文件放到桌上”。单看你这个指令序列助理先拿后放没问题。但如果助理为了快先把桌上腾空这步和拿文件没依赖再拿文件再放——单看你交代的任务结果一样。可如果这时候另一个人正好要用桌子就会看到“桌子突然空了但文件还没来”的中间状态。指令重排就是这个“自作主张的助理”。4. 从硬件回到JavaJMM如何把硬件规则翻译成代码语义4.1 JMM是什么一份给程序员的“硬件行为说明书”Java内存模型Java Memory ModelJMM不是真实存在的硬件而是一套规范。它规定了多线程环境下一个线程对共享变量的写在什么条件下对另一个线程可见也规定了哪些重排是允许的、哪些是禁止的。JMM的核心抽象是主内存和工作内存每个线程有自己的工作内存对应CPU缓存和寄存器共享变量存在主内存。线程读写变量都得经过工作内存。这套抽象把复杂的硬件细节屏蔽掉让程序员用统一的语义写代码。但JMM不是凭空造的它是对各种硬件内存模型的折中。它允许一定程度的重排为了性能但通过happens-before规则约束哪些重排不能做。4.2 happens-before判断可见性的黄金法则happens-before是JMM里最重要的概念。如果操作A happens-before 操作B那么A的结果对B可见且A的执行顺序在B之前。注意这是逻辑上的先后不是物理时间上的先后。几条核心规则程序顺序规则同一个线程内前面的操作happens-before后面的操作监视器锁规则对一个锁的解锁happens-before后续对这个锁的加锁volatile变量规则对volatile变量的写happens-before后续对这个变量的读传递性A hb BB hb C则A hb C线程启动规则Thread.start() happens-before 新线程里的所有操作线程终止规则线程里的所有操作happens-before其他线程检测到该线程终止。实际写代码时判断两个操作有没有可见性保证就套这几条规则。比如线程A在synchronized块里改了count线程B之后进入同一个锁的synchronized块读count根据监视器锁规则A的修改对B可见。这就是锁既保证原子性又保证可见性的原因。4.3 volatile的双重语义可见性 有序性但不含原子性volatile在JMM里的语义有两条可见性写volatile变量时会把该线程工作内存里的值刷到主内存读volatile变量时会从主内存重新读并让本地缓存失效有序性volatile读写前后会插入内存屏障禁止特定类型的重排。但volatile不保证原子性。volatile int count; count;依然是读改写三步多线程下照样丢更新。很多人栽在这个点上。那volatile适合什么场景状态标志位一个线程改volatile boolean running false其他线程能立刻看到并退出循环双重检查锁单例volatile修饰instance禁止new过程中的重排一次性发布写volatile变量前的所有操作对读该变量的线程可见。注意volatile的可见性保证是“写之后读能看到”不是“写的同时读立刻看到”。它保证的是happens-before关系不是实时性。别把它当成线程间同步的万能药。4.4 synchronized与锁内存语义进出门各刷一次synchronized的内存语义可以概括为加锁清空工作内存中共享变量的副本从主内存重新读取解锁把工作内存中修改过的共享变量刷回主内存。所以synchronized块里的操作对之后进入同一锁的线程完全可见。加上它本身保证临界区串行执行原子性、可见性、有序性三者全包。代价是性能。虽然JVM做了偏向锁、轻量级锁、锁消除等优化但高竞争下还是比CAS重。选型时如果临界区很短、竞争不激烈synchronized够用且代码简单如果竞争激烈或临界区长考虑ReentrantLock或原子类。4.5 final字段的特殊语义构造完成即安全发布final字段有一条特殊规则只要对象在构造过程中没有把this引用逸出那么其他线程看到这个对象时final字段一定已经初始化完成。这条规则是为了解决“半初始化对象逸出”问题。普通字段可能因为重排让其他线程看到默认值final字段则被JMM特殊保护构造完成后对其他线程可见的就是最终值。但前提是“构造过程中this没逸出”。如果你在构造函数里把this传给别的线程或注册到某个全局列表final的保护就失效了。这是很多人忽略的坑。5. 实操验证用代码亲手复现三大问题5.1 复现原子性丢失多线程countpublic class AtomicityTest { private static int count 0; public static void main(String[] args) throws InterruptedException { int threads 10; int perThread 10000; Thread[] ts new Thread[threads]; for (int i 0; i threads; i) { ts[i] new Thread(() - { for (int j 0; j perThread; j) { count; } }); ts[i].start(); } for (Thread t : ts) t.join(); System.out.println(期望: (threads * perThread)); System.out.println(实际: count); } }跑几次实际值基本都小于100000而且每次不一样。这就是原子性丢失。把count换成AtomicInteger.incrementAndGet()结果就稳定是100000。5.2 复现可见性丢失死循环读不到标志位public class VisibilityTest { private static boolean flag false; public static void main(String[] args) throws InterruptedException { Thread reader new Thread(() - { while (!flag) { // 空转 } System.out.println(reader 退出循环); }); reader.start(); Thread.sleep(1000); flag true; System.out.println(main 已设置 flag true); } }在部分JVM和机器上reader线程会一直卡在循环里哪怕main已经打印了设置完成。把flag加上volatile问题消失。实操心得这个实验在不同JDK版本、不同CPU上表现不一样。有的机器上不加volatile也能退出因为JIT还没优化到那一步或者缓存刷新恰好及时。别因为“我这儿跑着没问题”就认为代码是对的。并发bug的特点就是偶发测试环境复现不了不代表生产不会炸。5.3 复现有序性丢失半初始化对象逸出public class ReorderTest { private static ReorderTest instance; private int value; private ReorderTest() { value 42; } public static ReorderTest getInstance() { if (instance null) { synchronized (ReorderTest.class) { if (instance null) { instance new ReorderTest(); } } } return instance; } public int getValue() { return value; } }理论上另一个线程可能拿到instance ! null但value还是0的对象。实际复现比较难因为需要JIT重排和特定时序配合。但这不是理论问题是真实存在的风险。给instance加volatile就能杜绝。5.4 三种修复方案对比方案原子性可见性有序性性能适用场景synchronized保证保证保证中低临界区长、竞争一般volatile不保证保证保证高状态标志、DCLAtomicInteger保证保证保证高计数器、无锁结构选型逻辑先看要不要原子性。要原子性且操作简单自增、CAS用原子类要原子性且临界区复杂用锁。不要原子性只要可见性用volatile。6. 常见问题与排查技巧实录6.1 常见问题速查表现象可能原因排查方向解决手段计数器结果偏小原子性丢失检查读改写操作原子类或锁线程卡在循环出不来可见性丢失检查共享标志位volatile或锁偶发空指针/字段默认值有序性丢失检查对象发布volatile或final高并发下性能骤降伪共享检查热点变量布局填充或Contended换机器后行为不一致内存模型差异确认目标架构显式加屏障/volatile6.2 排查并发问题的三个习惯第一先怀疑共享可变状态。只要有多线程读写同一个变量且没有同步手段就先假设它有问题。别急着看业务逻辑。第二用工具而不是肉眼。jstack看线程栈jconsole/VisualVM看线程状态JOLJava Object Layout看对象内存布局。伪共享这种问题肉眼根本看不出来得靠工具。第三压力测试要够狠。并发bug往往在低负载下不出现。测试时线程数、循环次数、运行时长都要拉上去最好在和生产同架构的机器上跑。6.3 几个容易踩的坑以为volatile能替代锁volatile只管可见性和有序性复合操作照样丢更新以为局部变量线程安全就万事大吉如果局部变量引用了共享对象照样有并发问题以为加了synchronized就高枕无忧锁的对象不对比如锁了不同的实例、锁范围不对都白搭忽略伪共享高并发计数器、队列头尾指针这类场景伪共享能让性能差一个数量级在32位JVM上忽略long/double非volatile的long/double读写不保证原子性可能读到半个值。实操心得我排查过一个线上问题库存扣减偶尔少扣。代码里用了synchronized看起来没问题。最后发现锁的是this但服务是多实例部署每个实例锁自己的对象跨实例根本没互斥。这种问题光看代码看不出来得结合部署架构一起分析。并发问题从来不只是代码问题。6.4 性能与安全的权衡加同步一定影响性能但不加同步可能数据错乱。权衡的原则是能不用共享状态就不用用线程封闭ThreadLocal或消息传递必须共享时优先用不可变对象需要可变共享时按竞争程度选低竞争用synchronized高竞争用原子类或分段锁性能敏感的热点路径考虑无锁结构但要充分测试。没有银弹。每个选择都有代价关键是知道代价是什么。7. 我个人在实际操作中的体会把硬件层的东西捋一遍之后最大的收获不是记住了MESI的四种状态而是建立起一种判断直觉看到一段并发代码脑子里会自动问三个问题——这个操作是原子的吗改了别人能看到吗执行顺序会不会被重排这种直觉比背八股文有用得多。面试问volatile你能答出它插了什么屏障、对应x86的哪条指令、为什么不管原子性和只答“保证可见性不保证原子性”深度完全不一样。另外一点体会是并发问题一定要动手复现。光看书觉得懂了一写代码发现复现不出来或者复现出来了不知道怎么修。上面那几个实验建议你自己跑一遍改改参数看看不同JDK版本的表现。踩过坑的记忆比看十篇文章都牢。最后分享一个小技巧如果你不确定一段代码有没有可见性问题先给它加上最重的同步比如全用synchronized确认逻辑正确再逐步替换成更轻量的手段每换一次压测一轮。这样能把问题定位到具体的同步手段上而不是一上来就纠结用哪个。
阅读完成 · 觉得有帮助?
咨询建站