并发编程的问题与管程
1. 安全性、活跃性以及性能问题
分类 | 问题类型 | 典型表现 | 解决策略 |
安全性 | 数据竞争、竞态条件 | 结果不一致、错误 | 使用互斥锁(synchronized、ReentrantLock)等 |
活跃性 | 死锁、活锁、饥饿 | 程序卡住、不前进 | 公平锁、随机等待、资源调度优化 |
性能 | 串行比例过高 | 吞吐下降、响应变慢 | 减少锁持有时间、无锁并发设计 |
1.1. 安全性问题(Correctness)
定义:程序执行结果符合预期,不出现数据错误或逻辑异常。
本质:线程安全,即多个线程并发访问共享数据时,程序依然表现得“正确”。
原因分析:
安全性问题的根本来源是:
- 原子性问题:操作不可被中断
- 可见性问题:一个线程对共享变量的修改,另一个线程看不到
- 有序性问题:指令执行顺序与预期不一致
典型场景:
- 存在共享变量且变量状态可变(多个线程读写)
- 存在数据竞争(Data Race):多线程访问共享变量时没有同步机制
- 存在竞态条件(Race Condition):程序执行结果依赖线程执行顺序,如果顺序不同则结果不同。必须避免。
例子:
synchronized 不能解决所有问题
即使使用了 synchronized 包裹 get() 和 set() 方法,也无法保证 set(get()+1) 的原子性。
synchronized long get() { return count; }
synchronized void set(long v) { count = v; }
void add10K() {
int idx = 0;
while(idx++ < 10000) {
set(get()+1)
}
两个线程都可能读取 count=0,各自加 1 后设置为 1,导致结果不是 2 而是 1。
- add10K() 方法并不是线程安全的。
解决方案:
那面对数据竞争和竞态条件问题,又该如何保证线程的安全性呢?其实这两类问题,都可以用互斥这个技术方案,而实现互斥的方案有很多,CPU 提供了相关的互斥指令,操作系统、编程语言也会提供相关的 API。从逻辑上来看,我们可以统一归为:锁。
1.2. 活跃性问题(Liveness)(三种锁类型)
定义:程序在并发环境中“能否继续运行下去”。
常见问题类型:
- 死锁(Deadlock):多个线程互相等待对方释放资源,永久阻塞。
- 活锁(Livelock):线程不断地更换状态/“谦让”,但依然无法前进。
- 饥饿(Starvation):线程长期无法获得资源,导致迟迟无法执行。
解决方案:
- 死锁:避免嵌套锁、使用定时锁等手段预防。
- 活锁:使用“随机等待时间”打破同步,例如 Raft 协议中的随机选举等待。
- 饥饿:
-
- 使用公平锁(先来先服务)
- 降低锁持有时间
- 提高线程优先级的公平性
1.3. 性能问题(Performance)
度量性能的指标:
- 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
- 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
- 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。
锁带来的问题:
- 过度串行化会削弱多线程的优势。
- “锁”本质上是将并发转为串行,从而影响性能。
理论支持:阿姆达尔定律(Amdahl's Law):
加速比公式如下:
- S=1/((1−p)+p/n)
p
: 可并行的百分比n
: CPU 核数
举例:如果串行部分为 5%(p=0.95),无论多少线程,加速比最多为 20 倍。
提升性能的常用技术:
1. 无锁并发(Lock-Free)
-
- 线程本地存储(ThreadLocal)
- 写时复制(Copy-on-write)
- 乐观锁
- 原子类(如
AtomicInteger
) - Disruptor 等无锁队列
2. 减小锁持有时间
-
- 使用细粒度锁(如 ConcurrentHashMap 的分段锁)
- 使用读写锁(ReadWriteLock)
2. 管程(Monitor)
小结:
- 管程 = 封装共享资源 + 控制互斥访问 + 条件变量实现线程协作。
- Java 使用的是 MESA 模型,
synchronized
实际就是一种简化的管程。 - 管程能解决互斥 + 同步两个核心问题,掌握管程 = 掌握并发基础。
- 学好管程,对理解各种并发工具类的底层实现极有帮助。
2.1. 管程的概念
管程的定义:
管程是一种管理共享变量及其操作的同步机制,使类的操作在多线程环境中是线程安全的。
- Java 中的
synchronized
和wait()
、notify()
、notifyAll()
正是管程模型的体现。 - 管程和信号量是等价的,所谓等价指的是用管程能够实现信号量,也能用信号量实现管程。
- 但管程更易使用,Java 优选之。
在管程的发展史上,先后出现过三种不同的管程模型:
- Hasen 模型
- Hoare 模型
- MESA 模型( Java 管程的实现参考)。
2.2. MESA模型
并发编程核心问题:
- 互斥(Mutual Exclusion):同一时刻只能有一个线程访问共享资源。
- 同步(Synchronization):线程间如何协调、通信。
管程的解决方案:
1. 互斥:
- 将共享变量和其操作封装于管程中(如
enq()
、deq()
)。 - 只允许一个线程进入管程方法,其它线程等待,确保线程安全。
- 管程与面向对象思想高度契合。
2. 同步:
- 引入条件变量(Condition Variable)和等待队列。
- 若条件不满足,线程进入条件变量对应的等待队列。
- 条件满足后,其他线程调用
notify()
/signal()
通知等待线程。
- 必须在 while 循环中使用 wait(),这是 MESA 管程模型的编程范式。
- 除非经过深思熟虑,否则尽量使用 notifyAll()。使用 notify() 需要满足以下三个条件:
所有等待线程拥有相同的等待条件;
所有等待线程被唤醒后,执行相同的操作;
只需要唤醒一个线程。
MESA 与其他管程模型对比:
模型 | 执行逻辑说明 |
Hasen |
|
Hoare |
|
MESA |
|
MESA 好处:
- 逻辑简单,无需将
notify()
放最后 - 效率较高,无额外的阻塞/唤醒
副作用:
- 唤醒的线程未必能立即执行,可能条件不再满足 → 必须再次检查条件(
while
循环)
2.3. Java的管程
Java 参考了 MESA 模型,语言内置的管程(synchronized)对 MESA 模型进行了精简。MESA 模型中,条件变量可以有多个,Java 语言内置的管程里只有一个条件变量。
- Java 内置的管程方案(synchronized)使用简单,synchronized 关键字修饰的代码块,在编译期会自动生成相关加锁和解锁的代码,但是仅支持一个条件变量;
- 而 Java SDK 并发包实现的管程支持多个条件变量,不过并发包里的锁,需要开发人员自己进行加锁和解锁操作。
特性 | Java 内置(synchronized) | 并发包(Lock + Condition) |
条件变量数量 | 一个 | 支持多个 |
是否自动加锁 | 是(自动生成字节码) | 否(手动加锁) |
使用难度 | 简单 | 稍复杂,但更灵活 |