行业资讯

Java公平读写锁实现:基于AQS解决写线程饥饿问题

发布时间:2026/8/20 5:29:45
Java公平读写锁实现:基于AQS解决写线程饥饿问题 在并发编程中读写锁Reader-Writer Lock是提升多线程读性能的利器但传统的读写锁实现常常面临一个棘手问题写线程饥饿。当读线程源源不断时写线程可能永远无法获得锁导致数据更新延迟甚至系统假死。本文将深入探讨一种解决方案——公平读写锁FairRWLock它通过引入队列机制在保证高吞吐量的同时有效防止线程饥饿。无论你是正在学习Java并发的新手还是需要在生产环境中优化锁机制的中高级开发者本文将从原理、实现到实战带你构建一个真正可用的、抗饥饿的读写锁。1. 背景与核心概念为什么需要公平读写锁1.1 传统读写锁的困境读写锁的基本思想是允许多个线程同时读取共享资源但只允许一个线程进行写入。这种“读读共享、读写互斥、写写互斥”的策略在读多写少的场景下能极大提升性能。Java标准库中的ReentrantReadWriteLock就是其典型代表。然而它存在一个著名的缺陷写线程饥饿Writer Starvation。考虑以下场景一个共享资源被频繁读取。当一个写线程W正在等待锁时如果不断有新的读线程R到来由于“读读共享”这些新读线程可以立即获取读锁并执行。写线程W必须等待所有现有的和源源不断新来的读线程都释放锁后才能获得写锁。在极端情况下写线程可能无限期等待。这违背了公平性原则可能导致关键的数据更新操作被无限延迟影响系统实时性和一致性。1.2 公平读写锁FairRWLock的核心思想公平读写锁旨在解决上述饥饿问题。其核心设计原则是在锁的获取顺序上引入公平性通常遵循“先到先服务”FIFO的队列模型。具体策略有多种一种常见且有效的实现是所有请求锁的线程无论是读是写都进入一个统一的等待队列。当锁可用时队列头部的线程被唤醒尝试获取。如果队首是写线程则它独占锁。如果队首是读线程则它和其后连续的所有读线程可以“组队”一起获取读锁直到遇到一个写线程为止。这个写线程将成为新的队首等待当前这一批读线程全部完成后才能获取写锁。这种策略保证了公平性所有线程按申请顺序排队写线程不会因为后来者读线程而无限期等待。吞吐量连续的读线程仍然可以共享锁保留了读写锁在高并发读场景下的性能优势。可预测性锁的获取顺序是确定的便于调试和推理。接下来我们将从零开始实现一个具备上述特性的FairRWLock。2. 环境准备与设计思路2.1 环境说明本文示例基于Java 8及以上版本利用java.util.concurrent包下的底层同步器如AbstractQueuedSynchronizer, AQS进行构建。这是实现高性能、可重入锁的标准方式。JDK版本1.8IDEIntelliJ IDEA, Eclipse 或任何文本编辑器均可。构建工具Maven 或 Gradle本文示例为纯Java代码无需额外依赖。2.2 核心设计状态与队列管理我们要实现的FairRWLock需要维护以下核心状态锁状态state在AQS中一个int类型的state变量通常用于表示锁的持有数量。对于读写锁我们需要拆分这个int值以同时表示读锁数量和写锁持有状态。一种经典设计是高16位表示读锁数量readCount低16位表示写锁持有状态0或1。等待队列直接继承AQS它内部已经维护了一个FIFO的CLH队列这正是我们实现公平性的基础。当前持有读锁的线程集合用于实现锁的可重入性。我们将创建两个内部类FairReadLock和FairWriteLock它们共享同一个AQS同步器。3. 核心实现FairRWLock 源码拆解3.1 类结构与状态定义首先我们定义FairRWLock类它内部包含一个继承自AbstractQueuedSynchronizer的同步器。import java.util.concurrent.locks.AbstractQueuedSynchronizer; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReadWriteLock; public class FairRWLock implements ReadWriteLock { // 同步器是公平性的核心 private final Sync sync new Sync(); // 读锁视图 private final Lock readLock new ReadLock(); // 写锁视图 private final Lock writeLock new WriteLock(); Override public Lock readLock() { return readLock; } Override public Lock writeLock() { return writeLock; } // 内部同步器类 private static final class Sync extends AbstractQueuedSynchronizer { // 状态位分割常量 static final int SHARED_SHIFT 16; static final int SHARED_UNIT (1 SHARED_SHIFT); static final int MAX_COUNT (1 SHARED_SHIFT) - 1; static final int EXCLUSIVE_MASK (1 SHARED_SHIFT) - 1; // 获取读锁数量高16位 static int sharedCount(int c) { return c SHARED_SHIFT; } // 获取写锁状态低16位 static int exclusiveCount(int c) { return c EXCLUSIVE_MASK; } // 当前持有读锁的线程计数器用于重入 private transient ThreadLocalHoldCounter readHolds; // 缓存最后一个成功获取读锁的线程的计数器优化 private transient HoldCounter cachedHoldCounter; Sync() { readHolds new ThreadLocalHoldCounter(); setState(0); // 初始状态为0 } // 线程持有读锁次数的计数器 static final class HoldCounter { int count 0; final long tid Thread.currentThread().getId(); } // 使用ThreadLocal管理每个线程的读锁重入计数 static final class ThreadLocalHoldCounter extends ThreadLocalHoldCounter { public HoldCounter initialValue() { return new HoldCounter(); } } } }关键点解释SHARED_SHIFT16将32位int的高16位用于读锁计数最大支持65535个读锁理论上足够。EXCLUSIVE_MASK低16位的掩码用于判断写锁是否被持有通常为0或1因为写锁是独占的。ThreadLocalHoldCounter每个线程独立记录自己获取读锁的次数是实现读锁可重入的关键。3.2 写锁独占锁的实现写锁是独占锁其核心是tryAcquire和tryRelease方法。// 内部写锁类 private final class WriteLock implements Lock { Override public void lock() { sync.acquire(1); // 调用AQS的独占式获取 } // ... 其他Lock接口方法lockInterruptibly, tryLock, unlock等 } // 在Sync类中实现独占式获取与释放 private static final class Sync extends AbstractQueuedSynchronizer { // ... 前述代码 // 尝试获取写锁独占锁 protected boolean tryAcquire(int acquires) { Thread current Thread.currentThread(); int c getState(); int w exclusiveCount(c); // 当前写锁状态 if (c ! 0) { // 状态不为0说明有锁被持有 if (w 0) { // 写锁状态为0但有读锁持有则获取失败 return false; } // 写锁被持有检查是否是当前线程重入 if (current ! getExclusiveOwnerThread()) { return false; // 不是当前线程获取失败 } // 是当前线程重入检查是否超过最大重入次数 if (w acquires EXCLUSIVE_MASK) { throw new Error(Maximum lock count exceeded); } // 重入成功更新状态 setState(c acquires); return true; } // 此时c0锁完全空闲 // 公平性核心即使锁空闲也要检查队列中是否有前驱节点在等待 if (hasQueuedPredecessors()) { return false; // 有线程比自己更早排队获取失败 } // 尝试通过CAS设置写锁状态 if (compareAndSetState(c, c acquires)) { setExclusiveOwnerThread(current); // 设置独占所有者 return true; } return false; } // 尝试释放写锁 protected boolean tryRelease(int releases) { if (!isHeldExclusively()) { throw new IllegalMonitorStateException(); } int nextc getState() - releases; int w exclusiveCount(nextc); boolean free (w 0); if (free) { setExclusiveOwnerThread(null); // 完全释放清空所有者 } setState(nextc); return free; } // 判断当前线程是否独占持有写锁 protected boolean isHeldExclusively() { return getExclusiveOwnerThread() Thread.currentThread(); } }为什么这是公平的关键在于hasQueuedPredecessors()方法。这是AQS提供的方法用于检查同步队列中是否有比当前线程更早等待的线程。在tryAcquire中即使锁空闲c0如果队列中有其他线程在排队当前线程也会放弃获取并进入队列尾部排队。这严格遵循了FIFO顺序。3.3 读锁共享锁的实现读锁的实现更复杂需要处理共享获取、释放以及“组队”逻辑。// 内部读锁类 private final class ReadLock implements Lock { Override public void lock() { sync.acquireShared(1); // 调用AQS的共享式获取 } // ... 其他Lock接口方法 } // 在Sync类中实现共享式获取与释放 private static final class Sync extends AbstractQueuedSynchronizer { // ... 前述代码 // 尝试获取读锁共享锁 protected int tryAcquireShared(int unused) { Thread current Thread.currentThread(); int c getState(); // 公平性核心如果有写锁被持有或者等待队列中有其他线程可能是写线程在排队则读线程需要排队 if (exclusiveCount(c) ! 0 getExclusiveOwnerThread() ! current) { return -1; // 获取失败进入队列 } // 检查队列中第一个等待的节点是否是独占模式即写线程 // 如果是为了公平后来的读线程不能“插队”到写线程前面 if (hasQueuedPredecessors() firstReaderIsExclusive()) { return -1; } int r sharedCount(c); // 当前读锁数量 // 检查读锁数量是否超限 if (r MAX_COUNT) { throw new Error(Maximum lock count exceeded); } // 尝试通过CAS增加读锁计数 if (compareAndSetState(c, c SHARED_UNIT)) { // CAS成功更新当前线程的读锁持有计数 if (r 0) { firstReader current; firstReaderHoldCount 1; } else if (firstReader current) { firstReaderHoldCount; } else { HoldCounter rh cachedHoldCounter; if (rh null || rh.tid ! current.getId()) cachedHoldCounter rh readHolds.get(); else if (rh.count 0) readHolds.set(rh); rh.count; } return 1; // 获取成功 } // CAS失败循环重试 // 在实际完整实现中这里通常是一个循环为了简洁我们省略了完整自旋逻辑 // 完整版应参考AQS的doAcquireShared逻辑或使用fullTryAcquireShared } // 判断队列中第一个有效节点是否是独占模式写线程 private boolean firstReaderIsExclusive() { Node h, s; return ((h head) ! null (s h.next) ! null !s.isShared()); } // 尝试释放读锁 protected boolean tryReleaseShared(int unused) { Thread current Thread.currentThread(); // 更新当前线程的持有计数 if (firstReader current) { if (firstReaderHoldCount 1) firstReader null; else firstReaderHoldCount--; } else { HoldCounter rh cachedHoldCounter; if (rh null || rh.tid ! current.getId()) rh readHolds.get(); int count rh.count; if (count 1) { readHolds.remove(); if (count 0) throw new IllegalMonitorStateException(); } --rh.count; } // 循环CAS减少读锁计数 for (;;) { int c getState(); int nextc c - SHARED_UNIT; if (compareAndSetState(c, nextc)) { // 读锁完全释放读锁计数为0是释放成功 return nextc 0; } } } }“组队”逻辑体现在哪里在tryAcquireShared中读线程获取失败返回-1的条件是有写锁被其他线程持有或者队列中有等待节点且第一个节点是写线程。这意味着如果队首是读线程它成功获取后后续连续的读线程在检查hasQueuedPredecessors()时会发现队首已经是自己或同批的读线程因此可以继续获取形成“组队”。一旦队首是一个写线程后续新来的读线程在检查firstReaderIsExclusive()时会发现这一点从而返回-1进入队列尾部等待。这就防止了读线程“淹没”写线程。4. 完整实战案例测试 FairRWLock现在我们编写一个测试程序来验证FairRWLock的公平性和功能。4.1 创建测试类我们模拟一个读多写少的场景并观察写线程是否会被饥饿。import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; public class FairRWLockTest { private static final FairRWLock lock new FairRWLock(); private static final Lock readLock lock.readLock(); private static final Lock writeLock lock.writeLock(); private static int sharedData 0; private static volatile boolean writerStarved false; private static final int READER_COUNT 10; private static final int WRITER_COUNT 2; private static final CountDownLatch startLatch new CountDownLatch(1); private static final CountDownLatch endLatch new CountDownLatch(READER_COUNT WRITER_COUNT); static class Reader implements Runnable { private final int id; public Reader(int id) { this.id id; } Override public void run() { try { startLatch.await(); // 等待所有线程就绪 for (int i 0; i 5; i) { readLock.lock(); try { System.out.println(System.currentTimeMillis() - Reader id reads: sharedData); Thread.sleep(50); // 模拟读操作耗时 } finally { readLock.unlock(); } Thread.sleep(100); // 读间隔 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } } } static class Writer implements Runnable { private final int id; public Writer(int id) { this.id id; } Override public void run() { try { startLatch.await(); for (int i 0; i 3; i) { long waitStart System.currentTimeMillis(); writeLock.lock(); long waitTime System.currentTimeMillis() - waitStart; // 如果写线程等待时间过长可能意味着饥饿 if (waitTime 2000) { writerStarved true; System.err.println(Writer id 可能饥饿等待了 waitTime ms); } try { sharedData; System.out.println(System.currentTimeMillis() - ***Writer id writes: sharedData); Thread.sleep(100); // 模拟写操作耗时 } finally { writeLock.unlock(); } Thread.sleep(200); // 写间隔 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endLatch.countDown(); } } } public static void main(String[] args) throws InterruptedException { ExecutorService executor Executors.newFixedThreadPool(READER_COUNT WRITER_COUNT); // 提交任务让写线程先提交更早申请锁 for (int i 0; i WRITER_COUNT; i) { executor.submit(new Writer(i)); } Thread.sleep(10); // 稍微延迟增加写线程先入队的概率 for (int i 0; i READER_COUNT; i) { executor.submit(new Reader(i)); } startLatch.countDown(); // 所有线程同时开始竞争锁 endLatch.await(30, TimeUnit.SECONDS); // 等待所有线程完成 executor.shutdownNow(); System.out.println(\n测试结束。); System.out.println(最终共享数据值: sharedData); System.out.println(写线程是否出现长时间等待可能饥饿? writerStarved); if (!writerStarved) { System.out.println(FairRWLock 有效防止了写线程饥饿。); } } }4.2 运行与结果分析运行上述程序观察控制台输出。你会看到类似以下的日志片段时间戳为示例... 大量Reader输出 ... 1720000000000 - ***Writer 0 writes: 1 1720000000100 - Reader 5 reads: 1 1720000000200 - Reader 3 reads: 1 ... 可能又有一些Reader ... 1720000001000 - ***Writer 1 writes: 2关键观察点写线程确实能够获得锁尽管有大量读线程写线程Writer 0和Writer 1的输出仍然会出现。写操作没有无限期延迟通过waitTime 2000的检查可以监控写线程是否等待过久。在公平锁下这个等待时间应该是有限的。读线程仍然可以并发在写线程未持有锁期间多个读线程的输出时间戳非常接近说明它们共享了读锁。如果使用非公平锁如ReentrantReadWriteLock的非公平模式在极端情况下你可能根本看不到写线程的输出或者等待时间极长。4.3 与 ReentrantReadWriteLock 公平模式对比Java标准的ReentrantReadWriteLock也提供了公平模式new ReentrantReadWriteLock(true)。其公平策略与我们实现的FairRWLock类似也是基于AQS队列。你可以将测试代码中的FairRWLock替换为ReentrantReadWriteLock的公平模式进行对比结果应相似。我们自己实现的目的在于深入理解其原理。5. 常见问题与排查思路在实现和使用自定义读写锁时可能会遇到以下问题问题现象可能原因排查与解决思路死锁1. 同一个线程先获取读锁再尝试获取写锁锁升级而锁不支持升级。2. 多个线程以不同的顺序获取读写锁形成循环等待。1.避免锁升级如果需要写直接获取写锁。或者使用支持升级的锁如StampedLock。2.统一锁获取顺序在所有线程中约定先获取锁A再获取锁B。性能下降严格的公平性要求所有线程排队增加了上下文切换和调度开销尤其在锁竞争激烈时。1.评估场景如果写操作非常稀少且对延迟不敏感非公平锁可能吞吐量更高。2.考虑混合策略例如写线程优先但读线程在一定条件下可以“插队”。IllegalMonitorStateException1. 线程释放了并未持有的锁。2. 读锁和写锁的unlock()调用不匹配例如用写锁的unlock()去释放读锁。1. 确保lock()和unlock()成对出现并在finally块中释放锁。2. 检查代码逻辑确认释放的是正确的锁对象。读锁重入计数错误ThreadLocal中线程持有计数未正确更新导致提前释放或无法释放。1. 仔细检查tryAcquireShared和tryReleaseShared中对于HoldCounter的获取、更新和移除逻辑。2. 确保firstReader缓存机制正确处理。CAS操作失败循环compareAndSetState在高度竞争下可能多次失败导致自旋消耗CPU。这是AQS的正常行为。如果成为瓶颈说明锁竞争太激烈应考虑减少锁粒度或使用无锁数据结构。6. 最佳实践与工程建议6.1 选择合适的锁策略默认使用非公平锁在绝大多数读多写少且写延迟不敏感的场景ReentrantReadWriteLock的非公平模式提供了最高的吞吐量。仅在需要时使用公平锁当写线程的延迟至关重要或者你需要严格的FIFO顺序来避免饥饿时才启用公平模式包括我们实现的FairRWLock。记住公平性是以性能为代价的。考虑StampedLockJava 8 引入了StampedLock它提供了乐观读、锁升级等更复杂的模式在特定场景下性能可能优于ReadWriteLock但它不是可重入的。6.2 实现自定义锁的注意事项充分测试并发代码极其复杂。必须进行多线程压力测试、死锁测试、性能测试。继承AQS这是实现同步器最可靠的方式。直接操作state和队列比从头实现要安全得多。正确处理ThreadLocal用于记录线程本地状态如重入计数时务必注意内存泄漏。在tryReleaseShared中当线程持有计数降为0时应调用readHolds.remove()。遵循AQS模板tryAcquire/tryRelease和tryAcquireShared/tryReleaseShared的返回值、异常处理必须严格遵循AQS的约定。6.3 生产环境使用建议监控锁竞争使用JMX或APM工具监控锁的等待时间、持有时间。如果FairRWLock的队列长度持续很长说明它是性能瓶颈。设置超时总是使用tryLock(long time, TimeUnit unit)带有超时的方法避免死锁导致线程永久挂起。锁粒度要小锁保护的临界区应尽可能短只包含必须同步的操作。避免在锁内调用外部方法这容易引发死锁和性能问题。7. 总结本文从写线程饥饿问题出发深入剖析了公平读写锁FairRWLock的必要性和实现原理。我们基于AQS实现了一个具备FIFO公平性的读写锁并通过测试验证了其抗饥饿特性。核心收获理解饥饿问题传统读写锁在持续读请求下可能导致写线程无限等待。掌握公平性实现通过AQS的等待队列和hasQueuedPredecessors()方法可以强制线程按申请顺序获取锁。学会AQS实战通过拆分state的高低位来管理读/写状态利用ThreadLocal管理读锁重入计数是实现复杂同步器的通用模式。做出权衡决策公平性保障了顺序和避免饥饿但可能降低吞吐量。在实际工程中需要根据具体场景选择非公平锁、公平锁或更高级的锁机制。并发编程的世界里没有银弹。FairRWLock是解决特定问题写饥饿的一种有效工具。理解其内部机制不仅能帮助你在必要时实现自定义同步器更能让你在面对各种并发挑战时具备更深层次的排查和优化能力。建议读者将示例代码运行起来修改参数观察行为变化并尝试将其集成到自己的学习项目中进行体验。