图解ReentrantReadWriteLock实现分析

概述

本文主要分析JCU包中读写锁接口(ReadWriteLock)的重要实现类ReentrantReadWriteLock。主要实现读共享,写互斥功能,对比单纯的互斥锁在共享资源使用场景为频繁读取及少许修改的状况下能够较好的提升性能。html

ReadWriteLock接口简单说明

ReadWriteLock接口只定义了两个方法:java

public interface ReadWriteLock {
    /**
     * Returns the lock used for reading.
     *
     * @return the lock used for reading
     */
    Lock readLock();

    /**
     * Returns the lock used for writing.
     *
     * @return the lock used for writing
     */
    Lock writeLock();
}

经过调用相应方法获取读锁或写锁,获取的读锁及写锁都是Lock接口的实现,能够如同使用Lock接口同样使用(其实也有一些特性是不支持的)。node

ReentrantReadWriteLock使用示例

读写锁的使用并不复杂,能够参考如下使用示例:web

class RWDictionary {
    private final Map<String, Data> m = new TreeMap<String, Data>();
    private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    private final Lock r = rwl.readLock();
    private final Lock w = rwl.writeLock();
 
    public Data get(String key) {
      r.lock();
      try { return m.get(key); }
      finally { r.unlock(); }
    }
    public String[] allKeys() {
      r.lock();
      try { return m.keySet().toArray(); }
      finally { r.unlock(); }
    }
    public Data put(String key, Data value) {
      w.lock();
      try { return m.put(key, value); }
      finally { w.unlock(); }
    }
    public void clear() {
      w.lock();
      try { m.clear(); }
      finally { w.unlock(); }
    }
  }

与普通重入锁使用的主要区别在于须要使用不一样的锁对象引用读写锁,而且在读写时分别调用对应的锁。api

ReentrantReadWriteLock锁实现分析

本节经过学习源码分析可重入读写锁的实现。缓存

图解重要函数及对象关系

根据示例代码能够发现,读写锁须要关注的重点函数为获取读锁及写锁的函数,对于读锁及写锁对象则主要关注加锁和解锁函数,这几个函数及对象关系以下图:
从图中可见读写锁的加锁解锁操做最终都是调用ReentrantReadWriteLock类的内部类Sync提供的方法。与{% post_link 细谈重入锁ReentrantLock %}一文中描述类似,Sync对象经过继承AbstractQueuedSynchronizer进行实现,故后续分析主要基于Sync类进行。安全

读写锁Sync结构分析

Sync继承于AbstractQueuedSynchronizer,其中主要功能均在AbstractQueuedSynchronizer中完成,其中最重要功能为控制线程获取锁失败后转换为等待状态及在知足必定条件后唤醒等待状态的线程。先对AbstractQueuedSynchronizer进行观察。数据结构

AbstractQueuedSynchronizer图解

为了更好理解AbstractQueuedSynchronizer的运行机制,能够首先研究其内部数据结构,以下图:
图中展现AQS类较为重要的数据结构,包括int类型变量state用于记录锁的状态,继承自AbstractOwnableSynchronizer类的Thread类型变量exclusiveOwnerThread用于指向当前排他的获取锁的线程,AbstractQueuedSynchronizer.Node类型的变量headtail
其中Node对象表示当前等待锁的节点,Nodethread变量指向等待的线程,waitStatus表示当前等待节点状态,mode为节点类型。多个节点之间使用prevnext组成双向链表,参考CLH锁队列的方式进行锁的获取,但其中与CLH队列的重要区别在于CLH队列中后续节点须要自旋轮询前节点状态以肯定前置节点是否已经释放锁,期间不释放CPU资源,而AQSNode节点指向的线程在获取锁失败后调用LockSupport.park函数使其进入阻塞状态,让出CPU资源,故在前置节点释放锁时须要调用unparkSuccessor函数唤醒后继节点。
根据以上说明可得知此上图图主要表现当前thread0线程获取了锁,thread1线程正在等待。多线程

读写锁Sync对于AQS使用

读写锁中Sync类是继承于AQS,而且主要使用上文介绍的数据结构中的statewaitStatus变量进行实现。
实现读写锁与实现普通互斥锁的主要区别在于须要分别记录读锁状态及写锁状态,而且等待队列中须要区别处理两种加锁操做。
Sync使用state变量同时记录读锁与写锁状态,将int类型的state变量分为高16位与第16位,高16位记录读锁状态,低16位记录写锁状态,以下图所示:
Sync使用不一样的mode描述等待队列中的节点以区分读锁等待节点和写锁等待节点。mode取值包括SHAREDEXCLUSIVE两种,分别表明当前等待节点为读锁和写锁。oracle

读写锁Sync代码过程分析

写锁加锁

经过对于重要函数关系的分析,写锁加锁最终调用Sync类的acquire函数(继承自AQS

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

如今分状况图解分析

无锁状态

无锁状态AQS内部数据结构以下图所示:
其中state变量为0,表示高位地位地位均为0,没有任何锁,且等待节点的首尾均指向空(此处特指head节点没有初始化时),锁的全部者线程也为空。
在无锁状态进行加锁操做,线程调用acquire 函数,首先使用tryAcquire函数判断锁是否可获取成功,因为当前是无锁状态必然成功获取锁(若是多个线程同时进入此函数,则有且只有一个线程可调用compareAndSetState成功,其余线程转入获取锁失败的流程)。获取锁成功后AQS状态为:

有锁状态

在加写锁时若是当前AQS已是有锁状态,则须要进一步处理。有锁状态主要分为已有写锁和已有读锁状态,而且根据最终当前线程是否可直接获取锁分为两种状况:

  1. 非重入:若是知足一下两个条件之一,当前线程必须加入等待队列(暂不考虑非公平锁抢占状况)
    a. 已有读锁;
    b. 有写锁且获取写锁的线程不为当前请求锁的线程。
  2. 重入:有写锁且当前获取写锁的线程与当前请求锁的线程为同一线程,则直接获取锁并将写锁状态值加1。

写锁重入状态如图:
写锁非重入等待状态如图:
在非重入状态,当前线程建立等待节点追加到等待队列队尾,若是当前头结点为空,则须要建立一个默认的头结点。
以后再当前获取锁的线程释放锁后,会唤醒等待中的节点,即为thread1。若是当前等待队列存在多个等待节点,因为thread1等待节点为EXCLUSIVE模式,则只会唤醒当前一个节点,不会传播唤醒信号。

读锁加锁

经过对于重要函数关系的分析,写锁加锁最终调用Sync类的acquireShared函数(继承自AQS):

public final void acquireShared(int arg) {
        if (tryAcquireShared(arg) < 0)
            doAcquireShared(arg);
    }

同上文,如今分状况图解分析

无锁状态

无所状态AQS内部数据状态图与写加锁是无锁状态一致:
在无锁状态进行加锁操做,线程调用acquireShared 函数,首先使用tryAcquireShared函数判断共享锁是否可获取成功,因为当前为无锁状态则获取锁必定成功(若是同时多个线程在读锁进行竞争,则只有一个线程可以直接获取读锁,其余线程须要进入fullTryAcquireShared函数继续进行锁的获取,该函数在后文说明)。当前线程获取读锁成功后,AQS内部结构如图所示:
其中有两个新的变量:firstReaderfirstReaderHoldCountfirstReader指向在无锁状态下第一个获取读锁的线程,firstReaderHoldCount记录第一个获取读锁的线程持有当前锁的计数(主要用于重入)。

有锁状态

无锁状态获取读锁比较简单,在有锁状态则须要分状况讨论。其中须要分当前被持有的锁是读锁仍是写锁,而且每种状况须要区分等待队列中是否有等待节点。

已有读锁且等待队列为空

此状态比较简单,图示如:此时线程申请读锁,首先调用readerShouldBlock函数进行判断,该函数根据当前锁是否为公平锁判断规则稍有不一样。若是为非公平锁,则只须要当前第一个等待节点不是写锁就能够尝试获取锁(考虑第一点为写锁主要为了方式写锁“饿死”);若是是公平锁则只要有等待节点且当前锁不为重入就须要等待。
因为本节的前提是等待队列为空的状况,故readerShouldBlock函数必定返回false,则当前线程使用CAS对读锁计数进行增长(同上文,若是同时多个线程在读锁进行竞争,则只有一个线程可以直接获取读锁,其余线程须要进入fullTryAcquireShared函数继续进行锁的获取)。
在成功对读锁计数器进行增长后,当前线程须要继续对当前线程持有读锁的计数进行增长。此时分为两种状况:

  1. 当前线程是第一个获取读锁的线程,此时因为第一个获取读锁的线程已经经过firstReaderfirstReaderHoldCount两个变量进行存储,则仅仅须要将firstReaderHoldCount加1便可;
  2. 当前线程不是第一个获取读锁的线程,则须要使用readHolds进行存储,readHoldsThreadLocal的子类,经过readHolds可获取当前线程对应的HoldCounter类的对象,该对象保存了当前线程获取读锁的计数。考虑程序的局部性原理,又使用cachedHoldCounter缓存最近使用的HoldCounter类的对象,如在一段时间内只有一个线程请求读锁则可加速对读锁获取的计数。

第一个读锁线程重入如图:
非首节点获取读锁
根据上图所示,thread0为首节点,thread1线程继续申请读锁,获取成功后使用ThreadLocal连接的方式进行存储计数对象,而且因为其为最近获取读锁的线程,则cachedHoldCounter对象设置指向thread1对应的计数对象。

已有读锁且等待队列不为空

在当前锁已经被读锁获取,且等待队列不为空的状况下 ,可知等待队列的头结点必定为写锁获取等待,这是因为在读写锁实现过程当中,若是某线程获取了读锁,则会唤醒当前等到节点以后的全部等待模式为SHARED的节点,直到队尾或遇到EXCLUSIVE模式的等待节点(具体实现函数为setHeadAndPropagate后续还会遇到)。因此能够肯定当前为读锁状态其有等待节点状况下,首节点必定是写锁等待。如图所示:
上图展现当前thread0thread1线程获取读锁,thread0为首个获取读锁的节点,而且thread2线程在等待获取写锁。
在上图显示的状态下,不管公平锁仍是非公平锁的实现,新的读锁加锁必定会进行排队,添加等待节点在写锁等待节点以后,这样能够防止写操做的饿死。申请读锁后的状态如图所示:
如图所示,在当前锁被为读锁且有等待队列状况下,thread3thread4线程申请读锁,则被封装为等待节点追加到当前等待队列后,节点模式为SHARED,线程使用LockSupport.park函数进入阻塞状态,让出CPU资源,直到前驱的等待节点完成锁的获取和释放后进行唤醒。

已有写锁被获取

当前线程申请读锁时发现写锁已经被获取,则不管等待队列是否为空,线程必定会须要加入等待队列(注意在非公平锁实现且前序没有写锁申请的等待,线程有机会抢占获取锁而不进入等待队列)。写锁被获取的状况下,AQS状态为以下状态
在两种状况下,读锁获取都会进入等待队列等待前序节点唤醒,这里再也不赘述。

读等待节点被唤醒

读写锁与单纯的排他锁主要区别在于读锁的共享性,在读写锁实现中保证读锁可以共享的其中一个机制就在于,若是一个读锁等待节点被唤醒后其会继续唤醒拍在当前唤醒节点以后的SHARED模式等待节点。查看源码:

private void doAcquireShared(int arg) {
       final Node node = addWaiter(Node.SHARED);
       boolean failed = true;
       try {
           boolean interrupted = false;
           for (;;) {
               final Node p = node.predecessor();
               if (p == head) {
                   int r = tryAcquireShared(arg);
                   if (r >= 0) {
                     //注意看这里
                       setHeadAndPropagate(node, r);
                       p.next = null; // help GC
                       if (interrupted)
                           selfInterrupt();
                       failed = false;
                       return;
                   }
               }
               if (shouldParkAfterFailedAcquire(p, node) &&
                   parkAndCheckInterrupt())
                   interrupted = true;
           }
       } finally {
           if (failed)
               cancelAcquire(node);
       }
   }

for循环中,线程若是获取读锁成功后,须要调用setHeadAndPropagate方法。查看其源码:

private void setHeadAndPropagate(Node node, int propagate) {
       Node h = head; // Record old head for check below
       setHead(node);
       if (propagate > 0 || h == null || h.waitStatus < 0 ||
           (h = head) == null || h.waitStatus < 0) {
           Node s = node.next;
           if (s == null || s.isShared())
               doReleaseShared();
       }
   }

在知足传播条件状况下,获取读锁后继续唤醒后续节点,因此若是当前锁是读锁状态则等待节点第一个节点必定是写锁等待节点。

锁降级

锁降级算是获取读锁的特例,如在t0线程已经获取写锁的状况下,再调取读锁加锁函数则能够直接获取读锁,但此时其余线程仍然没法获取读锁或写锁,在t0线程释放写锁后,若是有节点等待则会唤醒后续节点,后续节点可见的状态为目前有t0线程获取了读锁。
所降级有什么应用场景呢?引用读写锁中使用示例代码

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

   void processCachedData() {
     rwl.readLock().lock();
     if (!cacheValid) {
       // Must release read lock before acquiring write lock
       rwl.readLock().unlock();
       rwl.writeLock().lock();
       try {
         // Recheck state because another thread might have
         // acquired write lock and changed state before we did.
         if (!cacheValid) {
           data = ...
           cacheValid = true;
         }
         // Downgrade by acquiring read lock before releasing write lock
         rwl.readLock().lock();
       } finally {
         rwl.writeLock().unlock(); // Unlock write, still hold read
       }
     }

     try {
       use(data);
     } finally {
       rwl.readLock().unlock();
     }
   }
 }

其中针对变量cacheValid的使用主要过程为加读锁、读取、释放读锁、加写锁、修改值、加读锁、释放写锁、使用数据、释放读锁。其中后续几步(加写锁、修改值、加读锁、释放写锁、使用数据、释放读锁)为典型的锁降级。若是不使用锁降级,则过程可能有三种状况:

  • 第一种:加写锁、修改值、释放写锁、使用数据,即便用写锁修改数据后直接使用刚修改的数据,这样可能有数据的不一致,如当前线程释放写锁的同时其余线程(如t0)获取写锁准备修改(尚未改)cacheValid变量,而当前线程却继续运行,则当前线程读到的cacheValid变量的值为t0修改前的老数据;
  • 第二种:加写锁、修改值、使用数据、释放写锁,即将修改数据与再次使用数据合二为一,这样不会有数据的不一致,可是因为混用了读写两个过程,以排它锁的方式使用读写锁,减弱了读写锁读共享的优点,增长了写锁(独占锁)的占用时间;
  • 第三种:加写锁、修改值、释放写锁、加读锁、使用数据、释放读锁,即便用写锁修改数据后再请求读锁来使用数据,这是时数据的一致性是能够获得保证的,可是因为释放写锁和获取读锁之间存在时间差,则当前想成可能会须要进入等待队列进行等待,可能形成线程的阻塞下降吞吐量。

所以针对以上状况提供了锁的降级功能,能够在完成数据修改后尽快读取最新的值,且可以减小写锁占用时间。
最后注意,读写锁不支持锁升级,即获取读锁、读数据、获取写锁、释放读锁、释放写锁这个过程,由于读锁为共享锁,如同时有多个线程获取了读锁后有一个线程进行锁升级获取了写锁,这会形成同时有读锁(其余线程)和写锁的状况,形成其余线程可能没法感知新修改的数据(此为逻辑性错误),而且在JAVA读写锁实现上因为当前线程获取了读锁,再次请求写锁时必然会阻塞而致使后续释放读锁的方法没法执行,这回形成死锁(此为功能性错误)。

写锁释放锁过程

了解了加锁过程后解锁过程就很是简单,每次调用解锁方法都会减小重入计数次数,直到减为0则唤醒后续第一个等待节点,如唤醒的后续节点为读等待节点,则后续节点会继续传播唤醒状态。

读锁释放过程

读锁释放过比写锁稍微复杂,由于是共享锁,因此可能会有多个线程同时获取读锁,故在解锁时须要作两件事:

  1. 获取当前线程对应的重入计数,并进行减1,此处天生为线程安全的,不须要特殊处理;
  2. 当前读锁获取次数减1,此处因为可能存在多线程竞争,故使用自旋CAS进行设置。

完成以上两步后,如读状态为0,则唤醒后续等待节点。

总结

根据以上分析,本文主要展现了读写锁的场景及方式,并分析读写锁核心功能(加解锁)的代码实现。Java读写锁同时附带了更多其余方法,包括锁状态监控和带超时机制的加锁方法等,本文不在赘述。而且读写锁中写锁可以使用Conditon机制也不在详细说明。

相关文章
相关标签/搜索