文章目录
基础概念
读锁&写锁
读锁(ReadLock)
顾名思义,读锁即对读操作加的锁,对应 ReadLock。
写锁(WriteLock)
顾名思义,写锁即对写操作加的锁,对应 WriterLock。
独占锁&共享锁
独占锁(WriteLock):
- 一次只能被一个线程持有。
- 当某个线程持有独占锁时,其他线程无法获取独占锁或共享锁。
- 写操作期间不允许其他线程进行读或写操作。
共享锁(ReadLock)
- 可以被多个线程同时持有。
- 当没有线程持有独占锁时,多个线程可以同时获取共享锁。
- 读操作期间不会阻塞其他线程的读操作,但会阻塞写操作。
使用场景
ReentrantReadWriteLock 适合在读操作频繁、写操作相对较少且对数据一致性要求较高的场景下使用。它允许多个线程同时进行读操作,从而提高了并发性能,同时通过独占的写锁保证了写操作的原子性和一致性。
- 读多写少的场景: 当并发访问中读操作远远多于写操作时,ReentrantReadWriteLock 可以提供更好的性能,因为多个线程可以同时获取读锁,提高了并发读取的效率。
- 数据一致性要求高的场景: 如果对共享资源的写操作需要保证原子性和一致性,可以使用 ReentrantReadWriteLock 来控制对共享资源的读写操作,确保数据的正确性。
- 降低锁竞争: 通过允许多个线程同时进行读操作,ReentrantReadWriteLock 在一定程度上降低了锁的竞争,提高了并发性能。
不适合的场景
- 写操作频繁的场景: 如果写操作非常频繁,可能会导致读锁长时间被阻塞,降低了并发读取的效率,此时可以考虑其他同步机制。
- 对内存开销要求严格的场景: ReentrantReadWriteLock 内部维护了多个状态变量和线程队列,可能会带来一定的内存开销,在对内存要求非常严格的场景下需要考虑内存开销的影响。
代码示例
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReentrantReadWriteLockExample {
private static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private static ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private static ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
private static int sharedResource = 0;
public static void main(String[] args) {
// 使用独占锁进行写操作
new Thread(() -> {
writeLock.lock();
try {
sharedResource++;
System.out.println("Write thread is writing the shared resource: " + sharedResource);
} finally {
writeLock.unlock();
}
}).start();
// 使用共享锁进行读操作
new Thread(() -> {
readLock.lock();
try {
System.out.println("Read thread is reading the shared resource: " + sharedResource);
} finally {
readLock.unlock();
}
}).start();
}
}
源码理解(JDK1.8)
UML 类图
核心变量
通过上述的类图可以清晰地看到,ReentrantReadWriteLock 核心变量包括 sync、readerLock 和 writerLock 三个部分,其中
- Sync 对象,内部类,加锁/释放等核心操作的具体实现。
- ReadLock,内部类,顾名思义是读锁,实际上持有 Sync 对象。
- WriteLock,内部类,顾名思义是写锁,实际上持有 Sync 对象。
public class ReentrantReadWriteLock
implements ReadWriteLock, java.io.Serializable {
private static final long serialVersionUID = -6992448646407690164L;
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
}
// ReadLock 内部类
public static class ReadLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -5992448646407690164L;
private final Sync sync;
}
// WriteLock 内部类
public static class WriteLock implements Lock, java.io.Serializable {
private static final long serialVersionUID = -4992448646407690164L;
private final Sync sync;
}
构造方法
ReentrantReadWriteLock 有两个构造方法
- 通过参数 fair 决定初始化 sync 为公平锁 or 非公平锁,并通过 sync 初始化读写锁。
- 无参构造默认为非公平锁。
public ReentrantReadWriteLock() {
this(false);
}
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
// 读锁构造
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
// 写锁构造
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
Sync 类
和 ReentrantLock 一样,ReentrantReadWriteLock 也有一个内部抽象类名为 Sync ,它也继承于 AQS,它的子类也分为公平实现和非公平实现。
核心常量
abstract static class Sync extends AbstractQueuedSynchronizer {
private static final long serialVersionUID = 6317671515068378041L;
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;
/** Returns the number of shared holds represented in count */
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
}
- SHARED_SHIFT = 16
先来回顾一下,在 ReentrantLock 的 Sync 中直接使用 AQS 中的 state 值来存储锁的状态,因为 ReentrantLock 只有一个锁,所以一个 int 类型的值就够了。
但是,ReentrantReadWriteLock 是区分读锁和写锁的,那么一个 int 值很难同时表征这两个语义的,因此将这个值按照低 16 位和高 16 位的方式隔成两份。
低 16 位供写锁使用,高 16 位供读锁使用。
了解 SHARED_SHIFT = 16 的原理后,接下来几个值就不难理解了。
- SHARED_UNIT
SHARED_UNIT 的值为 65536,对应的二进制为
0000 0000 0000 0001 0000 0000 0000 0000,因为低 16 位都是 0,所以与它进行 & 操作后保留下来的只有高 16 位,也就是读锁的数量。
- MAX_COUNT
MAX_COUNT 的值为 65535,对应的二进制为
0000 0000 0000 0000 1111 1111 1111 1111。
表示读锁和写锁的最大持有数量分别为 65535,因为读锁和写锁平均分了 32 位。
- EXCLUSIVE_MASK
EXCLUSIVE_MASK 的值也是 65535 ,对应的二进制为
0000 0000 0000 0000 1111 1111 1111 1111,和它进行 & 操作之后就能获取低 16 位数值,即写锁的数量。
需要注意的几个方法
- sharedCount
入参为 int 值 c,将 c 右移 16 位,实际上就是将高 16 位移动到了低 16 位上,原本高 16 位对应的就是读锁的数量,以此获取了读锁的数量。
/** Returns the number of shared holds represented in count */
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
- exclusiveCount
入参为 int 值 c,将 c 和 0000 0000 0000 0000 1111 1111 1111 1111 进行与操作,实际上就是将高 16 位抹去了,只保留低 16 位,以此获取了写锁的数量。
/** Returns the number of exclusive holds represented in count */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
- readerShouldBlock 和 writerShouldBlock
这两个是 sync 类中的抽象方法,在其子类公平锁和非公平锁的具体类中会实现。
readerShouldBlock() 确定当前的读锁获取操作是否应该被阻塞,返回 true 时阻塞。
writerShouldBlock() 确定当前的写锁获取操作是否应该被阻塞,返回 true 时阻塞。
abstract boolean readerShouldBlock();
abstract boolean writerShouldBlock();
关于其他核心方法在使用时再细说。
Sync 的公平锁与非公平锁实现
FairSync(公平锁实现)
在公平锁的实现中,writerShouldBlock 和 readerShouldBlock 方法的实现都会去判断线程队列中是否有其他排队线,一视同仁,也符合公平的理念。
static final class FairSync extends Sync {
private static final long serialVersionUID = -2274990926593161451L;
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
}
NonfairSync(非公平锁实现)
在非公平锁的实现中,对写锁的获取操作一定不会阻塞。
对读锁的操作相对复杂,需要判断线程队列中排队的第一个节点是否在请求获取写锁,如果是的话则让返回 true,对当前线程进行阻塞,让队列中的第一个节点先获取,以此来避免写锁一直拿不到,避免写锁的饥饿状态。
而如果第一个节点是读锁的话,则返回 false,不阻塞当前线程,也就是说可能插队拿到读锁,这也符合非公平的理念。
static final class NonfairSync extends Sync {
private static final long serialVersionUID = -8159625535654395037L;
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
}
ReadLock
lock 方法
- 判断写锁是否已经被获取,如果写锁已经被获取且持有线程也不是当前线程,则失败。
- 获取读锁数量,判断读锁是否需要被阻塞,这一步针对公平锁与非公平锁有不同实现。
- 公平锁中,读锁需要判断等待队列中是否有其他线程在排队,有则
- 非公平锁中,读锁需要判断等待队列中第一个节点是否为独占模式,是的话则需要阻塞。
- 如果读锁被阻塞,或超过读锁的最大值,或 cas 锁时失败了,则进入最终的拿锁流程 fullTryAcquireShared 方法中。
- fullTryAcquireShared 的流程类似,依旧是判断写锁是否被其他线程持有,以及判断是否需要阻塞等待,并尝试 cas 拿锁。
// 获取读锁。
// 如果当前 写锁 未被获取,则直接获取读锁,此时没有写操作也就不会有数据不一致问题。
// 如果当前 写锁 已经被获取,则阻塞,否则读的线程就可能出现数据不一致问题。
public void lock() {
// 直接调用 AQS 中的
sync.acquireShared(1);
}
// acquireShared 是 AQS 中的方法,tryAcquireShared 具体在 ReentrantReadWriteLock 中实现。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// exclusiveCount 获取写锁的数量,如果当前写锁已经被获取,
// 并且获取者不是当前线程,则直接返回 -1,进入 doAcquireShared 逻辑。
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// sharedCount 获取读锁数量
int r = sharedCount(c);
// readerShouldBlock 上文介绍过,返回 true 表示对读锁的操作阻塞。
// 如果读锁操作不被阻塞,并且未超过读锁的最大值,则 CAS 去拿锁
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// r==0 表示当前读锁还未被任何线程获取,则给 firstReader 赋值。
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
// firstReader 已经是当前线程,则自增。
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 走到这说明要么读锁操作被阻塞了,要么就是超过最大值了,或者 cas 拿锁时失败了,
// 此时进入到完整的拿锁流程中
return fullTryAcquireShared(current);
}
fullTryAcquireShared 方法
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
for (;;) {
int c = getState();
// 如果当前写锁已经被获取
if (exclusiveCount(c) != 0) {
// 并且获取者还不是当前线程,则失败返回
if (getExclusiveOwnerThread() != current)
return -1;
// 走到这说明写锁没有被获取,但是读锁的操作被阻塞了,也就是队列中有其他线程在
// 排队
} else if (readerShouldBlock()) {
if (firstReader == current) {
} else {
if (rh == null) {
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current)) {
rh = readHolds.get();
if (rh.count == 0)
readHolds.remove();
}
}
if (rh.count == 0)
return -1;
}
}
if (sharedCount(c) == MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 走到这表示写锁未获取,开始 cas 尝试拿读锁。然后就是设置 firstReader
if (compareAndSetState(c, c + SHARED_UNIT)) {
if (sharedCount(c) == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
if (rh == null)
rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
cachedHoldCounter = rh; // cache for release
}
return 1;
}
}
}
WriteLock
lock 方法
- 判断写锁是否已经被获取,如果写锁已经被获取且持有线程也不是当前线程,则失败。
- 获取读锁数量,如果读锁已经被获取并且不是的当前线程,则失败。
- 如果写锁的持有者是当前线程,则去尝试拿锁。
- 如果当前处于无锁状态,判断写锁是否需要阻塞,这一步针对公平锁与非公平锁有不同实现。
- 公平锁中,写锁需要判断等待队列中是否有其他线程在排队,有则阻塞。
- 非公平锁中,写锁不需要判断,不阻塞。
- 尝试 cas 拿锁。
public void lock() {
// 调用 sync 中 acquire 方法
sync.acquire(1);
}
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
// 尝试拿锁,成功返回 true,失败返回 false
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
// w = write 写锁的数量
int w = exclusiveCount(c);
// c!=0 表示当前对象有锁,可能是写锁也可能是读锁
if (c != 0) {
// 走到这说明写锁为0,也就是说有读锁
// (Note: if c != 0 and w == 0 then shared count != 0)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 走到这说明当前对象上有写锁,并且持有者就是当前线程,则判断重入次数是否
// 超过最大限制,超过则抛异常,未超过则尝试重入。
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
setState(c + acquires);
return true;
}
// 走到这说明当前对象上没有锁,则判断读锁是否被阻塞,不阻塞的话则尝试拿锁。
// writerShouldBlock 上文讲过,在公平锁中该方法会判断等待队列中是否有其他线程排队,在非公平锁中则直接返回 false。
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
总结
-
首先,我们从整体维度了解了 ReentrantReadWriteLock 读写分离的理念以及使用场景,其核心仍然为 Sync 同步器,同样基于 AQS 的能力完成加锁与释放行为,也同样包含公平与非公平两种实现,但将读和写拆分为了 ReadLock 和 WriteLock。
-
这之后,我们从数据结构的细节上去理解读写分离,对于锁状态的分离实际上是通过分隔低 16 位与高 16 位来实现的,借此理解了读锁与写锁的获取与写入方式,后续在其他源码中看到类似的 & 或 >> 等操作不要慌。
-
最后,我们深入到 ReadLock 和 WriterLock 的源码中,通过流程总结可以看到获取锁的大流程是一致的,也都遵循着同一套原则:读读共享,涉及到写都阻塞。
1. 如果一个线程已经持有了读锁,则其他线程的读锁请求可以进入,但是写锁请求会失败。
2. 如果一个线程已经持有了写锁,则其他线程的读锁和写锁请求都将失败。