利用 AQS 构建一个自己的公平共享锁

前期回顾

利用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 的语义和竞争逻辑,就能轻松实现各种不同特性的同步器。

相关推荐
梁山话事人2 小时前
Spring IOC
java·数据库·spring
计算机学姐2 小时前
基于SpringBoot的奶茶店点餐系统【协同过滤推荐算法+数据可视化统计】
java·vue.js·spring boot·mysql·信息可视化·tomcat·推荐算法
@土豆2 小时前
Java JVM参数环境变量详解及SkyWalking Agent集成技术文档
java·jvm·skywalking
Yupureki2 小时前
《Linux系统编程》19.线程同步与互斥
java·linux·服务器·c语言·开发语言·数据结构·c++
又来敲代码了2 小时前
Zrlog博客的系统部署
java·linux·运维·mysql·apache·tornado
砍光二叉树2 小时前
【设计模式】行为型-责任链模式
java·设计模式·责任链模式
kiki_24112 小时前
用IntelliJ IDEA编写Java程序,从0到1完整教程
java·ide·intellij-idea
liuyao_xianhui2 小时前
优选算法_锯齿形层序遍历二叉树_队列_C++
java·开发语言·数据结构·c++·算法·链表
八宝粥大朋友2 小时前
Android sqlite3 编译及安装
android·java·sqlite