0%

读多写少的性能考虑--ReentrantReadWriteLock的原理剖析

ReentrantLock 是独占锁, 某时只有一个线程可以获取该锁,而实际中会有写少读多的场景,ReentrantReadWriteLock 应运而生。ReentrantReadWriteLock 采用读写分离的策略,允许多个线程可以同时获取读锁。

读写锁的内部维护了一个ReadLock 和一个WriteLock ,它们依赖Sync 实现具体功能。

state的含义

ReentrantReadWriteLock 需要维护读状态和写状态, 为了用一个state 表示写和读两种状态,ReentrantReadWriteLock 巧妙地使用state高16位表示读状态,也就是获取到读锁的次数;使用低16 位表示获取到写锁的线程的可重入次数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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;


//返回以count表示的共享持有的数量[读线程数]
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }

//返回count中所代表的排他性持有的数量[写锁可重入次数]
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }


//用来每个线程的读锁可重入数的计数器
static final class HoldCounter {
int count = 0;
final long tid = getThreadId(Thread.currentThread());
}

写锁

写锁的获取

写锁是个独占锁, 某时只有一个线程可以获取该锁。如果当前没有线程获取到读锁和写锁, 则当前线程可以获取到写锁然后返回。如果当前己经有线程获取到读锁和写锁,则当前请求写锁的线程会被阻塞挂起。另外, 写锁是可重入锁,如果当前线程己经获取了该锁,再次获取只是简单地把可重入次数加1后直接返回。

1
2
3
public void lock() {
sync.acquire(1);
}

其中重写的tryAcquire方法的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
//w是写锁线程的可重入次数
int w = exclusiveCount(c);
//c!=0 说明已经有线程获获取了读锁或写锁
if (c != 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()用来实现公平锁和非公平锁的区分
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}

c是AQS状态变量,wc的低16位(写状态)。

c!=0&&w==0时说明状态值的的高16位(读状态)不为0,这说明了已经有线程获取了读锁,返回false

c!=0&&w!=0&&current!= getExclusiveOwnerThread() 则说明当前已经有线程获取了写锁,但是当前线程不是写锁的持有者,返回false

写锁的释放

如果当前线程持有写锁,调用锁释放方法会让该线程对该线程持有的AQS状态值减1 ,如果减去1后当前状态值为0 则当前线程会释放该锁, 否则仅仅减1而己。其中重写的tryRelease方法如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
protected final boolean tryRelease(int releases) {
//看是否是写锁拥有者调用的unlock
if (!isHeldExclusively())
throw new IllegalMonitorStateException();
//获取可重入值, 这里没有考虑高16位, 因为获取写锁时读锁状态值肯定为0
int nextc = getState() - releases;
//如采写锁可重入值为0 则释放锁,否则只是简单地更新状态值
boolean free = exclusiveCount(nextc) == 0;
if (free)
setExclusiveOwnerThread(null);
setState(nextc);
return free;
}

读锁

读锁的获取

写锁是共享锁。获取读锁,如果当前没有其他线程持有写锁,则当前线程可以获取读锁, AQS 的状态值state 的高16 位的值会增加1 ,然后方法返回。否则如果其他一个线程持有写锁, 则当前线程会被阻塞。共享锁的tryAcquireShared代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
protected final int tryAcquireShared(int unused) {
//获取当前状态值
Thread current = Thread.currentThread();
int c = getState();
//判断是否写锁被占用
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
//获取读锁计数,sharedCount返回c的高16位
int r = sharedCount(c);
//更新AQS状态值, 多个读线程只有一个会成功,不成功的进入fullTryAcquireShared进行重试。
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
//更新一些记录信息
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;

} 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;
}
//fullTryAcquireShared类似tryAcquireShared ,但是是自旋获取
return fullTryAcquireShared(current);
}

如上代码首先查看是否有其他线程获取到了写锁,如果是则直接返回-1。

如果当前要获取读锁的线程己经持有了写锁, 则也可以获取读锁。但是需要注意,当一个线程先获取了写锁,然后获取了读锁处理事情完毕后,要记得把读锁和写锁都释放掉,不能只释放写锁。

在线程尝试获取读锁前,会进行readerShouldBlock检查,其中非公平锁的readerShouldBlock 实现代码如下,代码的作用是检查队列的第一个元素是否存在以及是不是正在尝试获取写锁。

1
2
3
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
1
2
3
4
5
6
7
8
final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null &&
(s = h.next) != null &&
//判断第一个元素是不是独占锁
!s.isShared() &&
s.thread != null;
}

进行readerShouldBlock检查后,还要检查获取读锁的线程是否达到了最大值,然后才是执行CAS 操作将AQS 状态值的高16 位值增加1。

更新AQS状态值后,最后还要更新一些重要的变量:

  • firstReader:用来记录第一个获取到读锁的线程
  • firstReaderHoldCount:记录第一个获取到读锁的线程获取读锁的可重入次数
  • cachedHoldCounter:记录最后一个获取读锁的线程获取读锁的可重入次数
  • readHolds:ThreadLocal 变量, 用来存放除去第一个获取读锁线程外的其他线程获取读锁的可重入次数

fullTryAcquireShared类似tryAcquireShared ,但是是自旋获取。

1
2
3
4
5
6
7
8
9
10
11
final int fullTryAcquireShared(Thread current) {
HoldCounter rh = null;
//for循环,自旋获取
for (;;) {
//..code
if (compareAndSetState(c, c + SHARED_UNIT)) {
//..code
return 1;
}
}

读锁的释放

tryReleaseShared代码如下:

1
2
3
4
5
6
7
8
9
10
11
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
//code
//循环直到自己的读计数-1 , CAS 更新成功
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
return nextc == 0;
}
}

在无限循环里面,首先获取当前AQS 状态值并将其保存到变量c ,然后变量c被减去一个读计数单位后使用CAS 操作更新AQS 状态值,如果更新成功则查看当前AQS 状态值是否为0 ,为0则说明当前己经没有读线程占用读锁,则tryReleaseShared 返回true 。然后会调用doReleaseShared 方法释放一个由于获取写锁而被阻塞的线程,如果当前AQS 状态值不为0 ,则说明当前还有其他线程持有了读锁,所以trγReleaseShared 返回false 。如果tryReaseShared 中的CAS 更新AQS 状态值失败,则自旋重试直到成功。

读写锁使用案例

改造List,使得在读多写少的情况下保持性能和线程安全。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
//读多写少
public class SecureList <T> {

// 线程不安全的List
List<T> list=new ArrayList<>();
final ReentrantReadWriteLock reentrantReadWriteLock=new ReentrantReadWriteLock();
final Lock writeLock=reentrantReadWriteLock.writeLock();
final Lock readLock=reentrantReadWriteLock.readLock();

T get(int index){
readLock.lock();
try {
return list.get(index);
} finally {
readLock.unlock();
}
}

void set(int index,T ele){
writeLock.lock();
try {
list.set(index,ele);
} finally {
writeLock.unlock();
}

}
void remove(int index){
writeLock.lock();
try {
list.remove(index);
} finally {
writeLock.unlock();
}
}


}

读写锁的改进

StampedLock 是并发包里面JDK8 版本新增的一个锁,该锁提供了三种模式的读写控制,当调用获取锁的系列函数时,会返回一个long 型的变量,我们称之为戳记( stamp),这个戳记代表了锁的状态。其中t可系列获取锁的函数,当获取锁失败后会返回为0的stamp 值。当调用释放锁和转换锁的方法时需要传入获取锁时返回的stamp 值。

StampedLock 提供的三种读写模式的锁分别如下:

  • 写锁writeLock :独占锁。
  • 悲观读锁readLock :悲观是指在具体操作数据前其会悲观地认为其他线程可能要对自己操作的数据进行修改,所以需要先对数据加锁,这是在读少写多的情况下的一种考虑。
  • 乐观读锁tryOptimisticRead:它是相对于悲观锁来说的,该锁的一个特点是适用于读多写少的场景, 因为获取读锁只是使用位操作进行检验,不涉及CAS 操作,所以效率会高很多,但是同时由于没有使用真正的锁,所以返回的不是最新的数据,但是一致性还是得到保障的。