ReentrantLock源码探究、探究公平锁与非公平锁背后的奥秘

前言

更新点:结合自己阅读源码的经验,新增面试专栏,后续会一直更新。2023-02-16,如果回答的造成了误解,望斧正。看到并采纳会及时修正。

由于疫情,加上忙于工作的原因,也是有段时间没有写博客了,本文是基于以前写过的博客再整理出来的,本着加深理解的原则,发现以前的很多文章重新去温习的时候,读起来有点晦涩,于是萌生了再整理的想法,同时加了一个面试专栏,让大家各取所需

首先简单介绍几个概念

  • 重量级锁:用户起了几个线程,经过os调度,然后在交给java虚拟机执行。重量级锁是操作os函数来解决线程同步问题的,涉及到了内核态与用户态之间的切换,这个开销是很大的,因此被称为重量级锁。

  • 轻量级锁:由于重量级锁对os函数的频繁操作十分耗时,因此衍伸出来了轻量级锁,目的就是为了减少对内核的直接操作,减少一些可以避免的开销。而轻量级锁来解决线程同步问题一般都只涉及到jdk层面,且我们电脑执行代码是很快的。

  • 偏向锁:只要有人过来竞争,偏向锁就会升级。偏向锁的意义在于,在只有一个线程运行或者无竞争的情况下,减少轻量级锁带来的开销。

  • 可重入锁:同一个线程内多次获取同一把锁,进行lock操作而不会出现死锁的情况称为锁的可重入性

  • 公平锁:进行加锁前会进行判断看自己是否需要排队,即使自己是第一个进行lock的线程,遵循先来后到的原则

  • 非公平锁:没有队列的判断逻辑,谁先执行cas,谁就加锁成功,谁先抢到就是谁的

  • 自旋锁:一个线程在获取锁的时候,另外一个线程已经抢占了锁,那么此线程将一直陷入循环等待的状态,然后一直判断是否能获取锁成功,直到获取锁成功,退出循环

当然本文着重介绍ReentrantLock是怎么实现的。阅读本文可以收获如下知识

  1. 什么是可重入锁?
    • 同一个线程内多次获取同一把锁,进行lock操作而不会出现死锁的情况称为锁的可重入性
  2. ReentrantLock是一把什么类型的锁?哪里可以体现?
    • ReentrantLock是一把轻量级锁、可重入锁。可重入锁体现在同一个线程可以多次对同一把锁的lock、unlock操作而不会造成死锁的情况出现
  3. AQS是什么,AQS与ReentrantLock有什么关系?AQS核心是什么?
    • sync就是个AQS,AQS全称AbstractQueuedSynchronizer,ReentrantLock的加锁即sync.lock
    • AQS核心:park、自旋、cas
  4. 并发、并行,它们有啥差别?
    • 并发:并发不一定存在竞争,指同一个时间段内,线程数量
    • 并行:存在竞争,在同一片刻,竞争同一个资源
  5. 知道什么是公平锁、非公平锁吗?
    • 公平锁:在ReentrantLock中有一个队列来维护排队关系,即使锁被释放了,即使自己是队列排队的第一个,依然会进行判断自己是否有获取锁的资格。即遵循先来后到的规则
    • 非公平锁:对比公平锁是把队列部分给剔除了,谁先抢到锁谁就进行cas加锁成功
  6. 讲讲你对ReentrantLock的理解
    • 在jdk1.6前:Synchronized是通过操作os函数来实现线程间的同步问题的是一把重量级锁
    • jdk1.6之后ReentrantLock对比Synchronized都差不多,Synchronized底层做了优化,有一个锁升级的过程,然后就是ReentrantLock的调用方法更加丰富一点
  7. ReentrantLock是怎么实现的,有了解过吗?
    • ReentrantLock主要利用AQS实现的,而AQS核心又是park、自旋、cas
  8. ReentrantLock加锁的大概流程是怎么样的?
    • 先尝试去获取锁(先看是否需要排队,不需要排队则cas加锁。如果是同一个线程来操作,重入锁状态标识符++),获取不到锁把当前thread封装成一个Node节点放入队列中,维护好队列关系后,如果发现自己的排队的第一个人,那么最多还会去尝试获取锁2次,实在获取不到锁了,让当前Node睡眠,最后执行finally中的方法去取消当前线程的竞争
  9. ReentrantLock中的队列什么情况下会被初始化?
    • 至少存在俩个 线程竞争的情况下才会被初始化

ReentrantLock定义

首先 ReentrantLock 是一把可重入锁、轻量级锁,至于是公平锁还是非公平锁,看我们怎么把它实例化出来的,默认情况下是一把非公平锁,当我们创建实例的时候,传入参数 true,此时就是一把公平锁。
在这里插入图片描述

锁的可重入性

可重入锁示例代码体现如下,同个线程俩次获取同一把锁并未出现死锁的情况。

new Thread(() -> {
            int i = 0;
            lock.lock();
            System.out.println("初始化锁:" + lock);
            while (true) {
                lock.lock();
                System.out.println("第" + ++i + "次拿到锁" + lock);
                if (i == 100) {
                    break;
                }
                lock.unlock();
            }
            lock.unlock();
            System.out.println(i);
        }).start();

什么是AQS

ReentrantLock 的加锁本质是利用 sync 中的 lock()方法实现的。
在这里插入图片描述
而 sync 是继承了一个叫做 AbstractQueuedSynchronizer 的类,这个类也就是我们所说的 AQS(AbstractQueuedSynchronizer)那么我们要彻底搞懂 ReentrantLock 的加锁流程,阅读 AQS 的源码就好了。
在这里插入图片描述

公平、非公平锁区别一(lock方法)

公平锁
在这里插入图片描述
非公平锁
在这里插入图片描述
通过观察上图,不难发现不论是非公平、公平锁里面都用到了 acquire(1) 方法,唯一的区别就是,非公平锁遵循先来后到的原则,谁先 CAS 成功,谁就加锁成功,其他 CAS 失败的线程最终走 AQS 里面的那套逻辑。

而大家被面试官问到 ReentrantLock中非公平、公平锁的区别是什么的时候?

我们回答: ReentrantLock 中的非公平锁在加锁的时候,对第一个有加锁需求的线程直接
CAS 将 NonfairSync 中的 state 字段值改为了 1,后到的线程由于 CAS 修改 state 预期值不为 0 了,修改失败都走 AQS 中的 acquire()那套逻辑去了。
这就是 ReentrantLock中的非公平锁、公平锁的最浅显区别。如果你觉得自己够牛逼,还可以顺带提一嘴:值得一提的是,为了防止工作内存中的数据没有及时同步至主内存,设计 state 是被 volatile 关键字修饰的 ,写 state 时工作内存立即刷新主内存,读 state 时直接从主内存中读取

state 默认值是 0

在这里插入图片描述

接下来面试官觉得你有点东西就会问:你刚才说到的 AQS、acquire() 是个什么东西?可以讲讲吗?

核心 AQS 解读

上文提到了,不管是 ReentrantLock 公平锁、非公平锁,最终都用到了 acquire 方法,而里面其实可以拆分成 tryAcquire、acquireQueued、addWaiter、selfInterrupt 四小板块来分析,且 tryAcquire 分公平锁、非公平锁俩套逻辑,目的明确了,开始上菜。
在这里插入图片描述

AQS(tryAcquire)尝试去竞争锁

  1. state 为 0: 锁已经变为可抢状态,第一次没有抢到锁的线程还会进行一次 CAS 抢锁的操作
  2. state 为 1 :同一个线程重复加锁,由于第一个抢到锁的线程,CAS 成功了,state 变为了 1 ,但是此线程接着加锁操作,就会来到 tryAcquire 中这,由于占有锁的线程和正在加锁的线程是同一个,对 state++ 操作,这就是ReentrantLock 是可重入锁的原因。

在这里插入图片描述
在这里插入图片描述

AQS(addWaiter)维护双向链表

  • 尾节点非空时:多个线程同时没抢到锁,都走到了入队的逻辑,第一个入队成功,其他入队失败的线程就会走 enq(node)里面的逻辑,因此 enq 方法设置成一个死循环,没入队的线程必须要入队的

在这里插入图片描述

  • 尾节点为空时:假设当所有没抢到锁的线程进行入队的时候,可能同时执行 addWaiter 方法,同时判断尾节点为 null ,此时这些线程会去走 enq(node)里面的逻辑 CAS 尾插维护双向链表。还有一种情况就是:当尾节点已经有的时候直接 CAS 尾插入队,其他尾插入队失败的线程接着走 enq(node)里面的逻辑入队。值得一提的是:看下图我们可以得知双向链表的第一个节点是一个空的 node,
    在这里插入图片描述

AQS (acquireQueued)休眠第二个节点后的所有节点

值得一提的是代码片段一中的条件告诉我们:只有是第二个节点才有资格再次尝试获取锁,其他的节点都会走代码片段二中的逻辑,里面会对所有竞争失败且入队排在大于 2 位置的所有节点进行 park(线程休眠),只有当占有锁的线程释放锁的时候,休眠的线程才会接着走 for(;;)中的逻辑。剧透一下,ReentrantLock 释放锁的逻辑是按照,从链表依次从左往右的顺序唤醒线程的,这也能解释为什么代码片段三中,只有是前节点为头节点的 node 才能尝试竞争锁的设计了,且如果竞争锁成功后,要设置 setHead(node); ,这个就好比排队叫号,第一个被叫号的人人走掉了,第二个人是不是就是变成第一个排队的人了。

代码片段一

 if (p == head && tryAcquire(arg)) {}

代码片段二

  if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;

代码片段三

if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return interrupted;
            }

如果 node 节点的前一个节点非头节点,进行 park 休眠操作,直至符合条件的 node 节点继续尝试获取锁成功,然后 Return 了后执行 finally 中的逻辑,这里面的代码没有过多深究,改天专门写一篇文章分析 AQS。
在这里插入图片描述

小结 AQS (acquireQueued)作用

acquireQueued 方法会休眠不符合再次竞争锁条件的队列中的线程,同时设计成一个死循环,等待队列中被唤醒的线程重新去竞争锁,值得一提的是只有当该线程的前一个节点符合为头节点的条件,才能继续尝试竞争锁。同时里面在对线程进行 park 的时候会去修改 node 节点中的一些属性,例如:修改 waitStatus(修改成waitStatus非0以外的数,只有 waitStatus != 0 的节点才能被唤醒),重新更新链表的操作等。

 private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }

公平、非公平锁区别二(队列)

上文一直都在分析非公平锁,接下来分析公平锁源码,公平锁与非公平锁还有一个非常重要的区别就是,公平锁的 tryAcquire 方法实现上,在进行 CAS 加锁前,会去判断队列中是否存在节点,如果队列中还有节点,且没到取号的时候,这个线程是不能去竞争锁的,这也是公平锁先来后到的一个体现,其他流程和非公平锁的实现一摸一样。

在这里插入图片描述

释放锁

代码如下,可以看到释放锁成功,需要达到俩个条件

  • tryRelease(arg) 返回 true
  • 头节点不为 null 且,waitStatus !=0;

在这里插入图片描述

ReentrantLock(tryRelease(arg))消除重入次数

消除重入锁的次数,只有当重入的次数都消除后,此方法才会返回 true,才有机会走下面的 unparkSuccessor 中的逻辑。而 unparkSuccessor 里面会去唤醒队列中的线程,让第一次没有抢到锁的线程再次去 tryAcquire 去竞争锁

在这里插入图片描述

所以大家在使用 ReentrantLock 中的时候 每 lock 一次,一定要对应 unlock 一次看下图一,加锁了2次,只解锁一次,那么这个锁的还是没有被解开的,其他线程还处于 park 休眠状态,根本没机会被唤醒去竞争锁

图一

在这里插入图片描述

当 lock 与 unlock 方法配套使用的时候,可以看到除了线程 1 的线程被正常唤醒,也可以去竞争锁了。

在这里插入图片描述

ReentrantLock(unparkSuccessor) 尾节点扫描唤醒休眠线程

下图圈绿色的地方即为 尾节点扫描对应的节点,然后圈红的地方会去唤醒尾扫描得到节点线程,相信很多人被人问到:

在这里插入图片描述

被唤醒的线程接着执行目录:AQS (acquireQueued)休眠第二个节点后的所有节点里面的死循环逻辑,去 tryAcquire 去再次竞争锁,直至所有线程都加锁成功。

在这里插入图片描述

面试专栏

为什么要尾节点扫描去唤醒线程啊

答:维护链表的时候他先是建立了指向前一个节点的引用,在并发很高的时候,可能此时的链表引用还没维护好呢,所有节点都只有一个前向引用(入下图三),此时你要去唤醒线程,如果头节点扫描,压根就扫描不到节点,因为此时指向后继节点的引用都还未建立。

图一
在这里插入图片描述
图二
在这里插入图片描述
图三
在这里插入图片描述

讲讲你对 ReentrantLock 的理解。(问的比较宽泛,我们也简答一下,循序渐进的引导式的回答吧,如果面试官很羞涩,且我们自身实力够硬把从加锁到解锁的整个流程都说一遍)

答:ReentrantLock 是基于 AQS 实现的一把可重入锁、根据实例化传参的不同,也分公平锁、非公平锁。

能说说你对 ReentrantLock 公平锁、非公平锁的理解吗?

ReentrantLock 非公平锁在 lock 的时候不管先来后到直接 CAS 去修改 state 值为 1,其他修改失败的线程,会进行入队列,然后 park 休眠,等待被唤醒然后重新 CAS 去竞争锁。而 ReentrantLock 公平锁与其的区别就是,公平锁在 tryAcquire 的时候会去判断队列中是否存在 node 节点,有则排队去加入队列休眠,然后等待被唤醒再次去 tryAcquire 去竞争锁,而非公平锁在 tryAcquire 的时候,讲究的是一个先来后到,没有判断队列节点的逻辑。

说说你在实际开发中使用 ReentrantLock 遇到的问题?

多线程下 lock 与 unlock 方法没有配套使用,造成解锁的时候只是消除了 重入次数,并没有真正的去解锁,导致 队列中被 park 的线程没有被唤醒,导致许多逻辑没有正常执行。

🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹如果大家在面试中被问到和 ReentrantLock 的问题,本文看到回复会及时跟进,并同步文章的🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹🌹

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小咸鱼的技术窝

你的鼓励将是我最大的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值