24. 并发编程与同步机制

系统掌握并发与同步核心:竞态条件与临界区、原子性与可见性、互斥锁(synchronized/ReentrantLock/读写锁)、信号量与条件变量、自旋锁与乐观并发、内存模型与 volatile、无锁编程(CAS/ABA)、以及死锁/活锁/饥饿的成因与工程避免。

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,跨线程读仍可能错。

参考文章

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「计算机基础」更多文章

  1. 28. 网络应用层协议深入
  2. 27. 面向对象基础
  3. 26. 栈、队列与堆