1. 为什么需要JMM多线程Bug现场就是最好的引入先讲一个我前几天帮同事排查的例子。他写了一个很简单的计数器public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }开20个线程每个线程执行一万次increment跑完发现count根本不是20万有时候只有十几万有时候甚至更离谱。更诡异的是他在本地Windows上跑错误率没那么高一上Linux服务器数字波动得更厉害。这就是典型的一次编写到处踩坑。不是算法写错了不是线程没join干净而是Java在底层做了一系列我们看不见的优化这些优化在单线程环境下完全没问题一旦多线程共享变量就全爆了。1.1 眼见不为实被Java编译器和操作系统联手欺骗很多人都以为代码写出来是什么样执行就是什么样。实际上从你按下运行键开始代码要经历三个层面的篡改第一层是编译器优化。JVM在生成字节码、JIT编译成机器码时会通过指令重排序、寄存器缓存等手段提升执行效率。第二层是CPU层面的乱序执行。现代CPU为了填满流水线会动态调整指令执行顺序。第三层是CPU多级缓存架构导致不同核心看到的同一份内存数据可能不一致。这三层优化在单线程下是安全的因为最终结果和串行执行完全一致。可一旦多个线程同时操作同一个变量你写的先做什么再做什么的顺序以及读到的值是不是最新的全都变得不可靠。这就是我必须讲的第一个结论Java内存模型JMM不是一份技术规范文档而是一份Java和其他硬件/编译器之间达成的契约。它规定了什么时候、什么条件下一个线程对共享变量的修改对另一个线程是可见且有序的。没有这个契约多线程编程基本靠猜。1.2 JMM解决的核心三件事JMM围绕着三个特性展开这三个词在面试题里出现的频率非常高原子性一个或多个操作在CPU执行过程中不被中断要么全部执行成功要么全部不执行。典型的如i不是原子操作。可见性一个线程修改了共享变量的值其他线程能否马上看到有序性程序代码的执行顺序是否按源代码顺序编译器、CPU的指令重排序会打乱顺序。这三个特性单独看都不难难在它们之间会互相影响。比如volatile能保证可见性和有序性但保证不了原子性synchronized能同时保证三个但代价是锁的竞争开销。JMM真正的意义在于它给什么样的程序是正确同步的、什么样的操作是线程安全的下了一个明确的定义。你不需要关心底层CPU缓存具体怎么同步、JIT编译器具体怎么重排只需要按照JMM的规则去写程序就一定安全。这就是内存模型这个词的真正分量。2. JMM的底层框架主内存与工作内存的协作机制先记住一个抽象模型虽然它和物理硬件不完全对应但理解JMM全靠它。2.1 主内存和工作内存到底指什么JMM规定Java内存分为两部分主内存所有线程共享的内存区域存储所有共享变量的最新值。在物理实现上对应堆内存中的对象实例数据。工作内存每个线程私有的内存区域保存该线程用到的变量的副本。在物理实现上对应CPU缓存、寄存器、写缓冲区的组合。线程操作变量时不能直接读写主内存必须先把变量从主内存复制到自己的工作内存操作完再写回主内存。这就解释了一个经典现象线程A改了变量线程B读到的还是旧值。因为A改的是自己工作内存里的副本还没刷回主内存或者刷回了主内存但B的工作内存里还留着旧副本。我打个比方这就像公司里有个公告栏主内存每个员工手边有张便签纸工作内存。A员工在自己便签纸上改了方案内容忘记抄写到公告栏B员工过来看公告栏当然看不到更新。这时A说我改了呀B说我没看到呀两个人都没说谎但数据就是不一致了。2.2 8种内存交互操作JMM定义了8种原子操作用来描述变量在主内存和工作内存之间怎么传递。这8个动作是面试里比较细的考点但如果能理解它你对volatile什么时候刷回主内存这种问题会非常通透。操作作用lock锁定主内存变量标识为线程独占unlock解锁主内存变量解除独占read从主内存读取变量准备传输到工作内存load将read读到的值放入工作内存的变量副本use将工作内存的变量值传给执行引擎assign将执行引擎计算出的值赋给工作内存变量store将工作内存变量值传递给主内存准备写入write将store传来的值写入主内存变量一个变量从主内存旅行到工作内存再回去完整链路是这样的read - load - use执行引擎读取使用 assign - store - write执行引擎计算后写回加上lock、unlock负责在并发环境下对主内存变量加锁。JMM还规定了一些使用规则比如read和load、store和write必须成对出现不允许单独丢弃一个不允许线程把assign的最新数据在工作内存中滞留而不写回主内存新变量只能在主内存中诞生不能在私有工作内存中直接使用一个未初始化的变量。这些规则如果你记不住可以只在脑海里留一个概念工作内存就是线程的中间层它的存在是为了性能但带来了数据同步问题。JMM要做的就是在性能和一致之间找到平衡点。2.3 千万别把JMM和Java内存结构搞混这里必须专门辟个谣。很多初学者看到JMM是内存模型就以为它说的是堆、栈、方法区。这是完全不同的两码事Java内存结构运行时数据区描述JVM在运行Java程序时把内存划分为堆、虚拟机栈、本地方法栈、方法区、程序计数器这几块。解决的是数据放在哪里的问题。Java内存模型JMM描述多线程环境下共享变量在主内存与工作内存之间如何传递、如何保证一致性和顺序性。解决的是多个线程之间怎么协作的问题。前者是静态的内存区域划分后者是动态的并发一致性协议。如果面试官问JMM是什么你回答堆和栈那基本就凉了。正确的回答路径应该是JMM定义了主内存、工作内存以及它们之间的交互规则用来解决原子性、可见性、有序性问题。3. happens-before原则判断并发安全性的尺子如果让你手动保证每条内存访问都正确代码根本没法写。JMM设计者也很清楚这一点所以他们定了一套happens-before规则凡是被这套规则覆盖的操作JMM就保证前一个操作的结果对后一个操作可见。你不需要自己加同步只要操作之间存在happens-before关系编译器、CPU的重排序就不允许破坏这种关系。3.1 八个happens-before规则你只需要记最常用的四个技术规范里列了8条但实际工作和面试中最常用到的是以下这些。程序次序规则在一个线程内书写在后面的代码happens-before于书写在前面的代码。注意一个线程内这个前提。同一线程内重排序后的结果必须和串行执行一致所以这条规则本质上是as-if-serial语义的体现。volatile变量规则对一个volatile变量的写操作happens-before于后续对这个变量的读操作。这条规则是volatile实现可见性的根本依据。只要对变量加了volatile写完之后任何线程来读都能读到最新的值。锁规则对一个锁的解锁操作happens-before于后续对这个锁的加锁操作。这意味着如果你在临界区里修改了共享变量退出synchronized代码块后其他线程进入同一个锁保护的代码块时一定能看到这些修改。线程启动规则对线程的start()调用happens-before于被启动线程中的任何操作。也就是说主线程在调用start()之前对共享变量做的修改子线程启动后一定能看到。传递性如果A happens-before BB happens-before C那么A happens-before C。这条规则非常重要因为它可以把上面的规则串联起来形成一条完整的可见性链条。3.2 用一段代码理解happens-before的实际作用比如这样一个场景public class HappensBeforeDemo { private int value 0; private boolean ready false; // 线程A执行 public void writer() { value 42; ready true; } // 线程B执行 public void reader() { if (ready) { System.out.println(value); } } }如果不加任何同步线程B有可能打印出0也可能什么都不打印也可能打印出42。因为ready和value之间没有happens-before关系编译器可能把value42重排到readytrue后面也可能线程B先看到readytrue但value还没写回主内存。如果给ready加上volatileprivate volatile boolean ready false;根据volatile变量规则和传递性线程A里value42书写在readytrue之前所以value42 happens-before readytrueready是一个volatile写happens-before线程B对ready的读再通过传递性value42就happens-before了线程B的读操作。此时线程B看到readytrue后读value必然得到42。这就是happens-before规则最实用的地方你不用管底层到底怎么同步只需要按规则判断代码是否安全。3.3 happens-before不等于时间上先发生这里要警惕一个误区。happens-before并不是前一个操作在时间上先于后一个操作发生而是前一个操作的结果对后一个操作可见。这两个概念的区别很微妙。比如两个线程A和BA把变量x从0改成1B在时间上稍晚一些读了x。但因为没有happens-before关系B完全可能读到0。时间上的先后并不能保证结果可见只有happens-before规则能保证。我自己在项目里见过很多次类似的Bug都是我明明先执行了A再执行B为什么B读到的还是旧值。这种问题的根源往往就是两个线程之间缺少happens-before关系而不是代码逻辑有问题。理清这一点排查并发问题的思路会清晰很多。4. volatile与synchronized两种同步手段的底层逻辑很多面试者能把volatile和synchronized的区别背出来但问到底层为什么一个能保证可见性、一个能保证原子性就答不上来了。这里用JMM的视角拆开讲。4.1 volatile的可见性靠什么保证volatile的两大核心能力是可见性和禁止指令重排序。从可见性角度说JMM规定volatile变量的读写必须满足以下规则每次use前都必须先从主内存load最新值不允许直接使用工作内存中的缓存副本。每次assign后都必须立即store到主内存不允许在私有工作内存中拖延。翻译成人话就是volatile变量不经过缓存副本的私有缓存阶段每次读都强制从主内存拿每次写都强制立即刷回主内存。从禁止重排序角度说JMM在volatile变量的读写操作前后插入了内存屏障限制编译器和CPU的乱序执行。这个在下一章详聊这里先引出这个能力。4.2 为什么volatile保证不了原子性仍然是count的例子。如果把count声明为volatilecount 底层实际分为 1. 读取count的值到工作内存 2. 把值1 3. 把新值写回主内存volatile只保证了第1步读的是最新值、第3步写会立即刷回主内存。但第1步和第3步之间有一个窗口期两个线程可能同时读到了旧值42都在自己工作内存里算出了43再先后写回主内存结果count只增加了1而不是2。所以volatile不是万能药它只适合用在一个线程写、多个线程读的场景或者作为状态标志位使用。一旦涉及多个线程同时修改同一个变量就必须考虑原子性问题。4.3 synchronized的底层monitor锁与内存同步synchronized的原理可以从两个层面看。第一层是字节码层面。进入synchronized代码块时字节码会执行monitorenter指令退出时执行monitorexit指令。每个对象都关联一个monitor锁一个线程获取了monitor锁其他线程就得阻塞等待。第二层是内存语义层面。根据JMM的锁规则synchronized的锁释放会和后续的锁获取建立happens-before关系。执行流程中线程加锁时JMM会清空该线程工作内存中涉及共享变量的内容必须重新从主内存加载最新值。线程解锁时JMM会把该线程工作内存里所有修改过的共享变量立即刷回主内存。这样一清一刷就保证了临界区内的共享变量在锁的边界处数据是最新且一致的。而且synchronized临界区内的操作不会被重排序到临界区之外至少不会跨过锁的边界这又保证了有序性。所以synchronized能同时保证原子性、可见性、有序性但因为这把锁的存在会引入线程上下文切换和阻塞唤醒的开销。在Java 6之后synchronized还引入了偏向锁、轻量级锁、重量级锁的升级机制避免了每次都用操作系统级别的互斥量性能比早期版本好了很多。这个锁升级过程也是面试高频点这里不展开只要知道synchronized并不是一开始就很重就行。5. 指令重排序与内存屏障JMM的底层防线要说JMM里最抽象、最难的部分指令重排序和内存屏障排第二没别的敢排第一。但这两个概念恰恰是理解JMM设计意图的钥匙。5.1 重排序的三类来源和as-if-serial兜底指令重排序不是JVM一家的事它有三个来源重排序类型来源特点编译器重排序JIT编译器在不改变单线程语义前提下调整语句执行顺序处理器乱序执行CPU动态调度在执行阶段打乱指令顺序以填满流水线内存系统重排序缓存与写缓冲区使不同处理器看到的读写顺序不一致JMM有一个兜底原则as-if-serial语义。不管怎么重排序单线程程序的执行结果不能被改变。这句话反过来说更有意思只要能保证单线程结果一致编译器、CPU可以上天入地随便优化。比如这段代码int a 1; int b 2; int c a b;a和b的赋值顺序如果对最终结果没影响编译器就可能把b2放到a1之前执行。但cab一定不能在a、b赋值之前执行因为那样会改变单线程结果。理解这一点就明白为什么JMM只限制同步的可见性与有序性却允许大量优化存在——因为它要在性能和安全之间保持平衡而不是一味地牺牲性能换安全。5.2 数据依赖和重排序的边界如果两个操作之间存在数据依赖关系编译器就不会重排它们。数据依赖分三种写后读a1; ba; 第二条必须读到a的值。写后写a1; a2; 交换顺序结果不同。读后写ba; a1; 交换顺序结果不同。只要有这两种操作之间存在数据依赖重排序就被禁止。但问题来了数据依赖只约束单个处理器、单个线程内的操作。在多线程环境下线程A和线程B之间的数据依赖关系编译器根本感知不到。所以JMM才会引入内存屏障在关键位置强制建立顺序和可见性。5.3 内存屏障的四种基本类型内存屏障Memory Barrier是JMM和CPU架构之间的一个抽象层。JMM规定在某些指令位置插入屏障禁止特定类型的重排序。从操作类型上分有四类屏障类型指令示例作用LoadLoad屏障Load1; LoadLoad; Load2确保Load1的数据装载先于Load2及后续装载指令StoreStore屏障Store1; StoreStore; Store2确保Store1的数据对其他处理器可见先于Store2LoadStore屏障Load1; LoadStore; Store2确保Load1的数据装载先于Store2及后续存储指令StoreLoad屏障Store1; StoreLoad; Load2确保Store1的数据对其他处理器可见先于Load2的装载是最强大的屏障JMM针对volatile变量做了一套屏障插入策略在每个volatile写操作的前面插入一个StoreStore屏障禁止上面的普通写和下面的volatile写重排序。在每个volatile写操作的后面插入一个StoreLoad屏障防止volatile写和后面可能出现的volatile读/写重排序。在每个volatile读操作的后面插入LoadLoad屏障和LoadStore屏障防止volatile读和后续普通读/普通写重排序。另外还有规则防止普通写和volatile写重排。这些规则背起来确实枯燥但你可以从设计意图上去理解volatile要保证的是写完之后其他线程读到的就是最新值。所以写的前后要拦住一切可能破坏这个保证的重排序读的时候也要确保读到的是新鲜数据不被后续操作影响。6. JMM实战从单例模式看并发隐患的根源理论聊完必须落到代码上。这里用两个最常见的实际场景看看JMM到底怎么影响你的日常开发。6.1 双重检查锁定的单例为什么必须加volatile一个经典的面试连环问就是双重检查锁定的单例要不要加volatile为什么。public class Singleton { private static volatile Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }如果不加volatile这段代码在某些场景下会出问题。问题不在锁本身而在instance new Singleton()不是原子操作。它底层分为三步分配内存空间在内存上初始化Singleton对象把内存地址赋值给instance引用问题在于JIT编译器和CPU可能把第2步和第3步重排序变成先赋值地址再初始化对象。如果此时另一个线程进来发现instance ! null直接拿去用但对象里的字段可能还是默认值程序就会跑出莫名其妙的结果。加了volatile之后根据前面的屏障规则volatile写之前的普通操作不能重排到写之后所以对象初始化这个动作一定会发生在把new出来的地址赋值给instance之前就杜绝了拿到半初始化对象的问题。6.2 共享变量的状态标志位另一个常见应用场景是状态标志位。比如一个后台任务开关public class TaskController { private volatile boolean running true; public void stop() { running false; // 此时立刻刷回主内存 } public void run() { while (running) { // 执行任务 } } }因为running是volatile主线程调用stop()之后run()循环所在的线程必然能看到running变为false从而退出循环。如果不加volatile循环可能因为一直读取工作内存里的旧值而永远跑下去。注意这里没有任何多个线程同时修改running的问题只有一个线程写、多个线程读所以volatile完全够用不需要synchronized。6.3 如何排查一个并发问题当你在项目里遇到数据不一致的情况排查顺序建议这样走第一步先确认被共享的变量有没有被多个线程同时写。如果是先看是不是原子性问题考虑Atomic类或加锁。第二步如果变量是一个线程写在另一个线程读检查是否存在happens-before关系。没有的话就要考虑加volatile、final或通过锁、线程启动等方式建立关系。第三步如果代码逻辑正确但行为不符合预期重点排查是不是存在重排序问题。这种Bug在本地不容易复现通常需要在高并发、多核心环境下才会暴露。第四步接上JProfiler、dump线程栈观察处于阻塞或等待状态的线程确认加锁范围是否过大、是否存在死锁。这个排查链路我用了很多次绝大多数并发异常都能归结到原子性、可见性、有序性这三个维度之一。找到问题所对应的维度解法自然就出来了。7. 面试高频陷阱关于JMM的6个误区这一节针对初学者和面试者把常见误区一次性理顺。7.1 误区一volatile比synchronized快所以能多就用错。volatile只解决可见性和有序性不解决原子性。多线程更新同一个变量用volatile照样丢数据。它的适用面比synchronized窄得多。而且volatile禁用了大量优化写操作要强制穿透到主内存在频繁写的场景下未必比锁快。7.2 误区二把变量声明为final它就不会有可见性问题不完全对。final修饰的字段在构造方法中正确初始化后且对象逸出之前JMM保证其他线程能看到正确值。但如果final字段的引用在构造方法里逸出了比如在构造方法里启动一个新线程去读这个字段那就不一定保证正确了。7.3 误区三原子类型能解决所有并发问题AtomicInteger能解决count的原子性问题但它不能保证业务逻辑上的复合操作原子性。比如先检查余额再扣款这种操作如果不用锁或CAS版本号单靠原子类一样会有竞态问题。7.4 误区四内存屏障越多越好错误想法。内存屏障会阻止编译器和CPU优化用得越多性能损耗越大。JMM的设计目标是在足够安全的前提下尽可能优化。所以只有真正需要的地方才会插入屏障业务代码中大部分普通读写是没有屏障的这也正是多线程Bug难以复现的原因。7.5 误区五synchronized一定比Lock接口慢Java 6之后的synchronized经过锁升级优化在低竞争场景下性能很不错甚至可能优于ReentrantLock。只有在高竞争、需要可中断、可超时、公平锁等特性时Lock接口才更合适。拿synchronized一定慢说事是过时的观念。7.6 误区六JMM等于主内存工作内存如果你面试只答出这个模型会显得浅。JMM的核心包含三个层面主内存与工作内存模型、8种内存交互操作、happens-before规则。面试官如果真的深挖JMM他真正想看的是你对happens-before、volatile屏障、重排序的理解而不是简单的模型图解。8. JMM优化实践从代码层面减少并发风险与性能损耗前面主要讲理论这里给几个我在实际项目中验证过的优化思路既保证线程安全也不至于把性能拖垮。8.1 最小化同步范围synchronized包住的代码越少线程竞争的时间越短吞吐量越高。比如处理一批数据时不要把整个循环都锁住只锁住需要同步更新的那部分结果即可。8.2 优先考虑volatile和final如果共享变量是状态标志、配置开关、一次写入多次读取的不可变对象优先用volatile或final不要动不动就用锁。锁是重量级的能用轻量手段解决就不用重型武器。8.3 善用原子类与并发容器AtomicInteger、AtomicLong、ConcurrentHashMap这些类内部已经用CAS、分段锁做了精细优化比自己在业务代码里加synchronized更高效。比如计数器直接private AtomicInteger count new AtomicInteger(0); public void add() { count.incrementAndGet(); }8.4 注意伪共享问题这是一个容易被忽略的性能陷阱。当多个线程修改的变量恰好位于同一个CPU缓存行时会因为缓存行失效导致性能骤降。Java 8及之后可以用Contended注解或布局填充来避免但需要JVM参数开启。8.5 用长耗时操作规避锁如果一个操作很耗时但只有其中一小部分需要同步把耗时的部分移出临界区。比如网络请求、数据库查询这些操作如果放在synchronized里整个系统的吞吐量会被明显拖垮。实际项目里这类问题比理论上的并发正确性更常见。这些优化实践都围绕一个核心原则JMM给了你安全底线但底线之上的性能空间需要你自己设计。多想想每个共享变量天然需要的同步程度不要一刀切用锁也不要一刀切不用锁。回到开头的计数器问题。修好它最简单的办法就是把它换成AtomicInteger或者给increment方法加synchronized。但更重要的是你要理解为什么它修好了因为这才是面试官想听到的深度。JMM看起来是一堆抽象概念但它之所以存在就是因为真实世界里并发Bug太容易发生了必须用一个严格的契约来约束看似不可控的底层优化。把这个契约吃透多线程开发里的大多数问题你都能在酿成大祸之前提前嗅到味道。
