ARTICLE DETAIL

资讯详情

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

synchronized和volatile深度对比:从JMM到三大特性与锁升级

synchronized和volatile深度对比:从JMM到三大特性与锁升级 写并发代码的时候最让人头秃的不是业务逻辑而是明明代码看着没问题跑起来就是不对。你以为是运气问题其实多半是踩了原子性、可见性、有序性这三个坑。而synchronized和volatile正是Java并发里用来填坑的两个核心关键字。很多人背了八股文知道synchronized是锁、volatile是轻量级同步但真到了线上排查又分不清到底该用哪个甚至以为volatile能替代synchronized。这篇文章不聊虚的直接从JMMJava内存模型出发把这两个关键字的底层原理、三性保证、以及各自的天花板掰开揉碎讲清楚文末还附上一份实战对比表和排查经验希望你能少踩几个并发坑。1. 从JMM开始理解三大特性的来历1.1 为什么并发编程会出问题先问一个问题两个线程同时跑它们操作的是同一份变量吗在Java里答案是不一定。每个线程都有自己的工作内存可以理解为CPU缓存线程对共享变量的读写不是直接操作主内存堆内存而是先把变量加载到本地副本改完再写回去。这个设计是为了性能但也带来了隐患线程A修改了变量线程B可能还在用自己的旧副本这就是所谓的可见性问题。再加上CPU为了提速会做指令重排编译器和JIT也会做优化代码的物理执行顺序可能和源码完全不一样。逻辑上不相关或没有数据依赖的指令完全可能被换位置。单线程下没事因为重排会保证最终结果一致但多线程下两个线程互相配合一旦A线程的指令被重排B线程看到的状态就可能是“半成品”这就是有序性问题。至于原子性本质是“不可分割”一个操作要么全做要么不做不能被其他线程打断。比如i这一行代码在字节码层面其实是四步读i、把i加1、把结果写回、返回。如果两个线程同时执行i最后的结果很可能不是加两次而是只加了一次。这种问题靠轻量级的可见性工具是解决不了的必须用锁。1.2 原子性、可见性、有序性到底在说什么这三个概念放在一起容易混我给你一个生活中的类比假设你和室友共用一张借书登记表这张表就是共享变量。原子性就是“登记过程不能被打断”你不能翻到一半被电话打断结果登记了半行字。可见性就是“你改完表室友马上能看到最新版本”而不是他手里还攥着一份旧抄本。有序性就是“大家要按照约定的顺序操作”不能因为你觉得“先甩门再锁门”更顺手就颠倒流程导致室友搞不清你到底回没回来。Java内存模型JMM正是围绕这三个特性设计的规范。它规定了哪些操作是原子的、什么时候一个线程的修改对其他线程可见、以及哪些重排被允许。synchronized和volatile之所以被称为并发基础工具正是因为它们从不同角度兑现了JMM的承诺。理解了这一层后面再看具体机制就不会晕。2. synchronized如何搞定三性2.1 原子性Monitor锁如何互斥synchronized能保证原子性核心靠的是监视器锁Monitor。每个Java对象在底层都有一个Monitor关联当线程执行进入synchronized代码块或方法时会尝试获取对象的Monitor只有拿到Monitor的线程才能执行临界区代码没拿到的线程会被阻塞直到锁被释放。这样一来整个临界区代码在任意时刻只有一个线程在执行天然实现了“不可分割”。注意synchronized保证的原子性是“复合语句级别”的。不是说你写了三行代码它就把这三行打包成一条CPU指令而是它让这三行代码在锁的保护下成为一个逻辑上不可被其他线程穿插执行的整体。比如public synchronized void add() { count; }这个add方法里count的“读-加-写”三步在锁的包裹下不会和其他线程的count交织。每次只有一个线程能完整执行三步所以结果正确。用锁来实现原子性虽然不是硬件级别的但在业务场景下已经足够。2.2 可见性synchronized的内存语义synchronized的可见性保证来自Java内存模型的“锁规则”线程释放锁时会把工作内存中的共享变量强制刷新到主内存线程获取锁时会先把主内存中的最新值加载到工作内存然后才执行临界区代码。也就是说锁的获取和释放天然建立了“写-读”的happens-before关系。我讲得通俗一点假设线程A在synchronized代码块里修改了变量x它在出锁的时候x的最新值一定是写回主内存的。线程B拿到同一把锁进入代码块时第一步就是从主内存重新拉取x的值所以它一定能看到A的修改。这就是为什么synchronized既能保证原子性也能顺便解决可见性。很多人只知道synchronized是锁却忽略了它有完整的内存栅栏效应这个知识点面试常问。2.3 有序性锁如何挡住重排有序性这块synchronized的机制稍微“玄”一点。JMM规定在临界区内代码的执行可以被看作顺序执行因为锁的互斥保证了同一时刻只有一个线程在改共享状态。但synchronized并不会禁止JIT对临界区内部无关指令进行重排——不过由于锁的存在这种重排对其他线程来看是不可见的因为其他线程只有在拿到锁之后才能进入临界区而在它进入之前临界区的所有状态已经以完整的顺序固化在主内存中了。换句话说synchronized的有序性靠的不是禁止重排而是用“锁的排他性”把重排的副作用关进了临界区这个黑盒子里。只要线程能够看到临界区的执行结果一定是整个临界区全部执行完毕后的结果。这个理解很关键有序性未必非要禁止重排能隔离重排影响同样能达到目的。3. volatile能做什么不能做什么3.1 volatile的可见性和有序性volatile在Java里被称作轻量级同步机制它干了三件事第一任何线程修改了volatile变量会立即同步到主内存不需要等锁释放第二任何线程读取volatile变量都直接从主内存拿最新值而不是用线程本地副本第三它通过内存屏障禁止了变量周围的指令重排。这三件事组合起来保证了对单个volatile变量的读写在多线程之间的可见性以及读写操作的有序性。这里要重点说一下内存屏障。volatile写操作会在前面插入StoreStore屏障、后面插入StoreLoad屏障volatile读操作会在后面插入LoadLoad屏障和LoadStore屏障。看着很术语实际可以理解为JVM在编译时告诉CPU这条指令前后的读写不允许穿越到对侧从而锁定了操作顺序。这也是Double Check Lock双重检查锁单例模式能正确运行的关键支撑。3.2 volatile的原子性短板典型案例volatile最大的坑就是它不保证原子性。很多人看到“volatile变量读最新值”就想当然地认为“那我用它来计数器不就得了”结果一跑就翻车。原因很简单volatile只保证可见性和有序性但i的“读-改-写”三步volatile管不了。举个例子两个线程同时执行volatile int count的count它们都可能先读到同一个旧值比如5然后各自加1再写回结果都是6而不是期望的7。为什么因为volatile没有锁它无法阻止两个线程同时进入“读旧值”到“写完值”的这段间隙。所以如果需要一个多线程安全的计数器老老实实用AtomicInteger或者synchronized别拿volatile来替代锁。那volatile适合什么场景呢我总结了两类一是纯粹的标志位比如控制线程停止的boolean变量二是作为状态发布比如一个引用被初始化后多个线程只需要读取不需要修改。这两类场景里volatile比synchronized轻量、无锁、开销小且逻辑准确。4. synchronized底层原理与锁升级之路4.1 对象头与Monitor今天我们讲synchronized底层就绕不开对象头。在HotSpot虚拟机里每个Java对象头部都有一段Mark Word它记录了对象的哈希码、GC分代年龄、锁状态等信息。Mark Word是动态的随着锁状态的改变它的存储内容也会变化。Monitor锁的获取第一步就是操作这个Mark Word通过CAS比较并交换把锁状态从无锁改为偏向锁或轻量级锁。Monitor本身是操作系统层面的概念对应的重量级锁会让未获得锁的线程进入阻塞状态涉及用户态到内核态的切换开销比较大。JDK 1.6之后HotSpot对synchronized做了大量优化不再一上来就上Monitor而是设计了“偏向锁-轻量级锁-重量级锁”的升级路径。理解这条路径才能理解为什么synchronized在无竞争时性能并不差。4.2 偏向锁、轻量级锁、重量级锁偏向锁的出发点很务实大多数情况下锁不仅不竞争而且总是由同一个线程重复获取。于是在第一次获取锁时线程会在Mark Word中记录自己的线程ID之后再来就不用任何CAS操作直接进临界区开销几乎为零。但一旦有其他线程来竞争偏向锁就会撤销并升级为轻量级锁。轻量级锁也叫自旋锁。它的策略是不立刻把线程挂起而是让线程在用户态做几次CAS尝试用自旋等待锁释放。因为很多锁持有时间非常短自旋等待的成本远低于线程阻塞唤醒的内核态切换成本。JDK后来还引入了自适应自旋JVM会根据上一次自旋的成功率动态调整等待次数。如果自旋也等不到锁或者竞争非常激烈锁就会升级为重量级锁走Monitor阻塞通道。至此线程才真正进入挂起状态。所以synchronized的性能表现和竞争激烈程度强相关低竞争下它和普通代码差不多高竞争下才会有明显的线程调度开销。这也是很多老技术人敢在简单场景里继续用synchronized的原因。4.3 JIT优化与锁消除除了锁升级JIT编译器还给synchronized做了一些“魔法”优化。比如锁消除如果JVM通过逃逸分析发现某个锁对象只会被当前线程访问根本不会共享给其他线程它就会把这个synchronized块直接优化掉连锁都不用加。再比如锁粗化如果JVM发现连续多个synchronized块锁的都是同一个对象而且中间没有其他线程的干扰它会把多个小锁合并成一个大锁减少反复加解锁的次数。这些优化说明一个道理现代JVM下的synchronized已经不是早期那个“笨重”的synchronized了。在大多数业务场景下你根本不需要为了性能去手动搞什么Lock优化先把正确性和可读性做到位等真遇到竞争激烈导致性能瓶颈时再去分析锁竞争点可能更划算。5. synchronized与volatile的深度对比5.1 六大维度对比表很多人面试被问“synchronized和volatile区别”答来答去就是“一个锁一个轻量级”但真要动手选型又含糊。我做了一张表把这两个关键字的核心差异列出来方便你快速查阅。对比维度synchronizedvolatile原子性保证临界区代码的原子性不保证复合操作的原子性可见性通过锁释放/获取刷新和加载主内存通过强制主内存读写保证有序性用锁的排他性隔离重排影响通过内存屏障禁止重排用法修饰代码块、方法、类修饰变量不能修饰类和方法性能无竞争时接近零开销高竞争时线程阻塞开销大无锁性能开销低适用场景多线程修改共享变量、需要复合操作原子性单个变量的标志位发布、状态可见看着这张表你会很容易得出一个结论volatile和synchronized不是替代关系而是互补关系。真正需要原子性的操作光靠volatile是撑不住的但如果你只需要一个可见性保证用synchronized又有点大炮打蚊子会增加锁竞争和性能开销。5.2 什么场景用哪个我见过很多新手在写双重检查锁单例时把单例字段只加volatile不加同步块结果还是出错也见过有人在多个线程频繁读写的计数器上强行用volatile结果数据永远不对。这两种情况本质都是没搞清楚工具边界。我分享三条实战选型建议如果操作是一个单独的标志位读或写且不需要依赖旧值做计算用volatile就够了比如volatile boolean isRunning用于线程停止控制。如果操作是“读-改-写”的复合操作比如累加、扣减、账户余额变动选synchronized或AtomicInteger不要用volatile。如果临界区是一整段业务代码包含多个共享变量的多次读写选synchronized因为它能把整个代码块的原子性、可见性、有序性一次性搞定逻辑更清晰。当然synchronized也不是无敌的。如果你想实现超时等待、多个条件的精确唤醒就要考虑ReentrantLock了。不过那是另一个话题今天的重点还是先分清这俩基础工具。6. 常见问题与排查技巧实录6.1 为什么i在并发下总是丢数据这个问题几乎每天都有新人问。其实答案不复杂i不是原子操作它在字节码层面是GETFIELD、ICONST_1、IADD、PUTFIELD四条指令的组合。两个线程并发执行时可能都读到同一个旧值然后各自加1再写回导致其中一次加1被覆盖。我在实际代码里见过一个典型案例某个系统用static int count统计在线人数多个Web容器线程并发请求明明是1000个请求最后count只有600多。排查后发现问题就在这行代码上没有加锁也没有用原子类。修复方案很简单改用AtomicInteger.incrementAndGet()或者把方法加上synchronized。记住一个原则涉及共享变量的复合操作一定要用锁或原子类兜底不要相信运气。6.2 可见性问题导致的死循环另一个高频问题是可见性导致的线程停止失败。有人写了一个生产者线程用了普通boolean变量isRunning作为循环条件main线程调用了isRunning false想停掉它结果生产者线程还在死循环里跑。为什么因为生产者线程的本地副本里isRunning一直还是true主内存里的false它根本看不到。这种情况用volatile修饰isRunning就能解决因为它在修改后会立即同步到主内存并且在读取时强制拿主内存的值。这里补充一个排查经验当你怀疑是可见性问题时可以先看那个共享变量有没有被volatile或锁保护。如果没有基本可以断定是可见性问题。更靠谱的排查方式是用jstack获取线程dump看线程卡在哪个代码点如果是死循环CPU会飙高这时候再用jstack看栈很容易定位。6.3 从理论到实践一条编码规范聊了这么多其实到最后你会发现并发编程没那么多玄学。真正让人翻车的往往是最基础的规则没遵守。我个人在实际项目中养成了一套编码规范分享给你参考共享可变变量一律加锁或使用原子类不要裸奔。共享状态只有一个读取需求、多线程之间只是标志位再考虑volatile。锁对象选用私有final对象避免外部无意间拿到锁造成意外阻塞。持有锁的时间越短越好不要在临界区里做耗时操作比如网络请求、大文件读写这样可以显著降低锁竞争。排查并发Bug时先看变量有没有内存可见性保护再看复合操作有没有原子性保护最后才怀疑是不是业务逻辑冲突。这套规范帮我在线上少踩了很多坑。但不是让你死守条条框框而是当你对某个并发问题没有把握时先回到原子性、可见性、有序性这三个维度去审视代码基本不会跑偏。最后再分享一个小技巧我在写并发代码时习惯性先给每个共享变量标注它需要保证的三性要求比如“count需要原子性”“isRunning需要可见性”。标注完再选工具通常不会出错。别小看这一步它能逼你把问题想清楚而不是写完了再来猜。synchronized和volatile这对组合一个管重、一个管轻配合使用绝大多数业务并发需求都能覆盖得干干净净。
返回列表