前言
本篇博客是《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。