前期回顾
利用aqs构建一个自己的非公平独占锁
利用aqs构建一个自己的公平独占锁
利用 AQS 构建一个自己的非公平共享锁
本期目标:实现一个公平共享锁
在前一篇文章中,我们实现了一个非公平的共享锁,它允许新来的线程与等待队列中的线程竞争许可,可能会导致某些线程长时间得不到执行("饥饿"现象)。
今天,我们将在此基础上进行改造,实现一个公平的共享锁。公平锁的核心思想是:严格按照线程进入等待队列的顺序来获取锁,先等待的线程先获得许可,从而避免饥饿。
共享锁与独占锁在公平性实现上的主要区别在于:在尝试获取锁时,需要先判断当前线程是否位于等待队列的头部(或队列是否为空),如果是才允许参与竞争;否则直接进入队列等待。
代码实现
java
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
/**
* 基于 AQS 实现的公平共享锁
* 特点:
* 1. 多个线程可以同时持有(共享模式)
* 2. 按照等待队列的顺序公平获取许可
* 3. 状态值(state)表示剩余许可数量
*/
public class MyFairSharedLock {
// 内部同步器类
private static class Sync extends AbstractQueuedSynchronizer {
// 构造时指定最大许可数
Sync(int permits) {
if (permits <= 0) throw new IllegalArgumentException("Permits must be > 0");
setState(permits);
}
/**
* 尝试获取共享锁(公平模式)
* @param arg 本次要获取的许可数,固定为1
* @return 负数表示失败,非负数表示成功且返回剩余许可数
*/
@Override
protected int tryAcquireShared(int arg) {
if (arg != 1) throw new IllegalArgumentException("Arg must be 1");
// 公平性判断:如果等待队列中有其他线程在排队,且当前线程不是队首,则直接返回失败
// hasQueuedPredecessors() 是 AQS 提供的核心方法:
// - 返回 true:表示队列中有其他线程等待的时间比当前线程更长(即当前线程需要排队)
// - 返回 false:表示队列为空 或 当前线程就是队首节点(可以尝试获取)
if (hasQueuedPredecessors()) {
return -1; // 有前辈在排队,当前线程不能插队
}
// 没有前辈等待,尝试 CAS 更新状态
for (;;) {
int current = getState();
int next = current - arg;
if (next < 0) {
// 许可不足,获取失败
return -1;
}
if (compareAndSetState(current, next)) {
// 获取成功,返回剩余许可数(用于传播唤醒)
return next;
}
}
}
/**
* 尝试释放共享锁
* @param arg 本次要释放的许可数,固定为1
* @return true 表示释放成功且可能唤醒等待线程
*/
@Override
protected boolean tryReleaseShared(int arg) {
if (arg != 1) throw new IllegalArgumentException("Arg must be 1");
for (;;) {
int current = getState();
int next = current + arg;
// 简单起见不考虑溢出
if (compareAndSetState(current, next)) {
return true; // 释放成功,触发后续唤醒
}
}
}
// 获取当前剩余许可数
int getPermits() {
return getState();
}
}
private final Sync sync;
/**
* 构造函数,指定最大许可数
*/
public MyFairSharedLock(int permits) {
sync = new Sync(permits);
}
/**
* 获取锁(共享模式,公平)
*/
public void lock() {
sync.acquireShared(1);
}
/**
* 释放锁
*/
public void unlock() {
sync.releaseShared(1);
}
/**
* 尝试获取锁(非阻塞)
*/
public boolean tryLock() {
return sync.tryAcquireShared(1) >= 0;
}
/**
* 当前剩余许可数
*/
public int availablePermits() {
return sync.getPermits();
}
}
关键机制解析
1. 公平性的核心:hasQueuedPredecessors()
与非公平版本相比,唯一的关键变化就在 tryAcquireShared 方法中增加了一行判断:
java
if (hasQueuedPredecessors()) {
return -1; // 有前辈在排队,当前线程不能插队
}
hasQueuedPredecessors() 是 AQS 提供的一个工具方法,它的判断逻辑是:
-
返回 true:表示同步队列中已经有其他线程在等待,并且当前线程不是队列中的第一个等待节点(即存在更早等待的线程)。此时,当前线程不应该尝试获取锁,而应该直接返回失败,进入队列排队。
-
返回 false:表示队列为空,或者当前线程本身就是队列中的第一个等待节点(被唤醒后重试)。此时可以尝试获取锁。
这个简单的检查,就彻底消除了"新线程插队"的可能性,保证了获取顺序的公平性。
2. 为什么公平锁仍需要 hasQueuedPredecessors() 后的循环 CAS?
既然判断了没有前辈等待,那么直接 CAS 不就行了吗?为什么还要用 for (;😉 循环?
这是因为 hasQueuedPredecessors() 返回 false 的瞬间,队列状态可能发生变化(例如另一个线程恰好在这一刻释放了锁,并唤醒了队首节点)。但更重要的原因是:当线程被唤醒后再次尝试获取锁时,它已经是队首节点了,此时 hasQueuedPredecessors() 返回 false,但它仍然需要尝试 CAS 来实际获取许可。如果 CAS 失败(比如剩余许可被其他并发线程抢先消耗了),它需要自旋重试,而不是直接返回失败,否则会导致虚假唤醒。
测试代码
java
import java.util.concurrent.CountDownLatch;
public class MyFairSharedLockTest {
// 最大许可数设为 1,这样一次只能有一个线程持有锁,可以更清楚地观察获取顺序
private static final MyFairSharedLock lock = new MyFairSharedLock(1);
private static int sharedData = 0;
static class Worker extends Thread {
private final int id;
private final CountDownLatch latch; // 用于让所有线程同时启动
Worker(int id, CountDownLatch latch) {
this.id = id;
this.latch = latch;
}
@Override
public void run() {
try {
latch.await(); // 等待发令枪,确保同时启动
System.out.printf("[%tT] 线程 %d 开始尝试获取锁...%n", System.currentTimeMillis(), id);
lock.lock();
System.out.printf("[%tT] 线程 %d 成功获取锁,开始工作(剩余许可: %d)%n",
System.currentTimeMillis(), id, lock.availablePermits());
// 模拟工作,保持持有锁一段时间
Thread.sleep(1000);
System.out.printf("[%tT] 线程 %d 工作结束,释放锁%n", System.currentTimeMillis(), id);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
public static void main(String[] args) throws InterruptedException {
int threadCount = 6;
CountDownLatch startLatch = new CountDownLatch(1);
Worker[] workers = new Worker[threadCount];
// 按顺序创建线程(id 从 0 到 5)
for (int i = 0; i < threadCount; i++) {
workers[i] = new Worker(i, startLatch);
}
// 按顺序启动线程(虽然启动有先后,但都会在 latch 处等待)
for (Worker w : workers) {
w.start();
}
// 稍作停顿,确保所有线程都已进入等待状态
Thread.sleep(100);
System.out.println("=== 发令枪响,所有线程同时开始竞争 ===");
startLatch.countDown(); // 所有线程同时开始尝试获取锁
// 等待所有线程结束
for (Worker w : workers) {
w.join();
}
System.out.println("所有线程执行完毕");
}
}
text
=== 发令枪响,所有线程同时开始竞争 ===
[20:30:01] 线程 0 开始尝试获取锁...
[20:30:01] 线程 1 开始尝试获取锁...
[20:30:01] 线程 2 开始尝试获取锁...
...
[20:30:01] 线程 0 成功获取锁,开始工作(剩余许可: 0)
[20:30:02] 线程 0 工作结束,释放锁
[20:30:02] 线程 1 成功获取锁,开始工作(剩余许可: 0)
[20:30:03] 线程 1 工作结束,释放锁
[20:30:03] 线程 2 成功获取锁,开始工作(剩余许可: 0)
...
总结
本期,我们完成了从非公平共享锁到公平共享锁的演进。核心改动非常小,仅仅是在 tryAcquireShared 中添加了 hasQueuedPredecessors() 的前置检查,但这一个小小的改动却从根本上改变了锁的获取策略。
AQS 的设计体现了"模板方法模式"的精妙之处:它把复杂的队列管理、阻塞唤醒等底层细节都封装好了,留给我们的只需要根据需求重写几个关键方法(tryAcquireShared / tryReleaseShared),通过修改 state 的语义和竞争逻辑,就能轻松实现各种不同特性的同步器。