读写锁在同一时刻可以允许多个读线程访问,但是在写线程访问时,所有的读线程和其他写线程均被阻塞。读写锁维护了一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性相比一般的排它锁有了很大的提升。
一般情况下,读写锁的性能都会比排它锁好,因为大多数场景读是多于写的。在读多于写的情况下,读写锁能够提供比排它锁更好的并发性和吞吐量。Java并发包提供读写锁的实现是ReentrantReadWriteLock。它支持的特性有:
- 支持非公平和公平的锁获取方式,默认是非公平
- 支持锁的重进入
- 支持锁降级
ReentrantReadWriteLock是对接口ReadWriteLock的实现,ReadWriteLock中仅定义了获取读锁和获取写锁的两个方法,即:
这两个方法皆由ReentrantReadWriteLock类具体实现。通过观察ReentrantReadWriteLock的源码发现,其内部含有ReadLock和WriteLock这两个类,代表ReentrantReadWriteLock拥有的一对读锁和写锁,而这两个类又都是靠一个静态内部类Sync实现的。Sync是继承了AbstractQueuedSynchronizer,用于管理读写锁的同步状态。
读写锁的实现分析
1.读写状态的设计
读写锁依赖于自定义同步器来实现同步功能,其读写状态就是同步器的同步状态。读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,所以就需要“按位切割使用”这个整型变量。此处,读写锁将这个整型变量切分为了两个部分,高16位表示读,低16位表示写。划分方式如下图所示:
上图表示的同步状态显示有两个线程已经获取了读锁。读写锁是通过位运算迅速确定读和写各自的状态的,假设当前的同步状态为state,那么读状态和写状态的计算方式如下:
写状态: state & 0x0000ffff 写状态加1:state+1
读状态: state >>> 16 读状态加1:state+(1<<16)
2.写锁的获取与释放
首先看一下写锁的加锁源码:
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState(); // 获取同步状态
int w = exclusiveCount(c); // 根据同步状态获取写锁状态
// 已经有线程获取到了锁
if (c != 0) {
// 如果写线程数(w)为0(换言之存在读锁) 或者写锁不为0,同时持有锁的线程不是当前线程就返回失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 如果写入锁的数量大于最大数(65535,2的16次方-1)就抛出一个Error。
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 写锁重入
setState(c + acquires);
return true;
}
// 如果当且写线程数为0,并且当前线程需要阻塞那么就返回失败;或者如果通过CAS增加写线程数失败也返回失败。
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
// 如果c=0,w=0(没有写锁也没有读锁)或者c>0,w>0(重入),则设置当前线程为锁的拥有者
setExclusiveOwnerThread(current);
return true;
}
从上面的源码可以看出,写锁是一个支持重进入的排它锁。如果当前线程已经获取到了写锁,那么再次获取时,直接增加写状态。如果当前线程在获取写锁时,读锁已经被获取(读状态不为0)或者该线程不是已经获取写锁的线程,则当前线程进入等待状态。
之所以要判断读锁是否存在,是因为读写锁要确保写锁的操作对读锁可见,如果允许读锁在已被获取的情况下还能允许写锁的获取,那么正在运行的其他线程可能就无所感知当前写线程的操作。
写锁释放时,每次释放均减少写状态,当写状态为0时表示写锁已经被释放,从而等待读写线程能够继续访问读写锁,同时前一次写线程的修改对后续读写线程可见。
3.读锁的获取与释放
下面是读锁的源码:
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 获取读锁数量
int r = sharedCount(c);
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;
}
return fullTryAcquireShared(current);
}
读锁是一个支持重进入的共享锁,他能够被多个线程同时获取,在没有其他写线程访问(写状态为0)时,读锁总是会被成功地获取,而所做的也只是(线程安全地)增加读状态。
可以看到在tryAcquireShared(int unused)方法中,如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态。
如果当前线程获取了写锁或者写锁未被获取,则当前线程(线程安全,依靠CAS保证)增加读状态(增加的值是1<<16),成功获取读锁。
需要注意的是,读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在ThreadLocal中,由线程自身维护。
读锁的每次释放(线程安全的,可能有多个读线程同时释放读锁)均减少读状态,减少的值是“1<<16”。所以读写锁才能实现读读的过程共享,而读写、写读、写写的过程互斥。
4.锁降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放先前拥有的写锁的过程。
ReentrantReadWriteLock不支持锁升级,目的是为了保证数据可见性,如果读锁已被多个线程获取,其中任意线程成功获取了写锁并更新了数据,则其更新对其他获取到读锁的线程是不可见的。
读写锁的使用示例
package concurrent.lock;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
static Map<String,Object> map = new HashMap<String, Object>();
static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
static Lock r = rwl.readLock();
static Lock w = rwl.writeLock();
public static final Object get(String key){
r.lock();
try {
return map.get(key);
}finally {
r.unlock();
}
}
public static final Object put(String key,Object value){
w.lock();
try {
return map.put(key,value);
}finally {
w.unlock();
}
}
public static final void clear(){
w.lock();
try {
map.clear();
}finally {
w.unlock();
}
}
}
上述Cache类使用了一个非线程安全的HashMap作为缓存的实现,同时使用了读写锁和读锁和写锁来保证Cache是线程安全的。在数据的读方法get(String key)中,需要先获取读锁,然后读取数据,这样使得并发读数据时不会被阻塞。而对数据进行修改相关的put和clear方法中,需要先获取写锁,当获取了写锁之后,其他线程对于数据的读和写操作均会被阻塞,只有在写锁释放以后,其他的读写操作才能继续。
Cache类通过使用读写锁提升了读操作的并发性,也保证了每次写操作对所有的读写操作的可见性,同时还简化了编程方式。
参考:《Java并发编程的艺术》
https://tech.meituan.com/2018/11/15/java-lock.html