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

相关推荐
MY_TEUCK5 小时前
Sealos 平台部署实战指南:结合 Cursor 与版本发布流程
java·人工智能·学习·aigc
我爱cope6 小时前
【从0开始学设计模式-10| 装饰模式】
java·开发语言·设计模式
朝新_6 小时前
【Spring AI 】图像与语音模型实战
java·人工智能·spring
RH2312117 小时前
2026.4.16Linux 管道
java·linux·服务器
zmsofts7 小时前
java面试必问13:MyBatis 一级缓存、二级缓存:从原理到脏数据,一篇讲透
java·面试·mybatis
aq55356008 小时前
编程语言三巨头:汇编、C++与PHP大比拼
java·开发语言
我是无敌小恐龙9 小时前
Java SE 零基础入门Day01 超详细笔记(开发前言+环境搭建+基础语法)
java·开发语言·人工智能·opencv·spring·机器学习
心态与习惯10 小时前
Julia 初探,及与 C++,Java,Python 的比较
java·c++·python·julia·比较
一叶飘零_sweeeet10 小时前
优秀文章合集
java
zopple10 小时前
ThinkPHP5.x与3.x核心差异解析
java·python·php