【锁思想】读写锁插队策略和读写锁的升降级策略详解

TodoCoder大约 10 分钟Java锁思想编程思想读写锁

  大家好,我是Coder哥,在技术日新月异的今天,真正应该花费时间学习的是那些不变的编程思想,今天我们来接着上一篇文章来聊一下锁思想,我们上一篇”为什么synchronized是非公平的“详细的分析了公平锁与非公平锁的实现思想以及非公平锁固有的缺陷: 造成线程饥饿。那么今天我们再来聊一下读写锁到底是怎么解决饥饿问题。

在聊这个问题之前,我们先从以下几个问题出发:

  1. 读写锁的获取原则是什么?
  2. 读写锁在读多写少的场景中为什么非公平策略更容易造成线程饥饿?
  3. 读写锁是通过什么策略来避免写线程饥饿的?

读写锁的获取原则

我们快速的复习一下读写锁的规则,在使用读写锁时遵守下面的获取规则:

  1. 如果有线程占用读锁,则此时其他线程如果申请读锁,可以申请成功。
  2. 如果有线程占用读锁,则此时其他线程如果申请写锁,需要等待读锁释放了才可以获取,读写不能同时操作。
  3. 如果有线程占用了写锁,则此时其他线程要申请读锁或者写锁,都必须等待读锁释放了才可以获取,即写读,写写不能同时操作。

总结:要么是一个或多个线程同时有读锁,要么是一个线程有写锁,但是两者不会同时出现。也可以总结为:读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。

读写锁在读多写少的场景中为什么非公平策略更容易造成线程饥饿?

在文章《为什么synchronized是非公平的》open in new window中了解到,在没有读写锁的时候,假如我们使用普通的ReentrantLock,

  1. 如果使用公平策略,那么所有竞争的线程就会排队等待,高并发下会有大量的线程进行上下文切换,带来了时间的开销。
  2. 如果用非公平策略,在读并发极大的情况下,虽然会有一定的性能提升,可能会被读线程一直插队获取锁,造成写线程一直获取不到锁的情况。

比如:线程1先获取到锁了,写线程2,3,4在排队写入数据,这个时候假如说有大量的读线程5,6,7来插队,由于读锁可以共享,那么读线程1在没释放锁的情况下,读线程5,6,7就能获取到读锁,那么就会造成写线程一直获取不到锁的情况,就是所谓的线程饥饿,那么读写锁是怎么解决这个问题的呢?

读写锁是通过什么策略来避免写线程饥饿的?

在文章《为什么synchronized是非公平的》open in new window中,我们知道锁的策略公平和非公平的策略,其实读写锁也分公平和非公平策略的,我们可以查阅一下ReentrantReadWriteLock的源码,知道可以设置公平和非公平策略:

公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);

非公平锁:

ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

既然这样,读写锁也有排队和插队机制。我们从读写锁的公平和非公平策略的实现来看一下:在获取读锁之前,线程会检查 readerShouldBlock() 方法,同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队

先来看一下公平锁对于这两个方法的实现:

// 获取写锁时会调用这个方法,如果有线程在排队,返回true
final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
// 获取读锁时会调用这个方法,如果有线程在排队,返回true
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

可以看到,在公平锁的情况下,会判断hasQueuedPredecessors() 即等待队列中是否有线程在等待,也就是一律不允许插队,都会去排队,这也符合公平锁的思想。

下面让我们来看一下非公平锁的实现:

// 返回 false 意思是,不用阻塞,可以直接插队
final boolean writerShouldBlock() {
    return false; 
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}

在 writerShouldBlock() 这个方法可以看出,对于想获取写锁的线程而言,由于返回值是 false,所以它是随时可以插队的,这就和 ReentrantLock 的非公平策略是一样的了。

但是读锁不一样。这里的策略很有意思,先让我们来看下面这种场景:

假设线程 1 和线程 2 正在同时读取,即线程1,2已经持有读锁,此时线程 3 想要获取写锁写入,但由于线程 1,2 已经持有读锁了,所以线程 3 就进入等待队列进行等待。此时,线程 4 突然跑过来想要插队获取读锁:

读写锁非公平锁
读写锁非公平锁

面对这种情况有两种策略,一种是允许插队,另一种是不允许插队,基于这两种策略我们来分别分析一下(这里是重点):

第一种:允许插队

允许插队,这个看起来很合理,因为线程1,线程2都是读锁,虽然有写线程3在排队,刚好读线程们可以共用这把读锁,那么第一种策略就允许读线程4的插入和线程1,线程2一起去读取。

这种策略看上去增加了效率,但是有一个严重的问题:别忘了读写锁的场景(读多),也就是说读取的线程会不停地增加,比如读线程 5,那么线程 5 也可以插队,这样就会导致读锁长时间内不会释放,进而导致写线程 3 长时间内拿不到写锁陷入“饥饿”状态,它将在长时间内得不到执行。

读写锁公平锁
读写锁公平锁

第二种:不允许插队

这种策略认为由于写线程 3 已经提前等待了,所以虽然读线程 4 直接插队成功可以提高效率,但是我们依然让读线程 4 去排队等待:

读写锁公平锁按照这种策略读线程 4 会被放入等待队列中,并且排在写线程 3 的后面,让写线程 3 优先于读线程 4 执行,这样可以避免“饥饿”状态,这对于程序的健壮性是很有好处的,直到写线程 3 运行完毕,读线程 4 才有机会运行,这样谁都不会等待太久的时间。

所以我们可以看出,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的,目的是避免“饥饿”

策略的选择取决于具体锁的实现,ReentrantReadWriteLock 的实现选择了第二种策略 ,就有效的避免了饥饿的情况。

读写锁的升降级

为什么需要锁的降级?

比如说,代码块里面有读又有写,如果我们一直使用写锁,最后才释放写锁的话,虽然确实是线程安全的,但是也是没有必要的,因为我们代码块里面有读又有写。

如果还一直使用写锁的话,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率,所以这个时候利用锁的降级是很好的办法,可以提高整体性能。

支持锁的降级,不支持升级

如果我们运行下面这段代码,在不释放读锁的情况下直接尝试获取写锁,也就是锁的升级,会让线程直接阻塞,程序是无法运行的。

final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
public static void main(String[] args) {
    upgrade();
}
 
public static void upgrade() {
    rwl.readLock().lock();
    System.out.println("获取到了读锁");
    rwl.writeLock().lock();
    System.out.println("成功升级");
}

这段代码会打印出“获取到了读锁”,但是却不会打印出“成功升级”,因为 ReentrantReadWriteLock 不支持读锁升级到写锁。

为什么不支持锁的升级?

我们知道读写锁的特点是如果线程都申请读锁,是可以多个线程同时持有的,可是如果是写锁,只能有一个线程持有,并且不可能存在读锁和写锁同时持有的情况。

正是因为不可能有读锁和写锁同时持有的情况,所以升级写锁的过程中,需要等到所有的读锁都释放,此时才能进行升级。

假设有 A,B 和 C 三个线程,它们都已持有读锁。假设线程 A 尝试从读锁升级到写锁。那么它必须等待 B 和 C 释放掉已经获取到的读锁。如果随着时间推移,B 和 C 逐渐释放了它们的读锁,此时线程 A 确实是可以成功升级并获取写锁。

但是我们考虑一种特殊情况。假设线程 A 和 B 都想升级到写锁,那么对于线程 A 而言,它需要等待其他所有线程,包括线程 B 在内释放读锁。而线程 B 也需要等待所有的线程,包括线程 A 释放读锁。这就是一种非常典型的死锁的情况。谁都愿不愿意率先释放掉自己手中的锁。

但是读写锁的升级并不是不可能的,也有可以实现的方案,如果我们保证每次只有一个线程可以升级,那么就可以保证线程安全。只不过最常见的 ReentrantReadWriteLock 对此并不支持。

总结

对于 ReentrantReadWriteLock 而言。

  • 插队策略,公平策略下,只能排队获取。
  • 非公平策略下:如果允许读锁插队,由于读锁可以同时被多个线程持有,可能会造成源源不断的读线程一直插队成功,导致读锁一直不能完全释放,从而导致写锁一直等待,为了防止“饥饿”,在等待队列的头结点是尝试获取写锁的线程的时候,不允许读锁插队
  • 写锁可以随时插队,因为写锁和其他锁都互斥,并不容易插队成功,写锁只有在当前没有任何其他线程持有读锁和写锁的时候,才能插队成功,同时写锁一旦插队失败就会进入等待队列,所以很难造成“饥饿”的情况,允许写锁插队是为了提高效率。
  • 升降级策略:只能从写锁降级为读锁,不能从读锁升级为写锁。

书籍推荐:
《计算机内功修炼系列》https://www.todocoder.com/pdf/jichu/001001.htmlopen in new window
《Java编程思想》https://www.todocoder.com/pdf/java/002002.htmlopen in new window
《Java并发编程实战》https://www.todocoder.com/pdf/java/002004.htmlopen in new window