这次就彻底搞懂Java并发包中的锁原理,不用每次面试都去背一遍了
你是不是在面试过程中经常被问到了解Java中的锁? 可能你也会从网上的博客文章中看到过相关概念和知识,可是如果没有深入理解,对锁这块知识做相关的梳理,形成自己的知识脑图,过不久就会忘记。结果就是每次面试都得从头复习一遍,费时费力。
今天开始Java并发中锁的学习,主要的目的是梳理学习Java并发包中有关锁的API和组件。目标是知道如何使用以及具体实现原理。真正做到知其然知其所以然,才能得心应手的正确使用和应付面试。
为了降低读者的负担,这篇文章主要聊一下AQS,即 AbstractQueuedSynchronizer, 看看它是如何对锁语音进行实现的。
Lock 接口
说起锁,你肯定会想到 synchronized 关键字, 没错,这是在jdk1.5之前java程序用来实现锁功能的。而 jdk1.5 之后,并发包中增加了 Lock 接口用来实现锁功能,它的功能和 synchronized 类似,不过使用时需要显示的获取和释放锁。
Lock 的使用也很简单,如下demo所示:
Lock lock = new ReentrantLock();
lock.lock();
try {
} finally {
lock.unlock();
}
这里需要说明下,Lock接口提供的 synchronized 不具备的主要特性:尝试性的获取锁: 当前线程尝试获取锁,如果当前锁没有被其它线程获取到,则成果获取并持有。
能被中断的获取锁: 与 synchronized 不同的是,获取到锁的线程能够响应中断,当获取到锁的线程被其它线程中断时,中断异常被抛出,同时释放锁。
超时获取锁:在指定时间之前获取锁,超时无法获取则返回。
Lock 是一个接口,定义了锁的获取和释放基本操作:
从上到下依次说明下api的含义:获取锁,当前线程获取到锁后,从该方法返回,该方法获取锁过程中阻塞;
和lock() 的区别在于该方法会响应中断;
非阻塞尝试获取锁,方法立即返回,获取到返回true,否则返回false;
超时的获取锁,当超时、中断、获取未超时获取到了锁这三种场景都会返回;
释放锁,唤醒后继节点;
获取等待通知组件,该组件与当前锁绑定,必须获取到锁才能调用该组件的 wait 方法;
队列同步器(AQS)
队列同步器 AbstractQueuedSynchronizer 这个可以说是Java并发包中构建锁和各种安全容器实现的基石,比如 ReentrantLock、ReadWriteLock、CountDownLatch 等的实现中都少了AQS的身影。
AQS本身使用了一个int成员变量来表示同步状态,通过内置的FIFO队列,来完成资源获取线程的排队工作。 java并发包的作者 Doug Lea 大神在设计的时候就希望它成为实现大部分同步需求的基础。
同步器是实现锁的关键,当然也可以是任意的同步组件,在锁的实现中聚合同步器,利用同步器实现锁的语义。两者的关系可以理解为,锁是面向程序员的即Lock接口中定义的API,它定义了程序员使用的交互接口,隐藏了实现细节。而同步器则是面向锁的实现者的,它简化了锁的实现方式,屏蔽了同步状态管理、线程排队、等待与通知等底层实现细节。 这个设计是非常牛的,很好的隔离了使用者和实现者所需要关注的领域。
AQS的使用示例同步器AQS的设计时基于模板方法的,即使用者需要继承同步器并重写指定的方法,然后将同步器组合在自定义同步组件中,并调用同步器提供的模板方法,这些模板方法会调用使用者的重写方法。
同步器为了让使用者重写指定的方法,提供了三个基础方法:getState(),获取当前同步状态
setState(int newState):设置当前同步状态
compareAndSetState(int expect,int update):使用CAS设置当前状态,该方法能够保证状态 设置的原子性
同步可重写的方法分为独占式获取锁和共享式获取锁,这里为了不给读者增加负担,只列出独占式获取锁的可重写方法。下面列出简化的源码
protected boolean tryAcquire(long arg) {
throw new UnsupportedOperationException();
}
protected boolean tryRelease(long arg) {
throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {
throw new UnsupportedOperationException();
}
可以看到这些需要重写的方法都是没有具体实现的,所以在使用的时候需要我们去实现。
上面列出需要需要自定义同步组件实现的方法,接下来我们看看同步器提供了哪些模板方法,由于篇幅原因,为了不给读者的阅读带来压力,所以只列出几个核心的方法,具体的大家可以看到JDK源码中 AbstractQueuedSynchronizer 的具体实现。
独占式获取锁
可响应中断的获取锁
释放锁
总的来说,同步器提供的模板方法基本上分为3类:独占式获取与释放同步状态、共享式获取与释放同步状态和查询同步队列中的等待线程情况。自定义同步组件将使用同步器提供的模板方法来实现自己的同步语义。
说了这么多,接下来,我们自己实现一个独占锁,采用组合自定义同步器AQS的方式,帮助大家掌握同步器的工作原理,只有搞懂了AQS才能更加深入的去学习理解 并发包中的其它同步组件。
示例如下如下:
如示例代码所示,大家可以看到实现一个简单的独占锁利用AQS是非常容易的。Mutex中定义了一个静态内部类,它继承了同步器实现了独占式获取和释放同步状态。
在 tryAcquire(int acquires) 方法中,如果经过CAS设置成功(同步状态设置为1),则代表获 取了同步状态,而在 tryRelease(int releases) 方法中只是将同步状态重置为0。用户使用Mutex时并不会直接和内部同步器的实现打交道,而是调用Mutex提供的方法,在Mutex的实现中,以获 取锁的 lock() 方法为例,只需要在方法实现中调用同步器的模板方法acquire(int args) 即可,这样大大简化了实现一个可靠自定义同步组件的门槛。
AQS实现源码分析
AQS结构
先来看下AQS中都有哪些属性,看了这个你基本就知道AQS实现锁的套路了。
看了之后你会发现很简单吧,就只有三个核心属性。
同步器依赖内部的同步队列来完成同步状态管理,流程是这样的:当线程获取同步状态失败时,同步器会将当前线程以及等待状态构造成为一个节点(Node),将其加入到队列,同时阻塞当前线程,当同步状态释放时,会把首节点中的线程唤醒,使其再次尝试获取锁同步状态。
同步队列中的节点用来保存获取同步状态失败的线程的引用、等待状态以及前驱和后继节点,我们来看下代码:
Node 的数据结构其实不复杂,就是thread + waitStatus + pre + next + nextWaiter五个属性而已,大家先要有这个概念在心里。
节点是构成同步队列的基础,同步器拥有首尾节点,获取同步失败的线程将会成为节点加入到该队列尾部,同步队列的基本结构如下图:
通过上面的介绍,你可能着急了,想要看看AQS到底是如何获取锁和释放锁的,别着急,学原理,慢即是快!
那么接下来就跟着具体的实现代码,我也不多啰嗦了。
获取锁
上面也说了,获取锁分为独占式和共享式,为了使阅读更加顺畅,这里我们只看下独占式获取锁,相信你掌握了独占式获取锁模式,再去看共享获取也是没有一点问题的。
上面代码很少,逻辑还是比较清晰的。首先会调用 tryAcquire(arg) 方法,上面也提到了,这个方法是需要同步组件自己实现的,比如 上面我们自己实现的Mutex锁。 该方法保证线程安全的获取同步状态, tryAcquire(arg) 返回 true 表示获取成功也就正常退出了。否则会 构造同步节点(独占式Node.EXCLUSIVE)并通过 addWaiter(Node mode) 方法将加入到同步队列的尾部,最后调用acquireQueued(final Node node, int arg) 通过 “死循环”的方式获取同步状态。如果获取不到则阻塞节点中对应的线程,而被阻塞后的唤醒只能依靠前驱节点出队或者阻塞线程被中断来实现。
下面来看下节点的构造以及加入到同步队列。
上述代码在将构造的节点加入到同步队列末尾时,使用 compareAndSetTail(pred, node) 方法来确保节点能够被线程安全的添加。
下面我们来看下当上面的快速加入同步队列末尾不满足条件时(即上面代码中显示的队列为空或者有多线程并发入队),走到了 enq(node) 方法,即采用自旋的方式入队。
具体就不啰嗦了,上述代码已经写得很清晰了,就是 enq(final Node node) 方法,在死循环中通过CAS将节点设置为尾结点之后,线程才从该方法返回。否则当前线程不断的尝试。 可以看到这个方法使用场景本来不是线程安全的,因为同时可能有很多 调用 tryAcquire 方法获取同步状态失败的线程要进行入队操作。此处巧妙的用自旋加CAS将并发请求变得 “串行化了”。
经过上述方法,节点就进入了同步队列中后,就进入到了一个下一个自旋的过程,每个节点(即获取锁失败的线程)都在自省的观察,当条件满足,获取到了同步状态,就可以从这个自旋过程中退出,否则就苦逼的滞留在这个自旋过程中,并且阻塞节点线程。具体代码如下:
如上,假如当前node本来就不是队头或者就是 tryAcquire(arg) 没有抢赢别人,就是走到下一个分支判断:shouldParkAfterFailedAcquire(p, node) 当前线程没有抢到锁,是否需要挂起当前线程?
上面的代码你一定要自己的理解,如果思路断了希望从上面在顺一遍,以免浪费时间。
这里我们分析下private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) 这个方法返回值的情况:如果返回true, 说明前驱节点的 waitStatus==-1,是正常情况,那么当前线程需要被挂起,等待以后被唤醒,就等着前驱节点拿到锁,然后释放锁的时候叫你好了;
如果返回false, 说明当前不需要被挂起,为什么呢?往后看
shouldParkAfterFailedAcquire(Node pred, Node node) 这个方法返回后,是true 则执行 parkAndCheckInterrupt() 方法:
接下来说说如果 shouldParkAfterFailedAcquire(p, node) 返回false的情况:仔细看shouldParkAfterFailedAcquire(p, node),我们可以发现,其实第一次进来的时候,一般都不会返回true的,原因很简单,前驱节点的 waitStatus=-1 是依赖于后继节点设置的。也就是说,我都还没给前驱设置-1呢,怎么可能是true呢,但是要看到,这个方法是套在循环里的,所以第二次进来的时候状态就是-1了。
如果看到这里思路还是比较清晰的话,那么这里我们再来解释下为什么shouldParkAfterFailedAcquire(p, node) 返回false的时候不直接挂起线程?
这是因为经过这个方法后,当前节点的前一个节点有可能因为超时或者中断而取消阻塞退出同步队列因此设置了新的父节点,这个父节点有可能就已经是head了,这里有没有恍然大悟的感觉。。。
说到这里也就明白了 AQS同步器获取锁的过程,还是希望你能多看几遍 acquireQueued(final Node node, int arg) 方法。 代码不多,花时间推演下各个分支进入的原因,这个时间是值得投入的。
释放锁当前线程获取同步状态并执行了相应逻辑之后,就需要释放同步状态,使得后续节点能够继续获取同步状态。通过调用同步器的release(int arg)方法可以释放同步状态,该方法在释放了同步状态之后,会唤醒其后继节点(进而使后继节点重新尝试获取同步状态)。
唤醒的代码还是比较简单的,你如果上面加锁的都看懂了,下面都不需要看就知道怎么回事了!
唤醒线程以后,被唤醒的线程将从以下代码中继续往前走:
好了,能看完到这里的你肯定已经对于AQS同步器独占式获取锁和解锁流程有了一定的了解,这篇文章就不继续怼源码了。 相信你看懂了上面的,如果还有问题或者想看下非独占式获取释放锁流程,自己去老老实实仔细看看代码吧。
总结
总结一下吧。
Java并发包中提供了锁的另一种实现Lock接口,它定义了锁的获取和释放基本操作。
队列同步器 AbstractQueuedSynchronizer 这个可以说是Java并发包中构建锁和各种安全容器实现的基石,比如 ReentrantLock、ReadWriteLock、CountDownLatch 等的实现中都少了AQS的身影。
在并发环境下,并发包中提供了实现了Lock接口的各种锁,他们依赖AQS同步器完成加锁解锁操作。而AQS的实现主要需要下面三个组件协调:锁状态。我们要知道锁是不是被别的线程占有了,这个就是 state 的作用,它为 0 的时候代表没有线程占有锁,可以去争抢这个锁,用 CAS 将 state 设为 1,如果 CAS 成功,说明抢到了锁,这样其他线程就抢不到了,如果锁重入的话,state进行 +1 就可以,解锁就是减 1,直到 state 又变为 0,代表释放锁,所以 lock() 和 unlock() 必须要配对啊。然后唤醒同步队列中的第一个线程,让其来占有锁。
线程的阻塞和解除阻塞。AQS 中采用了 LockSupport.park(thread) 来挂起线程,用 unpark 来唤醒线程。
阻塞队列。因为争抢锁的线程可能很多,但是只能有一个线程拿到锁,其他的线程都必须等待,这个时候就需要一个 queue 来管理这些线程,AQS 用的是一个 FIFO 的队列,就是一个链表,每个 node 都持有后继节点的引用。
示例图
这幅图用来回顾下获取锁的流程,如果看完还是有点蒙圈的话,这里还有一次机会帮你梳理思路,结合这幅图仔细思考,脑中有这个思路流程,再去看一遍源码。
(本文完)
参考资料:周志明:《深入理解Java虚拟机》
方腾飞:《Java并发编程的艺术》