ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

并发 03 · volatile 与 synchronized

并发 03 · volatile 与 synchronized 并发 - volatile 与 synchronized上一篇我们在 JMM 那层把规则讲透了happens-before、内存屏障、volatile/synchronized/final各自的承诺。这一篇我们从抽象规则落到最常用的两个关键字本身回答两个非常具体的问题volatile到底该在什么场景用、又常被误用在哪里synchronized从 Java 6 开始被优化成了能不重就不重的锁——它凭什么能做到后一个问题就是这篇的重头戏锁实现如何随竞争变化。很多人对synchronized的印象还停留在它是重量级锁、性能差、能用Lock就别用它——这是一个过时了十几年的偏见。Java 6 之后HotSpot 会尽量先采用较轻的获取路径竞争加剧时才膨胀到重量级 monitor。本文用 JDK 8 的无锁 → 偏向锁 → 轻量级锁 → 重量级锁经典模型讲清设计思路同时明确现代 JDK 已经移除偏向锁。这篇按这条线索展开先快速把volatile的能与不能用三个场景钉死再看synchronized的三种用法和它背后的 Monitor 本质然后引出承载锁状态的对象头 Mark Word接着一级一级走完锁升级的全过程讲清每一级为什么存在最后补上 JIT 的锁消除与锁粗化并给出volatile和synchronized的选择建议。目录volatile能干什么不能干什么synchronized 的三种用法与 Monitor 本质对象的身份证对象头与 Mark Word锁升级从偏向到重量的四级台阶锁消除与锁粗化JIT 的两手优化怎么选volatile vs synchronized一、volatile能干什么不能干什么volatile的原理可见性靠 volatile 写 hb 读、有序性靠内存屏障上一篇已经讲透这里不重复只解决一个更实用的问题它到底该用在哪、不该用在哪。记住一句话——volatile保证可见性和有序性但不保证原子性——它的所有正确用法和错误用法都是这句话的推论。✅ 场景一状态标志位最经典、最该用的场景。一个线程改标志、其他线程读标志来决定要不要停这正是volatile的主场privatevolatilebooleanrunningtrue;// ✓ 必须 volatilepublicvoidrun(){while(running){doWork();}// 别的线程改了这里立刻能看到}publicvoidstop(){runningfalse;}为什么这里volatile就够了、不需要加锁因为这个操作只有单纯的读和写没有读-改-写的复合动作不涉及原子性。只要保证改了能被看见就正确了——这恰是volatile的能力圈。第 1 篇那个关不掉的循环病根就是漏了这个volatile。✅ 场景二双重检查锁定DCL单例——volatile在这里是防重排序。publicclassSingleton{privatestaticvolatileSingletoninstance;// ✓ volatile 不能省publicstaticSingletongetInstance(){if(instancenull){// 第一次检查不加锁快synchronized(Singleton.class){if(instancenull){// 第二次检查加锁后再确认instancenewSingleton();// ← 关键在这行}}}returninstance;}}这里volatile防的不是可见性而是有序性。回顾第 1 篇instance new Singleton()底层是①分配内存 → ②初始化 → ③引用指向内存三步若 ② ③ 被重排成 ①③②另一个线程在第一次检查时就可能拿到一个引用不为 null、但还没初始化完的半成品对象。volatile的内存屏障禁止了这个重排序DCL 才安全。这就是volatile保证有序性的一个真实、高频的用武之地。❌ 反例用 volatile 做计数器——这是头号误用。privatevolatileintcount0;publicvoidincrement(){count;}// ✗ 依然线程不安全count是读-改-写三步的复合操作volatile只能保证每一步读到的是最新值却管不住三步之间被别的线程插队。两个线程可能都读到 100、都算出 101、都写回 101结果丢了一次自增。只要涉及基于当前值算出新值volatile就无能为力得靠下一节的synchronized、或后面第 4 篇的原子类CAS。一句话收口volatile的边界读写独立的状态标志、或需要禁止重排序的发布场景用volatile只要出现复合操作 / 依赖旧值就别用volatile上锁或 CAS。二、synchronized 的三种用法与 Monitor 本质synchronized是 Java 内置的锁三性全包原子、可见、有序。它有三种写法但万变不离其宗锁的永远是某个对象。// ① 修饰实例方法 —— 锁的是当前实例 thispublicsynchronizedvoidm(){...}// ② 修饰静态方法 —— 锁的是当前类的 Class 对象publicstaticsynchronizedvoidsm(){...}// ③ 修饰代码块 —— 锁的是括号里指定的对象最灵活推荐publicvoidm2(){synchronized(lock){...}// 锁对象 lock}最关键的理解锁是加在对象上的不是加在代码上的。这直接决定了两段synchronized代码会不会互斥——只有当它们抢的是同一个锁对象时才会互斥。所以这里有个经典陷阱一个synchronized实例方法锁的是this如果你new了两个不同的对象两个线程各调各的锁对象根本不同压根不会互斥。判断两段同步代码互不互斥永远先问一句“它们锁的是同一个对象吗”那synchronized底层怎么实现互斥的编译成字节码后能看到端倪——同步代码块会生成一对monitorenter/monitorexit指令monitorenter // 进入尝试获取对象的 monitor ... 临界区 ... monitorexit // 正常退出释放 monitor monitorexit // 异常退出也要释放编译器自动加的兜底保证锁一定被释放同步方法则不靠这对指令而是在方法的访问标志上打一个ACC_SYNCHRONIZED标记JVM 调用时看到这个标记就自动加锁——效果一样。这里的monitor监视器才是本质。每个 Java 对象天生都可以关联一个 monitorHotSpot 里由ObjectMonitor结构实现它内部有几个关键字段owner当前持有锁的线程谁进了临界区EntryList抢锁失败、被阻塞的线程排队区对应线程状态BLOCKEDWaitSet调用了wait()而主动挂起、等待被唤醒的线程区对应WAITING。synchronized的互斥、以及wait()/notify()的等待唤醒机制全靠这个 monitor 支撑。顺带点破一个常被忽略的事实synchronized也是可重入的。也就是说一个线程已经拿到某个对象的锁它可以再次进入用同一把锁保护的其他同步代码而不会把自己卡死。实现上ObjectMonitor 除了记owner还维护一个重入计数同一线程每重入一次计数1退出一次-1减到 0 才真正释放锁。为什么必须可重入看这个例子——如果不可重入a()拿着锁去调b()b()又要拿同一把锁就会自己等自己、直接死锁publicsynchronizedvoida(){b();}// 持有 this 的锁去调 b()publicsynchronizedvoidb(){...}// b() 又要 this 的锁 —— 靠可重入同一线程直接进正因为synchronized可重入这段父子调用才安全。记住这点后面第 5、6 篇ReentrantLock名字里的 “Reentrant”可重入也就顺理成章了——它俩在可重入这件事上是一致的。但——注意了——真正去操作这个重量级的 ObjectMonitor是要向操作系统申请互斥量、可能让线程陷入内核态阻塞的代价不小。这就是重量级锁重的地方。为了避免动不动就付这份重成本Java 6 引入了一套精妙的锁升级机制能不用 monitor 就先不用。要讲清它得先看锁的状态到底存在哪——对象头。三、对象的身份证对象头与 Mark WordJDK 8一个对象存在堆里除了你定义的那些字段它还带着一小块元数据叫对象头Object Header。对象头里有一部分叫Mark Word标记字下面以 64 位 JDK 8 HotSpot 为例它占64 bit锁状态会编码在其中。不同 JVM、位数和 JDK 版本的具体布局可能不同不应把这张位布局当成 Java 语言规范。Mark Word 是一块复用的内存它会根据对象当前的锁状态用不同的格式解释同样这 64 位。这种设计是为了省空间——对象头寸土寸金不可能给每种状态都留独立字段于是让同一块内存身兼数职。它靠末尾的2 位锁标志位 1 位偏向标志来区分自己当前是哪种格式锁状态Mark Word 主要内容64 位锁标志位无锁对象 hashCode、分代年龄01偏向位 0偏向锁持有偏向的线程 ID、epoch、分代年龄01偏向位 1轻量级锁指向线程栈中**锁记录Lock Record**的指针00重量级锁指向ObjectMonitor的指针10GC 标记GC 过程使用11看这张表能立刻get到两件事一是**无锁和偏向锁的锁标志位都是01靠那个单独的偏向位区分**——这暗示偏向锁在设计上被当作几乎等于无锁的状态二是轻量级锁指向线程栈里的锁记录重量级锁才指向堆外的 ObjectMonitor——这正是两者轻重之别的根源轻量级锁的数据待在自己线程的栈上、不惊动操作系统重量级锁才真的去申请系统级的 monitor。记住锁状态存在 Mark Word、升级就是改这几位、越往下越重这三点下一节的锁升级就是水到渠成。四、锁升级以 JDK 8 的四级台阶为例下面这套无锁 → 偏向锁 → 轻量级锁 → 重量级锁描述的是JDK 8 时代 HotSpot 的经典实现。它的设计哲学是根据竞争程度使用刚好够用的手段竞争加剧时再膨胀到更重的实现。现实中大量同步块并没有长期竞争为它们一开始就付出重量级 monitor 的代价并不划算。我们跟着竞争一步步加剧走完这四级台阶第 0 级 · 无锁。对象刚创建没被锁过Mark Word 存着 hashCode 和分代年龄锁标志01。第 1 级 · 偏向锁——“总是同一个线程来那就免检”。如果一个锁总是被同一个线程获取大量单线程场景下的库类就是这样比如StringBuffer、Vector在单线程里被调用那每次都做加锁解锁的 CAS 操作纯属浪费。偏向锁的思路是第一个线程拿到锁时把它的线程 ID 直接记进 Mark Word偏向标志置 1往后只要还是这个线程来一比对 ID 相同就直接进临界区一次 CAS 都不用做——几乎零成本。这就是偏向这把锁偏心地认准了第一个来的线程。只有当另一个线程也来抢这把偏向锁时偏向才被打破撤销偏向、升级到轻量级锁。注意撤销偏向本身有成本要等到一个安全点、暂停持有偏向的线程去检查所以偏向锁在确实存在多线程交替的场景下反而是负担——这也埋下了它后来被默认关闭的伏笔见第六节。第 2 级 · 轻量级锁——“竞争不激烈自旋等一下就好别惊动内核”。当出现两个及以上线程交替获取锁、但并不同时激烈争抢时升级为轻量级锁。它的核心是CAS 自旋全程不涉及操作系统线程进临界区前在自己的栈帧里建一个锁记录Lock Record用CAS尝试把对象的 Mark Word 替换成指向这个锁记录的指针CAS 成功 → 拿到锁Mark Word 锁标志变00CAS 失败 → 说明有别的线程正持锁。此时不立刻阻塞而是自旋空转着反复重试 CAS赌对方很快就释放。为什么要自旋而不直接阻塞因为阻塞线程 陷入内核态 上下文切换这份开销第 1 篇讲过可能比多空转几圈还大。如果临界区很短、对方马上就放锁自旋几下就拿到省掉了整个阻塞/唤醒的重成本——这是用 CPU 空转换取不切换上下文的权衡。第 3 级 · 重量级锁——“抢得太凶了都去排队睡觉吧”。但自旋不能无限转下去——如果竞争很激烈、临界区又长自旋就成了一堆线程围着空转烧 CPU得不偿失。所以当自旋超过一定次数还拿不到锁或竞争线程过多时锁最终膨胀为重量级锁Mark Word 指向堆外的ObjectMonitor抢不到锁的线程被真正阻塞、扔进EntryList排队进入BLOCKED状态不再占 CPU直到持锁线程释放时被唤醒。这才是那个重的、要向操作系统申请互斥量的锁。把这四级台阶和 Mark Word 的变化画到一起整条升级链就一目了然一句话串起这条链偏向锁赌只有一个线程轻量级锁赌竞争很短、自旋能等到重量级锁则认清竞争激烈老实阻塞排队最省。升级是单向的——一旦升上去一般不会再降回来。理解了这套按竞争强度逐级加码的策略就理解了为什么synchronized慢是个过时的说法在没竞争或轻度竞争的绝大多数场景里它压根走不到重量级那一步。版本提示偏向锁在JDK 15起被默认禁用并标记为废弃JEP 374在JDK 18中连实现代码也被移除。因此JDK 18 已经不存在打开偏向锁这件事Mark Word 也不再使用上文那套偏向位布局。学习源码或回答面试题时应明确版本JDK 8 可以讲完整四级模型现代 JDK 通常从普通的未锁定状态进入轻量级锁竞争加剧后再膨胀为重量级 monitor。五、锁消除与锁粗化JIT 的两手优化除了锁升级JIT 编译器还会在运行时对锁做两手自作主张的优化进一步降低同步开销。锁消除Lock Elimination——发现这锁根本没必要直接删了。JIT 通过逃逸分析判断如果一个加了锁的对象根本不可能被其他线程访问它只在方法内部使用、没有逃逸出去那这把锁就是多余的直接消除掉。最典型的例子是方法内部用StringBuffer拼字符串publicStringconcat(Stringa,Stringb){StringBuffersbnewStringBuffer();// sb 是局部变量没逃逸sb.append(a).append(b);// append 是 synchronized 的returnsb.toString();}StringBuffer.append是同步方法但这里的sb是纯局部变量别的线程碰不到它加锁毫无意义。JIT 会把这些锁直接消除运行时等同于无锁——所以在这种场景下StringBuffer和StringBuilder的性能差异其实被 JIT 抹平了。锁粗化Lock Coarsening——反复加解锁太碎合并成一把大锁。如果一连串操作紧挨着反复对同一个对象加锁解锁JIT 会把锁的范围粗化、扩大到整串操作外面只加解锁一次省掉中间那些无谓的反复// 优化前循环里每次都加解锁碎for(inti0;in;i){synchronized(lock){...}// 加锁、解锁 …… 加锁、解锁 ……}// JIT 锁粗化后 ≈ 把锁提到循环外只加解锁一次synchronized(lock){for(inti0;in;i){...}}反复申请释放同一把锁的开销比持有锁久一点更亏粗化就是这个权衡。这两手优化都是 JIT 自动做的、对你透明但理解它们的存在有个实用价值它告诉你该加锁就加锁别为了怕开销而手动做那些奇怪的规避——很多微优化 JIT 早就帮你做了你手写的花活反而可能干扰它。六、怎么选volatile vs synchronized最后把这两个关键字放一起给个清爽的选择标准。它俩不是竞争关系而是解决不同层面的问题维度volatilesynchronized原子性✗ 不保证✓ 保证可见性✓✓有序性✓禁重排✓是否阻塞不阻塞无锁可能阻塞升到重量级时作用对象变量代码块 / 方法适用一写多读的状态标志、DCL 发布复合操作、需要互斥的临界区选择的决策树其实很简单你要保护的只是一个读写独立的状态标志或引用一个线程写、其他线程读不依赖旧值→用volatile够了还更轻。一旦涉及**“复合操作 / 依赖当前值算新值 / 要保证多个操作作为整体的原子性”**比如count、先检查后执行的if(x) do(x)→volatile不行必须上synchronized或后面第 4 篇的原子类、第 5、6 篇的Lock。一个记忆锚点volatile是轻量的可见性保证synchronized是重一点但完整的互斥保证。能用volatile解决的绝不上锁但只要沾上原子性volatile就必须让位。这一篇我们把两个最基础的并发关键字落到了实处。volatile的要点是记住它的能力边界——保可见性和有序性、不保原子性用对场景状态标志、DCL它就是最轻的解法。synchronized的要点则是彻底更新那个它很重的旧印象JDK 8 的经典实现包含无锁 → 偏向 → 轻量级 → 重量级的演进路径现代 JDK 已移除偏向锁但仍会在轻量级获取和重量级 monitor 之间按竞争情况权衡配合 JIT 的锁消除与锁粗化它的开销早已今非昔比。带走三句话①volatile一沾复合操作就失效别拿它当计数器②synchronized锁的是对象判互斥先看是不是同一把锁③ 四级锁模型属于 JDK 8 的经典实现偏向锁在 JDK 15 默认关闭、JDK 18 已移除讨论锁实现一定要带版本。下一篇我们去看synchronized之外的另一条路——CAS 与原子类。既然轻量级锁靠的就是 CAS那 CAS 本身到底是什么、它凭什么能无锁地保证原子性、又有什么坑比如 ABA 问题这是通往整个java.util.concurrent无锁世界的钥匙。
返回列表