Java锁深入理解2——ReentrantLock

前言

本篇博客是《Java锁深入理解》系列博客的第二篇,建议依次阅读。
各篇博客链接如下:
Java锁深入理解1——概述及总结
Java锁深入理解2——ReentrantLock
Java锁深入理解3——synchronized
Java锁深入理解4——ReentrantLock VS synchronized
Java锁深入理解5——共享锁

概述

虽然我们常用的可能是Synchronized,但我们还是先看JDK锁。因为它由JDK实现,有可见的源代码。分析起来会方便一些。

理解了之后,在去看Synchronized,会容易很多(毕竟都是锁,不管是谁实现的,大致的思想应该有共同之处)。

由于后面要从Demo一路深入到JDK源码。而看多线程源码和普通单线程源码还不太一样。如果还没尝试过多线程debug的,可以先看一下Java锁深入理解1——概述及总结,其中讲了如何多线程debug。

Demo1

JDK锁有很多,我们就以最常用的ReentrantLock(可重入锁,也是一种排他锁)来举例

public void testReentrantLock() {
    ReentrantLock mylock = new ReentrantLock();

    mylock.lock();//抢锁 加锁
    System.out.println("------do something....");//线程安全操作
    mylock.unlock();//释放锁
}

在这段demo中,如果有多个线程都会执行这个方法。那么同一时间,只会有一个线程进入到mylock.lock();mylock.unlock();之间。可以在其中做一些需要线程安全的操作。

Demo2

Demo1只是一种最基本的使用方式,通过lock-unlock来圈定一个安全区(也叫临界区),来保证线程安全。

还有两个操作await, signal也挺常见。分别是用来把自己阻塞,把别人唤醒。其实这两个操作对线程安全并没有什么直接作用。已经不属于“解决多线程客观问题”的范畴,而是属于“把多线程玩出更多花样”的范畴。如果说lock-unlock是锁的核心功能,那么await/signal则属于锁的附属功能。

    ReentrantLock mylock = new ReentrantLock();
    Condition c = mylock.newCondition();    

	public void testReentrantLock2() {

        mylock.lock();//抢锁 加锁
        System.out.println("------do something....");//线程安全操作
        try {
            c.await();//把自己阻塞
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        mylock.unlock();//释放锁
    }

    public void testReentrantLock2_1() {

        mylock.lock();//抢锁 加锁
        System.out.println("------do something....");//线程安全操作
        c.signal();//把阻塞的线程唤醒(配合await使用)
        mylock.unlock();//释放锁
    }

Demo2中,首先是增加了Condition c = mylock.newCondition();,不知道怎么翻译。自面意思就是“条件”,一般我们就直接称之为Condition。

语言和语言体系之间必然不可能一一对应。而专业领域的翻译有“精度要求”。当含义误差比较大时,就没必要硬翻译。

此时中文里夹杂英文专业词汇不叫装逼,而是为了表意更准确。(日常表达是没必要的)

testReentrantLock2方法中,在lock-unlock划定的临界区里(这个条件很重要),使用了c.await();。当某个线程A执行到这里的时候,会被阻塞在这里。

此时该线程A会失去锁(它虽然还身在临界区里,但却处于休眠状态)。相当于其他线程忽略线程A的存在,可以继续抢锁。

testReentrantLock2_1中,在lock-unlock划定的临界区里(这个条件很重要),使用了c.signal();。当线程B执行到这里的时候,会把阻塞的线程唤醒(比如上面的线程A)。

此时你可能有个疑问:那如果另一个线程B立马抢到锁,并唤醒A。是不是会和刚醒来的线程A同时身处临界区。

答:是的。 而且如果B使用的是signalAll(),还有可能唤醒一堆被阻塞线程。(所以不要误认为“临界区”同一时间只能有一个线程)

但区别就是:B手中有锁,只要B不出来,其他线程就进不来。而那些被B唤醒的线程能做的 只能默默的把剩下的路走完。

问题

如果用过锁,或许会产生一些疑问:

  • 代码为什么会在mylock.lock()位置停下来
  • 代码为什么会在c.await()位置停下来
  • 抢到锁的本质是什么
  • 怎么保证只有一个线程抢到锁
  • 什么时候才能抢锁

内部机制

下面就正式进入ReentrantLock类的内部,来解答上面的疑惑。

代码结构

在这里插入图片描述

这张图就表示ReentrantLock类的总体结构。(图中并没有严格按照URL的规范画。包含关系直接使用了更直观的嵌套,而不是用线条表示。箭头含义是按规范画的:A—>B表示A继承B)

当new ReentrantLock()时,其实使用的是FairSync(公平锁)或者NonfairSync(非公平锁)。
在这里插入图片描述
也可以通过传参数true,来创建公平锁
在这里插入图片描述

而这两种锁的顶级父类就是AbstrateQueuedSynchronizer(AQS)。

AQS

先整体看一下这个锁的核心类,AQS原理示意图

在这里插入图片描述

这张图相当于图代码结构示意图中,AQS部分的进一步放大,可以看到其中更多丰富的细节。

图中关键的两个东西:一个是state,一个是同步队列

队列中的一个个节点封装着一个个线程。绿色代表是当前获得锁的,在队列中位列第一。后面的黄色节点则处于阻塞状态。AQS就是通过这个队列来管理线程,实现“先来后到”的方式顺序执行。

state是一个标志,相当于一个红绿灯(更像公共厕所的锁上的显示:有人/无人):1表示有线程正在占有锁,其他线程不用白费力气去抢了。0表示当前没有占用,其他线程有机会去抢。当同一个线程在前一个锁还没释放的时候,就又再次抢锁也是可以的,此时state会加到2,以此类推,重入几次,state就是几。

图中的另外一种队列(红色的那种),画了两个,表示这种队列可以有多个(也可以没有)。叫条件队列。也就是代码中,我们使用await之后,线程节点被放置的位置。再被signal唤醒之后,线程节点就从这个红色队列中脱离出来(脱离的优先级也是按照先来后到的方式,从队列头部一个一个的脱落),然后重新回到同步队列中排队。

名词统一

关于AQS中的两种队列的名字,有点乱(有些博客自己都前后不一致)。我根据源码上的注释,给本文统一如下:

等待队列(wait queues):上面两种队列的统称。这两种队列都是有AQS类中的内部类Node类组成的,都是阻塞等待状态(除了同步队列的头节点)。(参考AQS源码中的Node类上的注释的第一句:Wait queue node class)

同步队列(sync queue):也就是实现lock-unlock的核心队列,图中第一条队列。(参考AQS源码中的transferForSignal方法的注释:Transfers a node from a condition queue onto sync queue.)

条件队列(condition queue):就是图中的红色队列。(参考AQS源码中的transferForSignal方法的注释:Transfers a node from a condition queue onto sync queue.)

Transfers a node from a condition queue onto sync queue.意思是:将节点从条件队列转移到同步队列。

Node

上面那个AQS的原理图中,Node只是一个小方块,我们继续放大这个方块,以及两个队列链表

Node节点示意图:
在这里插入图片描述

同步队列示意图:
在这里插入图片描述

条件队列示意图:
在这里插入图片描述

可以看到Node节点之间通过prev和next,组成了同步队列的双向链表。通过nextWaiter,组成了条件队列的单向链表。

线程组织成队列的逻辑场景

那这个两个队列是怎么用Node节点自动组织起来的呢。以同步队列为例介绍一下。

一般情况下,我们会把锁的定义

ReentrantLock mylock = new ReentrantLock();
Condition c = mylock.newCondition();

写在方法外面,因为只需要定义一个即可,后面不需要重复定义。

有了这两句话,我们的AQS容器,以及其中的Condition就生成了。后面只要有线程碰到这个容器,它就像一个高速公路检查站一样,在里面触发一系列的操作。看一下AQS的初始化时的示意图(注意观察和【AQS原理图】的差异):

在这里插入图片描述

在容器里,除了有state之外,还有headtail(组织队列的关键元素)。

当某个线程进来之后,在state的指挥下,被包装成Node节点,然后被head和tail引用。

然后是第二个,它会自动被追加到第一个节点的后面,然后是第三个,第四个,,,

最后就形成了前面我们看到的【AQS原理图】的样子。

可重入锁逻辑

通过上面的介绍,我们基本就掌握了ReentrantLock以及AQS的基本原理。下面是一些源码细节。

流程图

在这里插入图片描述
这个流程图很重要。结合这张图,会帮助理解后面各种操作的逻辑。

lock()

公平锁上锁逻辑

看看不能抢(看state状态,是不是锁定中(其他线程正在运行中))

  • 1-1. 如果能抢,就抢就抢一抢(不断循环尝试)
    • 1-1-1. 抢到了,就把老大给踢出队列(如果有老大的话),自己做老大
    • 1-1-2. 没抢到,自己就阻塞
  • 1-2. 如果不能抢,就进队列去等
    • 1-2-1. 进了队列,发现自己是老二,那么就去尝试抢一抢(进入1-1的循环)
    • 1-2-2. 进了队列,发现自己不是老二,那么就阻塞

解释

  • 老大:也就是头节点,抢到锁的线程。

  • 这里忽略了一些细节:

    • 抢的过程中也可能发现是自己重入(上一次抢到锁的就是自己,现在绕了一圈又进来了),那么也算抢成功(自己是老大,抢完之后还是老大)
    • 等待中:被取消的,会被踢出队列
    • 我们看到类似中断的一些代码,仔细看这些代码,其实并不会引起中断。只是在收到中断信号之后,这个中断信号会唤醒阻塞(但因为在循环里面,所以并不影响结果),然后这个中断信号被抹去,最后又给恢复了(如果感觉有点晕,没关系,你只要知道这个逻辑无伤大雅,不用去刻意理解这部分逻辑,后面会讲到中断这块)。
  • 我们一开始可能认为:一个线程队列,如果简单设计的话。前一个运行完,触发后一个运行似乎是最简单的。

    但实际设计的方案是:老大运行完,确实“通知”老二了。但这个“通知”的意思是:唤醒后一个线程(从阻塞变为非阻塞)。

    就是说:老大退位了,并不意味值老二自动变老大。只是告诉老二,你有权利上位了(上位的过程还是老二主动循环尝试去争取)。

    其实想想也好理解:线程和线程之间都是独立的,没有很强的耦合关系。最大的耦合就是signal唤醒了。

  • 老二怎么踢掉的老大:源码

    setHead(node);
    p.next = null; // help GC

这里的p就是当前节点(老二)的前面的节点(老大)。就是说把老大的next引用指到null。
第一句的setHead方法里,把原本指向前节点的引用指向null。
也就是把双向的引用都断掉。而且把head也指向了老二。老大彻底“失联”,等着被GC回收。

非公平锁上锁逻辑

直接抢抢试试(不去判断state)

  • 1-1. 如果成功,自己直接做老大
  • 1-2. 如果失败,进入“公平锁”流程
unlock()

解锁流程,无论是公平锁还是非公平锁都一样

  1. 把锁的状态改为“非锁定中”
  2. 唤醒下一个节点(unpark)
  3. 从节点上退下来? 【并没有这一步!老大的位置是被老二踢下来的】
await()
  1. 排进条件队列
  2. 释放锁(这一步就是unlock的操作)
  3. 阻塞
signal()
  1. 找到条件队列的第一个节点
  2. 让这个节点从条件队列脱离掉(first.nextWaiter = null;)
  3. 让这个节点排到同步队列的队尾(tail.next = node;)
  4. 唤醒这个节点(unpark)
小结
  • 在AQS中,试图抢锁的只有老大,老二和还未入队列“外来者”,其他节点都处于阻塞状态
  • unlock和await(注意:能做这两个动作的只有拿到锁的头节点),都会调用同一个释放锁的过程(改锁状态为0,唤醒同步队列里的第二个节点)。
    不同点是:await后还会把自己加入条件队列,然后阻塞自己(其实可以说await流程中包含unlock的流程)。
  • 无论是同步队列里的自动阻塞(那些黄色节点),还是使用await后的阻塞(红色节点),本质原理是一样的,都是用park阻塞,都需要被别的线程用unpark唤醒。区别在于:
    • 在哪阻塞:前者在同步队列里阻塞,后者在条件队列里阻塞。
    • 被谁唤醒:前者被头节点释放锁后唤醒,后者被其他线程(其实还是头节点)使用signal唤醒。
  • unlock和signal,都会涉及到唤醒节点(unpark)的操作。
    前者是唤醒的是同步队列里的第二个节点,后着是唤醒条件队列里的第一个节点。
  • 唤醒(unpark):就是是给指定节点“解穴”,让它继续动起来。
  • 被唤醒之后,至于去干什么,取决于线程当前执行到哪了,后面还要做什么。如果是同步队列的节点,被唤醒后就是继续抢锁。而条件队列里的节点,正常就是默默的继续往下执行代码。当然,如果它身处一个循环语句之中,转一圈,它也许还会再次去抢锁。

其实前面的流程已经把取消等流程都给省略了,但还是太细节,太复杂。再画一个更简化版的整体动态概览图(两条实线表示节点的变换位置的方向)
在这里插入图片描述

可能的困惑
  1. lock是为了实现线程安全,那么lock源代码本身的线程安全怎么保证?
    比如:lock()源码中,抢锁(改锁状态),线程入队列都是用了CAS(也是AQS的核心),保证了线程安全。而await的时候在节点入队列时,却直接使用的=,不会出现线程安全问题吗?
    请添加图片描述

答:这是一个思维盲区。或许有读者已经想到问题出在哪了。
因为await只能在lock和unlock之间(临界区)的线程安全区里调用,所以await内不用担心线程安全问题。
整个过程,其实只有抢锁的时候,需要考虑线程安全。后面的操作一直到unlock其实都是线程安全的,其他线程都被阻止在抢锁那一步了。

  1. 线程被唤醒后,在哪复活?是不是像打游戏一样,在泉水(出生地)里复活?

答:这就是想多了。它在哪阻塞,就在哪被唤醒。例如下面的await方法代码
请添加图片描述
线程在LockSupport.park(this);阻塞,那么当它被其他线程唤醒时,就还是从这句话开始执行。
但是,之所以可能引起困惑。从await()开始,到park最终停下,最后再次被唤醒开始往下执行,中间经历了很长的流程,如下图所示:

在这里插入图片描述

这个一维流程图看着晕?再换个二维流程图视角看看:
在这里插入图片描述

我们还看到park这句话被while语句包裹着。也就意味着:即使被唤醒,也又可能立马又阻塞。
这个写法也值得我们学习:线程被唤醒后别晕着头就往下执行,最好看看当前什么状况,如果不能往下执行,也许还得继续阻塞。

  1. 如果锁重入了多次,比如重入了三次,state的值被加到3。此时做await()操作。state值需要清零吗。
    答:不需要,这里的重入就有点像事务,你进了多少层事务,最后都得一层层的出来。除非程序报错。
CAS(compareAndSet)和自旋锁

在说AQS的时候总会有人说CAS和自旋锁。
首先明确一点:CAS本身是不会自旋的,只试一次:返回true或者false
那自旋体现在哪呢,有两段循环语句:

  1. 这是当前线程节点 作为一个“外来节点”(还没排到同步队列里)的接下来的行为:入队
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }
    

代码逻辑:

  • 如果队列的结尾是空(根本没人排队),就去尝试当那第一个节点(也可能尝试失败)。
  • 否则就尝试排到队尾(不一定能排进去)。
  • 这两个条件内的方法都是CAS尝试,如果失败了,就再次循环执行一遍,直到排进去为止。
  1. 这是当前节点,作为同步队列里一个节点,接下来的行为:抢锁或阻塞
    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

代码逻辑:

  • 如果前面那个节点是头节点,就抢锁(不一定抢到)
  • 否则就阻塞(等着前面的节点执行完,唤醒我)
  • 循环上面两步,一直到抢到为止

简化代码写法分析及思考

在ReentrantLock源代码中有这么两处典型的if判断语句

public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
    
和

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

以第一段为例,他的逻辑其实是

public final void acquire(int arg) {
        if (!tryAcquire(arg)) {
            if(acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
                selfInterrupt();
        }
            
    }

这并不难看出。因为&&的作用,if条件语句中的第一个条件其实也相当于一个判断,对第二个条件的执行与否造成影响。
但如果按照我日常开发的习惯,我基本会写成第二种拆开的写法。甚至写成这样:

public final void acquire(int arg) {
        boolean tryAcquireRes = !tryAcquire(arg);
        if (tryAcquireRes) {
        	//看代码我们就会明白,下面两句话是顺序执行的两句,也给拆开
            Node newWaiter = addWaiter(Node.EXCLUSIVE);
            boolean acquireQueuedRes = acquireQueued(newWaiter, arg);
            if(acquireQueuedRes) {
                selfInterrupt();
            }
                
        }
            
    }

原因无他,只是为了让代码更易读。减少团队合作中的沟通成本,一眼就看出逻辑(这相当于团队之间用代码在沟通)。
但是,这里的写法我是认可的。因为这是在封装工具包,而且是多线程这种对性能要求极高的代码。当然是能多榨取一点性能就多榨取一点。作为开源软件,测试是非常到位的,不担心出bug。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值