Java锁深入理解2——ReentrantLock
Posted 发现存在
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了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之外,还有head和tail(组织队列的关键元素)。
当某个线程进来之后,在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()
解锁流程,无论是公平锁还是非公平锁都一样
- 把锁的状态改为“非锁定中”
- 唤醒下一个节点(unpark)
从节点上退下来?【并没有这一步!老大的位置是被老二踢下来的】
await()
- 排进条件队列
- 释放锁(这一步就是unlock的操作)
- 阻塞
signal()
- 找到条件队列的第一个节点
- 让这个节点从条件队列脱离掉(first.nextWaiter = null;)
- 让这个节点排到同步队列的队尾(tail.next = node;)
- 唤醒这个节点(unpark)
小结
- 在AQS中,试图抢锁的只有老大,老二和还未入队列“外来者”,其他节点都处于阻塞状态
- unlock和await(注意:能做这两个动作的只有拿到锁的头节点),都会调用同一个释放锁的过程(改锁状态为0,唤醒同步队列里的第二个节点)。
不同点是:await后还会把自己加入条件队列,然后阻塞自己(其实可以说await流程中包含unlock的流程)。 - 无论是同步队列里的自动阻塞(那些黄色节点),还是使用await后的阻塞(红色节点),本质原理是一样的,都是用park阻塞,都需要被别的线程用unpark唤醒。区别在于:
- 在哪阻塞:前者在同步队列里阻塞,后者在条件队列里阻塞。
- 被谁唤醒:前者被头节点释放锁后唤醒,后者被其他线程(其实还是头节点)使用signal唤醒。
- unlock和signal,都会涉及到唤醒节点(unpark)的操作。
前者是唤醒的是同步队列里的第二个节点,后着是唤醒条件队列里的第一个节点。 - 唤醒(unpark):就是是给指定节点“解穴”,让它继续动起来。
- 被唤醒之后,至于去干什么,取决于线程当前执行到哪了,后面还要做什么。如果是同步队列的节点,被唤醒后就是继续抢锁。而条件队列里的节点,正常就是默默的继续往下执行代码。当然,如果它身处一个循环语句之中,转一圈,它也许还会再次去抢锁。
其实前面的流程已经把取消等流程都给省略了,但还是太细节,太复杂。再画一个更简化版的整体动态概览图(两条实线表示节点的变换位置的方向)
可能的困惑
- lock是为了实现线程安全,那么lock源代码本身的线程安全怎么保证?
比如:lock()源码中,抢锁(改锁状态),线程入队列都是用了CAS(也是AQS的核心),保证了线程安全。而await的时候在节点入队列时,却直接使用的=,不会出现线程安全问题吗?
答:这是一个思维盲区。或许有读者已经想到问题出在哪了。
因为await只能在lock和unlock之间(临界区)的线程安全区里调用,所以await内不用担心线程安全问题。
整个过程,其实只有抢锁的时候,需要考虑线程安全。后面的操作一直到unlock其实都是线程安全的,其他线程都被阻止在抢锁那一步了。
- 线程被唤醒后,在哪复活?是不是像打游戏一样,在泉水(出生地)里复活?
答:这就是想多了。它在哪阻塞,就在哪被唤醒。例如下面的await方法代码
线程在LockSupport.park(this);
阻塞,那么当它被其他线程唤醒时,就还是从这句话开始执行。
但是,之所以可能引起困惑。从await()开始,到park最终停下,最后再次被唤醒开始往下执行,中间经历了很长的流程,如下图所示:
这个一维流程图看着晕?再换个二维流程图视角看看:
我们还看到park这句话被while语句包裹着。也就意味着:即使被唤醒,也又可能立马又阻塞。
这个写法也值得我们学习:线程被唤醒后别晕着头就往下执行,最好看看当前什么状况,如果不能往下执行,也许还得继续阻塞。
- 如果锁重入了多次,比如重入了三次,state的值被加到3。此时做await()操作。state值需要清零吗。
答:不需要,这里的重入就有点像事务,你进了多少层事务,最后都得一层层的出来。除非程序报错。
CAS(compareAndSet)和自旋锁
在说AQS的时候总会有人说CAS和自旋锁。
首先明确一点:CAS本身是不会自旋的,只试一次:返回true或者false
那自旋体现在哪呢,有两段循环语句:
- 这是当前线程节点 作为一个“外来节点”(还没排到同步队列里)的接下来的行为:入队
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尝试,如果失败了,就再次循环执行一遍,直到排进去为止。
- 这是当前节点,作为同步队列里一个节点,接下来的行为:抢锁或阻塞
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。
Java并发编程,深入理解ReentrantLock
ReentrantLock简介
ReentrantLock重入锁,是实现Lock接口的一个类,也是在实际编程中使用频率很高的一个锁, 支持重入性,表示能够对共享资源能够重复加锁,即当前线程获取该锁再次获取不会被阻塞。 ReentrantLock还支持公平锁和非公平锁两种方式。 那么,要想完完全全的弄懂ReentrantLock的话, 主要也就是ReentrantLock同步语义的学习:
-
重入性的实现原理
-
公平锁和非公平锁
重入性的实现原理
要想支持重入性,就要解决两个问题:
-
1. 在线程获取锁的时候,如果已经获取锁的线程是当前线程的话则直接再次获取成功
-
-
由于锁会被获取n次,那么只有锁在被释放同样的n次之后,该锁才算是完全释放成功
针对第一个问题,我们来看看ReentrantLock是怎样实现的, 以非公平锁为例,判断当前线程能否获得锁为例,核心方法为nonfairTryAcquire(),源码如下:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); //1. 如果该锁未被任何线程占有,该锁能被当前线程获取 if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } //2.若被占有,检查占有线程是否是当前线程 else if (current == getExclusiveOwnerThread()) { // 3. 再次获取,计数加一 int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
为了支持重入性,在第二步增加了处理逻辑,如果该锁已经被线程所占有了, 会继续检查占有线程是否为当前线程, 如果是的话,同步状态加1返回true,表示可以再次获取成功。每次重新获取都会对同步状态进行加1的操作。
针对第二个问题,依然还是以非公平锁为例,核心方法为tryRelease,源码如下:
protected final boolean tryRelease(int releases) { //1. 同步状态减1 int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { //2. 只有当同步状态为0时,锁成功被释放,返回true free = true; setExclusiveOwnerThread(null); } // 3. 锁未被完全释放,返回false setState(c); return free; }
重入锁的释放必须得等到同步状态为0时锁才算成功释放,否则锁仍未释放。 如果锁被获取n次,释放了n-1次,该锁未完全释放返回false,只有被释放n次才算成功释放,返回true。
公平锁与非公平锁
ReentrantLock支持两种锁:
-
公平锁
-
非公平锁
何谓公平性,是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就 应该符合请求上的绝对时间顺序,满足FIFO。 ReentrantLock的无参构造方法是构造非公平锁,源码如下:
public ReentrantLock() { sync = new NonfairSync(); }
ReentrantLock的有参构造方法,传入一个boolean值,true时为公平锁,false时为非公平锁,源码如下:
public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); }
公平锁的获取,tryAcquire()方法,源码如下:
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } }
逻辑与nonfairTryAcquire基本上一致, 唯一的不同在于增加了hasQueuedPredecessors的逻辑判断, 方法名就可知道该方法用来判断当前节点在同步队列中是否有前驱节点的判断, 如果有前驱节点说明有线程比当前线程更早的请求资源,根据公平性,当前线程请求资源失败。 如果当前节点没有前驱节点的话,才有做后面的逻辑判断的必要性。 公平锁每次都是从同步队列中的第一个节点获取到锁, 而非公平性锁则不一定,有可能刚释放锁的线程能再次获取到锁。
公平锁与非公平锁的比较:
-
公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序, 而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象。
-
公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换, 而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量。
免费Java高级资料需要自己领取,涵盖了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高并发分布式等教程,一共30G。
传送门:https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT0IRL8Q
以上是关于Java锁深入理解2——ReentrantLock的主要内容,如果未能解决你的问题,请参考以下文章
深入显出一篇能懂Java锁机制,Synchronized和ReentrantLock