读写锁使用与原理

168 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第4天,点击查看活动详情

使用

在业务开发过程中,我们对数据的操作大多时读多写锁的场景,但是当某个数据变更时,此时该数据不允许被其他线程进行读写,只能等待线程写操作完成后,其他线程才能进行读写;

  final Map<K, V> m =
    new HashMap<>();
  final ReadWriteLock rwl =
    new ReentrantReadWriteLock();
  // 读锁
  final Lock r = rwl.readLock();
  // 写锁
  final Lock w = rwl.writeLock();
  // 读缓存
  V get(K key) {
    r.lock();
    try { return m.get(key); }
    finally { r.unlock(); }
  }
  // 写缓存
  V put(K key, V value) {
    w.lock();
    try { return m.put(key, v); }
    finally { w.unlock(); }
  }
}
使用规则
  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

原理

分析规则

首先读写互斥,那么必然存在一个共享变量进行cas操作保证互斥;同一时刻只允许一个线程进行写操作,其他线程需要等待,必然存在一个队列保存处于等待的线程.这个思想其实与AQS一致; 所以现在结合AQS来分析其实现;

获取读锁
protected final int tryAcquireShared(int unused) {
    /*
     * Walkthrough:
     * 1. If write lock held by another thread, fail.
     * 2. Otherwise, this thread is eligible for
     *    lock wrt state, so ask if it should block
     *    because of queue policy. If not, try
     *    to grant by CASing state and updating count.
     *    Note that step does not check for reentrant
     *    acquires, which is postponed to full version
     *    to avoid having to check hold count in
     *    the more typical non-reentrant case.
     * 3. If step 2 fails either because thread
     *    apparently not eligible or CAS fails or count
     *    saturated, chain to version with full retry loop.
     */
    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 != getThreadId(current))
                cachedHoldCounter = rh = readHolds.get();
            else if (rh.count == 0)
                readHolds.set(rh);
            rh.count++;
        }
        return 1;
    }
    return fullTryAcquireShared(current);
}

state
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count  */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

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;

state在共享锁模式下,被分为两部分,高16位代表读锁次数,低16位代表获取写锁次数(包含重入次数);

抢占逻辑
  1. 抢占成功,累加state中读锁次数.记录当前线程重入次数(采用ThreadLocal线程隔离的思想保存HoldCounter)
  2. 抢占不成功,加入到clh队列中;

image.png

firstReader 如何能保证始终是一个获取读锁的线程

关于firstReader的可见性从这么几个方面来分析: 前置条件state=0;

  1. 当线程t1先进行cas操作成功时,那么此时state必定大于0;
  2. 线程t2进行通过原值为o去cas操作时必定失败; 其实存在三条happens-before规则;
  3. 程序顺序性规则 state=0 在cas 之前;
  4. volatile变量规则,对volatile的写操作happens-before后续的读操作
  5. 传递性规则 A happens-before B,B happens-before C,那么A happens-before C;
抢占写锁逻辑

抢占写锁与互斥锁逻辑一致不在进行分析;

特殊场景

假设存在三个线程进行抢占读写锁;t1获取读锁成功,t2获取写锁,t3获取读锁 初始状态: image.png

公平锁模式

线程t3抢占读锁

static final class FairSync extends Sync {
    private static final long serialVersionUID = -2274990926593161451L;
    final boolean writerShouldBlock() {
        return hasQueuedPredecessors();
    }
    final boolean readerShouldBlock() {
        return hasQueuedPredecessors();
    }
}

再公平锁模式下,只要队列中存在等待的node节点,那么该线程就进入clh队列,阻塞并等待唤醒

image.png

非公平锁模式
static final class NonfairSync extends Sync {
    private static final long serialVersionUID = -8159625535654395037L;
    final boolean writerShouldBlock() {
        return false; // writers can always barge
    }
    final boolean readerShouldBlock() {
        /* As a heuristic to avoid indefinite writer starvation,
         * block if the thread that momentarily appears to be head
         * of queue, if one exists, is a waiting writer.  This is
         * only a probabilistic effect since a new reader will not
         * block if there is a waiting writer behind other enabled
         * readers that have not yet drained from the queue.
         */
        return apparentlyFirstQueuedIsExclusive();
    }
}

当且仅当第一个node节点是独占模式的线程时才被阻塞;这么做的目的是避免抢占锁的线程出现线程饥饿;试想一下:其他线程频繁获取读锁不释放,导致获取写锁的线程一直无法被唤醒,进而造成线程饥饿死锁''

线程饥饿

线程饥饿是一种因为长期无法获取到共享资源或CPU而导致线程无法被执行的现象''

锁升级

先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜 ReadWriteLock 并不支持这种升级;

原因:

一个线程获取到读锁,然后又获取写锁,那么其他获取读锁的线程势必也能进行,那么这样会造成同时获取写锁的线程存在多个,与写锁线程互斥的原则相违背;

锁降级

先是获取写锁,然后再降级为读锁;

class CachedData {
  Object data;
  volatile boolean cacheValid;
  final ReadWriteLock rwl =
    new ReentrantReadWriteLock();
  // 读锁  
  final Lock r = rwl.readLock();
  //写锁
  final Lock w = rwl.writeLock();
  
  void processCachedData() {
    // 获取读锁
    r.lock();
    if (!cacheValid) {
      // 释放读锁,因为不允许读锁的升级
      r.unlock();
      // 获取写锁
      w.lock();
      try {
        // 再次检查状态  
        if (!cacheValid) {
          data = ...
          cacheValid = true;
        }
        // 释放写锁前,降级为读锁
        // 降级是可以的
        r.lock(); ①
      } finally {
        // 释放写锁
        w.unlock(); 
      }
    }
    // 此处仍然持有读锁
    try {use(data);} 
    finally {r.unlock();}
  }
}

从上述代码可以看出,先获取写锁成功后,在未释放写锁时获取读锁也能成功; 与之相对应的源码为:


protected final int tryAcquireShared(int unused) {

if (exclusiveCount(c) != 0 &&
    getExclusiveOwnerThread() != current)
    return -1;
}

当写锁次数大于0说明存在获取写锁的线程,当获取线程的线程与当前线程相等时才进行下一步操作,否则获取读锁失败;