前言
我们第一章提到了JUC的几大组成部分,其中就包含了“锁”,实际上Lock锁在业务上的使用频率恐怕是最高的,它弥补了很多synchronized关键字无法顾及的场景,灵活而强大。今天我们就来看一看这个强大的 Lock 以及它的核心类 AbstractQueuedSynchronizer (AQS)
一、Lock怎么用
1. 基础使用
我们先上一段代码,逻辑是用两个线程各为变量a加上10000次1。
public static void main(String[] args) { SoutA soutA = new SoutA(); new Thread(soutA).start(); new Thread(soutA).start(); } static class SoutA implements Runnable { static int a = 0; static Lock lock = new ReentrantLock(); @Override public void run() { for (int i = 0 ; i < 10000; i++) { // lock.lock(); a++; // lock.unlock(); } System.out.println("Thread : " + Thread.currentThread().getName() + ", a = " + a); } }
我们都知道,在没有锁的情况下,结果是不固定的,两个线程结束后,a最终可能并没有达到20000
如果我们把lock的两行代码放开,结果就能恢复正常
如果你有兴趣,可以考虑一下,如果此处不用锁,仅对变量a 加上 volatile修饰,最终结果能否输出20000?
此处我们可以看到lock的用法是要先 new 一个Lock实现类,然后使用lock() unlock()进行上锁、解锁。需要注意的是,当同步区域代码出现异常时,lock锁不会自动解锁,最好使用try catch finally 句式,这与sunchronized是有所不同的,所以lock锁的通常格式是这样的
try { lock.lock(); // 同步代码 lock.unlock(); } catch (Exception e) { // 异常处理 } finally { lock.unlock(); }
2. Lock提供的方法
我们上面显示了lock() unlock() 两个方法,我们来看看Lock接口还定义了哪些方法,以及这些方法的功能
- lock
- 申请获取锁后立即返回,如果未获取到,则线程出于调度目的将被休眠,直到其获得锁
- lockInterruptibly
- 同lock,但如果在获取锁之前,或者休眠状态中,线程被设置了中断标志,将会向外抛出中断异常
- newCondition
- 为锁新建一个条件队列,并返回这个条件队列
- tryLock
- 尝试获取锁,无论是否成功,均立即返回,返回值就是是否成功的布尔值
- tryLock(long time, TimeUnit unit)
- 尝试获取锁,成功立即返回,即使不成功在指定时间后也返回,返回值就是是否成功的布尔值
- unlock
- 释放锁,只有锁的持有者才能释放锁
除了 newCondition() 大部分接口都非常简单,关于condition这个条件队列是什么,有什么用,我们后续会讲到
二、AQS
前面我们提到了ReentrantLock,但是其具体是怎么实现的,相信大家还有很多疑惑。其实ReentrantLock 的核心功能都是由AQS实现的,我们本章就先讲一讲AQS,AQS是整个Lock最核心的类,因此我们会详细去讲
1. AQS的作用
我们还是先看类图
从名字和方法不难看出,与我们上面说的锁的模型是吻合的,锁其实是一堆线程在竞争资源,这堆线程分为两种:锁的持有线程、队列里等待着的线程。前者由抽象类AOS来操作,后者由AQS来操作,又因为AQS继承了AOS,因此AQS就拥有了管理所有线程的能力
AQS 一般被翻译成队列同步器,那么它的作用显而易见:实现一种同步模型,使得各个线程对资源的竞争能够有序进行。它定义了一个锁需要的所有基础方法 以及 部分实现,如果你想要自定义一种锁,那么直接继承AQS,或者包含一个继承AQS的实例,能大大减轻你的工作量
2. AQS的实现
以我的习惯,是更喜欢直接看源码的。但当我学习的时候,会发现看源码虽然深刻,但速度却十分缓慢。当时就想着,要是有个人能带着我把各个功能描述清楚。有我不懂的细节再去看源码,效率会大大提高,而我现在想做的就是这样事
我们回顾上述的图,AQS如果想实现这张图的功能,那么有几个方面是它必须考虑的
比如:锁是怎么获取的?没获取到锁线程会怎样?条件队列是怎么实现的~ 下面我们将分小节来详谈。
2.1 锁的竞争
AQS其实没有实现获取锁这部分方法,我们可以看到,这里是直接报错的,所以这段获取锁的逻辑交由其子类去实现,我们会在 ReentrantLock 里对这一部分做一些解析
但我们必须意识到一点,锁往往对应的是并发环境,所以锁的竞争必须是个原子操作,这一点上,几乎所有的锁都是一致的,大部分的锁都会使用一个变量作为标志,通过对变量进行CAS操作,根据结果判断是否获取成功。synchonized 底层是这样,AQS也是如此。
2.2 竞争成功的处理
竞争成功的线程,其实已经意味着线程是锁的持有者,如果锁是排他锁,则需要记录锁的持有线程,关这部分内容,是由AOS完成的,我们可以看看AOS的逻辑
可以看到,AOS其实就维护了一个线程属性,和它的get set方法。所以还是十分简单的,注意这里只有一个线程属性:意味着用于排他锁,无法适用于共享锁
2.3 竞争失败的处理
从我们前面的模型来看,竞争失败的线程会被放入条件队列,也叫等待队列,事实也确实如此
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
在AQS里,竞争失败的线程会被封装进一个 Node 节点,然后加入等待队列尾部,这个等待队列是一个双向链表结构。
进入同步队列后,仍然会在队列中继续尝试获取锁,即 acquireQueued 方法的含义。
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); } }
如果在队列中还是没获取到锁,方法内会判断是否需要进行线程的park阻塞。当然,这里获取锁是在循环体内操作的,阻塞的线程被唤醒或被中断都会苏醒,醒来后又继续尝试获取锁。我们在这里需要注意两个点
- 队列竞争:阻塞链表以头尾节点为锚进行链表节点的增删,为保证并发场景正确,头尾节点使用了volatile修饰,节点变动以CAS进行原子操作
- head节点:head作为链表起始点,是个空节点,不包含线程。关于链表头是空节点的,在算法中经常被使用,能减少逻辑复杂度
- 中断响应,因为 UNSAFE.park() 可以响应中断,唤醒线程,所以本方法也会被唤醒,但本方法响应中断后并没有退出循环,如果想中断就使线程退出竞争,可以使用doAcquireInterruptibly方法