1. 竞态与临界区
1.1 问题的本质
并发编程的一切问题源于竞态条件(Race Condition):多个线程读写共享数据,最终结果取决于执行顺序,而这个顺序不可控。
# 经典竞态:count++
# 反汇编: load count → add 1 → store count(三步非原子)
# 两个线程并发执行时,可能都读到旧值,各加一次,结果只加 1
解决竞态的通用思路是临界区(Critical Section):保证「同一时刻只有一个线程访问共享资源」。原子性、互斥、同步都是围绕临界区展开的工具。
2. 原子性与可见性
2.1 两个容易混淆的概念
- 原子性(Atomicity):操作不可分割。由锁、CAS、原子类保证。
- 可见性(Visibility):一个线程的写入对另一个线程可见。由
volatile、锁、synchronized的内存屏障保证。
# 可见性问题
# 线程 A 写 flag=true(可能只写寄存器/缓存,未刷主存)
# 线程 B 读 flag,可能一直读到旧值 → 死循环
# 用 volatile 修饰 flag → 读写直接走主存(建立 happens-before)
volatile 只保证可见性,不保证原子性:volatile int count++ 仍是竞态。这是新手最常见的误解。
3. 互斥锁
3.1 锁的层次与选型
| 锁 | 特点 | 适用 |
|---|---|---|
| synchronized | 内置、可重入、非公平,JDK 优化(偏向/轻量/重量级) | 简单临界区、代码块 |
| ReentrantLock | 可中断、可超时、公平可选、可绑定多条件 | 高级并发控制 |
| ReadWriteLock | 读读共享、写写互斥 | 读多写少场景 |
| StampedLock | 乐观读(无锁读 + 校验) | 读多且读很频繁 |
// ReentrantLock 的基本用法
ReentrantLock lock = new ReentrantLock();
lock.lock();
try { /* 临界区 */ } finally { lock.unlock(); }
工程要点:能 synchronized 就别上 ReentrantLock,内置锁的优化足够;需要「可中断、tryLock 超时」时才升级。锁必须 try-finally 释放,否则异常时死锁。
3.2 锁粒度
锁的粒度决定并发度:粗锁简单但并发低,细锁并发高但易错。分段锁(ConcurrentHashMap 1.7)、分桶锁是「细粒度」的工程典范——把冲突分散到多个锁上。
4. 信号量与条件变量
4.1 信号量:计数控制
**信号量(Semaphore)**控制「同时最多 N 个线程进入」:acquire 减少计数,release 增加。用于限流、资源池、多许可场景。
// 限流:最多 3 个并发请求
Semaphore sem = new Semaphore(3);
sem.acquire(); // 无许可则阻塞等待
try { doWork(); } finally { sem.release(); }
注意二值信号量(计数 1)与互斥锁语义并不等同——信号量不拥有所有权,任何线程都能 release,别当互斥锁用。
4.2 条件变量:等待与通知
**条件变量(Condition)**让线程「等待某个条件成立」而「不自旋空转」:wait 释放锁并阻塞,signal 唤醒等待者。
// 生产者-消费者经典模式
// producer: lock → enqueue → condition.signal() → unlock
// consumer: lock → while (empty) condition.await() → dequeue → unlock
关键点:条件等待必须用 while 循环包裹(防止虚假唤醒与信号丢失),这是所有条件变量(Java Condition/Python Condition/C 的 pthread_cond)的统一要求。
5. 内存模型与 happens-before
5.1 Java 内存模型(JMM)
JMM 规定线程间共享变量的可见性规则,核心是 happens-before:若操作 A happens-before B,则 A 的结果对 B 可见。
# 主要 happens-before 规则
# 1) 程序顺序: 单线程内前面的操作 happens-before 后面的
# 2) 锁: unlock happens-before 后续的 lock(同把锁)
# 3) volatile: volatile 写 happens-before 后续的 volatile 读
# 4) 线程启动/终止: start() happens-before 线程内操作
# 5) 传递性
工程价值:不要凭经验猜可见性,用 happens-before 规则推导。保证正确性的手段就是「让共享变量的读写满足 happens-before」。
6. 无锁编程与 CAS
6.1 CAS 的原理与局限
CAS(Compare And Swap):比较当前值与期望值,相等则替换,否则重试——硬件级原子操作,无锁但可能自旋。
// 原子类的 CAS 本质
// do { old = value.get(); } while (!value.compareAndSet(old, old + 1));
// 失败则重读重试(自旋)
// ABA 问题: 值从 A→B→A,CAS 误判"没变过"
// 解决: 加版本号(AtomicStampedReference)
CAS 适合「并发度高、临界区极短、争用少」的场景;争用激烈时自旋反而烧 CPU。不要为了"无锁"而无锁——能上锁就上锁,CAS 只在确需极限并发时才用。
6.2 无锁与锁的对比
# 锁: 阻塞等待,上下文切换开销,但实现简单、易推理
# 无锁(CAS): 自旋重试,无切换,但忙等耗 CPU、易活锁
# 工程选型: 默认锁;热点短临界区可试 CAS;终极场景组合优化
7. 死锁、活锁与饥饿
7.1 死锁四条件
死锁必须同时满足:互斥、占有且等待、不可剥夺、循环等待。打破任一条即可预防:
# 打破循环等待: 所有线程按固定顺序加锁(锁排序)
# 打破占有且等待: 一次性申请全部资源(tryLock 失败即释放)
# 检测: jstack 抓线程栈找 "deadlock";JVM 可自动检测
7.2 活锁与饥饿
- 活锁:线程不阻塞但互相让步,永远无法推进(如两人让路互相让)。加随机退避或优先级可破。
- 饥饿:低优先级线程长期得不到 CPU/锁。用公平锁(ReentrantLock(true))或让高优先线程主动让步缓解。
8. 工程实践清单
# 并发代码的工程检查单
# 1) 所有共享变量: 明确原子性 + 可见性,用锁/volatile/原子类
# 2) 锁范围: 尽量小,不把 IO/慢操作放锁内
# 3) 条件等待: 一律 while + await,防虚假唤醒
# 4) 释放: 锁/许可/连接,全部 try-finally
# 5) 避免: 锁内调外部服务(易死锁/拖死锁持时间)
# 6) 测试: 高并发压测 + 竞态检测工具(ThreadSanitizer/FindBugs)
一条铁律:能用不可变对象就不用可变共享。不可变对象天然线程安全,是并发最简单的一课——很多并发 bug 源于「可变对象被共享」。
9. 常见陷阱
- double-checked locking 的可见性:不 volatile 的字段在 DCL 里可能读到未初始化对象,现代用 holder class 或 volatile 实例字段。
- 锁内 sleep/网络调用:锁持有时间失控,放大阻塞面。
- String/Integer 作为锁对象:可能被别处引用导致死锁(字符串常量池共享),用私有 final 对象。
- 低估可见性:只加 synchronized 但字段没 volatile,跨线程读仍可能错。
参考文章
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。