Java 高并发编程实战:同步工具、锁与线程池
- 约 12 分钟读完 - 全文 4490 字Java 高并发编程实战:同步工具、锁与线程池
高并发程序的核心不是“让线程跑得多快”,而是让多个线程在共享可变状态下安全、可预期地协作。JDK 的
java.util.concurrent(JUC)包已经为我们提供了大量经过打磨的同步工具,善用它们比手写wait/notify要可靠得多。
一、高层次思想:并发编程在解决什么问题?
并发编程的本质是约束:当多个线程访问同一组可变资源时,通过某种机制保证以下三件事至少满足其一:
- 互斥:同一时刻只有一个线程能访问临界区。
- 可见性:一个线程修改的状态,其他线程能正确看到。
- 有序性:指令不会被编译器或 CPU 乱序到破坏程序语义。
graph TD
A[共享可变状态] -->|互斥| B[锁 / 信号量]
A -->|可见性| C[volatile / happens-before]
A -->|有序性| D[内存屏障 / 同步原语]
B --> E[正确的并发程序]
C --> E
D --> E
首选策略金字塔(从推荐到不推荐):
graph LR
A[不可变对象 & 线程封闭] --> B[JUC 并发集合 / 线程池]
B --> C[高级同步工具 CountDownLatch Semaphore 等]
C --> D[ReentrantLock & Condition]
D --> E[synchronized]
E --> F[手写 wait/notify]
避坑指南 #1:不到万不得已不要手写
wait/notify,极容易写出遗漏notify、虚假唤醒、持锁休眠等问题。JUC 工具类已经帮你封装好了。
二、线程状态与原语对照
| 方法 | 调用者 | 是否释放锁 | 进入状态 | 唤醒条件 |
|---|---|---|---|---|
Thread.sleep(ms) |
当前线程 | 否 | TIMED_WAITING |
时间到 |
Thread.yield() |
当前线程 | 否 | RUNNABLE(让出 CPU) |
OS 调度 |
t.join() / t.join(ms) |
当前线程等 t | 否 | WAITING / TIMED_WAITING |
t 结束或超时 |
obj.wait() |
当前线程 | 是 | WAITING |
notify/notifyAll 或超时 |
obj.notify() |
当前线程 | 否 | — | 唤醒一个等待线程 |
stateDiagram-v2
[*] --> 新建: new Thread()
新建 --> 就绪: start()
就绪 --> 运行: 调度
运行 --> 就绪: yield / 时间片到
运行 --> 阻塞: 等待锁
阻塞 --> 就绪: 获得锁
运行 --> 等待: wait / join / await
等待 --> 就绪: notify / 超时 / 被等待线程结束
运行 --> 超时等待: sleep / wait(timeout)
超时等待 --> 就绪: 时间到
运行 --> 终止: 执行完毕
避坑指南 #2:
sleep不释放锁,若在 synchronized 块内 sleep,其他线程会被长时间阻塞。需要“让出锁去等”时,用wait/Condition.await。
三、多用 JUC 同步类,少用 wait/notify
3.1 Semaphore(信号量)
信号量维护一组“许可”,控制同时访问某资源的线程数量。
Semaphore semaphore = new Semaphore(3); // 最多 3 个线程并发
try {
semaphore.acquire();
// 访问受限资源
} finally {
semaphore.release();
}graph LR
A[线程1] -->|acquire| S[许可池 3]
B[线程2] -->|acquire| S
C[线程3] -->|acquire| S
D[线程4] -->|acquire 阻塞| S
S -->|release| S
避坑指南 #3:
release()一定要放在finally里,否则异常会导致许可泄漏,最终所有线程都拿不到许可。
3.2 CountDownLatch 与 CyclicBarrier
| 特性 | CountDownLatch | CyclicBarrier |
|---|---|---|
| 方向 | 一个线程等待其他线程 | 多个线程互相等待 |
| 可复用 | 否(一次性) | 是(可 reset) |
| 计数归零后 | 门闩打开,直接退出 | 所有线程同时继续 |
graph LR
subgraph CountDownLatch
M[主线程 await] -.->|计数=0| M2[继续执行]
W1[工作线程1] -->|countDown| C[计数器]
W2[工作线程2] -->|countDown| C
end
subgraph CyclicBarrier
T1[线程A await] --> B[屏障]
T2[线程B await] --> B
T3[线程C await] --> B
B -->|全部到达| Go[一起继续]
end
避坑指南 #4:不要把
CountDownLatch当循环栅栏用,计数到 0 后无法重置;需要循环同步用CyclicBarrier。
3.3 Exchanger
Exchanger 让两个线程在某个同步点交换数据,适合生产者-消费者配对场景,但使用频率较低。
四、volatile:可见性与有序性,但不保证原子性
volatile 的三条语义:
- 可见性:写操作会刷入主内存,读操作会从主内存刷新。
- 有序性:禁止指令重排序(happens-before)。
- 不保证原子性:复合操作(如
i++)仍需要锁或原子类。
graph LR
A[线程A 写 volatile flag=true] -->| happens-before | B[线程B 读 flag]
B --> C[线程B 看到 flag 之前所有写入]
// 错误示例:volatile 不能保证 i++ 原子
private volatile int count = 0;
public void increment() {
count++; // 读-改-写,非原子
}
// 正确做法
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}避坑指南 #5:只有在单次写、多次读、且不依赖前值的场景下才用
volatile(如状态标志volatile boolean running = true)。
五、死锁:原因与破解
5.1 典型死锁
graph LR
T1[线程1] -->|持有 A 锁| B[等待 B 锁]
T2[线程2] -->|持有 B 锁| A[等待 A 锁]
B --> T2
A --> T1
// 错误:锁顺序不一致
devoid transfer(Account a, Account b, int amount) {
synchronized(a) {
synchronized(b) { ... }
}
}5.2 常见诱因
- 不一致的加锁顺序:两个线程以不同顺序获取多把锁。
- 在同步块内调用外部同步方法:引入了不可控的锁依赖。
- 持锁等待 I/O / 网络 / 用户输入:放大了死锁窗口。
5.3 破解方案
- 全局统一锁顺序:先按对象哈希值排序再加锁。
- 使用
tryLock超时获取锁,失败则回退并重试。 - 减少锁粒度,避免在同步块内调用外部方法(Open Call)。
// 使用 ReentrantLock 的 tryLock 避免死锁
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lockB.tryLock(1, TimeUnit.SECONDS)) {
try {
// 执行业务
} finally { lockB.unlock(); }
}
} finally { lockA.unlock(); }
}避坑指南 #6:死锁往往不是锁本身的问题,而是锁的顺序和依赖关系的问题。设计阶段先画锁依赖图,能发现大部分隐患。
六、公平锁 vs 非公平锁
ReentrantLock fairLock = new ReentrantLock(true); // 公平
ReentrantLock unfairLock = new ReentrantLock(false); // 非公平(默认)graph LR
subgraph 公平锁
Q[等待队列 T1→T2→T3] -->|按顺序| L[锁]
end
subgraph 非公平锁
N[新线程] -->|插队尝试| L2[锁]
Q2[队列 T1→T2] -.->|可能继续等待| L2
end
- 公平锁:按请求顺序分配锁,减少饥饿,但上下文切换多、吞吐低。
- 非公平锁:允许“插队”,若锁恰好可用,新线程可直接获取,减少唤醒延迟;但老线程可能长时间等待。
避坑指南 #7:默认选非公平锁即可,性能更好。除非业务明确要求严格的调度公平性(如资源分配器),否则不必打开公平模式。
七、Condition:比 wait/notify 更精确的线程协作
一个 ReentrantLock 可以创建多个 Condition,分别管理不同的等待队列。
graph TD
L[ReentrantLock] --> C1[Condition A 队列]
L --> C2[Condition B 队列]
L --> C3[Condition C 队列]
C1 -->|signal| W1[唤醒指定线程]
public class PrintABC {
private final Lock lock = new ReentrantLock();
private final Condition a = lock.newCondition();
private final Condition b = lock.newCondition();
private final Condition c = lock.newCondition();
private volatile int index = 0;
private void print(String name, int expect, Condition wait, Condition next) {
new Thread(() -> {
lock.lock();
try {
while (index != expect) {
wait.await();
}
System.out.print(name);
index = (expect + 1) % 3;
next.signal();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}).start();
}
public static void main(String[] args) {
PrintABC p = new PrintABC();
p.print("A", 0, p.a, p.b);
p.print("B", 1, p.b, p.c);
p.print("C", 2, p.c, p.a);
}
}避坑指南 #8:
Condition.await/signal与wait/notify一样,必须在持有锁的线程中调用,且await要用while循环而不是if,防止虚假唤醒。
八、线程池:不要裸开线程
8.1 常见工厂方法
| 方法 | 特点 | 隐患 |
|---|---|---|
newSingleThreadExecutor |
单线程,按提交顺序执行 | 无界队列,任务堆积会 OOM |
newFixedThreadPool(n) |
固定线程数 | 同样使用无界队列 |
newCachedThreadPool |
线程可复用、可无限创建 | 高并发下线程数爆炸 |
newScheduledThreadPool |
支持定时/周期任务 | 注意任务异常终止 |
8.2 ThreadPoolExecutor 核心参数
graph LR
T[提交任务] -->|核心线程数未满| C[创建核心线程]
T -->|队列未满| Q[进入阻塞队列]
T -->|队列满且线程数<max| M[创建非核心线程]
T -->|队列满且线程数=max| R[拒绝策略]
C --> E[执行]
Q --> E
M --> E
ThreadPoolExecutor executor = new ThreadPoolExecutor(
4, // 核心线程数
8, // 最大线程数
60L, TimeUnit.SECONDS, // 非核心线程空闲存活时间
new ArrayBlockingQueue<>(100), // 有界队列
new ThreadFactoryBuilder().setNameFormat("biz-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);避坑指南 #9:
- 生产环境尽量直接创建
ThreadPoolExecutor,不要用 Executors 的便捷工厂(无界队列/无限线程是 OOM 元凶)。- 任务抛异常会导致线程被回收重建,建议任务内部
try/catch并记录。- 应用关闭前调用
shutdown()或shutdownNow(),否则 JVM 可能不会退出。
九、阻塞队列
| 队列 | 有界性 | 实现 | 特点 |
|---|---|---|---|
ArrayBlockingQueue |
有界 | 数组 + ReentrantLock | 高吞吐、需要指定容量 |
LinkedBlockingQueue |
可选有界 | 链表 + 两把锁(take/put) | 默认无界,小心 OOM |
PriorityBlockingQueue |
无界 | 堆 | 按优先级出队 |
DelayQueue |
无界 | 堆 | 元素到延迟时间才能出队 |
SynchronousQueue |
零容量 | 无缓冲区 | 一手交钱一手交货 |
LinkedTransferQueue |
无界 | 无锁 CAS | 支持 transfer 直接传递 |
LinkedBlockingDeque |
无界 | 双向链表 | 支持两端操作 |
graph LR
P[生产者 put] --> Q[阻塞队列]
Q --> C[消费者 take]
P -.->|队列满| W1[阻塞]
C -.->|队列空| W2[阻塞]
避坑指南 #10:无界阻塞队列在生产者快、消费者慢时会把内存撑爆。优先使用
ArrayBlockingQueue或指定容量的LinkedBlockingQueue。
十、自旋锁、ReentrantLock 与 synchronized
10.1 自旋锁
public class SpinLock {
private final AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread t = Thread.currentThread();
while (!owner.compareAndSet(null, t)) {
// 自旋等待,不进入内核态
}
}
public void unlock() {
Thread t = Thread.currentThread();
owner.compareAndSet(t, null);
}
}graph LR
A[互斥锁 sleep] -->|上下文切换| B[内核阻塞]
C[自旋锁 CAS] -->|用户态循环| D[CPU 忙等]
- 优点:没有线程上下文切换开销。
- 缺点:持锁时间长会浪费 CPU;同一线程不可重入,递归调用会死锁。
10.2 ReentrantLock 与 synchronized 对比
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 可重入 | 是 | 是 |
| 可中断 | 否 | lockInterruptibly() |
| 超时获取 | 否 | tryLock(timeout) |
| 公平锁 | 否 | 可选 |
| 多条件队列 | 一个 wait set | 多个 Condition |
| 性能 | JDK6+ 优化后接近 | 接近 |
避坑指南 #11:
ReentrantLock必须显式unlock(),否则即使异常也会死锁;务必放在finally中。synchronized则由 JVM 保证自动释放。
10.3 偏向锁
偏向锁是 JVM 对无竞争同步的优化:第一次获取锁时把线程 ID 记录到对象头,之后同一线程再进入无需 CAS。
注意:JDK 15 开始偏向锁默认禁用(
-XX:+UseBiasedLocking已废弃),因为现代应用中锁竞争更频繁,撤销偏向锁的开销反而可能大于收益。
十一、线程安全的 Singleton
11.1 饿汉式(类加载时初始化)
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}借助类加载机制保证线程安全,简单可靠;若实例创建开销大且未必使用,则浪费资源。
11.2 静态内部类(推荐)
public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}graph LR
A[调用 getInstance] -->|首次触发| B[加载 Holder 类]
B --> C[初始化 INSTANCE]
C --> D[返回唯一实例]
延迟加载 + 类加载保证线程安全,是大多数场景下的最佳实践。
11.3 双重检查锁(DCL)+ volatile
public class Singleton {
private Singleton() {}
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton(); // 禁止指令重排
}
}
}
return instance;
}
}避坑指南 #12:DCL 必须加
volatile,否则new Singleton()的指令重排会导致其他线程读到未构造完成的对象。
十二、synchronized 的底层行为
synchronized 可以修饰方法或代码块,锁对象分别是:
- 实例方法:当前实例
this。 - 静态方法:类的
Class对象。 - 代码块:括号里指定的对象。
graph TD
O[对象头 Mark Word] -->|无锁| A[无锁状态]
A -->|第一个线程获取| B[偏向锁]
B -->|出现竞争| C[轻量级锁 CAS]
C -->|竞争加剧| D[重量级锁 操作系统 Mutex]
避坑指南 #13:不要把
synchronized当成“只要包住就安全”的万金油。锁的对象要选对,锁的粒度要尽量小,避免锁住整个方法或无关代码。
十三、避坑速查表
| 不要这样做 | 推荐做法 |
|---|---|
手写 wait/notify 做复杂协调 |
用 CountDownLatch、CyclicBarrier、Semaphore |
用 volatile 做 i++ |
用 AtomicInteger 或 LongAdder |
裸 new Thread() |
用 ThreadPoolExecutor |
Executors.newFixedThreadPool 无脑用 |
自定义有界队列 + 拒绝策略 |
| 在锁里调外部同步方法 / 阻塞 I/O | 缩小锁范围,使用 Open Call |
| 不一致的加锁顺序 | 全局排序或使用 tryLock |
DCL 不加 volatile |
必须加 volatile,或改用静态内部类 |
| 自旋锁里递归加锁 | 使用可重入锁 ReentrantLock |
忽略 InterruptedException |
恢复中断标志 Thread.currentThread().interrupt() |
| 线程池任务吞掉异常 | 任务内部 try/catch 或使用 Future.get() |
十四、总结
Java 高并发编程的演进路径,其实是从“手动管理线程和锁”走向“使用高层抽象”。牢记下面三句话:
- 能不变就不变:优先使用不可变对象、线程封闭和 JUC 并发集合。
- 能用高层就不用底层:用
Semaphore、CountDownLatch、Executor替代wait/notify。 - 锁是最后手段:如果必须用锁,确保加锁顺序一致、粒度最小、持有时间最短。
把这三点落地到代码里,配合上面的图例和示例,就能避开大多数并发陷阱。
参考与延伸阅读
- 《Java 并发编程实战》(Java Concurrency in Practice)
- 《Java 并发编程的艺术》
- JDK 文档:java.util.concurrent