本篇博客是《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。
JDK锁有很多,我们就以最常用的ReentrantLock(可重入锁,也是一种排他锁)来举例
public void testReentrantLock() {
ReentrantLock mylock = new ReentrantLock();
mylock.lock();//抢锁 加锁
System.out.println("------do something....");//线程安全操作
mylock.unlock();//释放锁
}
在这段demo中,如果有多个线程都会执行这个方法。那么同一时间,只会有一个线程进入到mylock.lock();
和mylock.unlock();
之间。可以在其中做一些需要线程安全的操作。
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唤醒的线程能做的 只能默默的把剩下的路走完。
如果用过锁,或许会产生一些疑问:
下面就正式进入ReentrantLock类的内部,来解答上面的疑惑。
这张图就表示ReentrantLock类的总体结构。(图中并没有严格按照URL的规范画。包含关系直接使用了更直观的嵌套,而不是用线条表示。箭头含义是按规范画的:A—>B表示A继承B)
当new ReentrantLock()时,其实使用的是FairSync(公平锁)或者NonfairSync(非公平锁)。
也可以通过传参数true,来创建公平锁
而这两种锁的顶级父类就是AbstrateQueuedSynchronizer(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.意思是:将节点从条件队列转移到同步队列。
上面那个AQS的原理图中,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的基本原理。下面是一些源码细节。
这个流程图很重要。结合这张图,会帮助理解后面各种操作的逻辑。
公平锁上锁逻辑:
看看不能抢(看state状态,是不是锁定中(其他线程正在运行中))
解释:
老大:也就是头节点,抢到锁的线程。
这里忽略了一些细节:
我们一开始可能认为:一个线程队列,如果简单设计的话。前一个运行完,触发后一个运行似乎是最简单的。
但实际设计的方案是:老大运行完,确实“通知”老二了。但这个“通知”的意思是:唤醒后一个线程(从阻塞变为非阻塞)。
就是说:老大退位了,并不意味值老二自动变老大。只是告诉老二,你有权利上位了(上位的过程还是老二主动循环尝试去争取)。
其实想想也好理解:线程和线程之间都是独立的,没有很强的耦合关系。最大的耦合就是signal唤醒了。
老二怎么踢掉的老大:源码
setHead(node);
p.next = null; // help GC
这里的p就是当前节点(老二)的前面的节点(老大)。就是说把老大的next引用指到null。
第一句的setHead方法里,把原本指向前节点的引用指向null。
也就是把双向的引用都断掉。而且把head也指向了老二。老大彻底“失联”,等着被GC回收。
非公平锁上锁逻辑:
直接抢抢试试(不去判断state)
解锁流程,无论是公平锁还是非公平锁都一样
其实前面的流程已经把取消等流程都给省略了,但还是太细节,太复杂。再画一个更简化版的整体动态概览图(两条实线表示节点的变换位置的方向)
答:这是一个思维盲区。或许有读者已经想到问题出在哪了。
因为await只能在lock和unlock之间(临界区)的线程安全区里调用,所以await内不用担心线程安全问题。
整个过程,其实只有抢锁的时候,需要考虑线程安全。后面的操作一直到unlock其实都是线程安全的,其他线程都被阻止在抢锁那一步了。
答:这就是想多了。它在哪阻塞,就在哪被唤醒。例如下面的await方法代码
线程在LockSupport.park(this);
阻塞,那么当它被其他线程唤醒时,就还是从这句话开始执行。
但是,之所以可能引起困惑。从await()开始,到park最终停下,最后再次被唤醒开始往下执行,中间经历了很长的流程,如下图所示:
我们还看到park这句话被while语句包裹着。也就意味着:即使被唤醒,也又可能立马又阻塞。
这个写法也值得我们学习:线程被唤醒后别晕着头就往下执行,最好看看当前什么状况,如果不能往下执行,也许还得继续阻塞。
在说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;
}
}
}
}
代码逻辑:
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。