并发编程基础:线程、锁与内存可见性
如果说单线程编程是”确定性的世界”,那并发编程就是”不确定性的世界”。
多线程 bug 之所以难查,是因为它不是每次都复现——可能跑了 100 次都正常,第 101 次突然出错。
要驾驭并发,得先理解它为什么难。
并发问题的三大根源
1. 原子性:一个操作被”打断”
比如 count++,看起来是一步,实际上是三步:
- 读取 count 的值
- 加 1
- 写回 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)能解决原子性和可见性,但它有代价:
- 阻塞:抢不到锁的线程要挂起等待,线程切换开销大
- 死锁风险:多个锁相互等待,程序卡死
- 降低并发度:锁的粒度太大,会退化成”串行”
所以并发编程的核心权衡就是:在”正确性”和”性能”之间找平衡。
死锁的四个条件
死锁需要同时满足四个条件:
- 互斥:资源不能共享
- 持有并等待:拿着一个锁,等另一个锁
- 不可剥夺:锁不能被强行抢走
- 循环等待:A 等 B,B 等 A
破坏任意一个条件,就能避免死锁。最常用的是破坏”循环等待”——规定所有线程按相同的顺序获取锁。
从 synchronized 到现代并发工具
Java 的并发工具是逐步进化的:
synchronized:最基础,语法简单,但功能有限Lock+Condition:更灵活,支持超时、可中断、公平锁AtomicInteger等原子类:基于 CAS,无锁,性能好ConcurrentHashMap:分段锁 + CAS,读多写少场景极快CountDownLatch、Semaphore、CyclicBarrier:线程协作工具
别一上来就自己用 wait/notify 写并发原语,优先用现成的高级工具。
并发编程的心法
- 优先无锁方案:能不用共享可变状态,就不用(局部变量、不可变对象、线程封闭)
- 优先高级工具:能用
ConcurrentHashMap就别自己加锁 - 缩小锁的粒度:锁的范围越小,并发度越高
- 先正确,后性能:先保证不出 bug,再谈优化
写在最后
并发编程的难点,本质是”人脑是单线程的,但系统是多线程的“。
所以别靠”直觉”判断并发代码对不对,靠的是:
- 理解三大根源(原子性、可见性、有序性)
- 理解 JMM(Java 内存模型)和 happens-before
- 用测试和工具(如压测、断点、日志)验证
下一篇是基础系列最后一篇——整洁代码与代码审查,把视角从”正确”提升到”可维护”。
