如果说单线程编程是”确定性的世界”,那并发编程就是”不确定性的世界”。

多线程 bug 之所以难查,是因为它不是每次都复现——可能跑了 100 次都正常,第 101 次突然出错。

要驾驭并发,得先理解它为什么难。

并发问题的三大根源

1. 原子性:一个操作被”打断”

比如 count++,看起来是一步,实际上是三步:

  1. 读取 count 的值
  2. 加 1
  3. 写回 count

两个线程同时执行 count++,可能都读到 0,都加 1,都写回 1,结果本应是 2,却变成了 1。

这就是原子性问题:多个步骤的操作,在执行过程中被别的线程插入了。

解决:用锁(synchronized、Lock)或原子类(AtomicInteger)保证操作不可分割。

2. 可见性:改了但别人看不到

每个线程都有自己的工作内存(缓存),变量修改可能先改在缓存里,没有及时同步到主内存。

结果就是:线程 A 改了变量,线程 B 却看不到,还在读旧值。

这就是可见性问题。解决:用 volatile 关键字,保证变量修改立刻对其他线程可见。

volatile 的作用是:保证可见性 + 禁止指令重排序。注意它不保证原子性,volatile int count; count++ 依然不是线程安全的。

3. 有序性:代码执行顺序被打乱

CPU 和编译器为了性能,会做指令重排序——只要不影响单线程的最终结果,执行顺序可以调整。

但多线程下,这种重排序可能让别的线程看到”半初始化”的对象(前面单例模式里 DCL 的例子就是典型)。

解决:用 volatile 或锁,建立 happens-before 关系,约束重排序。

锁的代价

锁(synchronized、Lock)能解决原子性和可见性,但它有代价:

  1. 阻塞:抢不到锁的线程要挂起等待,线程切换开销大
  2. 死锁风险:多个锁相互等待,程序卡死
  3. 降低并发度:锁的粒度太大,会退化成”串行”

所以并发编程的核心权衡就是:在”正确性”和”性能”之间找平衡。

死锁的四个条件

死锁需要同时满足四个条件:

  1. 互斥:资源不能共享
  2. 持有并等待:拿着一个锁,等另一个锁
  3. 不可剥夺:锁不能被强行抢走
  4. 循环等待:A 等 B,B 等 A

破坏任意一个条件,就能避免死锁。最常用的是破坏”循环等待”——规定所有线程按相同的顺序获取锁。

从 synchronized 到现代并发工具

Java 的并发工具是逐步进化的:

  • synchronized:最基础,语法简单,但功能有限
  • Lock + Condition:更灵活,支持超时、可中断、公平锁
  • AtomicInteger 等原子类:基于 CAS,无锁,性能好
  • ConcurrentHashMap:分段锁 + CAS,读多写少场景极快
  • CountDownLatch、Semaphore、CyclicBarrier:线程协作工具

别一上来就自己用 wait/notify 写并发原语,优先用现成的高级工具。

并发编程的心法

  1. 优先无锁方案:能不用共享可变状态,就不用(局部变量、不可变对象、线程封闭)
  2. 优先高级工具:能用 ConcurrentHashMap 就别自己加锁
  3. 缩小锁的粒度:锁的范围越小,并发度越高
  4. 先正确,后性能:先保证不出 bug,再谈优化

写在最后

并发编程的难点,本质是”人脑是单线程的,但系统是多线程的“。

所以别靠”直觉”判断并发代码对不对,靠的是:

  1. 理解三大根源(原子性、可见性、有序性)
  2. 理解 JMM(Java 内存模型)和 happens-before
  3. 用测试和工具(如压测、断点、日志)验证

下一篇是基础系列最后一篇——整洁代码与代码审查,把视角从”正确”提升到”可维护”。