重入锁ReentrantLock
ReentrantLock 排他锁,排他锁在同一时刻仅有一个线程可以进行访问,但是在大多数场景下,大部分时间都是提供读服务,而写服务占有的时间较少。然而读服务不存在数据竞争问题,如果一个线程在读时禁止其他线程读势必会导致性能降低。所以就提供了读写锁。
读写锁ReentrantReadWriteLock
读写锁维护着一对锁,一个读锁和一个写锁。通过分离读锁和写锁,使得并发性比一般的排他锁有了较大的提升:在同一时间可以允许多个读线程同时访问,但是在写线程访问时,所有读线程和写线程都会被阻塞。
读写锁的主要特性:
- 公平性:支持公平性和非公平性。
- 重入性:支持重入。读写锁最多支持65535个递归写入锁和65535个递归读取锁。
- 锁降级:遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁
读写锁 ReentrantReadWriteLock 实现接口 ReadWriteLock ,该接口维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 writer,读取锁可以由多个 reader 线程同时保持。写入锁是独占的。
1 | public interface ReadWriteLock { |
ReadWriteLock 定义了两个方法。readLock() 返回用于读操作的锁,writeLock() 返回用于写操作的锁。ReentrantReadWriteLock 定义如下:
1 | /** 内部类 读锁 */ |
ReentrantReadWriteLock 与 ReentrantLock 一样,其锁主体依然是 Sync ,它的读锁、写锁都是依靠 Sync 来实现的。所以 ReentrantReadWriteLock 实际上只有一个锁,只是在获取读取锁和写入锁的方式上不一样而已,它的读写锁其实就是两个类:ReadLock、writeLock,这两个类都是 lock 实现。
在 ReentrantLock 中使用一个 int 类型的 state 来表示同步状态,该值表示锁被一个线程重复获取的次数。但是读写锁 ReentrantReadWriteLock 内部维护着两个一对锁,需要用一个变量维护多种状态。所以读写锁采用“按位切割使用”的方式来维护这个变量,将其切分为两部分,高16为表示读,低16为表示写。分割之后,读写锁是如何迅速确定读锁和写锁的状态呢?通过位运算。假如当前同步状态为 S ,那么写状态等于 S & 0x0000FFFF(将高16位全部抹去),读状态等于S >>> 16(无符号补0右移16位)。代码如下:
1 | static final int SHARED_SHIFT = 16; |
写锁
(写锁就是一个支持可重入的排他锁)
写锁的获取
(写锁的获取最终会调用 tryAcquire(int arg) ,该方法在内部类 Sync 中实现:)
1 | protected final boolean tryAcquire(int acquires) { |
该方法和 ReentrantLock 的 tryAcquire(int arg) 大致一样,在判断重入时增加了一项条件:读锁是否存在。因为要确保写锁的操作对读锁是可见的,如果在存在读锁的情况下允许获取写锁,那么那些已经获取读锁的其他线程可能就无法感知当前写线程的操作。因此只有等读锁完全释放后,写锁才能够被当前线程所获取,一旦写锁获取了,所有其他读、写线程均会被阻塞。
写锁的释放
(获取了写锁用完了则需要释放,WriteLock提供了 unlock() 方法释放写锁)
1 | public void unlock() { |
写锁的释放最终还是会调用 AQS 的模板方法 release(int arg) 方法,该方法首先调用 tryRelease(int arg) 方法尝试释放锁,tryRelease(int arg) 方法为读写锁内部类 Sync 中定义了,如下:
1 | protected final boolean tryRelease(int releases) { |
写锁释放锁的整个过程和独占锁ReentrantLock相似,每次释放均是减少写状态,当写状态为0时表示 写锁已经完全释放了,从而等待的其他线程可以继续访问读写锁,获取同步状态,同时此次写线程的修改对后续的线程可见。
读锁
(读锁为一个可重入的共享锁,它能够被多个线程同时持有,在没有其他写线程访问时,读锁总是会获取成功)
读锁的获取
(读锁的获取可以通过ReadLock的lock()方法)
1 | public void lock() { |
Sync的acquireShared(int arg)定义在AQS中:
1 | public final void acquireShared(int arg) { |
tryAcqurireShared(int arg)尝试获取读同步状态,该方法主要用于获取共享式同步状态,获取成功返回 >= 0的返回结果,否则返回 < 0 的返回结果。
1 | protected final int tryAcquireShared(int unused) { |
读锁获取的过程相对于独占锁而言会稍微复杂下,整个过程如下:
- 因为存在锁降级情况,如果存在写锁且锁的持有者不是当前线程则直接返回失败,否则继续
- 依据公平性原则,判断读锁是否 塞,读锁持有线程数小于最大值(65535),且设置锁状态成功,执行以下代码(对于HoldCounter下面再阐述),并返回1。如果不满足改条件,执行fullTryAcquireShared()。
1 | final int fullTryAcquireShared(Thread current) { |
fullTryAcquireShared(Thread current)会根据“是否需要阻塞等待”,“读取锁的共享计数是否超过限制”等等进行处理。如果不需要阻塞等待,并且锁的共享计数没有超过限制,则通过CAS尝试获取锁,并返回1
读锁的释放
(与写锁相同,读锁也提供了unlock()释放读锁)
1 | public void unlock() { |
unlcok()方法内部使用Sync的releaseShared(int arg)方法,该方法定义在AQS中:
1 | public final boolean releaseShared(int arg) { |
调用tryReleaseShared(int arg)尝试释放读锁,该方法定义在读写锁的Sync内部类中:
1 | protected final boolean tryReleaseShared(int unused) { |
锁降级
锁降级:从写锁变成读锁;
锁升级:从读锁变成写锁。
读锁是可以被多线程共享的,写锁是单线程独占的。也就是说写锁的并发限制比读锁高,这可能就是升级/降级名称的来源。
如下代码会产生死锁,因为同一个线程中,在没有释放读锁的情况下,就去申请写锁,这属于锁升级,ReentrantReadWriteLock是不支持的。
1 | ReadWriteLock rtLock = new ReentrantReadWriteLock(); |
ReentrantReadWriteLock支持锁降级,如下代码不会产生死锁。
1 | ReadWriteLock rtLock = new ReentrantReadWriteLock(); |
以上这段代码虽然不会导致死锁,但没有正确的释放锁。从写锁降级成读锁,并不会自动释放当前线程获取的写锁,仍然需要显示的释放,否则别的线程永远也获取不到写锁。锁降级就意味着写锁是可以降级为读锁的,但是需要遵循先获取写锁、获取读锁在释放写锁的次序。注意如果当前线程先获取写锁,然后释放写锁,再获取读锁这个过程不能称之为锁降级,锁降级一定要遵循那个次序。
锁降级中读锁的获取释放为必要?肯定是必要的。试想,假如当前线程A不获取读锁而是直接释放了写锁,这个时候另外一个线程B获取了写锁,那么这个线程B对数据的修改是不会对当前线程A可见的。如果获取了读锁,则线程B在获取写锁过程中判断如果有读锁还没有释放则会被阻塞,只有当前线程A释放读锁后,线程B才会获取写锁成功。
读写锁例子
1 | public class ReadWriteLockTest { |
1 | Thread-0 be ready to read data! |
使用读写锁模拟一个缓存器:
1 | import java.util.HashMap; |