再次认识ReentrantReadWriteLock读写锁

前言

最近研究了一下juc包的源码。
在研究ReentrantReadWriteLock读写锁的时候,对于其中一些细节的思考和处理以及关于提高效率的设计感到折服,难以遏制想要分享这份心得的念头,所以在这里写一篇小文章做为记录。java

本片文章创建在已经了解并发相关基础概念的基础上,可能不会涉及不少源码,以思路为主。
若是文章有什么纰漏或者错误,还请务必指正,预谢。设计模式

1. 从零开始考虑如何实现读写锁

首先咱们须要知道独占锁(RenentractLock)这种基础的锁,在juc中是如何实现的:
它基于java.util.concurrent.locks.AbstractQueuedSynchronizer(AQS)这个抽象类所搭建的同步框架,利用AQS中的一个volatile int类型变量的CAS操做来表示锁的占用状况,以及一个双向链表的数据结构来存储排队中的线程。缓存

简单地说:一个线程若是想要获取锁,就须要尝试对AQS中的这个volatile int变量(下面简称state)执行相似comapre 0 and swap 1的操做,若是不成功就进入同步队列排队(自旋和重入之类的细节不展开说了)同时休眠本身(LockSupport.park),等待占有锁的线程释放锁时再唤醒它。安全

那么,若是不考虑重入,state就是一个简单的状态标识:0表示锁未被占用,1表示锁被占用,同步性由volatile和CAS来保证。数据结构

上面说的是独占锁,state能够不严谨地认为只有两个状态位。并发

可是若是是读写锁,那这个锁的基本逻辑应该是:读和读共享读和写互斥写和写互斥app

如何实现锁的共享呢?
若是咱们再也不把state当作一个状态,而是当作一个计数器,那仿佛就能够说得通了:获取锁时compare n and swap n+1,释放锁时compare n and swap n-1,这样就可让锁不被独占了。框架

所以,要实现读写锁,咱们可能须要两个锁,一个共享锁(读锁),一个独占锁(写锁),并且这两个锁还须要协做,写锁须要知道读锁的占用状况,读锁须要知道写锁的占用状况。ide

设想中的简单流程大概以下:性能

clipboard.png

clipboard.png

设想中的流程很简单,然而存在一些问题:

  • 关于读写互斥:

    • 对于某个线程,当它先得到读锁,而后在执行代码的过程当中,又想得到写锁,这种状况是否应该容许?
    • 若是容许,会有什么问题?若是不容许,当真的存在这种需求时怎么办?
    • 关于写写互斥是否也存在上面两条提到的状况和问题呢?
  • 咱们知道通常而言,读的操做数量要远远大于写的操做,那么颇有可能读锁一旦被获取就长时间处于被占有的状况(由于新来的读操做只须要进去+1就行了,不须要等待state回到0,这样的话state可能永远不会有回到0的一天),这会致使极端状况下写锁根本没有机会上位,该如何解决这种状况?
  • 对于上面用计数器来实现共享锁的假设,当任意一个线程想要释放锁(即便它并未获取锁,由于解锁的方法是开放的,任何获取锁对象的线程均可以执行lock.unlock())时,如何判断它是否有权限执行compare n and swap n-1

    • 是否应该使用ThreadLocal来实现这种权限控制?
    • 若是使用ThreadLocal来控制,如何保证性能和效率?

2. 带着问题研究ReentrantReadWriteLock

在开始研究ReentrantReadWriteLock以前,应当先了解两个概念:

重入性
一个很现实的问题是:咱们时常须要在锁中加锁。这多是由代码复用产生的需求,也可能业务的逻辑就是这样。
可是无论怎样,在一个线程已经获取锁后,在释放前再次获取锁是一个合理的需求,并且并不生硬。
上文在说独占锁时说到若是不考虑重入的状况,state会像boolean同样只有两个状态位。那么若是考虑重入,也很简单,在加锁时将state的值累加便可,表示同一个线程重入此锁的次数,当state归零,即表示释放完毕。

公平、非公平
这里的公平和非公平是指线程在获取锁时的机会是否公平。
咱们知道AQS中有一个FIFO的线程排队队列,那么若是全部线程想要获取锁时都来排队,你们先来后到井井有理,这就是公平;而若是每一个线程都不守秩序,选择插队,并且还插队成功了,那这就是不公平。
可是为何须要不公平呢?
由于效率。
有两个因素会制约公平机制下的效率:

  • 上下文切换带来的消耗
  • 依赖同步队列形成的消耗

咱们之因此会使用锁、使用并发,可能很大一部分缘由是想要挖掘程序的效率,那么相应的,对于性能和效率的影响须要更加敏感。
简单地说,上述的两点因为公平带来的性能损耗极可能让你的并发失去高效的初衷。
固然这也是和场景密切关联的,好比说你很是须要避免ABA问题,那么公平模式很适合你。
具体的再也不展开,能够参考这篇文章:深刻剖析ReentrantLock公平锁与非公平锁源码实现


回到咱们以前提的问题:

对于某个线程,当它先得到读锁,而后在执行代码的过程当中,又想得到写锁,这种状况是否应该容许?

咱们先考虑这种状况是否实际存在:假设咱们有一个对象,它有两个实例变量ab,咱们须要在实现:if a = 1 then set b = 2,或者换个例子,若是有个用户名字叫张三就给他打钱。

这看上去仿佛是个CAS操做,然而它并非,由于它涉及了两个变量,CAS操做并不支持这种针对多个变量的疑似CAS的操做。
为何不支持呢?由于cpu不提供这种针对多个变量的CAS操做指令(至少x86不提供),代码层面的CAS只是对cpu指令的封装而已。
为何cpu不支持呢?能够,但不必鄙人也不是特别清楚(逃)。

总而言之这种状况是存在的,可是在并发状况下若是不加锁就会有问题:好比先判断获得这个用户确实名叫张三,正准备打钱,忽然中途有人把他的名字改了,那再打这笔钱就有问题了,咱们判断名字和打钱这两个行为中间应当是没有空隙的。
那么为了保证这个操做的正确性,咱们或许能够在读以前加一个读锁,在我释放锁以前,其余人不得改变任何内容,这就是以前所说的读写互斥:读的期间不许写。
可是若是照着这个想法,就产生了自相矛盾的地方:都说了读期间不能写,那你本身怎么在写(打钱)呢?

若是咱们顺着这个思路去尝试解释“本身读的期间还在写”的行为的正当性,咱们也许能够设立一个规则:若是读锁是我本身持有,则我能够写。
然而这会出现其余的问题:由于读锁是共享的,你加了读锁,其余人仍然能够读,这是否会有问题呢?假如咱们的打钱操做涉及更多的值的改变,只有这些值所有改变完毕,才能说此时的总体状态正确,不然在改变完毕以前,读到的东西都有多是错的。
再去延伸这个思路彷佛会变得很是艰难,也许会陷入耦合的地狱。

可是实际上咱们不须要这样作,咱们只须要反过来使用读写互斥的概念便可:由于写写互斥(写锁是独占锁),因此咱们在执行这个先读后写的行为以前,加一个写锁,这一样能防止其余人来写,同时还能够阻止其余人来读,从而实现咱们在单线程中读写并存的需求。

这就是ReentrantReadWriteLock中一个重要的概念:锁降级

对于另外一个子问题:若是在已经获取写锁的期间还要再获取写锁的时候怎么办?
这种状况仍是很常见的,多数是因为代码的复用致使,不过相应的处理也很简单:对写锁这个独占锁增长容许单线程重入的规则便可。


极端状况下写操做根本没有机会上位,该如何解决这种状况?

若是咱们有两把锁,一把读锁,一把写锁,它们之间想要互通各自加锁的状况很简单——只要去get对方的state就好了。
可是只知道state是不够的,对于读的操做来讲,它若是只看到写锁没被占用,也无论有多少个写操做还在排队,就去在读锁上+1,那极可能发展成为问题所说的场景:写操做永远没机会上位。

那么咱们理想的状况应该是:读操做若是发现写锁空闲,最好再看看写操做的排队状况如何,酌情考虑放弃这一次竞争,让写操做有机会上位。
这也是我理解的,为何ReentrantReadWriteLock不设计成两个互相沟通的、独立的锁,而是公用一个锁(class Sync extends AbstractQueuedSynchronizer)——由于它们看似独立,实际上对于耦合的需求很大,它们不只须要沟通锁的状况,还要沟通队列的状况。

公用一个锁的具体实现是:使用int state的高16位表示读锁的state,低16位表示写锁的state,而队列公用的方式是给每一个节点增长一个标记,代表该节点是一个共享锁的节点(读操做)仍是一个独占锁的节点(写操做)。

上面说到的“酌情放弃这一次竞争”,ReentrantReadWriteLock中体如今boolean readerShouldBlock()这个方法里,这个方法有两个模式:公平非公平,咱们来稍微看一点源码
先看公平模式的实现

final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

当线程发现本身能够获取读锁时(写锁未被占用),会调用这个方法,来判断本身是否应该放弃这次获取。
hasQueuedPredecessors()这个方法咱们不去看源码,由于它的意思很显而易见(实际代码也是):是否存在排队中的线程(Predecessor先驱者能够理解为先来的)。
若是有,那就放弃竞争去排队。
在公平模式下,不管读写操做,只须要你们都遵照FIFO的秩序,就不会出现问题描述的状况

再来看看非公平模式下的实现代码:

final boolean readerShouldBlock() {  
    return apparentlyFirstQueuedIsExclusive();
}

final boolean apparentlyFirstQueuedIsExclusive() {
    // Node表示同步队列中的一个节点
    Node h, s;
    // head是当前队列的头节点的一个公共引用,它是一个没有实际意义的节点,null or not只能标识队列是否初始化过
    // next是当前节点的下一个节点的引用
    // isShared()方法代表这个节点是一个共享锁(读锁)的节点仍是独占锁(写锁)的节点
    return (h = head) != null &&
        (s = h.next)  != null &&
        !s.isShared()         &&
        s.thread != null;
}

总结一下语义:若是队列不为空,且队列最前面的节点是个独占锁的节点,则放弃竞争。也就是咱们上面说的“根据队列状况酌情放弃”。


如何控制读锁释放的权限?应该使用ThreadLocal吗?它会对性能形成影响吗?

通常而言的读操做的线程对于state的操做可能只是+1而后-1,而若是发生重入,那就会是n次+1而后n次-1。
可是无论怎样,每个线程都应当有一份记录本身持有共享锁数量的信息,这样释放锁的时候才能知道本身可不能够去-1。
这也许很简单,咱们能够在锁里增长一个Map对象,用相似tid(k)-count(v)的数据结构来记录每一个线程的持有数量;也能够为每一个线程建立一个ThreadLocal,让它们本身拿着。

如今咱们面前有两条路比较直观:将全部线程的小计数器维护在一个Map中,或是每一个线程在ThreadLocal中维护本身的小计数器。
就这两条途径而言,应该是Map的这一条路比较高效,由于若是选择ThreadLocal也许会频繁进行其内部的ThreadLocalMap对象的建立和销毁,这很消耗资源。

然而事实是,ReentranctReadWriteLock选择的实现方式是后者,即便用ThreadLocal来实现,可是为何选择这种方式正是我十分好奇的地方,由于根据经验,必定是利用Map统一管理小计数器的方式较为高效,且单个线程针对单个key的value进行+1或者-1的操做应该是知足as-if-serial原则的,也不存在安全问题。
所以针对两种不一样的实现方式进行了一些测试:四线程并行状况下一千万次加解锁时间测试

  • Map统一管理实现
public static void main(String[] args) {
    long total = 0;
    for (int i = 0; i < 30; i++) {
        total += execute();
    }
    System.out.println(total / 30);
}

private static long execute() {

    var map = new HashMap<Long, Integer>();
    var readerPool = new ThreadPoolExecutor(
            4,
            4,
            5L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),
            new CustomizableThreadFactory("readerPool"));

    var countDown = new CountDownLatch(10000000);
    for (int i = 0; i < 10000000; i++) {
        readerPool.execute(() -> {
            try {
                countDown.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
                return;
            }
            mapImplement(map);
        });
        countDown.countDown();
    }

    long startTime = System.currentTimeMillis();
    while (readerPool.getCompletedTaskCount() < 10000000) {
        LockSupport.parkNanos(100);
    }
    long total = System.currentTimeMillis() - startTime;

    System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total);
    return total;
}

private static void mapImplement(HashMap<Long, Integer> map) {
    // lock
    var tid = Thread.currentThread().getId();
    Integer count;
    if ((count = map.get(tid)) != null) {
        map.put(tid, count + 1);
    } else {
        map.put(tid, 1);
    }

    // unlock
    int afterDecrement = -999;
    if ((count = map.get(tid)) == null ||
            (afterDecrement = (count - 1)) < 0) {
        System.out.println("error, count: " + count + ", afterDecrement: " + afterDecrement);
        return;
    }
    map.put(tid, afterDecrement);
}

三十次测试过程的平均执行时间为:2378毫秒,我的认为这个结果仍是比较乐观的。

  • ThreadLocal各自持有实现
// 小计数器实体
static final class HoldCounter {
    int count;
}

// threadLocal
static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> {
    @Override
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}

public static void main(String[] args) {
    long total = 0;
    for (int i = 0; i < 30; i++) {
        total += execute();
    }
    System.out.println(total / 30);
}

private static long execute() {
    
    var readHolds = new ThreadLocalHoldCounter();
    var readerPool = new ThreadPoolExecutor(
            4,
            4,
            5L, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),
            new CustomizableThreadFactory("readerPool"));

    var countDown = new CountDownLatch(10000000);
    for (int i = 0; i < 10000000; i++) {
        readerPool.execute(() -> {
            try {
                countDown.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
                return;
            }
            threadLocalImplement(readHolds);
        });
        countDown.countDown();
    }

    long startTime = System.currentTimeMillis();
    while (readerPool.getCompletedTaskCount() < 10000000) {
        LockSupport.parkNanos(100);
    }
    long total = System.currentTimeMillis() - startTime;

    System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total);
    return total;
}

private static void threadLocalImplement(ThreadLocalHoldCounter readHolds) {
    // lock
    var hc = readHolds.get();
    ++hc.count;

    // unlock
    hc = readHolds.get();
    --hc.count;
    if (hc.count == 0) {
        readHolds.remove();
    }
}

三十次测试过程的平均执行时间为:3079毫秒

能够看到,使用Map集中管理小计数器的实现方式的执行效率要比ThreadLocal的实现方式快20%以上。

难道我做为一个Java萌新都能想到的性能差距,Doug Lea这样的大神会想不到吗?

固然不会

实际上,ReentranctReadWriteLock在针对小计数器的具体实现上,增长了相似两层缓存的设计,大概以下:

// ThreadLocal对象本体
private transient ThreadLocalHoldCounter readHolds;

// 二级缓存:最近一次获取锁的线程所持有的小计数器对象的引用
private transient HoldCounter cachedHoldCounter;

// 一级缓存:首次获取锁的线程的线程对象引用以及它的计数
private transient Thread firstReader;
private transient int firstReaderHoldCount;

当线程尝试获取锁时,会执行以下的流程:

  1. 判断当前共享锁总计数器是否为0(当前锁处于空闲状态) firstReader == Thread.currentThread()
    是: 则直接在firstReaderHoldCount上进行+1(以及执行firstReader = Thread.currentThread()
    否: 前往2
  2. 判断cachedHoldCounter.tid == Thread.currentThread().getId()
    是: 则直接在cachedHoldCounter.count上进行+1
    否: 前往3
  3. 执行readHolds.get()进行获取或初始化,而后再对小计数器进行操做

当线程释放锁时,执行流程也大体类似,都是先对两级缓存进行尝试,逼不得已再去对ThreadLocal进行操做。
因为读操做的实际执行内容通常至关简单(相似return a),因此在绝大多数状况下,线程的加解锁行为都会命中一级缓存

我尝试在ReentranctReadWriteLock的加解锁行为内埋了几个计数点来测试两级缓存的命中率,四线程并行1000万次加解锁操做,结果是:

  • 一级缓存命中率大概为90~95%
  • 二级缓存命中率大概为5~10%
  • ThreadLocal本体命中率大概为1~5%

而执行效率,进行1000万次加解锁,循环三十次获得的平均执行时间是:2027毫秒
比上面提到的使用Map实现的方式更要快了15%左右。

虽然说上面的小测试的编码也好,测试环境也好,都不算特别严谨,可是仍是能很是直观地说明问题的

3. ReentranctReadWriteLock实际设计概览

clipboard.png

如图,ReentranctReadWriteLock中有五个内部类:

  • Sync
    Sync继承自AbstractQueuedSynchronizer,上文提到的volatile int state以及同步队列的实际实现都是由AbstractQueuedSynchronizer这个抽象类提供的,它还提供了一些在锁的性质不一样时实现也会不一样的可重写方法,Sync须要作的事情就是将这些通用的方法和规则加以实现和扩充,造成本身想要实现的锁。
    Sync也是咱们上文提到的,同时实现了读写两种性质的锁的根本。
    另外,上文提到的关于分段使用state、利用公平性避免机会不均衡的问题、分级缓存共享锁小计数器等特性,均在此类中实现,须要特别关注。
  • NonfairSyncFairSync
    这两个类都继承自Sync,它们提供了由Sync定义的两个用于进行公平性判断的方法:boolean writerShouldBlock()boolean readerShouldBlock()。实际使用ReentranctReadWriteLock时,咱们会经过构造方法选择须要构造公平仍是非公平的锁,相应的会经过这两个子类构造实际的Sync类的对象,从而影响到加解锁过程当中的一些判断。
  • ReadLockWriteLock
    这两个类都会持有上面提到的Sync类的对象的引用,并向用户(使用者)提供包装好但实现不一样的操做,好比:

    • 读锁获取

      public void lock() {
          sync.acquireShared(1);
      }
    • 写锁获取

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

更多的源码就再也不赘述了,搜索一下就会有很是多的文章解读源码并不是做者懒得贴了

后记

juc这套并发框架的设计者和创始人Doug Lea,能够说是java开发者金字塔顶端的巨佬之一了,他所编写的juc包的代码,不管是代码结构的合理性、各类设计模式的使用、代码的优雅程度都使人叹为观止。看完源码以为整我的都升华了

笔者学习了源码以后,以为在面对这些充满了考虑的设计细节时产生的思考,才是真正可使人获得长远的提高的东西。

所以整理出来,做为心得体会的记录。若是能够对其余小伙伴带来启发,那就更好了。最后,若是文章内有什么纰漏或是错误,还请务必指正,再次感谢。

相关文章
相关标签/搜索