ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从synchronized同步代码看懂JMM、JVM与线程调度

从synchronized同步代码看懂JMM、JVM与线程调度 从一行同步代码看穿JMM、JVM与线程调度这件事值得你花时间做。很多学习Java的人会背“JMM是抽象内存模型”“线程有6种状态”“synchronized是基于Monitor实现的”但一旦面对一段真实代码两个线程调一个synchronized方法底层究竟是谁在排队、谁在刷新缓存、谁在分配时间片往往就卡壳了。这恰恰是面试题最爱考的深度也是线上并发问题排查绕不开的底层认知。这篇文章用一段加了synchronized的increment方法作为线索从Thread.start()开始一路追踪锁的获取、临界区执行、内存可见性保障、CPU缓存一致性直到最终锁释放把Java内存模型、JVM运行时机制和线程调度管理串成一条完整的流水线。读完后你自己也能对着任意一段同步代码画出它在不同层面的执行轨迹。1. 从一段同步代码出发先定位它牵扯的三层机制1.1 一个经典的并发丢失更新谜题先看一个很常见的代码片段。public class Counter { private int count 0; public void increment() { synchronized (this) { count; } } public int getCount() { return count; } }假设有两个线程每个线程对同一个Counter实例调用10000次increment()正常预期结果是20000。如果在去掉synchronized的情况下运行结果经常只有13000多或者17000多每次数字还不一样。很多初学者以为count是“一条语句”天然原子但字节码层它是三步操作读取当前count的值、count加1、写回count。当两个线程同时读到旧值0各自加1后再写回写回的仍然是1而不是2这就是典型的丢失更新。加上synchronized之后底层就变成了一条单行道同一时刻只有一个线程能进入临界区另外的线程只能等着。问题在于这条单行道从JVM的线程状态机到操作系统的时间片调度再到CPU多核缓存同步每一层都动起来了。你需要弄清的是这些层各自负责什么、以什么顺序发生、彼此之间又怎么衔接。1.2 代码背后真正干活的三个角色要理解这段同步代码必须分清三个角色。第一层是并发语义的裁判也就是JMMJava内存模型。它规定了共享变量什么时候对另一个线程可见、操作之间是否有序、什么情况下可以重排。JMM不直接参与CPU执行它像一部交通法规约束JVM生成的机器指令必须满足哪些规则。第二层是运行时的承办者也就是JVM。线程栈的分配、对象的堆分配、synchronized编译成Monitor机制、锁的膨胀与释放、JIT编译时要不要做锁消除全部由JVM完成。第三层是操作系统的执行者也就是线程调度。Java线程在HotSpot下的主流实现是1:1映射到操作系统线程真正把线程放到CPU核心上跑的、分配时间片、在线程阻塞时把CPU腾出来的都是操作系统内核。JVM在这里更像一个“提需求的人”而不是“分时间片的人”。这三层常常被混着说尤其是把JMM和JVM内存模型当成一回事这是最典型的认知混淆。后面专门辟一节讲区别这里先记住JMM管的是规则JVM管的是运行时设施操作系统管的是物理执行。1.3 为什么同步块是最合适的观察窗口很多人直接去看JMM规范、虚拟机栈、锁实现每一个单独拎出来都很枯燥而且离代码太远。同步块的好处在于它把三件事同时压缩进了一个临界区。它要求互斥于是必然涉及线程的阻塞与唤醒线程调度必须登场。它要求释放锁后主动刷新工作内存、加锁后重新加载主内存JMM的可见性规则从这里生效。它要求操作共享变量于是涉及堆上的对象访问、栈上的引用传递JVM内存布局必须解释清楚。它还涉及真实的CPU缓存一致性因为加锁和释放锁的底层指令一定会触发缓存行失效与同步。更妙的是synchronized是最容易出性能问题的同步手段一旦发生锁竞争等待线程状态从RUNNABLE变成BLOCKED操作系统需要做上下文切换这个代价比CAS自旋高一个数量级。你只有把整条链路看明白才能真正理解为什么高并发场景下大家都在用无锁或轻量级方案而不是无脑堆synchronized。2. Thread.start()之后JVM的线程调度与虚拟机栈2.1 start()方法最终把线程交给了谁代码里调用thread.start()的那一刻Java层面并没有启动线程而是调用了一个native方法start0这个方法由JVM实现最终通过宿主操作系统的线程能力创建线程。在HotSpot JVM中Java线程与操作系统线程默认就是1:1的关系。JVM负责为这个线程分配独立的虚拟机栈默认栈大小通常在512KB到1MB之间可以通过-Xss调整具体值取决于平台与JDK版本并为每段方法调用生成栈帧操作系统负责把线程挂到就绪队列里等待CPU分配时间片。这里有一个容易忽略的细节JVM在创建线程时栈空间是一次性向操作系统申请的虚拟内存而不是马上把物理内存都占住。栈帧里的局部变量表、操作数栈随着方法调用逐步分配。所以线程真正启动后它拥有一块完全私有的执行区域另一个线程看不见它栈里的东西。回到我们的同步代码每个线程进入increment()时会在自己的虚拟机栈上压入一个栈帧栈帧里保存着this引用、操作数栈上的临时数据但count本身不在栈上它在堆上的Counter对象里。这是线程之间同步的根本前提——大家访问的是同一个堆对象里的同一个字段才有同步的必要性。2.2 线程状态机是如何被锁改变的Java的线程状态在Thread.State枚举中定义了六种NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。当主线程启动两个工作线程后它们是RUNNABLE状态。但注意RUNNABLE在Java层面只代表“可以被调度”不代表它此刻真的在CPU上跑。一旦线程A先拿到Counter对象的Monitor线程B再去执行synchronized块时JVM会让B进入BLOCKED状态把它放入EntryList等待队列。这里很多面试者会答错一个细节线程B的BLOCKED和wait()导致的WAITING不是一回事。BLOCKED是因为竞争Monitor被拒之门外等待的是锁的释放WAITING则是线程主动调用了wait()/join()/park()等待的是被notify或者unpark。synchronized的普通同步块不使用wait/notify时只会看到BLOCKED。等到线程A执行完、释放Monitor的瞬间B从EntryList中被唤醒回到RUNNABLE重新参与调度去抢锁。整个等待期间B不占CPU也不消耗CPU时间片这是重量级锁并发能力差却不会导致CPU空转的原因之一。2.3 虚拟机栈上的私有隔离共享变量为什么必须放在堆里虚拟机栈是线程私有的这意味着栈里的局部变量天然线程安全。比如你在increment()里写int localCopy count; localCopy localCopy 1;localCopy这个变量属于当前线程的栈帧另一个线程完全摸不到它所以不会产生同步问题。但这也正是问题所在——如果只对局部变量做操作所有线程各加各的count在主内存中没有任何变化最终结果还是错的。共享变量必须放在堆里的对象字段中。堆是所有线程共享的运行时数据区任何线程通过对象引用都能访问。Counter对象在堆上count就是堆上的字段每个线程操作它时都要经过一套JMM规定的主内存与工作内存交互流程。这一段可以简化理解为堆上的数据看得见、可能冲突所以需要锁栈上的数据私有、天然隔离不需要锁。理解了这一点你再去调优并发代码就不会犯“把共享数据塞进局部变量试图解决问题”的错误。如果你想让共享数据在线程间高效传递就必须意识到它要经过主内存/缓存的刷新链路而不只是换个变量类型那么简单。2.4 抢占式调度背后的时间片与优先级陷阱现代操作系统对线程的调度以抢占式为主线程不会一直执行下去而是被分配一个时间片时间片用完就强制切走换另一个就绪线程执行。这个过程叫上下文切换涉及保存当前线程的寄存器、程序计数器、栈指针恢复下一个线程的对应状态代价在微秒到几十微秒量级。Java层面能影响调度的只有Thread.yield()和setPriority()但这两个都只是给调度器的“建议”。yield()只表示当前线程愿意放弃CPU调度器完全可以忽略优先级在Windows下有点用在Linux下默认调度策略中它对普通线程的权重影响也非常有限。你用高优先级去做业务并发控制指望它保证某线程先执行基本不靠谱。真正可靠的“调度控制”要靠锁、信号量和条件变量来同步而不是靠线程优先级和时间片设置。这也是为什么同步块在线程调度过程如此重要的原因它不依赖调度器的随机性而是用阻塞机制给执行顺序加了一个确定的栅栏。3. synchronized底层到底做了什么对象头、Monitor与锁升级3.1 从字节码看方法锁和块锁的两种形态把上面Counter类中的increment()编译后再javap查看字节码synchronized关键字在两种写法下的表现完全不同。synchronized实例方法是ACC_SYNCHRONIZED标志JVM在调用方法时检查这个标志自动获取对象的Monitor方法正常返回或异常退出时自动释放不需要在字节码里显式插入指令。synchronized块则更直白字节码里会生成monitorenter和monitorexit两条指令。注意monitorexit可能是两条因为JVM会把正常退出和异常退出都覆盖到确保锁一定会被释放。这也是为什么Java的synchronized自动具备异常安全特性不像某些语言要手动release。我们用一个锁对象的Monitor来理解每个Java对象都可以关联一个Monitor在HotSpot中这个Monitor由ObjectMonitor结构体承担里面有关键字段_owner代表当前持锁线程_recursions记录重入次数EntryList保存阻塞抢锁线程WaitSet保存调用了wait()的线程。3.2 每个对象头里藏着一把锁Mark Word与ObjectMonitor每个Java对象在堆里都有一块对象头其中Mark Word区域记录了对象的运行时元数据。64位JVM里Mark Word是8字节它会根据锁状态动态地存储不同信息无锁时存hashCode与分代年龄偏向锁时存持有线程ID轻量级锁时存指向线程栈中Lock Record的指针重量级锁时存指向ObjectMonitor的指针。所以“给对象加锁”这句口头禅落到字节码上就是获取这个对象的Monitor落到内存布局上就是往Mark Word里写入对应的锁状态并在线程栈里建立一个Lock Record来记录锁的特征信息。这段对面试官特别有吸引力你越能把Mark Word的复用讲清楚就越显得对对象头的结构有真实认知而不是只背结论。3.3 锁升级链路与偏向锁的现状经典教材里会说锁有四种状态无锁、偏向锁、轻量级锁、重量级锁随着竞争加剧升级。JDK 6到JDK 8时代偏向锁会在第一次被某个线程获取时把线程ID记录进Mark Word。后续同一线程再次进入时只要校验一下线程ID一致就不再走CAS速度极快。只有当不同线程来竞争时偏向锁才撤销并升级为轻量级锁。轻量级锁用CAS在Mark Word和Lock Record之间互相替换尝试短时间内自旋获取锁自旋失败后膨胀为重量级锁线程进入操作系统级的阻塞队列。但现实情况变了。JDK 15开始默认禁用偏向锁JEP 374后来的版本逐步把偏向锁相关代码淘汰。原因是偏向锁在高负载、多线程竞争频繁的场景下撤销偏向锁需要触发安全点STW性能反而更差而现代JVM对锁的重入、轻量级锁的快速路径做了其他优化旧的偏向锁收益已经不明显。所以你在面试或实际调优时不要再把“偏向锁肯定好用”挂在嘴边更合理的说法是现代JVM默认情况下更依赖轻量级锁的CAS快速路径以及JIT编译期的锁消除、锁粗化。锁升级路线仍然存在但“偏向锁是默认最优解”的时代已经过去了。3.4 阻塞唤醒为什么是同步代码最大的成本一旦锁升级为重量级锁一个没有抢到锁的线程会被JVM调用操作系统提供的同步原语挂起线程状态变为BLOCKED。这个挂起和后续唤醒需要从用户态切换到内核态执行系统调用加上线程上下文切换的开销成本是微秒到几十微秒级别。如果临界区很短比如只有一个count可能锁内执行只有几纳秒但锁竞争导致的阻塞唤醒要花费几微秒吞吐量立刻被拉垮。这也是高并发场景下大家不愿频繁使用synchronized的原因不是它功能有问题而是它提供的“最强互斥”在竞争激烈时付出了过高的调度成本。反过来说如果一个临界区本身很大、执行时间远高于上下文切换成本用synchronized的重量级锁反而是最简单可靠的选择因为JVM替你把等待队列和唤醒逻辑都处理好了不会出现自旋空转烧CPU的问题。4. 同步代码穿越JMM原子性、可见性、有序性与happens-before4.1 count三个指令拆开后看到的原子性问题把count放到CPU和JIT优化层面看它在Java字节码中是GETFIELD、IADD、PUTFIELD三次操作在机器指令层面更是要被拆成从内存加载count的值到寄存器、在寄存器里加1、把新值写回内存。三个操作之间没有原子性线程A加载到旧值之后、写回新值之前线程B可能也执行了同样的加载。三条指令互相穿插结果必然出错。synchronized在临界区上加的“互斥”本质上是把这三步操作重新绑定成了不可被其他线程插入的原子整体。注意这里的“原子性”是锁提供的语义保证不是CPU硬件层面的单条指令原子性。理解这一步你就能解释为什么用volatile修饰count解决不了并发问题volatile保证了写后对其他线程可见、禁止指令重排但它不管“读-改-写”三步的捆绑两个线程还是可能同时做一次读改写。4.2 工作内存与主内存JMM的定义和它不等于物理内存的地方JMM规定了一个抽象的双层存储模型主内存保存所有共享变量每条线程有自己的工作内存。线程不能直接对主内存的共享变量读写只能先把变量从主内存复制到工作内存操作完再写回主内存。这里必须强调JMM的“主内存”并不等于物理内存中的某块区域工作内存也不等于CPU寄存器。JMM是一种抽象模型它把寄存器、多级缓存、写缓冲器这些真实硬件统一抽象成“工作内存”把进程内共享的堆对象区域抽象成“主内存”。目的是给出一个跨平台的规范让Java程序在不同CPU架构上都保持一致的并发语义。synchronized在这个模型下的语义是进入临界区时JVM会让线程工作内存中的相关共享变量失效重新从主内存加载退出临界区时把工作内存中修改过的共享变量刷新到主内存。这正是“锁释放后修改对后续持锁线程可见”的机制来源。4.3 happens-before锁让后一个线程看到了前一个线程的修改JMM里最核心的概念是happens-before规则。它定义了一个偏序关系如果操作A happens-before操作B那么A的结果对B可见且A的执行顺序必须在B前面。对于同一把锁JMM明确规定解锁操作happens-before后续对这个锁的加锁操作。也就是说线程A解锁前对共享变量做的所有修改线程B接下来获取同一把锁时都必须能看到。这个过程不依赖程序员自己用volatile去逐个控制可见性只要锁用对了JMM会自动建立这道顺序屏障。反过来这也是一个危险信号的来源如果一个线程读count时不加锁、另一个线程写count时加锁读写双方没有共同的happens-before关系数据竞争依然存在读线程完全可能读到过期值。同步必须成对出现单边加锁保护不了整个共享变量的安全。4.4 重排序、内存屏障和JIT的暗中配合为了保证有序性JIT编译器和CPU都可能在满足单线程语义的前提下对指令重排序读操作提前、写操作延后、无关操作调换位置。重排序在单线程内无所谓但并发环境下会制造出匪夷所思的执行结果。synchronized如何挡住重排序JVM会在monitorenter之后、monitorexit之前插入合适的内存屏障指令并且禁止临界区内的共享变量访问被重排到锁外。进入监控区时有一个LoadLoad/LoadStore屏障防止后续读操作越过锁获取退出时有一个StoreLoad/StoreStore屏障保证锁内的写操作在释放锁之前对其他处理器可见。JIT还会做锁粗化和锁消除。锁粗化是把连续多次获取同一把锁的相邻代码段合并成一次加锁锁消除是当JVM通过逃逸分析发现某个锁对象永远不会被其他线程访问时直接去掉锁操作。极端情况下一个看起来加锁的同步块在JIT优化后可能根本没有锁指令这也是为什么微基准测试里测出的synchronized性能有时比“想象中”好得多。5. 数据从寄存器到主内存CPU缓存视角的同步代码5.1 一次count在缓存层次上走过多远的路现代CPU为了保证单核速度采用多级缓存结构。一个线程执行count时数据从主内存读到L3缓存、再到L2缓存、再到L1缓存最后进入寄存器执行加法。这些路径的延迟差异巨大L1命中通常在1纳秒左右L2在几纳秒L3在十几纳秒访问主内存则要到几十到上百纳秒。这也是为什么JMM里强调“工作内存”与“主内存”之间的刷新不是即时的。当synchronized让线程A修改count并退出锁时JMM语义要求它把最新值写回主内存同时通过缓存一致性协议让其他核心上缓存的旧副本失效。下一次线程B获取同一把锁、重新读取count时会发生缓存未命中只能从更上层缓存或主内存重新拉取最新数据。放到流水线视角里一次加锁执行“缓存行失效拉取新值修改写回通知其他核”远比一次普通的count开销大。很多人以为锁慢在Java语法层其实最底层慢在CPU缓存同步。5.2 MESI协议如何维持多核缓存的一致性多核CPU中每个核心都有自己的L1/L2缓存一份count可能同时存在于多个核心的缓存行中。MESI协议用四个状态管理缓存行Modified已修改、Exclusive独占、Shared共享、Invalid无效。当线程A在核心0上修改了count所在的缓存行该缓存行变成Modified状态同时通过总线或目录协议广播让核心1上的同一缓存行变成Invalid。线程B再去读取时发现缓存行无效必须重新从主内存或其他缓存中获得最新数据。synchronized释放与获取之间发生的正是这一整套缓存状态流转。这也解释了为什么在高并发下即使临界区很短多个线程之间仍然会出现总线流量上升因为每个锁的获取都可能触发无效化请求和缓存行填充。值得说明的是MESI协议是硬件层的一致性策略Java程序员不需要直接操控它但理解它能让你明白“可见性”不是Java意义上的幻象而是硬件层面的真实同步动作。加了锁后数据一定正确靠的是JVM生成同步指令配合CPU缓存一致性协议完成。5.3 锁竞争为什么让人又爱又恨缓存行失效与伪共享锁竞争最头疼的副作用不只是线程阻塞唤醒还有缓存行反复失效的问题。设想两个线程频繁争抢同一把锁修改同一个count每次锁交接都要让持有锁的缓存行失效再重建。当线程数增多时这种一致性开销可能比实际操作本身还高程序吞吐量呈断崖式下跌。更隐蔽的坑是伪共享两个线程修改的变量完全不相关但它们恰好落在同一个64字节缓存行里。线程A写变量x导致整个缓存行失效线程B写变量y时也要重新加载缓存行二者互相拖累表现就像在发生锁竞争一样。经典的解决方案是缓存行填充或使用Java 8以后提供的sun.misc.Contended注解在字段间填充空白避免共享缓存行。排查伪共享需要用到性能分析工具看缓存未命中率普通业务代码里不一定值得过度优化但一碰到并发高频读写的计数器或队列类组件时它往往是性能瓶颈的真实元凶。5.4 逃逸分析与锁消除当同步块根本不出线程有一种情况会让上面所有锁机制全部失效JIT通过逃逸分析发现锁对象根本不会逃逸出当前线程。比如你写了一个局部变量作为锁对象且该对象不会传到其他方法、不会发布给其他线程JVM判定不存在竞争可以直接把这个对象的锁消除掉同步块变成一个普通代码块。这段执行过程里线程调度层面没有任何等待JMM层面也不需要跨线程可见性保护锁的代码成了纯粹的执行指令。这种优化对微基准测试极其有迷惑性你在单线程里测synchronized性能JIT可能已经把它优化没了。而线上多线程真正发生竞争时锁的成本才会原形毕露。所以做并发与性能分析时既不能因为微基准里同步块快就误判实际场景也不能因重量级锁慢就认定synchronized永远不行。一切结论要放到真实的竞争度和临界区大小中评估。6. 这段代码引出的高频面试题与常见误区6.1 JMM和JVM内存模型是同一个东西吗这是面试里出现频率极高的混淆点。JMMJava内存模型是JSR 133定义的并发语义规范用来解决共享变量在多线程环境下的原子性、可见性、有序性问题它不关心方法区、堆、栈具体怎么划分而是关心操作之间的happens-before关系。JVM内存模型则通常指Java虚拟机运行时数据区分为程序计数器、虚拟机栈、本地方法栈、堆、方法区等区域。你把“JVM内存模型”背得再熟也只能回答运行时内存布局回答不了为什么volatile能保证可见性、synchronized为什么能建立happens-before。两者名字中都带“内存模型”却完全是两套知识体系。遇到这类问题第一句话就要澄清你指的是哪个然后分别说明JMM是规范JVM内存模型是运行时结构synchronized既依赖JVM的Monitor设施也依赖JMM的锁规则。6.2 volatile和synchronized到底差在哪用一句话先概括volatile是轻量级的可见性与有序性保障synchronized是“可见性有序性原子性”的全套互斥方案。能力volatilesynchronized原子性不保证读改写复合操作会错保证临界区互斥和原子性可见性写入后立即对其他线程可见进入重新加载、退出刷新有序性禁止指令重排序有限度锁边界建立禁止重排屏障阻塞唤醒不阻塞线程竞争激烈时线程会阻塞重入性不涉及锁重入支持可重入回到我们的同步代码中如果只把count声明成volatile、去掉synchronizedcount仍然会丢更新。因为volatile保证“A线程写完后B能看到”但保证不了“A在读改写期间B不能同时读改写”。volatile非常适合做状态标志、双重检查锁中的实例引用、以及单个字段的原子发布。6.3 时间线一次完整同步执行过程的串联复盘把前面所有机制拼起来一次典型的同步块执行时间线大致是这样。线程A首先调用increment()JIT判断锁对象无逃逸时可能直接消除锁。假设锁对象逃逸且存在竞争A执行monitorenter通过CAS把对象头Mark Word替换为轻量级锁记录成功获取锁。读取count的主内存值到CPU缓存执行加1写回缓存并最终写回主内存。随后A执行monitorexit释放锁并让Mark Word恢复同时触发缓存一致性协议让其他核心的缓存行失效。线程B此前尝试获取同一把锁时CAS失败先自旋重试自旋一段时间后膨胀为重量级锁进入ObjectMonitor的EntryList状态变为BLOCKED被操作系统挂起。A释放锁后B被唤醒从EntryList出队重新与其他线程竞争获得Monitor的所有权进入临界区时按JMM规则重新加载count的最新值。整个过程里操作系统的调度器始终在背后掌控时间片切换JMM的happens-before规则保证B一定能看到A对count的修改。这串时间线就是标题里“一段同步代码执行”的完整答案。你在面试中能按这个顺序讲清楚基本可以证明JMM、JVM、线程调度三门功课都过关了。6.4 走出“加了锁一定安全”和“锁越少越快”的误区“加了锁就一定安全”是新手最容易有的错觉。锁只保证同一把锁相关临界区的互斥与可见性如果代码里有的地方加了synchronized、有的地方没加或者在两段代码里用了不同对象的锁数据竞争照样存在。锁设计必须讲求一致性读写同一个共享变量的所有路径都要用相同的锁规则缺少一处就是漏洞。“锁越少越快”也不全对。不加锁固然避免了同步开销但在高并发场景下无锁实现往往依赖CAS自旋。CAS是由硬件提供的原子指令多线程同时CAS同一个变量时大量线程自旋空转遇到激烈竞争时CPU占用率可能飙升吞吐量也不一定比synchronized好。所以从这段同步代码出发更该养成的是一种分析框架先判断共享变量到底有几个线程访问、访问频率多高、临界区执行时间多长再去选择synchronized、volatile、ReentrantLock、CAS还是ThreadLocal。框架比某一个具体方案更值钱这也是我写这篇内容真正想让你带走的东西。把整条链路梳理到最后我想分享一个实际体会排查线上并发问题最有效的方式不是盯着异常栈而是拿着这段“同步执行时间线”去对照。看到数据不一致先问可见性是否被锁规则覆盖看到CPU飙升先问是否自旋空转或伪共享看到大量线程BLOCKED先问锁竞争是否过于激烈、临界区是否过大。这套思维来自无数次压测和线上问题的复盘它不会一次性记住所有JMM规则但能在关键时刻帮你快速定位问题所在的层级。
返回列表