并发编程系列之重入锁VS读写锁

>>强大,10k+点赞的 SpringBoot 后台管理系统竟然出了详细教程!

前言

点击蓝字关注我们

并发编程系列之重入锁VS读写锁

上节我们介绍了Java中的锁基础篇,也算是对锁有了个基本的认识,对锁底层的一些原理有所掌握,那么今天我们就来看看2个最常见的锁的实例应用,重入锁和读写锁,这是今天旅途最美的两大景点,是不是有点迫不及待了,OK,那就让我们一起开启今天的并发之旅吧,祝您今天的旅途愉快。

并发编程系列之重入锁VS读写锁

景点一:重入锁

什么是重入锁?

重入锁ReentrantLock指的是支持重进入的锁,表示该锁能够支持同一个线程对资源的重复加锁,也就是说当线程对某个资源获取锁之后,该线程继续获取该资源的锁时不会被阻塞;

synchronized关键字就是一种可重入锁,不过他是隐式的支持可重入,不需要开发者手动的获取锁,解锁,其实本质上synchronized锁机制实现同步本身就是隐式的,对开发者完全是透明的,这样虽然简化了开发,但是也限制了对锁的可控性;

ReentrantLock虽然没有像synchronized一样支持隐式的重进入,但是在调用了lock方法之后,已经获得锁的线程还能继续调用lock方法获得锁,而不会被自己阻塞;


锁获取公平性问题

什么是公平的获取锁:在绝对时间上,先对锁进行获取的请求一定会先获取到锁,那么这个锁就成为公平性锁,也就是说,等待时间越长的线程越优先获取锁,获取锁的顺序是跟请求顺序一致的,先到先得的规则,满足这种规则就是公平性锁,反正就是非公平性锁,ReentrantLock通过一个Boolean值来控制是否为公平性锁:

// 公平性可重入锁 
ReentrantLock fairReentrantLock = new ReentrantLock(true);
// 非公平性可重入锁
ReentrantLock  unFairReentrantLock = new ReentrantLock(false);


公平性锁和非公平性锁比较:公平锁事实上没有非公平性锁效率高,主要是因为公平性锁每次都是从同步队列中的第一个节点获取锁(为了保证FIFO特性)。那么有人就会疑问,既然效率是非公平性高,那么我们是不是就应该尽量多的去使用非公平性锁代替公平性锁呢?当然答案是否定的,非公平性锁虽然效率上比较高,但是出现线程饥饿的情况概率比较大,这是因为刚刚释放锁的线程再次获得锁的概率比较大,这样就会导致某些线程会出现一直获取不到锁的机会,而一直在同步队列中等待着,也正是非公平锁的这种随机不公平性导致饥饿概率大大提升;

对于我们的ReentrantLock,其实还是更倾向于高效率的非公平性锁,我们看如下源码就能很清晰的看到这一点:

ReentrantLock  reentrantLock = new ReentrantLock();
public ReentrantLock() {
       // new 一个非公平性锁
       sync = new NonfairSync();
   }

公平性锁和非公平性锁总结:公平性锁保证了锁的获取按照FIFO规则进行,而代价是进行大量的线程切换,降低了处理效率;非公平性锁,虽然会造成线程饥饿情况发生,但是极少的线程切换,换来了更大的吞吐量,提高处理效率。

并发编程系列之重入锁VS读写锁

如何实现重进入

重进入是指任意线程在获取到锁之后还能再次获取该锁,而不会被阻塞,实现重进入主要需要解决下面2个问题:

  • 线程再次获取锁:锁需要去识别获取锁的线程是否为当前已经获得锁的线程,如果是,那就再次获取锁成功,不是则进入同步队列等待下次获取;

  • 锁的最终释放:同一个线程重复N次获取了锁,那么就需要在随后第N次释放锁之后,其他线程才能获取到该锁。其实现主要依赖于一个计数器,对于某个线程获取某个资源都有一个计数器,每次该线程再次获取该锁时计数器+1,而锁被释放则-1,当计数器为0时,才能代表真正释放成功,才能被其他线程获取;

上面我们提到ReentrantLock有2种锁,那么我们接下来就分别就这2种分析其获取和释放是如何实现的;


公平性锁的获取

fairReentrantLock.lock();

我们再看看公平性锁lock方法内部实现:其调用Sync的lock方法

public void lock() {
       sync.lock();
   }

我们在看下如何获取同步状态的:

static final class FairSync extends Sync {
       private static final long serialVersionUID = -3000897897090466540L;

       final void lock() {
           acquire(1);
       }

       protected final boolean tryAcquire(int acquires) {
           final Thread current = Thread.currentThread();
           int c = getState();
           // 判断是否是重进入,如果不是重进入,首次获取锁,得先判断是有有前驱节点
           if (c == 0) {
                       // 判断当前线程节点在同步队列中是否有前驱节点,如果有
               // 则表示有线程比当前线程更早的请求获取锁,因此需要等待
               // 前驱线程获取并释放锁之后才能继续获取锁,如果没有前驱
               // 节点,并且CAS操作成功则说明获取同步状态成功,即获取锁成功,返回
               if (!hasQueuedPredecessors() &&
                   compareAndSetState(0, acquires)) {
                   setExclusiveOwnerThread(current);
                   return true;
               }
           }
           // 如果是重进入就直接获取锁成功,计数器+1
           else if (current == getExclusiveOwnerThread()) {
               int nextc = c + acquires;
               if (nextc < 0)
                   throw new Error("Maximum lock count exceeded");
               setState(nextc);
               return true;
           }
           return false;
       }
   }


公平性锁的释放

同样的在释放锁也需要根据计数器判断,先调用释放锁方法:

try {
           // do some thing
       }finally {
           fairReentrantLock.unlock();
       }

然后我们看下释放锁的源码:假设获取锁的次数为N次,则前N-1次都返回false,第N次才返回true,真正释放锁

protected final boolean tryRelease(int releases) {
           // 计数器-1
           int c = getState() - releases;
           if (Thread.currentThread() != getExclusiveOwnerThread())
               throw new IllegalMonitorStateException();
           boolean free = false;
           // 如果计数器=0,说明最终释放条件满足,设置当前同步状态为0
           // 并且将当前占有线程设置为null,并返回true
           if (c == 0) {
               free = true;
               setExclusiveOwnerThread(null);
           }
           setState(c);
           return free;
       }


非公平性获取锁

ReentrantLock  unFairReentrantLock = new ReentrantLock(false);
unFairReentrantLock.lock();

非公平性获取锁和公平性获取锁区别不大,主要体现在不需要判断同步队列中的当前线程需要有前驱节点,它不需要保证节点的FIFO,我们看源码分析:

final boolean nonfairTryAcquire(int acquires) {
           final Thread current = Thread.currentThread();
           // 判断是否是首次获取锁
           int c = getState();
           // 如果是首次获取锁,直接CAS操作获取锁成功,返回true
           if (c == 0) {
               if (compareAndSetState(0, acquires)) {
                   setExclusiveOwnerThread(current);
                   return true;
               }
           }
           // 否则,加锁计数器+1,获取锁成功,返回true
           else if (current == getExclusiveOwnerThread()) {
               int nextc = c + acquires;
               if (nextc < 0) // overflow
                   throw new Error("Maximum lock count exceeded");
               setState(nextc);
               return true;
           }
           return false;
       }

代码逻辑:判断当前线程是否为获取锁的线程,如果是获取锁的线程再次请求,则将同步状态计数器+1并返回true,获取锁成功,如果不是则表现当前线程是第一次获取锁,直接进行CAS修改同步状态,修改成功返回true,完成非公平性获取锁的操作,非公平性释放锁流程和源码跟公平性是一样的,都是tryRelease方法,就不做重复的讲解了。

并发编程系列之重入锁VS读写锁

景点二:读写锁

什么是读写锁?

上面所说的重入锁是排他锁,排他锁在同一时刻只允许一个线程进行访问,而读写锁在同一时刻可以允许多个读线程访问,但是在写操作时,所有对该资源的读线程和写线程都将被阻塞;

读写锁实质上是维护这一对锁,一个读锁和一个写锁,通过分离读锁和写锁,使得并发性有很大的提升;

读写锁实现:在读操作的时候获取锁,写操作时获取写锁即可,当写锁被获取时,后续的读写操作都会阻塞,写锁释放之后,所有操作继续执行,而读锁是可以同时并发访问的;


读写锁的特性

读写锁比其他排他锁具有更好的并发性和吞吐量,主要有下面三大特性:

  • 公平性选择:支持公平和非公平的锁获取方式,吞吐量考虑非公平锁优先公平锁

  • 重进入:读写锁支持重进入

  • 锁降级:遵循写锁——读锁——释放写锁的次序,写锁也能降级为读锁

  • 支持中断:读取锁和写入锁都支持锁获取时的中断

  • Condition:写入锁提供了Condition的实现


读写锁的接口

ReadWriteLock接口只定义了两个方法,所以一般情况下,我们都是使用实现好的类ReentrantReadWriteLock,我们先看下ReadWriteLock定义的两个方法如下:

并发编程系列之重入锁VS读写锁

ReentrantReadWriteLock类提供的方法如下:

并发编程系列之重入锁VS读写锁

并发编程系列之重入锁VS读写锁

并发编程系列之重入锁VS读写锁

读写锁的实现原理

读写状态的设计

读写锁同样依赖自定义同步器来实现同步功能,读写状态就是其同步器的状态,读写锁的自定义同步器系统在同步状态上维护多个读线程和一个写线程的状态;

读写锁采用按位划分的思想,将一个整型变量切分成两个部分,高16位表示读,低16位表示写,如下图所示:

并发编程系列之重入锁VS读写锁

并发编程系列之重入锁VS读写锁

写锁的获取和释放

写锁是一个支持重进入的排他锁,如果当前线程已经获取写锁,则写状态+1,如当前线程在获取写锁的时候,读锁已经被获取或者获取写锁的不是当前线程,则当前线程进入等待状态,我们看源码如下:

/** 如果读计数器不为0,或者写计数器不为0,并且当前线程
*  不是已经获得锁的线程时,则失败;
*
*  如果计数器饱和,则失败;
*
*  否则当前线程就可以获得锁,要么为可重入的,要么就是
*  FIFO策略下的,如果满足,则更新同步状态,获取锁成功
*/

protected final boolean tryAcquire(int acquires) {
           Thread current = Thread.currentThread();
           int c = getState();
           int w = exclusiveCount(c);
           if (c != 0) {
               // 如果存在读锁或者当前线程不是已经获得写锁的线程时
               if (w == 0 || current != getExclusiveOwnerThread())
                   return false;
               if (w + exclusiveCount(acquires) > MAX_COUNT)
                   throw new Error("Maximum lock count exceeded");
               // Reentrant acquire
               setState(c + acquires);
               return true;
           }
           // 是否为读锁 或者CAS操作失败
           if (writerShouldBlock() ||
               !compareAndSetState(c, c + acquires))
               return false;
           setExclusiveOwnerThread(current);
           return true;
       }

我们会发现这里还增加了一个是否为读锁判断,如果存在读锁则写锁就不能被获取,这是什么原因呢?是因为:读写锁要保证写锁的操作对读锁是可见的,也就是说,每个写操作的结果一定要对后续所有的读操作是可见的。所以,写锁一定要等待其他读线程全部都释放了锁才能被当前线程获取,而写锁一旦被获取,则其他后续读写线程都必须被阻塞;

写锁的释放:写锁的释放与ReentrantLock的释放过程基本一样,每次释放时都减少写状态,当写状态计数器为0时,则最终完全释放,从而等待的读写线程才能够继续访问读写锁,并且当前释放的写操作结果对后续操作均可见。

并发编程系列之重入锁VS读写锁

读锁的获取和释放

读锁是一个支持可重入的共享锁,它能够被多个线程同时获取,在没有线程对资源访问时,读锁就会获取成功,增加读状态,如果在获取读锁的过程中,该资源的写锁已经被其他线程获取,则读锁进入等待状态,等待写锁的释放,再尝试获取读锁。

获取读锁的逻辑:如果其他线程已经获取了写锁,则当前线程获取读锁失败,进入等待状态;如果当前线程获取写锁或者写锁没有被其他线程获取,则当前线程获取读锁成功,增加读状态,也就是说同一个线程可以在获取写锁的前提下再次获得该读锁,有人就会问,不是所有的写必须对后续读可见吗?万一这里写线程执行比读慢不就不满足写结果对读的可见了吗,这里我要做个说明:上面我在讲读写锁特性时提到过锁降级,必须遵循写锁——读锁——释放写锁的次序,也就是说同一个线程对同一个对象的操作,必须写优先于读,所以写的结果一定在读之前,不用去担心脏读的发生;具体源码见下:

protected final int tryAcquireShared(int unused) {
           Thread current = Thread.currentThread();
           int c = getState();
           if (exclusiveCount(c) != 0 &&
               getExclusiveOwnerThread() != current)
               return -1;
           int r = sharedCount(c);
           if (!readerShouldBlock() &&
               r < MAX_COUNT &&
               compareAndSetState(c, c + SHARED_UNIT)) {
               if (r == 0) {
                   firstReader = current;
                   firstReaderHoldCount = 1;
               } else if (firstReader == current) {
                   firstReaderHoldCount++;
               } else {
                   HoldCounter rh = cachedHoldCounter;
                   if (rh == null || rh.tid != current.getId())
                       cachedHoldCounter = rh = readHolds.get();
                   else if (rh.count == 0)
                       readHolds.set(rh);
                   rh.count++;
               }
               return 1;
           }
           return fullTryAcquireShared(current);
       }

其中SHARED_UNIT为:

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;


读锁的释放:跟前面讲的都差不多,都是每次释放减少读状态,减少的值是1<<16,我们前面说了高16位为读状态。当减少若干次数高16位等于0时,则代表读锁全部释放完毕;

并发编程系列之重入锁VS读写锁

锁降级

锁降级指的是写锁降级为读锁,是线程先获取写锁——然后又获取了读锁——随后释放写锁的过程;如果是获取写锁——释放写锁——再获取读锁,这种分段完成的过程不能称之为锁降级;

有人就会问锁降级过程中,获取读锁的步骤可不可以省略,这当然是不行的,读锁在这里的目的是为了保证数据的可见性,假设线程1获取了写锁,然后释放写锁,而没有获取读锁,如果此时线程2刚好获取了写锁,就会把线程1更新的值直接覆盖掉,而对于其他线程完全不可见,也就是说线程1的修改完全没有被其他线程感知到;

读写锁是没有锁升级的,主要目的也是为了保证数据的可见性,如果某个对象读锁已经被多个线程获取,而此时其中一个线程升级为写锁了,那么该线程对数据的更新,对于剩下的其他获取到读锁的线程就是不可见的。


以上就是今天重入锁和读写锁两大景点的全部内容,是不是有种意犹未尽的感觉,没关系,今天的旅途累了,我们休息一下,下节继续,感谢关注,感谢阅读!!!


相关文章:

并发编程系列之锁基础篇

并发编程系列之重入锁VS读写锁

原文始发于微信公众号(Justin的后端书架):并发编程系列之重入锁VS读写锁