很“抠”性能的 ReadWriteLock

855 阅读12分钟

这是我参与8月更文挑战的第2天,活动详情查看:8月更文挑战

源码版本:OpenJDK 11

读写锁

之前分析过并发场景下高频使用的 Reentrantlock 源码解析 (juejin.cn),该类实现 Lock接口,以达到独占获取的语义。

而这次分析ReentrantReadWriteLock 实现的是 ReadWriteLock接口。

ReadWriteLock 我理解为是 Lock在特定场景下的扩展,当然我们都知道这个场景就是读多写少。在读多写少的场景下,如果依旧是独占式获取资源,很显然会出现性能瓶颈。

ReadWriteLock所表达的读写锁语义是在同一时刻允许被多个读访问,但是如果是写访问,则其他的写与读都会被阻塞image-20210803102404332

至于为啥说ReadWriteLockLock在读多写少场景下的扩展,因为接口唯二的两个方法的返回值都是 Lock啊,果然锁的大爹还得是 Lock

public interface ReadWriteLock {
    Lock readLock();
    Lock writeLock();
}

读写锁的用处

比较典型读多写少场景想来应该是缓存了。项目里面使用缓存主要是为了提高访问某些资源的速度,常见的就是应用和数据库之间加一个缓存。

但是直接使用缓存会带来某些不一致的问题:

  • 不同的缓存更新策略会带来不同的一致性问题

    (这里列出了常见的缓存更新策略,有兴趣的可以自行查一下)

    • Cache Aside Pattern(旁路缓存模式)
    • Read/Write Through Pattern(读写穿透)
    • Write Behind Pattern(异步缓存写入
  • 缓存失效时,可能大量的读请求落到数据库击穿,造成缓存击穿

如果是对一致性要求比较严格的情况,可以考虑使用读写锁。或避免缓存击穿,但是一般典型情况下可能只在设置缓存的节点直接使用独占锁。

写个假缓存的demo

public class ReadWriteLockDemo {
    private Integer cache;
    private ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public Integer getFromCache() {
        lock.readLock().lock();
        System.out.println(Thread.currentThread().getName() + ":开始读取缓存");
        Integer res = cache;
        System.out.println(Thread.currentThread().getName() + ":结束读取缓存");
        lock.readLock().unlock();
        if (cache == null) {
            return loadCache();
        }
        return cache;
    }

    private Integer loadCache() {
        lock.writeLock().lock();
        Integer val;
        if (cache == null) {
            System.out.println(Thread.currentThread().getName() + ":开始加载数据库");
            // get from database...
            _load();
            val = new Random(5).nextInt();
            cache = val;
            System.out.println(Thread.currentThread().getName() + ":结束加载数据库");
            lock.writeLock().unlock();
        } else {
            lock.readLock().lock();
            lock.writeLock().unlock();
            System.out.println(Thread.currentThread().getName() + ":开始读取缓存");
            val = cache;
            System.out.println(Thread.currentThread().getName() + ":结束读取缓存");
            lock.readLock().unlock();
        }
        return val;
    }

    private void _load() {
        // 模拟读写数据库的耗时
        try {
            TimeUnit.SECONDS.sleep(1L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void setCache(Integer val) {
        lock.writeLock().lock();
        System.out.println(Thread.currentThread().getName() + ":开始写缓存");
        // write to database...
        _load();
        cache = val;
        System.out.println(Thread.currentThread().getName() + ":结束写缓存");
        lock.writeLock().unlock();
    }
}

并发读

// 默认缓存已生效
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.getFromCache());
// 输出
pool-1-thread-1:开始读取缓存
pool-1-thread-2:开始读取缓存
pool-1-thread-1:结束读取缓存
pool-1-thread-3:开始读取缓存
pool-1-thread-2:结束读取缓存
pool-1-thread-3:结束读取缓存

可以看到并发的读可以同时进行,并不会相互阻塞。

读写阻塞

// 默认缓存已生效
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.loadCache());
executorService.execute(() -> cache.getFromCache());
executorService.execute(() -> cache.getFromCache());
// 输出
pool-1-thread-2:开始读取缓存
pool-1-thread-4:开始读取缓存
pool-1-thread-4:结束读取缓存
pool-1-thread-2:结束读取缓存
pool-1-thread-3:开始读取缓存
pool-1-thread-3:结束读取缓存
// 写缓存
pool-1-thread-5:开始加载数据库
pool-1-thread-5:结束加载数据库
// 写完才能继续读
pool-1-thread-6:开始读取缓存
pool-1-thread-6:结束读取缓存
pool-1-thread-7:开始读取缓存
pool-1-thread-7:结束读取缓存

可以看出线程 2、3、4 都结束以后,才能去执行线程 5 写缓存;而线程 5 结束以后,才能开始读缓存。

这应该足够说明读写操作是相互阻塞的。

并发写

executorService.execute(() -> cache.loadCache());
executorService.execute(() -> cache.loadCache());
executorService.execute(() -> cache.loadCache());
// 输出
pool-1-thread-1:开始加载数据库
pool-1-thread-1:结束加载数据库
pool-1-thread-2:开始读取缓存
pool-1-thread-2:结束读取缓存
pool-1-thread-3:开始读取缓存
pool-1-thread-3:结束读取缓存

在缓存失效的情况下,可以做持有写锁的 double check,避免大量并发读转为串行的写锁。

锁降级

锁降级是指在持有写锁的情况下,可以去获取读锁,然后释放写锁;那么当前线程就变成仅持有读锁。

lock.writeLock().lock();
writeCache();
lock.readLock().lock();
lock.writeLock().unlock();
readCache();
doSomeThing();
lock.readLock().unlock();

其目的是在避免前后执行过程中,缓存的不一致性的同时,保证并发性能。

如果没有锁降级的话,要么释放写锁后,再获取读锁,那么线程前后不一定能取到刚设置的值;要么一直持有写锁直到执行结束,那么其他读就会被阻塞。

image-20210804185618410

而如果支持锁降级的话,那么上述提到的问题就能得到有效解决。

image-20210803193421696

不支持锁升级

Java 的读写锁是不支持锁升级的,就是说,在持有读锁的情况下,是无法再去获取写锁的。

原因在于,读是并发的,在同一时间可以有多个线程持有读锁;当这些线程去尝试获取写锁时,却只有一个线程能获得写锁。而不同线程的读写锁肯定是相互阻塞的,所以一个线程这样的情况下必须要求其他线程释放读锁才能获取写锁;当然其他线程也不会释放读锁,同样也在尝试获取写锁,结果就会导致死锁。

ReentrantReadWriteLock 源码分析

ReentrantReadWriteLock 是 Java 中读写锁的实现,该类实现了 ReadWriteLock接口,提供了实现Lock的读写锁;而 Java 中并发工具的大爹 AQS 肯定也是不会缺席的,读写锁依赖 AQS 作为根基实现了锁的能力。

ReadWriteLock-第 3 页

Lock 体系

上面提到过 ReentrantReadWriteLock 实现了 ReadWriteLock 接口,并依赖 AQS 再实现读写锁(公平或不公平模式)。

ReadWriteLock-第 4 页

public class ReentrantReadWriteLock implements ReadWriteLock {
    public ReentrantReadWriteLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
        readerLock = new ReadLock(this);
        writerLock = new WriteLock(this);
    }
    public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
    public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }
    public static class ReadLock implements Lock {
        private final Sync sync;
    }
    public static class WriteLock implements Lock {
        private final Sync sync;
    }
    abstract static class Sync extends AbstractQueuedSynchronizer {}
}

依赖 AQS 实现锁,相信熟悉 AQS 和 ReentrantLock的同学应该都知道怎么回事,不熟悉就看看 Reentrantlock 源码解析 (juejin.cn)AbstractQueuedSynchronizer(AQS):并发工具的基石 (juejin.cn)

因为 Lock的实现一般都是如同 ReentrantLock,直接代理给 AQS 的实现类;所以本次就不再关注WriteLockReadLock的源码。直击 AQS 的子类 Sync,探究是怎么在一个临界资源上实现两个锁,两个锁的公平模式和非公平模式又是如何?

补充一点,ReadLock是不支持 Condition 的,毕竟本身是共享锁,只会因为写锁而阻塞,处理好写锁与读锁的协调即可。

高低分界,读写一体

AQS 的临界资源 state类型为 int,需要同时代表读锁和写锁。为了防止冲突,这里用了比较巧妙的设计:将 int 的 32 位分为高低两部分,高 16 位代表读锁,低 16 位代表写锁;读写锁相关操作(也就是 AQS 的独占和共享操作)时,分别操作不同位置的值。

static final int SHARED_SHIFT   = 16;// 读锁(共享)的移位
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);// 读锁(共享)的增量单位:00000000 00000001 00000000 00000000
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;// 读锁(共享)的最大获取数量 或 写锁(独占)的最大可重入次数
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;// 写锁(独占)的掩码:00000000 00000000 11111111 111111111

所以统计写锁(独占)和读锁(共享)的资源时,就需要排除其他锁的干扰信息:

/** Returns the number of shared holds represented in count. */
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }// 丢弃低 16 位的写锁信息
/** Returns the number of exclusive holds represented in count. */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }// 丢弃高 16 位的读锁信息,因为写锁掩码高 16 都是 0

ReadWriteLock-第 5 页

获取读锁,追求性能

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);
    // 这里先进行一个简单的尝试, 认为一般情况下都是无锁竞争(CAS 不失败、获取锁不排队)、非大并发(锁数量不超过最大值)、无重入的场景
    // 是否阻塞, 取决于是否为公平模式。一般存在多线程竞争,公平模式下就需要排队了
    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 != LockSupport.getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    // 走到这里,认为是多线程竞争的情况, 走更加复杂的逻辑
    return fullTryAcquireShared(current);
}

读写锁的实现中,充斥了作者大佬对性能的追求。不论是锁数量记录,还是锁的获取,都认为一般情况下,都是不并发、无竞争的场景,优先采用更简单的方式去处理来避免不必要的性能浪费。

锁数量记录操作的性能优化

Sync会记录每个线程持有读锁的数量(当然不用记录写锁,毕竟 only one),本身是为了支持 ReentrantReadWriteLock返回当前线程持有读锁的数量getReadHoldCount

static final class HoldCounter {
    int count;          // initially 0
    // Use id, not reference, to avoid garbage retention
    final long tid = LockSupport.getThreadId(Thread.currentThread());
}
static final class ThreadLocalHoldCounter
    extends ThreadLocal<HoldCounter> {
    public HoldCounter initialValue() {
        return new HoldCounter();
    }
}
private transient ThreadLocalHoldCounter readHolds;

但是Sync为了避免无锁竞争时还要额外操作 ThreadLocal(因为内部还有新增 Entry、清除 Entry的操作,具体可见把 ThreadLocal 拆开揉碎了看看 (juejin.cn)),在内部冗余了两个数量记录:

  • 第一个获取读锁的线程和持有的读锁数量,只有当这个线程释放锁之后,才会置为 null;

    private transient Thread firstReader;
    private transient int firstReaderHoldCount;
    
  • 最后一次获取读锁的线程和持有的读锁数量

    private transient HoldCounter cachedHoldCounter;
    

每次记录数量时,都会优先进行判断当前线程是否为 firstReadercachedHoldCounter他两一定代表两个不一样的线程,只有出现firstReader之外的线程获取读锁时,才会记录到 cachedHoldCounter ),来直接操作数量。虽然在并发竞争时多了一些判断损耗,但是在那样的情况下,这种消耗是比较小的;而且这种优化在无竞争时带来的提升是明显的。

后续很多操作都是有和 tryAcquireShared类似的记录逻辑,就不再一一展开了。

这里获取写锁,如果没有竞争情况,直接可以根据 CAS 获取,否则就走下面的逻辑:

final int fullTryAcquireShared(Thread current) {
    HoldCounter rh = null;
    for (;;) {// 读锁共享, 又不允许超标, 肯定不会排队, 所以一直重试
        int c = getState();
        if (exclusiveCount(c) != 0) {
            if (getExclusiveOwnerThread() != current)
                // 只有持有写锁的线程,才能锁降级
                return -1;
            // 持有写锁, 就不管阻塞了, 否则就死锁了
            // A 持有写锁, B 获取读锁被 A 阻塞; 
            // A 获取写锁被 B 的排队阻塞, 无法释放写锁, 最终死锁
        } else if (readerShouldBlock()) {
            // Make sure we're not acquiring read lock reentrantly
            if (firstReader == current) {
                // 说明当前线程是第一个获取读锁的线程且从未释放, 这次是重入, 不管阻塞可以继续
            } else {
                if (rh == null) {
                    rh = cachedHoldCounter;
                    if (rh == null ||
                        rh.tid != LockSupport.getThreadId(current)) {
                        rh = readHolds.get();
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                if (rh.count == 0)
                    // 不是重入, 会被阻塞
                    return -1;
            }
        }
        if (sharedCount(c) == MAX_COUNT)
            // 超标是不排队的, 直接报错
            throw new Error("Maximum lock count exceeded");
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // 省略记录数量的逻辑
            return 1;
        }
    }
}

这个方法会处理可重入的情况,这种时候下对于读锁阻塞要有特殊处理。

还有就是不同于其他的锁或者 AQS 的原生语义,当资源不足的时候居然不排队等待资源释放,而是直接报错。

读锁共享, 又不允许超标, 肯定不会排队, 所以一直重试

这也和读写一体有关系,一会给你答案。

还有一种 tryReadLock 失败直接返回,不会被阻塞,所以单独的方法,和上面唯一的不同就是没有对readerShouldBlock()的判断和处理。ps:WriteLock 同理

final boolean tryReadLock() {
    Thread current = Thread.currentThread();
    for (;;) {
        int c = getState();
        if (exclusiveCount(c) != 0 &&
            getExclusiveOwnerThread() != current)
            return false;
        int r = sharedCount(c);
        if (r == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // 省略记录数量的逻辑
            return true;
        }
    }
}

读锁释放,可 0 不可超

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    if (firstReader == current) {
        // assert firstReaderHoldCount > 0;
        if (firstReaderHoldCount == 1)
            // 第一个获取的线程释放锁, 就不再记录了
            firstReader = null;
        else
            firstReaderHoldCount--;
    } else {
         // 省略记录数量的逻辑
    }
    for (;;) {
        int c = getState();
        int nextc = c - SHARED_UNIT;
        if (compareAndSetState(c, nextc))
            // 按照一般情况, 返回 true 代表有新的资源, 但是读写一体的情况下, 很难区分读锁资源和写锁资源
            // 所以在 tryAcquireShared 中, 不允许读锁资源不足时等待.
            // 那这里直接就代表写锁可用了
            return nextc == 0;
    }
}

结合上面不允许读锁资源不足时等待,那这边tryReleaseShared return true 就只能代表读锁为 0,不阻塞写锁了。

否则就没有办法搞定 readLock = 0readLock <= MAX_COUNT了。

写锁获取,老生常谈

protected final boolean tryAcquire(int acquires) {
    Thread current = Thread.currentThread();
    int c = getState();
    int w = exclusiveCount(c);
    if (c != 0) {
        // c != 0, w 代表的写锁 = 0, 那不就是读锁 != 0 嘛
        if (w == 0 || current != getExclusiveOwnerThread())
            // 写锁唯一
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            // 写锁也不允许超标
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
        // 可重入能获取成功, 一定是无读锁(即高16 bit = 0), 所以 c 可以直接 + acquires, 不用拿 state
        setState(c + acquires);
        return true;
    }
    // 会存在读锁的排队或竞争阻塞, 或写锁的竞争
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

相比较而言,写锁因为独占的特性,又排斥读锁,就是典型的可重入独占锁的模式,就比较好理解了。

写锁释放,万事皆空

那就更简单了,直接全部 = 0,万事皆空。

protected final boolean tryRelease(int releases) {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    int nextc = getState() - releases;
    boolean free = exclusiveCount(nextc) == 0;
    if (free)
        setExclusiveOwnerThread(null);
    setState(nextc);
    return free;
}

看到这里,一切都讲得差不多了。但是仔细看看,还有个东西没讲,那就是公平和非公平下,不同的读写阻塞策略。

abstract boolean readerShouldBlock();
abstract boolean writerShouldBlock();

公平锁:众生平等

static final class FairSync extends Sync {
    final boolean writerShouldBlock() {
        return hasQueuedPredecessors();
    }
    final boolean readerShouldBlock() {
        return hasQueuedPredecessors();
    }
}

总的来说,公平模式下,只要有锁请求排队,就让路。因为读锁会循环获取,且不允许超标,所以一般就是写锁请求或之前持有写锁导致的读锁请求在排队;

非公平锁:写锁都是爷

static final class NonfairSync extends Sync {
    final boolean writerShouldBlock() {
        return false; // writers can always barge
    }
    final boolean readerShouldBlock() {
        return apparentlyFirstQueuedIsExclusive();
    }
}

总的来说,写锁都是爷,豪横滴很。

写锁之间你争我抢;读锁只要撞见写锁排队(排队队头刚好是写锁请求),就装孙子让路。

这点和 MySQL 的表锁机制也是类似的,写锁优先级高于读锁。

总结

这玩意门道还真的多,读写一体,读锁的性能追求,还是非公平锁的“爷”,学废了学废了。

希望这篇文章对大伙有帮助,求个三连😁😁😁😁

源码注释:openjdk11/ReentrantReadWriteLock.java at 545d26c92a833ac66476b33a7cfc78d5be5ad5ba · SaltyFishInJiang/openjdk11 (github.com)