看了一些关于该问题的文章,大部分都不够清晰,这里做个总结。
ReentrantReadWriteLock读写锁
ReentrantReadWriteLock
读写锁在同一时刻允许多个读线程访问,在写线程访问时,所有的读写线程均阻塞。该锁维护了一个读锁和一个写锁,通过分离读写锁使并发性相比排他锁有了很大提升,适用于多读少写的场景。
读写锁依赖 AQS
来实现同步功能,读写状态就是其同步器的同步状态。读写锁的自定义同步器需要在同步状态,即一个 int
变量上维护多个读线程和一个写线程的状态。读写锁将变量切分成了两个部分,高 16
位表示读,低 16
位表示写。
为什么要把一个
int
类型变量state
拆成两半,而不是用两个int
型变量分别表示读锁和写锁的状态呢?这是因为无法用一次CAS
同时操作两个int
变量,所以用了一个int
型的高16
位和低16
位分别表示读锁和写锁的状态。
-
写锁是可重入排他锁,如果当前线程已经获得了写锁则增加写状态,如果当前线程在获取写锁时,读锁已经被获取或者该线程不是已经获得写锁的线程则进入等待。写锁的释放与
ReentrantLock
的释放类似,每次释放减少写状态,当写状态为0
时表示写锁已被释放。 -
读锁是可重入共享锁,能够被多个线程同时获取,在没有其他写线程访问时,读锁总会被成功获取。如果当前线程已经获取了读锁,则增加读状态。如果当前线程在获取读锁时,写锁已被其他线程获取则进入等待。读锁每次释放会减少读状态,减少的值是
(1<<16)
,读锁的释放是线程安全的。
锁降级
锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
部分源码解析(ReadLock
读锁对象):
lock方法
public void lock(){
sync.acquireShared(1);
}
acquireShared方法
public final void acquireShared(int arg){
//调用ReentrantReadWriteLock的sync的tryAcquireShared方法
if(tryAcquireShared(arg) < 0){
//调用AQS中的doAcquireShared方法
doAcquireShared(arg);
}
}
tryAcquireShared方法(部分)
Thread current = Thread.currentThread();
// 当前状态
int c = getState();
// 存在写锁,并且写锁不等于当前线程时返回,否则继续往下获取读锁。
if (exclusiveCount(c) != 0 & getExclusiveOwnerThread() != current)
return -1;
//读锁获取
从上面可以看出,一旦一个线程要进行读操作,读锁获取时会嵌套调用lock -> acquireShared -> tryAcquireShared
,最终逻辑会判断写锁是否被占有以及当前线程是否占有写锁,一旦当前线程占有写锁,那么将进行锁降级操作。
锁降级的必要性
书上原文:锁降级中读锁的获取是否必要呢?答案是必要的。主要是为了保证数据的可见性,如果当前线程不获取读锁而是直接释放写锁, 假设此刻另一个线程(记作线程T)获取了写锁并修改了数据,那么当前线程无法感知线程T的数据更新。如果当前线程获取读锁,即遵循锁降级的步骤,则线程T将会被阻塞,直到当前线程使用数据并释放读锁之后,线程T才能获取写锁进行数据更新。
解读:
首先,需要正确理解“当前线程无法感知线程T数据的更新”这一句话,这句话的意思并不是说会破坏数据的内存可见性,因为当线程T获取写锁后,一旦释放,其他线程中的数据还是会被更新的,也就是说数据更新操作对其他线程是可见的。个人觉得原文作者想表达的意图可能只是其他线程的写操作修改对当前线程来说是‘透明’的,换句话说,当前线程原本只是想读到写锁释放时刻数据的值,但最后读到的数据实际上已经被其他线程篡改过。
从另一个层面来看,如果没有锁降级,当前线程在释放写锁后一旦被其他线程拿到,那么后续的读线程将全被阻塞,直到当前写线程释放锁后才被唤醒。因此在这种情况下会产生大量的线程阻塞唤醒开销。而如果使用锁降级,将保证当前线程在写时进行读请求可以直接切换为读状态,非常适用于边读边写的程序。