源码并发理解常见误区?

访客 源码剖析 2

本文目录导读:

  1. 误区一:“synchronized 保证可见性,所以写操作立即对其他线程可见”
  2. 误区二:“volatile 保证原子性,写 volatile 变量是线程安全的”
  3. 误区三:“final 字段在构造器中赋值,其他线程一定能看到正确的值”
  4. 误区四:“LockSupport.park()/unpark()Object.wait()/notify() 是一回事”
  5. 误区五:“AtomicInteger 所有操作都无锁,所以性能一定比 synchronized 好”
  6. 总结:从“直觉正确”到“源码正确”

这是一个很有价值的切入点,很多开发者能写出“能跑的并发代码”,但遇到高并发、高负载场景就崩盘,大多是因为陷入了某些直觉上正确、实际上危险的误区。

我把源码级别的并发理解常见误区归纳为以下 5 大核心陷阱,结合底层原理(JMM/CPU架构/操作系统)来解析:

synchronized 保证可见性,所以写操作立即对其他线程可见”

错误直觉:进入 synchronized 块,读到的就是最新值;退出 synchronized 块,其他线程立即看到改动。 源码真相synchronized 确实保证了进入时刷新退出时冲刷,但这是发生在同一把锁的前提下。

  • 反直觉案例:线程 A 持有锁 obj1 修改变量 x,线程 B 持有锁 obj2 读变量 x,两者没有 happens-before 关系,B 可能永远看不到 A 的修改(因为锁对象不同)。
  • 底层monitorentermonitorexit 指令会触发内存屏障(LoadLoad/StoreStore),但锁对象不同,屏障各自独立,本质是违反了 JMM 的“同步规则”——只对同一个监视器有效。
  • 正确理解锁对象就是契约的中间人,必须对同一个对象加锁,才能建立 happens-before 关系。

volatile 保证原子性,写 volatile 变量是线程安全的”

错误直觉:既然 volatile 能保证可见性和禁止重排序,那 volatile int i++; 就是原子操作。 源码真相i++ 在字节码层面是 3 条指令getfield -> iconst_1 -> iadd -> putfield)。volatile 只保证每次 putfield 写入主存,但读取和写入之间,其他线程可能已经修改了该值

  • 反直觉案例:两个线程同时 volatile int count++,即使 countvolatile,最终结果极大概率小于 20000。
  • 底层volatile 写前插入 StoreStore 屏障,写后插入 StoreLoad 屏障;读后插入 LoadLoadLoadStore 屏障。这些屏障只解决了可见性和有序性,没有锁总线或 CPU 缓存一致性协议(如 MESI)的独占排他权
  • 正确理解volatile轻量级的,只能保证对单个 volatile 变量读-写操作(即 x = 1)是原子的,但读-改-写(如 x++)不是。

final 字段在构造器中赋值,其他线程一定能看到正确的值”

错误直觉:对象构造完成后,final 字段就被安全发布了,其他线程看到的一定是最终值。 源码真相构造器中的 this 引用逸出(escape) 会彻底破坏 final 的保证。

  • 反直觉案例
    public class FinalEscape {
        final int x;
        static FinalEscape instance;
        public FinalEscape() { 
            x = 42; 
            instance = this; // 致命:构造函数还没结束,this 就逃逸了
        }
    }

    另一个线程可能读取到 instance.x == 0(默认值),因为 JVM 可能将 instance = this 重排序到 x=42 之前。

  • 底层规避:JVM 会在 final 字段写之后、构造函数返回之前插入 StoreStore 屏障,防止 final 字段的写入被重排序到构造函数外。但如果 this 在构造函数中途逃逸,屏障的位置就不确定了
  • 正确理解安全发布(如 static 初始化、ConcurrentHashMap 放入、volatile 写引用)与 final 双重保障才是王道,构造器中的 this 逸出是 JVM 并发安全的“禁区”。

LockSupport.park()/unpark()Object.wait()/notify() 是一回事”

错误直觉:反正都是让线程阻塞/唤醒,LockSupportwait/notify 的改进版。 源码真相语义和协作模式完全不同,混淆会导致死锁或丢失通知(Lost Notification)。

  • 差异核心
    • wait/notify基于对象监视器,必须在 synchronized 块内使用,顺序固定:wait 释放锁 -> 其他线程 notify -> wait 线程重新争抢锁。notify 在 wait 之前执行,通知就丢失了。
    • park/unpark基于线程许可(permit),不需要同步块。unpark 可以提前给线程“发一个许可”,后面 park 时直接消费许可通过。即使 unpark 在 park 之前,也不会丢失
  • 底层Unsafe.park/unpark 直接调用操作系统(Linux 上为 pthread_cond_wait + pthread_mutex 的封装,但 permit 机制用计数器实现)。
  • 正确理解wait/notify协作式同步原语,必须成对出现且在锁内。park/unpark线程阻塞工具,不依赖锁,且有“先通知后等待”的安全保障。

“AtomicInteger 所有操作都无锁,所以性能一定比 synchronized 好”

错误直觉:CAS 是乐观锁,比悲观锁 synchronized 快,所以所有并发计数器都应该用 Atomic源码真相高竞争下,CAS 的自旋(SpinLoop)可能比 synchronized 的阻塞更差

  • 反直觉场景:线程数 > CPU 核数,且激烈更新同一个 AtomicInteger,每次 CAS 失败后都会重试,导致:
    1. 大量 CPU 时间被浪费在无意义的循环上(忙等待)。
    2. 缓存一致性风暴:每次 CAS 失败成功都会引发其他线程的缓存行失效(MESI 协议的 Invalidate Bus),导致性能断崖式下跌。
    3. ABA 问题的存在(虽然通常可用版本号解决)。
  • 底层AtomicIntegerUnsafe.compareAndSwapInt 实现,这是一个 CPU 级别的原子指令(如 x86 的 LOCK CMPXCHG),高并发下,指令总线被锁,导致所有核心停顿(Lock Prefix 会将其他核心的锁缓存行失效)。
  • 正确理解低竞争Atomic > synchronized(偏向锁、轻量级锁)。高竞争时:
    • synchronized 会膨胀为重量级锁,让线程进入操作系统阻塞(wait/notify),一旦阻塞就不消耗 CPU
    • Atomic 依然在自旋,消耗 CPU,吞吐量反而不如正确使用 synchronizedLongAdder(分段 CAS)。
    • Atomic 不适合极度高竞争,LongAdderLongAccumulator 才是正解(它们通过 Cell 数组分散热点,最后再 sum)。

从“直觉正确”到“源码正确”

常见误区 你以为的保证 源码实际保证 核心突破点
synchronized 全局可见 写后立即可见 同一把锁可预见 锁对象隔离
volatile 原子递增 安全自增 不保证读-改-写原子性 指令组合非单条
final 安全发布 所有线程看最终值 this 不逃逸时才安全 构造函数重排序
park/unparkwait/notify 类似 通知等待机制 许可机制 vs 监视器机制 锁依赖/顺序依赖
Atomic 高并发无锁总最快 无锁一定效率高 高竞争自旋开销惨重 忙等待 vs 阻塞

跳出误区的钥匙

  1. 信任 JMM 规范,而非直觉:多了解 happens-before 规则。
  2. 从 CPU 缓存一致性协议(MESI)、内存屏障(Memory Barrier)、指令重排(Reordering)的视角看问题
  3. 阅读 JDK 源码(UnsafeAbstractQueuedSynchronizerLockSupport,理解底层实现,而不是停留在 API 文档。

如果你对某个误区特别感兴趣(缓存一致性风暴”或“锁膨胀过程”),我可以展开讲得更深一些。

标签: 并发

抱歉,评论功能暂时关闭!