Java并发同步器与StampedLock详解
本文总结了Java四个核心并发工具:
- Semaphore:控制同时访问资源的线程数量。
- CountDownLatch:用于一次性等待,一个线程等待多个任务完成。
- CyclicBarrier:用于多线程同步,互相等待到达屏障点。
- StampedLock:通过乐观读优化读写锁性能。
在 Java 并发编程中,Semaphore、CyclicBarrier 和 CountDownLatch 都是重量级的同步器,它们位于 java.util.concurrent 包(通常简称 JUC)。StampedLock 是一个较为现代的、用于优化读写锁性能的锁机制,位于 java.util.concurrent.locks 包。
下面我将详细介绍它们各自的用途、工作原理以及使用场景。
1. Semaphore (信号量)
Semaphore(信号量)用于控制同时访问特定资源的线程数量。它维护了一个内部计数器,这个计数器表示可用资源的许可数量。
核心概念
- 许可 (Permits): 信号量维护的一组许可。线程在访问资源前必须获取许可,在完成后必须释放许可。
- 资源限制: 如果初始化时设置了 个许可,那么最多只有 个线程可以同时执行获取许可后的代码块。
核心方法
| 方法 | 描述 |
|---|---|
Semaphore(int permits) |
创建具有给定许可数量的信号量。 |
acquire() |
尝试获取一个许可。如果没有可用许可,线程将阻塞直到有许可释放。 |
release() |
释放一个许可,将其返回给信号量。这可能会唤醒一个等待中的线程。 |
tryAcquire() |
尝试获取许可,立即返回结果,不阻塞。 |
工作原理与用途
信号量通常用于 资源池 的管理,例如数据库连接池、限制对高负载服务的并发请求数。
场景示例: 停车场只有 3 个空位,但有 10 辆车要停。
// 假设停车场只有 3 个车位
Semaphore parkingLot = new Semaphore(3);
public void enter() {
try {
parkingLot.acquire(); // 尝试获取车位
System.out.println(Thread.currentThread().getName() + " 停车成功,正在停车...");
Thread.sleep(2000); // 模拟停车时间
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
System.out.println(Thread.currentThread().getName() + " 离开,释放车位。");
parkingLot.release(); // 释放车位
}
}
2. CountDownLatch (倒计时门闩)
CountDownLatch 允许一个或多个线程等待,直到在其他线程中执行的一组操作完成为止。它是一个一次性的同步工具。
核心概念
- 计数器 (Count): 初始化时设置一个正数作为计数器。
- 等待与递减: 调用
await()的线程会阻塞,直到计数器减到零。其他线程调用countDown()来递减计数器。
核心方法
| 方法 | 描述 |
|---|---|
CountDownLatch(int count) |
创建一个初始化计数的门闩。 |
countDown() |
将计数器减 1。 |
await() |
使当前线程等待,直到计数器为零。 |
工作原理与用途
CountDownLatch 常用于 “主线程等待所有子任务完成” 的场景,如:
- 启动一个服务,等待所有必要的外部服务或资源初始化完成。
- 启动一个大型计算,将任务拆分为 N 个子任务,主线程等待所有 N 个子任务完成并返回结果。
场景示例: 赛跑比赛,裁判等待所有运动员都准备好。
final int numRunners = 5;
// 裁判等待 5 个运动员准备就绪
CountDownLatch startSignal = new CountDownLatch(numRunners);
// 运动员线程:
for (int i = 0; i < numRunners; i++) {
new Thread(() -> {
System.out.println("运动员 " + Thread.currentThread().getId() + " 准备好了。");
startSignal.countDown(); // 准备好,计数减 1
}).start();
}
// 裁判线程:
startSignal.await(); // 阻塞,直到计数器归零 (所有运动员准备就绪)
System.out.println("所有运动员准备就绪,比赛开始!");
3. CyclicBarrier (循环屏障)
CyclicBarrier(循环屏障)允许一组线程互相等待,直到所有线程都到达一个 共同的屏障点 (Barrier Point) 之后,才能继续执行。
核心概念
- 循环性 (Cyclic): 一旦所有等待的线程都被释放,
CyclicBarrier可以被重置并再次使用(这也是它与CountDownLatch最主要的区别)。 - 屏障点动作 (Barrier Action): 可以在所有线程达到屏障点后,执行一个可选的
Runnable任务。
核心方法
| 方法 | 描述 |
|---|---|
CyclicBarrier(int parties) |
创建一个需要给定数量线程才能通过的屏障。 |
CyclicBarrier(int parties, Runnable barrierAction) |
允许在所有线程到达后执行一个动作。 |
await() |
线程到达屏障点并等待其他线程。如果它是最后一个到达的,则屏障打开,所有等待线程被释放。 |
工作原理与用途
CyclicBarrier 常用于 分阶段任务 或 迭代计算 中,例如:
- 并行数据处理: 将一个大文件切分成多块并行处理,在进入下一阶段处理前,需要等待所有线程完成对当前阶段块的处理。
- 多玩家游戏准备: 等待所有玩家进入游戏房间,然后开始游戏。
场景示例: 收集 4 个龙珠才能召唤神龙。
final int numDragonBalls = 4;
CyclicBarrier barrier = new CyclicBarrier(numDragonBalls, () -> {
// 屏障点动作:当所有线程都到达时执行
System.out.println("\n 四个龙珠集齐!召唤神龙!**");
});
for (int i = 1; i <= numDragonBalls; i++) {
new Thread(() -> {
try {
System.out.println(Thread.currentThread().getName() + " 找到了一个龙珠,到达屏障点。");
barrier.await(); // 等待其他线程
System.out.println(Thread.currentThread().getName() + " 开始执行下一阶段任务。");
} catch (Exception e) {
// ...
}
}, "线程-" + i).start();
}
总结比较 (JUC 同步器)
| 特性 | CountDownLatch | CyclicBarrier | Semaphore |
|---|---|---|---|
| 用途 | 一个或多个线程等待 其他线程完成任务。 | 一组线程互相等待, 到达同步点 后一起继续。 | 控制同时访问 共享资源的线程数量。 |
| 计数器 | 递减,当减到 0 时释放所有等待线程。 | 递增(内部实现),当达到设定值时释放所有等待线程,并重置。 | 表示可用许可数量。acquire() 递减,release() 递增。 |
| 可重用性 | 一次性,计数器归零后无法再次使用。 | 可循环使用,屏障打开后可以重置。 | 可重复使用,只要有许可就可以获取。 |
4. StampedLock (邮戳锁)
StampedLock 是 Java 8 引入的,用于优化 ReentrantReadWriteLock(可重入读写锁)。它提供了一种乐观读机制,显著提升了在多读少写场景下的性能。
核心概念
- 邮戳 (Stamp):
StampedLock的所有锁定操作都会返回一个 邮戳(long 类型的值)。这个邮戳用于在后续操作中验证或释放锁。 - 三种锁定模式:
- 写锁 (Write Lock): 独占锁,与
ReentrantReadWriteLock的写锁类似。 - 悲观读锁 (Read Lock): 共享锁,与
ReentrantReadWriteLock的读锁类似。 - 乐观读 (Optimistic Read): 最核心的特性。它不阻塞写操作,线程可以“假设”没有写操作发生,并快速读取数据。
- 写锁 (Write Lock): 独占锁,与
核心方法
A. 写锁 (Write Lock)
| 方法 | 描述 |
|---|---|
writeLock() |
获取一个独占的写锁,返回一个邮戳。 |
unlockWrite(long stamp) |
释放写锁。 |
B. 悲观读锁 (Read Lock)
| 方法 | 描述 |
|---|---|
readLock() |
获取一个共享的读锁,返回一个邮戳。 |
unlockRead(long stamp) |
释放读锁。 |
C. 乐观读 (Optimistic Read) - 性能优化关键
tryOptimisticRead(): 尝试获取一个乐观读邮戳。如果当前没有写锁被持有,则返回一个非零邮戳。validate(long stamp): 在读取完共享变量后,使用这个方法验证邮戳是否仍然有效(即在读取过程中有没有发生写操作)。
乐观读的使用流程
- 获取乐观读邮戳:
long stamp = lock.tryOptimisticRead(); - 读取共享变量。
- 验证邮戳:
if (!lock.validate(stamp)) { ... } - 如果验证失败,说明在读取过程中被其他线程写入了数据,需要回退到悲观读锁 重新获取数据。
- 如果验证成功,则认为读取到的数据是有效的。
为什么使用 StampedLock?
ReentrantReadWriteLock 的读锁是悲观的,当一个线程持有读锁时,其他写线程会被阻塞,直到读锁释放。
StampedLock 的乐观读机制允许写操作在读操作进行的同时发生。如果发现写入操作干扰了读取,则读线程可以优雅地回退到传统的悲观读锁,这大大减少了读线程之间的同步开销,特别适用于读多写少的场景。
注意: StampedLock 不支持重入,并且如果在使用过程中没有正确释放锁或使用了错误的邮戳,可能会导致死锁或其他并发问题,因此使用上比 ReentrantReadWriteLock 更复杂一些。