读写锁ReentrantReadWriteLock是JDK1.5提供的一个工具锁,适用于读多写少的场景,将读写分离,从而提高并发性。读写锁允许的情况:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。
ReentrantReadWriteLock可用于提高某些集合的并发性。仅当集合预计很大时,读线程比写线程多,并且需要用超过同步开销的开销时,使用ReentrantReadWriteLocks通常是值得的。
ReentrantReadWriteLock实现了ReadWriteLock接口。
ReadWriteLock接口:
ReadWriteLock接口暴露了两个Lock对象,一个用来读,另一个用来写。读取ReadWriteLock锁守护的数据,必须先获得读取的锁;当需要修改ReadWriteLock锁守护的数据时,必须先获得写入的锁。读写锁加锁策略允许多个同时存在的读锁,但只允许一个写者。也就是说,读锁是共享锁,写锁是排他锁,读锁和写锁不能同时存在。
- 读锁 - 如果没有线程锁定ReadWriteLock进行写入,则多线程可以访问读锁。
- 写锁 - 如果没有线程正在读或写,那么一个线程可以访问写锁。
1. 可重入
顾名思义,ReentrantReadWriteLock是可重入锁,它的读锁、写锁都是可重入的。
public class ReentrantLockTest {
private static final ReentrantReadWriteLock reentrantReadWriteLock
= new ReentrantReadWriteLock(true);
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
.writeLock();
public void reentrantRead() {
readLock.lock();
read();
readLock.unlock();
}
public void reentrantWrite() {
writeLock.lock();
write();
writeLock.unlock();
}
public static void read() {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
public static void write() {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
Thread.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) {
ReentrantLockTest test = new ReentrantLockTest();
test.reentrantRead();
test.reentrantWrite();
}
}
运行结果:
2. 公平锁
ReentrantReadWriteLock可以事公平锁,只需在构造函数的参数中传入 true:
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队:
final boolean writerShouldBlock() {
return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
return hasQueuedPredecessors();
}
在公平锁的情况下,只要等待队列中有线程在等待,也就是 hasQueuedPredecessors() 返回 true 的时候,那么 writer 和 reader 都会阻塞,也就是一律不允许插队。因此,对于公平锁而言,在某个线程释放锁之后,等待的线程获取锁的策略是以请求获取锁的时间为标准的,即使先请求获取锁的线程先拿到锁。下面的测试代码做了一个简单的验证:
package com.java.concurrency.in.practice.ch12;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class FairLocking {
public static final boolean FAIR = true;
private static final int NUM_THREADS = 3;
private static volatile int expectedIndex = 0;
public static void main(String[] args) throws InterruptedException {
ReentrantReadWriteLock.WriteLock lock = new ReentrantReadWriteLock(FAIR).writeLock();
// we grab the lock to start to make sure the threads don't start until we're ready
lock.lock();
for (int i = 0; i < NUM_THREADS; i++) {
new Thread(new ExampleRunnable(i, lock)).start();
// a cheap way to make sure that runnable 0 requests the first lock
// before runnable 1
// 这里休眠,可以保证每个线程在第一次循环的时候,都没有获得锁,从第二次
// 循环开始后,每个线程轮流去尝试获得锁
Thread.sleep(10);
}
// let the threads go
lock.unlock();
}
private static class ExampleRunnable implements Runnable {
private final int index;
private final ReentrantReadWriteLock.WriteLock writeLock;
public ExampleRunnable(int index, ReentrantReadWriteLock.WriteLock writeLock) {
this.index = index;
this.writeLock = writeLock;
}
public void run() {
while(true) {
writeLock.lock();
try {
// this sleep is a cheap way to make sure the previous thread loops
// around before another thread grabs the lock, does its work,
// loops around and requests the lock again ahead of it.
Thread.sleep(10);
} catch (InterruptedException e) {
//ignored
}
if (index != expectedIndex) {
System.out.printf("Unexpected thread obtained lock! " +
"Expected: %d Actual: %d%n", expectedIndex, index);
System.exit(0);
}
expectedIndex = (expectedIndex+1) % NUM_THREADS;
writeLock.unlock();
}
}
}
}
这段测试代码给每个线程绑定了一个下标,每个线程在各自的循环中轮流去获取写锁。如果不是遵循先请求先获取的方式,那么期望的下标值跟获得锁的线程下标必然会不一致。运行这段代码,会发现一直在循环中,证明了公平性的阻塞策略。
3. 非公平锁的插队策略
在构造函数的参数中传入 false,就是非公平锁。ReentrantReadWriteLock默认是非公平锁。非公平锁对读写锁排队的实现如下:
final boolean writerShouldBlock() {
return false; // writers can always barge
}
final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
}
可以看到, 写锁的线程返回值是 false,所以写锁是随时可以插队的;而对于读锁线程来说,就没那么简单了,它需要判断队列中第一个等待的结点是否是写线程,如果是,则读线程不允许插队,否则读线程可以闯入。也就是说,读锁只有在头结点不是写线程的情况是可以插队!
3.1 写者闯入
写者是随时可以插队的,以下代码可以验证:
package com.java.concurrency.in.practice.ch12;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class WriteLockBargepQueue {
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
System.out.println(Thread.currentThread().getName() + "尝试获得读锁");
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
System.out.println(Thread.currentThread().getName() + "尝试获得写锁");
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> write(),"线程一").start();
new Thread(() -> read(),"线程二").start();
new Thread(() -> read(),"线程三").start();
new Thread(() -> write(),"线程四").start();
new Thread(() -> read(),"线程五").start();
new Thread(() -> read(),"线程六").start();
}
}
运行结果:
线程二尝试获得读锁
线程一尝试获得写锁
线程三尝试获得读锁
线程二得到读锁,正在读取
线程四尝试获得写锁
线程五尝试获得读锁
线程六尝试获得读锁
线程二释放读锁
线程一得到写锁,正在写入
线程一释放写锁
线程四得到写锁,正在写入
线程四释放写锁
线程三得到读锁,正在读取
线程五得到读锁,正在读取
线程六得到读锁,正在读取
线程三释放读锁
线程五释放读锁
线程六释放读锁
Process finished with exit code 0
线程三原本排在线程四之前,但是当线程一释放写锁后,线程四优先拿到写锁。
3.2 读者闯入
读者已经获得锁,写锁排在等待队列的首位,接着读者线程加入队列中。排在写锁后面,如果允许读者线程闯入,这样看似提高了效率,但如果想要读取的线程不停地增加,读线程不断闯入,那么降导致写锁线程长时间拿不到写锁,造成写者饥饿。下面用代码验证复原一下这个场景:
package com.java.concurrency.in.practice.ch12;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ReadLockBargepQueue {
private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock.readLock();
private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock.writeLock();
private static void read() {
System.out.println(Thread.currentThread().getName() + "尝试获得读锁");
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放读锁");
readLock.unlock();
}
}
private static void write() {
System.out.println(Thread.currentThread().getName() + "尝试获得写锁");
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println(Thread.currentThread().getName() + "释放写锁");
writeLock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
new Thread(() -> read(),"Thread-2r").start();
new Thread(() -> read(),"Thread-4r").start();
new Thread(() -> write(),"Thread-3w").start();
new Thread(() -> {
for (int i = 0; i < 50; i++) {
new Thread(() -> read(),"线程" + i).start();
}
}).start();
}
}
这段代码中,2号、4号线程是读线程,获得读锁;紧随其后是3号写线程,它在队首等待,其后是50个读线程,测试结果如下:
3w线程在2r、4r线程之后,当2r、4r线程获得读锁后,3w线程自然就排在队首了。
如测试结果所示,2r、4r线程释放后,3w线程拿到写锁。可见,当写线程排在队首时,读者是无法闯入的。
如果调整一下顺序,使得排在队首的是读线程,那么读者就可以闯入了:
public static void main(String[] args) throws InterruptedException {
new Thread(() -> read(),"Thread-2r").start();
new Thread(() -> read(),"Thread-4r").start();
new Thread(() -> {
for (int i = 0; i < 50; i++) {
new Thread(() -> read(),"读者线程" + i).start();
}
}).start();
Thread.sleep(5);
new Thread(() -> write(),"Thread-3w").start();
}
2线程本来排在3线程之前,但3线程却提前拿到读锁。
ReentrantReadWriteLock使用的注意事项 读锁不支持条件变量 重入时升级不支持:持有读锁的情况下去获取写锁,会导致获取永久等待 重入时支持降级: 持有写锁的情况下可以去获取读锁
这个类的构造函数接受一个可选的公平参数。当设置为true时,在争用项下,锁倾向于授予对等待时间最长的线程的访问权限。 但是,请注意,锁的公平性并不能保证线程调度的公平性。因此,使用公平锁的多个线程中的一个可以连续多次获得该锁,而其他活动线程则没有进展,目前也没有保存该锁。
4. 支持锁降级
锁降级指的是写锁降级成为读锁。遵循获取写锁、获取读锁,再释放写锁次序,写锁能够降级成为读锁。
锁降级主要是为了防止数据没有刷回到主内存,导致其他线程取到的值不一致!如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。 锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。
锁降级也可以有助于提高效率。如果一直使用写锁,最后释放写锁,虽然线程安全,但是有时候没这个必要,假设只有一处需要更新数据,后面的只是读,这个时候还用写锁就不能多个线程读了,浪费资源,这个时候就用锁的降级提高整体效率。
以下代码是一个典型的锁降级例子:
package com.java.concurrency.in.practice.ch12;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class CachedData {
Object data;
// 保证可见性
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
// 首先获取读锁
rwl.readLock().lock();
// 判断缓存是否有效,有效直接输出
if (!cacheValid) {
// 无效就把读锁释放,上写锁
// 锁降级从写锁获取到开始,这个时候有可能有其他线程抢先获取了写锁
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 因为可能有其他写线程抢到写锁并更新了缓存,所以需要再次判断如果缓存还是无效
if (!cacheValid) {
// 更新data,缓存设为有效
data = new Object();
cacheValid = true;
}
// 因为我们想要使用数据(在后续打印出data),所以请求读锁
rwl.readLock().lock();
} finally {
// 确保写锁释放,整个时候就是读锁了,然后打印data
rwl.writeLock().unlock();
}
}
try {
System.out.println(data);
} finally {
// 确保读锁能释放
rwl.readLock().unlock();
}
}
}
锁降级的正确步骤是:持有写锁 -> 持有读锁 -> 释放写锁 -> 释放读锁,为什么要在写锁释放之前获取读锁呢?如果在释放写锁后再拿到读锁,当线程A写锁释放,线程B抢先拿到写锁,并修改数据,此时A再拿到读锁,读到将是被线程B破坏掉的数据,从而产生“脏读”。锁降级的本质也是锁的重入性,可以帮助我们拿到当前线程修改后的结果而不被其他线程所破坏,防止更新丢失。
5. 不支持锁升级
ReentrantReadWriteLock不支持锁的升级。
在ReentrantReadWriteLock中,读锁和写锁之间是互斥的。当一个线程持有读锁时,其他线程可以继续获取读锁,但不能获取写锁。这是为了保证读操作的并发性,即多个线程可以同时读取共享资源。
当一个线程持有读锁时,如果尝试获取写锁,由于写锁的独占性,写锁无法被其他线程获取。如果两个以上读线程都想要升级为写锁,并且不释放读锁,就会陷入永久等待的状态,造成死锁。