并发编程之——读锁源码分析(解释关于锁降级的争议)

1. 前言

在前面的文章 并发编程之——写锁源码分析中,我们分析了 1.8 JUC 中读写锁中的写锁的获取和释放过程,今天来分析一下读锁的获取和释放过程,读锁相比较写锁要稍微复杂一点,其中还有一点有争议的地方——锁降级

今天就来解开迷雾。

2. 获取读锁 tryAcquireShared 方法

首先说明,获取读锁的过程是获取共享锁的过程。

代码加注释如下:

protected final int11 tryAcquireShared(int unused) {

    Thread current = Thread.currentThread();
    int c = getState();
    // exclusiveCount(c) != 0 ---》 用 state & 65535 得到低 16 位的值。如果不是0,说明写锁别持有了。
    // getExclusiveOwnerThread() != current----> 不是当前线程
    // 如果写锁被霸占了,且持有线程不是当前线程,返回 false,加入队列。获取写锁失败。
    // 反之,如果持有写锁的是当前线程,就可以继续获取读锁了。
    if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
        // 获取锁失败
        return -1;
    // 如果写锁没有被霸占,则将高16位移到低16位。
    int r = sharedCount(c);// c >>> 16
    // !readerShouldBlock() 和写锁的逻辑一样(根据公平与否策略和队列是否含有等待节点)
    // 不能大于 65535,且 CAS 修改成功
    if (!readerShouldBlock() && r < 65535 && compareAndSetState(c, c + 65536)) {
        // 如果读锁是空闲的, 获取锁成功。
        if (r == 0) {
            // 将当前线程设置为第一个读锁线程
            firstReader = current;
            // 计数器为1
            firstReaderHoldCount = 1;

        }// 如果读锁不是空闲的,且第一个读线程是当前线程。获取锁成功。
         else if (firstReader == current) {// 
            // 将计数器加一
            firstReaderHoldCount++;
        } else {// 如果不是第一个线程,获取锁成功。
            // cachedHoldCounter 代表的是最后一个获取读锁的线程的计数器。
            HoldCounter rh = cachedHoldCounter;
            // 如果最后一个线程计数器是 null 或者不是当前线程,那么就新建一个 HoldCounter 对象
            if (rh == null || rh.tid != getThreadId(current))
                // 给当前线程新建一个 HoldCounter
                cachedHoldCounter = rh = readHolds.get();
            // 如果不是 null,且 count 是 0,就将上个线程的 HoldCounter 覆盖本地的。
            else if (rh.count == 0)
                readHolds.set(rh);
            // 对 count 加一
            rh.count++;
        }
        return 1;
    }
    // 死循环获取读锁。包含锁降级策略。
    return fullTryAcquireShared(current);
}

总结一下上面代码的逻辑吧!

  1. 判断写锁是否空闲。
  2. 如果不是空闲,且当前线程不是持有写锁的线程,则返回 -1 ,表示抢锁失败。如果是空闲的,进入第三步。如果是当前线程,进入第三步。
  3. 判断持有读锁的数量是否超过 65535,然后使用 CAS 设置 int 高 16 位的值,也就是加一。
  4. 如果设置成功,且是第一次获取读锁,就设置 firstReader 相关的属性(为了性能提升)。
  5. 如果不是第一次,当当前线程就是第一次获取读锁的线程,对 “第一次获取读锁线程计数器” 加 1.
  6. 如果都不是,则获取最后一个读锁的线程计数器,判断这个计数器是不是当前线程的。如果是,加一,如果不是,自己创建一个新计数器,并更新 “最后读取的线程计数器”(也是为了性能考虑)。最后加一。返回成功。
  7. 如果上面的判断失败了(CAS 设置失败,或者队列有等待的线程(公平情况下))。就调用 fullTryAcquireShared 方法死循环执行上面的步骤。

步骤还是有点多哈,画个图吧,更清晰一点。

大图地址:https://upload-images.jianshu.io/upload_images/4236553-c747934c55844272.png?imageMogr2/auto-orient/

 

其实,上面的逻辑里,是有锁降级的逻辑在里面的。但我们等会放在后面说。

 

先看看 fullTryAcquireShared 方法,其实这个方法和 tryAcquireShared 高度类似。代码加注释如下:

final int fullTryAcquireShared(Thread current) {
    /*
     * 这段代码与tryAcquireShared中的代码有部分重复,但整体更简单。
     */
    HoldCounter rh = null;
    // 死循环
    for (;;) {
        int c = getState();
        // 如果存在写锁
        if (exclusiveCount(c) != 0) {
            // 并且不是当前线程,获取锁失败,反之,如果持有写锁的是当前线程,那么就会进入下面的逻辑。
            // 反之,如果存在写锁,但持有写锁的是当前线程。那么就继续尝试获取读锁。
            if (getExclusiveOwnerThread() != current)
                return -1;
        // 如果写锁空闲,且可以获取读锁。
        } else if (readerShouldBlock()) {
            // 第一个读线程是当前线程
            if (firstReader == current) {
            // 如果不是当前线程
            } else {
                if (rh == null) {
                    rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current)) {
                        // 从 ThreadLocal 中取出计数器
                        rh = readHolds.get();
                        if (rh.count == 0)
                            readHolds.remove();
                    }
                }
                if (rh.count == 0)
                    return -1;
            }
        }
        // 如果读锁次数达到 65535 ,抛出异常
        if (sharedCount(c) == MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // 尝试对 state 加 65536, 也就是设置读锁,实际就是对高16位加一。
        if (compareAndSetState(c, c + SHARED_UNIT)) {
            // 如果读锁是空闲的
            if (sharedCount(c) == 0) {
                // 设置第一个读锁
                firstReader = current;
                // 计数器为 1
                firstReaderHoldCount = 1;
            // 如果不是空闲的,查看第一个线程是否是当前线程。
            } else if (firstReader == current) {
                firstReaderHoldCount++;// 更新计数器
            } else {// 如果不是当前线程
                if (rh == null)
                    rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))// 如果最后一个读计数器所属线程不是当前线程。
                    // 自己创建一个。
                    rh = readHolds.get();
                else if (rh.count == 0)
                    readHolds.set(rh);
                // 对计数器 ++
                rh.count++;
                // 更新缓存计数器。
                cachedHoldCounter = rh; // cache for release
            }
            return 1;
        }
    }
}

这两个方法其实高度相似的。就不再解释了。

到这里,其实留下了几个问题:一个是 firstReader和 firstReaderHoldCount 的作用,还有就是 cachedHoldCounter 的作用。最后是锁降级。

解释一下:

  • firstReader 是获取读锁的第一个线程。如果只有一个线程获取读锁,很明显,使用这样一个变量速度更快。
  • firstReaderHoldCount是 firstReader的计数器。同上。
  • cachedHoldCounter是最后一个获取到读锁的线程计数器,每当有新的线程获取到读锁,这个变量都会更新。这个变量的目的是:当最后一个获取读锁的线程重复获取读锁,或者释放读锁,就会直接使用这个变量,速度更快,相当于缓存。

关于锁降级,重点解释一下,毕竟是我们的标题。

3. 锁降级的争议

首先,什么是锁降级?在读锁的哪个地方体现?

回答第一个问题,引自 JDK 的解释:

锁降级:
重入还允许从写入锁降级为读取锁,其实现方式是:先获取写入锁,然后获取读取锁,最后释放写入锁。但是,从读取锁升级到写入锁是不可能的。

体现在读锁哪里?

 

在 tryAcquireShared 方法和 fullTryAcquireShared 中都有体现,例如下面的判断:

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

上面的代码的意思是:当写锁被持有时,如果持有该锁的线程不是当前线程,就返回 “获取锁失败”,反之就会继续获取读锁。称之为锁降级。

在很多书和文章中,对锁降级都会有类似下面的解释:

上面提到,锁降级中,读锁的获取的目的是 “为了保证数据的可见性”。而得到这个结论的依据是 “如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程 T)获取了写锁并修改了数据,那么当前线程无法感知线程 T 的数据更新”。

这里貌似有个漏洞:如果另一个线程获取了写锁(并修改了数据),那么这个锁就被独占了,没有任何其他线程可以读到数据,更不用谈 “感知数据更新”。

楼主认为,锁降级说白了就是写锁的一种特殊重入机制。通过这种重入,可以减少一步流程——释放写锁后 再次 获取读锁。

使用了锁降级,就可以减去释放写锁的步骤。直接获取读锁。效率更高。而且没有线程争用。和 “可见性” 并没有关系。

用一幅图来展示锁降级:

大图地址:https://upload-images.jianshu.io/upload_images/4236553-f545a504abde8c2f.png?imageMogr2/auto-orient/

 

总的来说,锁降级就是一种特殊的锁重入机制,JDK 使用 先获取写入锁,然后获取读取锁,最后释放写入锁 这个步骤,是为了提高获取锁的效率,而不是所谓的可见
最后再总结一下获取锁的逻辑,首先判断写锁释放被持有了,如果被持有了,且是当前线程,使用锁降级,如果没有,读锁正常获取。

获取过程中,会使用 firstReader 和 cachedHoldCounter 提高性能。

4. 读锁的释放 tryReleaseShared 方法

代码加注释如下:


protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    // 如果是第一个线程
    if (firstReader == current) {
        // 如果是 1,将第一个线程设置成 null。结束。
        if (firstReaderHoldCount == 1)
            firstReader = null;
        // 如果不是 1,减一操作
        else
            firstReaderHoldCount--;
    } else {//如果不是当前线程
        HoldCounter rh = cachedHoldCounter;
        // 如果缓存是 null 或者缓存所属线程不是当前线程,则当前线程不是最后一个读锁。
        if (rh == null || rh.tid != getThreadId(current))
            // 获取当前线程的计数器
            rh = readHolds.get();
        int count = rh.count;
        // 如果计数器小于等于一,就直接删除计数器
        if (count <= 1) {
            readHolds.remove();
            // 如果计数器的值小于等于0,说明有问题了,抛出异常
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        // 对计数器减一
        --rh.count;
    }
    for (;;) {// 死循环使用 CAS 修改状态
        int c = getState();
        // c - 65536, 其实就是减去一个读锁。对高16位减一。
        int nextc = c - SHARED_UNIT;
        // 修改 state 状态。
        if (compareAndSetState(c, nextc))
            // 修改成功后,如果是 0,表示读锁和写锁都空闲,则可以唤醒后面的等待线程
            return nextc == 0;
    }
}

释放还是很简单的,步骤如下:

  1. 如果当前线程是第一个持有读锁的线程,则只需要操作 firstReaderHoldCount 减一。如果不是,进入第二步。
  2. 获取到缓存计数器(最后一个线程的计数器),如果匹配到当前线程,就减一。如果不匹配,进入第三步。
  3. 获取当前线程自己的计数器(由于每个线程都会多次获取到锁,所以,每个线程必须保存自己的计数器。)。
  4. 做减一操作。
  5. 死循环修改 state 变量。

5. 总结

“读写锁没有想象中简单” 是此次阅读源码的最大感慨。事实上,花的最多时间是锁降级,因为对这块的不理解,参照了一些书籍和博客,但还是云里雾里,我也不敢确定我说的就是全对的,但我敢说,我写的是经过我思考的。

总结下读锁的获取逻辑。

读锁本质上是个共享锁。

但读锁对锁的获取做了很多优化,比如使用 firstReader 和 cachedHoldCounter 最第一个读锁线程和最后一个读锁线程做优化,优化点主要在释放的时候对计数器的获取。

同时,如果在获取读锁的过程中写锁被持有了,JUC 并没有让所有线程痴痴的等待,而是判断入如果获取读锁的线程是正巧是持有写锁的线程,那么当前线程就可以降级获取写锁,否则就会死锁了(为什么死锁,当持有写锁的线程想获取读锁,但却无法降级,进入了等待队列,肯定会死锁)。

还有一点就是性能上的优化,如果先释放写锁,再获取读锁,势必引起锁的争抢和线程上下文切换,影响性能。

 

作者:莫那·鲁道

个人博客:thinkinjava.cn

  • Trackback 关闭
  • 评论 (0)
  1. 暂无评论

return top