java并发编程(七)----(JUC)ReadWriteLock

时间:2021-12-29 16:58:15

前面我们已经分析过JUC包里面的Lock锁,ReentrantLock锁和semaphore信号量机制。Lock锁实现了比synchronized更灵活的锁机制,Reentrantlock是Lock的实现类,是一种可重入锁,都是每次只有一次线程对资源进行处理;semaphore实现了多个线程同时对一个资源的访问;今天我们要讲的ReadWriteLock锁将实现另外一种很重要的功能:读写分离锁。

假设你的程序中涉及到对一些共享资源的读和写操作,且写操作没有读操作那么频繁。在没有写操作的时候,两个线程同时读一个资源没有任何问题,所以应该允许多个线程能在同时读取共享资源。但是如果有一个线程想去写这些共享资源,就不应该再有其它线程对该资源进行读或写,也就是说:读-读能共存,读-写不能共存,写-写不能共存。这就需要一个读/写锁来解决这个问题。

ReadWriteLock简介

我们在JUC包可以看到ReadWriteLock是一个接口,他有一个实现类:ReentrantReadWriteLock,先让我们对读写访问资源的条件做个概述:

- 读取: 没有线程正在做写操作,且没有线程在请求写操作。
- 写入: 没有线程正在做读写操作。

如果某个线程想要读取资源,只要没有线程正在对该资源进行写操作且没有线程请求对该资源的写操作即可。同样当有线程想要写资源,但是此刻有线程正在读取资源,那么此刻写资源的操作是不能继续下去的。
我们来看一个例子:

public class ReadWriteLockTest2 {
public static void main(String[] args) {
final int threadCount = 2;
final ExecutorService exService = Executors.newFixedThreadPool(threadCount);
final ScoreBoard scoreBoard = new ScoreBoard();
exService.execute(new ScoreUpdateThread(scoreBoard));
exService.execute(new ScoreHealthThread(scoreBoard));
exService.shutdown();
}
}

class ScoreBoard {
private boolean scoreUpdated = false;
private int score = 0;
String health = "不可用";
final ReentrantReadWriteLock rrwl = new ReentrantReadWriteLock();

public String getMatchHealth() {
rrwl.readLock().lock();
if (scoreUpdated) {
rrwl.readLock().unlock();
rrwl.writeLock().lock();
try {
if (scoreUpdated) {
score = fetchScore();
scoreUpdated = false;
}
rrwl.readLock().lock();
} finally {
rrwl.writeLock().unlock();
}
}
try {
if (score % 2 == 0) {
health = "Bad Score";
} else {
health = "Good Score";
}
} finally {
rrwl.readLock().unlock();
}
return health;
}

public void updateScore() {
try {
rrwl.writeLock().lock();
scoreUpdated = true;
} finally {
rrwl.writeLock().unlock();
}
}

private int fetchScore() {
Calendar calender = Calendar.getInstance();
return calender.get(Calendar.MILLISECOND);
}
}

class ScoreHealthThread implements Runnable {
private ScoreBoard scoreBoard;
public ScoreHealthThread(ScoreBoard scoreTable) {
this.scoreBoard = scoreTable;
}
@Override
public void run() {
for(int i= 0; i< 5; i++) {
System.out.println("Match Health: "+ scoreBoard.getMatchHealth());
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

class ScoreUpdateThread implements Runnable {
private ScoreBoard scoreBoard;
public ScoreUpdateThread(ScoreBoard scoreTable) {
this.scoreBoard = scoreTable;
}
@Override
public void run() {
for(int i= 0; i < 5; i++) {
System.out.println("Score Updated.");
scoreBoard.updateScore();
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}

打印结果:

Score Updated.
Match Health: Good Score
Score Updated.
Match Health: Good Score
Score Updated.
Match Health: Good Score
Score Updated.
Match Health: Good Score
Score Updated.
Match Health: Good Score

基本用法见上例,读写分离锁很好的控制了多个线程对同一个资源的访问。

ReentrantReadWriteLock

由名字我们可以看到读写锁也有可重入的实现类。ReentrantReadWriteLock具有关联的读取和写入锁定,可以重新获取锁定。它可表现为公平和不公平的模式两者。 默认行为是不公平的。 非公平锁的性能更好,虽然有可能读写器或写入器锁可以被推迟许多次,并且持续地尝试锁定。 在公平锁定的情况下,锁定请求按照最长等待的单个写入器锁或读取锁定组请求的顺序来完成,无论谁具有最长等待时间将获得对共享资源的锁定。 在重入ReentrantReadWriteLock可以写入锁定降级读锁。 这意味着如果线程已经获得写锁定,它可以将其锁从写降级到读锁。 顺序将是首先获得写锁定,执行写操作,然后获取读锁,然后解锁写锁,并且在读操作后最终解锁读锁。

ReentrantReadWriteLock 也是基于 AbstractQueuedSynchronizer 实现的,它具有下面这些属性:

  • 获取顺序

此类不会将读取者优先或写入者优先强加给锁访问的排序。但是,它确实支持可选的公平 策略。

1.非公平模式(默认)

当非公平地(默认)构造时,未指定进入读写锁的顺序,受到 reentrancy 约束的限制。连续竞争的非公平锁可能无限期地推迟一个或多个 reader 或 writer 线程,但吞吐量通常要高于公平锁。

2.公平模式

当公平地构造线程时,线程利用一个近似到达顺序的策略来争夺进入。当释放当前保持的锁时,可以为等待时间最长的单个 writer 线程分配写入锁,如果有一组等待时间大于所有正在等待的 writer 线程 的 reader 线程,将为该组分配写入锁。

如果保持写入锁,或者有一个等待的 writer 线程,则试图获得公平读取锁(非重入地)的线程将会阻塞。直到当前最旧的等待 writer 线程已获得并释放了写入锁之后,该线程才会获得读取锁。当然,如果等待 writer 放弃其等待,而保留一个或更多 reader 线程为队列中带有写入锁*的时间最长的 waiter,则将为那些 reader 分配读取锁。

试图获得公平写入锁的(非重入地)的线程将会阻塞,除非读取锁和写入锁都*(这意味着没有等待线程)。(注意,非阻塞 ReentrantReadWriteLock.ReadLock.tryLock() 和 ReentrantReadWriteLock.WriteLock.tryLock() 方法不会遵守此公平设置,并将获得锁(如果可能),不考虑等待线程)。

  • 重入

此锁允许 reader 和 writer 按照 ReentrantLock 的样式重新获取读取锁或写入锁。在写入线程保持的所有写入锁都已经释放后,才允许重入 reader 使用它们。

此外,writer 可以获取读取锁,但反过来则不成立。在其他应用程序中,当在调用或回调那些在读取锁状态下执行读取操作的方法期间保持写入锁时,重入很有用。如果 reader 试图获取写入锁,那么将永远不会获得成功。

  • 锁降级

重入还允许从写入锁降级为读取锁,其实现方式是:先获取写入锁,然后获取读取锁,最后释放写入锁。但是,从读取锁升级到写入锁是不可能的。

  • 锁获取的中断

读取锁和写入锁都支持锁获取期间的中断。

  • Condition 支持

写入锁提供了一个 Condition 实现,对于写入锁来说,该实现的行为与 ReentrantLock.newCondition() 提供的 Condition 实现对 ReentrantLock 所做的行为相同。当然,此 Condition 只能用于写入锁。读取锁不支持 Condition,readLock().newCondition() 会抛出 UnsupportedOperationException。

  • 监测

此类支持一些确定是保持锁还是争用锁的方法。这些方法设计用于监视系统状态,而不是同步控制。

此类行为的序列化方式与内置锁的相同:反序列化的锁处于解除锁状态,无论序列化该锁时其状态如何。

下面的代码展示了如何利用重入来执行升级缓存后的锁降级(为简单起见,省略了异常处理):

class CachedData {
Object data;
volatile boolean cacheValid;
ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 在获得写锁之前必须释放读锁
rwl.readLock().unlock();
rwl.writeLock().lock();
// Recheck state because another thread might have acquired
// write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
//通过在释放写锁之前获得读锁来降级
rwl.readLock().lock();
rwl.writeLock().unlock(); // 解锁写锁,但是任然持有读锁
}

use(data);
rwl.readLock().unlock();
}
}

与互斥锁对比

互斥锁一次只允许一个线程访问共享数据,哪怕进行的是只读操作;读写锁允许对共享数据进行更高级别的并发访问:对于写操作,一次只有一个线程(write线程)可以修改共享数据,对于读操作,允许任意数量的线程同时进行读取。

与互斥锁相比,使用读写锁能否提升性能则取决于读写操作期间读取数据相对于修改数据的频率,以及数据的争用——即在同一时间试图对该数据执行读取或写入操作的线程数。