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

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 方法死循环执行上面的步骤。

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

image.png

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

先看看 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;
        }
    }
}

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

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

解释一下:

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

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

3. 锁降级的争议

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

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

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

体现在读锁哪里?

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

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

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

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

image.png

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

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

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

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

用一幅图来展示锁降级:

image.png

总的来说,锁降级就是一种特殊的锁重入机制,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 并没有让所有线程痴痴的等待,而是判断入如果获取读锁的线程是正巧是持有写锁的线程,那么当前线程就可以降级获取写锁,否则就会死锁了(为什么死锁,当持有写锁的线程想获取读锁,但却无法降级,进入了等待队列,肯定会死锁)。

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

还有一个就是,读书的时候,要有怀疑精神,一定要思考,而不是顺着他的思路去看书,诚然,书中大部分时候都是正确的,但我们贪婪的希望,那错误的一小部分尽量不要影响到我们。

后记

2022年03月23日

最近再次回顾这个问题,有一些新的思考, 写在下面。

描述:当使用读写锁时,当前线程由写锁降级为读锁的过程, 称为锁降级。

如果没有锁降级可以吗?

答案是不可以。

首先看 jdk 注释代码:

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 变量 为 false;

测试代码:

import lombok.SneakyThrows;
import org.junit.Test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantReadWriteLock;

import static org.junit.Assert.*;

public class Test {

    @Test
    public void test1() throws InterruptedException {
        CachedData cachedData = new CachedData();

        CyclicBarrier cyclicBarrier = new CyclicBarrier(20);
        CountDownLatch latch2 = new CountDownLatch(20);
        CountDownLatch latch3 = new CountDownLatch(20);

        for (int i = 0; i < 20; i++) {
            int a = i;
            new Thread(new Runnable() {
                @SneakyThrows
                @Override
                public void run() {
                    cyclicBarrier.await();
                    latch3.countDown();
                    cachedData.processCachedData("" + a);
                    latch2.countDown();
                }
            }).start();
        }

        latch3.await();
        TimeUnit.MILLISECONDS.sleep(2100);
        cachedData.cacheValid = false;
        System.out.println("更新cacheValid ");
        latch2.await();
    }
}

class CachedData {
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    void processCachedData(String d) throws InterruptedException {
        rwl.readLock().lock();
        if (!cacheValid) {
            TimeUnit.SECONDS.sleep(1);
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                TimeUnit.SECONDS.sleep(1);
                if (!cacheValid) {
                    System.out.println(Thread.currentThread().getName() + " update");
                    data = d;
                    cacheValid = true;
                }
                rwl.readLock().lock();
            } finally {
                rwl.writeLock().unlock();
            }
        } else {
            rwl.readLock().unlock();
        }

        try {
            Thread.sleep(3000);
            System.out.println(Thread.currentThread().getName() + " get " + data);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
           if (rwl.getReadHoldCount() > 0) {
                rwl.readLock().unlock();
            }
        }
    }
}

通过测试,我们得出了以下结论。

结论:如果想实现上面的需求,那么,必须使用锁降级,才能够实现;

其他的想法,例如释放写锁,再上读锁,能不能实现呢?其实这样可能会发生释放写锁后,被其他线程修改变量值,导致当前线程无法读到刚刚修改的数据。也就是人们常说的可见性问题。不过我理解,这里的可见性其实是并发期间有一段地方没有锁住,才会导致这个问题。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,511评论 1 330
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,495评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,595评论 0 225
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,558评论 0 190
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,715评论 3 270
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,672评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,112评论 2 291
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,837评论 0 181
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,417评论 0 228
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,928评论 2 232
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,316评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,773评论 2 234
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,253评论 3 220
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,827评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,440评论 0 180
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,523评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,583评论 2 249

推荐阅读更多精彩内容