【原创】 强哥Java架构之路 2019-05-30 07:01:00
对于一个Java程序员来说,多线程是考验你基本功的一个非常重要的点,而说到多线程,不得不引出一个概念:锁。在多线程环境下,一个共享变量如果不加锁,那么这个变量就变得不可控了,到底怎么不可控呢?最明显的一个特征就是这个变量最终获得的结果可能并不是我们想要的结果。
导致上面不可控的原因主要是由于Java内存模型(JMM)所决定的,我们知道对于每一个线程来说都有自己的工作内存,它不能直接操作主内存中的共享变量,而是从主内存中复制一份共享变量到自己的工作内存中,然后对工作内存的副本进行操作,操作完成,在适当的时候刷新到主存中。Java线程工作内存和主内存简易图如下:
锁大致可分为独占锁和共享锁,所谓的独占锁就是说只有一个线程在某一时刻才能进入临界值,其他线程只能阻塞等待,JDK中一个关键字synchronized就是一个独占锁,这一篇文章我们另一种实现机制:通过关键字volatile+CAS原理,Doug Lea大神为我们设计了一个抽象的同步队列框架AbstractQueuedSynchronizer,简称AQS框架,所有的锁都是基于它实现的,那么接下来我们分析以下AQS是怎样实现独占锁模式的。
AQS内容知识点非常的多,我准备利用五篇文章进行详细的介绍:
第一篇文章:分析AQS的独占模式资源获取
第二篇文章:图解AQS的独占模式资源获取
第三篇文章:分析AQS的共享模式资源获取
第四篇文章:图解AQS的共享模式资源获取
第五篇文章:分析AQS具有Condition条件时操作
本篇文章主要内容如下:
1:AQS中的数据结构 2:AQS中独占模式资源的获取 3:AQS中独占模式资源的释放
AQS是Java并发包中实现锁的基本框架,所有的核心操作都在这一个类中,那接下来我们看一下它是怎样定义的。
首先我们看一下AQS的底层数据结构Node的定义
static final class Node { //定义锁的模式,共享模式 static final Node SHARED = new Node(); //定义锁的模式:独占模式 static final Node EXCLUSIVE = null; //定义节点的状态:CANCELED为取消状态,唯一一个大于0的状态 static final int CANCELLED = 1; //定义节点状态:SIGNAL为后继线程需要被唤醒状态 static final int SIGNAL = -1; //定义节点状态:CONDITION为线程在等待条件的状态 static final int CONDITION = -2; //定义节点状态:PROPAGATE为下一个acquireShared无条件传播的状态,用于共享模式,在下一篇讲解共享模式时会详细讲解 static final int PROPAGATE = -3; //节点状态,它的值就是上面定义的节点状态,默认值为0 volatile int waitStatus; //当前节点的前驱节点 volatile Node prev; //当前节点的后继节点 volatile Node next; //当前节点所对应的线程 volatile Thread thread; Node nextWaiter; //判断此节点是否是共享模式 final boolean isShared() { return nextWaiter == SHARED; } //获取当前节点的前驱节点 final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } //空构造函数:主要用于初始化头结点(head)或者创建共享模式 Node() { } //1:当前线程,2:节点模式,主要用于把节点添加到队列中 Node(Thread thread, Node mode) { this.nextWaiter = mode; this.thread = thread; } //1:当前线程,2:节点状态,主要用于条件中 Node(Thread thread, int waitStatus) { this.waitStatus = waitStatus; this.thread = thread; } }
从上面的源码可以看出,AQS底层的数据结构Node是一个双向链表,总结如下:
1:定义了节点模式:独占模式和共享模式 2:定义了节点的状态 3:定义了节点的前驱节点 4:定义了节点的后继节点 5:第一个构造函数:无参数构造,主要用于创建头结点head和创建共享模式 6:第二个构造函数:参数为当前线程和节点模式,主要用于创建添加到等待队列中的节点。 7:第三个构造函数:参数为当前线程和节点状态,主要用于有Condition条件的时候
上面介绍了Node节点的定义,接下来继续介绍AQS中的成员变量。
//定义等待队列头结点 private transient volatile Node head; //定义等待队列的尾节点 private transient volatile Node tail; //定义同步状态,被volatile修饰。 private volatile int state;
上面AQS定义了等待队列的首节点和尾节点,并且定义了贯穿全文的非常重要的变量state,被volatile修饰。所以总结AQS如下
1:维护着一个等待队列,多线程竞争资源失败后都会封装成一个Node节点进入这个队列。 2:维护者一个共享变量state,并且被关键字volatile修饰,这个变量是AQS的核心。 state=0:表示锁还未被占用 state=1:表示锁已经被占用 state>1:表示可重入锁
独占的意思就是在某一时刻只能有一个线程获取到锁,在AQS中有三种独占锁的获取方法
第一种:忽略中断的获取方法:acquire(int arg) 第二种:响应中断的获取方法:acquireInterruptibly(int arg) 第三种:超时的获取方法:tryAcquireNanos(int arg, long nanosTimeout)
这三种方法内部的原理基本一致,主要区别就是第一种在获取时会忽略中断,而第二种则是获取时响应中断,第三种是获取时,如果超时则立即返回,我们首先跟进acquire()方法
public final void acquire(int arg) { //两个条件: //第一个条件tryAcquire():尝试获取锁,如果不成功则调用第二个条件 //第二个条件:acqureQueued():等待队列中获取资源,直到获取成功才返回 if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
acquire方法是获取锁的基础,这个方法会忽略中断,意思是说如果节点对应的线程中断,则acquire()方法会忽略,只有从等待队列中返回true才最终调用selfInterrupt方法响应中断。对上面两个条件的方法进行详细的说明。
1:尝试获取资源state,tryAcquire(int arg)
protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
这个方法在AQS中并没有实现,说明它的子类会实现它,它的核心就是操作state
2:获取资源失败,则封装成Node节点加入到等待队列中,addWaiter()
private Node addWaiter(Node mode) { //将当前线程和节点模式封装成Node节点 Node node = new Node(Thread.currentThread(), mode); //下面的代码就是将上面的节点Node加到等待队列的尾部 Node pred = tail; if (pred != null) { //走到这一步:说明等待队列不为空,将当前尾部节点tail赋值给新节点Node的前驱节点。 node.prev = pred; //由于是多线程,可能并发修改tail节点,所以通过CAS原子修改tail节点 if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } //走到这一步:说明此时等待队列是空队列。 enq(node); return node; } private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { //调用无参构造创建一个节点,然后通过CAS设置head节点,如果成功则将头结点head赋值给tail节点 if (compareAndSetHead(new Node())) tail = head; } else { //将封装的Node节点加到尾部 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
上面的addWaiter()方法其实就是将新封装的节点Node加入到等待队列的尾部。对这个方法总结如下:
1:首先判断等待队列是否是空队列,如果不是空队列,则通过CAS方法将新创建的节点添加到等待队列的尾部,变成新的tail。 2:如果是空队列,则首先通过无参构造创建new Node(),创建头结点head,然后继续循环将新节点加入到尾部。
流程图如下:
3:在等待队列中获取资源:acquireQueued
其实这个方法非常的好理解,前面已经尝试获取资源,但是失败了,并且加到了等待队列中,在等待中可以判断自己是否可以休息以下,如果可以休息,那就等待着其他线程唤醒自己,在继续获取资源,直到成功才返回。
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { //判断是否中断的标记 boolean interrupted = false; //无限循环,就是我们所说的自旋 for (;;) { //获取当前节点的前驱节点 final Node p = node.predecessor(); //如果当前节点的前驱节点是头结点head,那说明当前线程在等待队列中排行第二,就有资格尝试去获取资源了,可能前驱释放了资源。 if (p == head && tryAcquire(arg)) { //说明获取资源成功,将自己设置成头结点。 setHead(node); p.next = null; // help GC failed = false; return interrupted; } //走到这一步:说明前驱节点不是头结点head,或者尝试获取锁失败 //第一个条件:如果获取锁失败,判断是否应该休息 //第二个条件:第一个条件true,说明自己可以休息了则调用park方法休息 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
这个方法其实就是有两个意思。
第一个意思:判断自己是否能获取资源,条件就是自己的前驱节点是头结点head,这样自己才有获取资源的机会。如果自己的前驱不是head,那自己还没有权限获取资源。
第二个意思:如果自己的前驱节点不是head,或者虽然是head,但是调用tryAcquire获取资源失败,那就需要判断自己是否可以休息了。而shouldParkAfterFailedAcquire()方法就是判断是否可以休息的,parkAndCheckInterrupt()方法就是调用休息方法。
//pred:当前节点的前驱节点 //node:当前节点 private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { //获取前驱节点的状态 int ws = pred.waitStatus; //如果前驱节点状态为SIGNAL(-1),那自己就可以放心去休息了 if (ws == Node.SIGNAL) return true; //节点状态只有CANCEL(-1)时才大于0,说明这个节点取消了。那就需要把取消的节点从等待队列中移除掉。do{}while()循环就是干这件事的。 if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { //走到这一步:说明前驱节点既不是SIGNAL,也不是CANCEL,那接下来就把前驱节点的状态通过CAS变成SIGNAL(-1) compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
自己是否可以休息,主要有前驱节点的状态决定,
第一点:如果前驱节点状态为SIGNAL(-1):说明自己可以安心去休息了,当前驱节点释放资源时会唤醒自己。
第二点:如果前驱节点被取消了,则需要将取消的节点从等待队列中移除掉,直到找到没有被取消的节点,然后作为自己的前驱节点。
第三点:前驱节点不是上面两个状态,则把前驱节点的状态通过CAS改成SIGNAL(-1)
上面知道了我是否可有休息,如果可以休息,那就需要调用休息方法了。
private final boolean parkAndCheckInterrupt() { //park:就是让自己休息 LockSupport.park(this); //返回当前线程是否被中断过 return Thread.interrupted(); }
接下来在总结以下流程如下:
不响应中断的获取资源方法acquire就讲解结束了,不响应中断意思就是在等待队列中获取资源时如果线程被中断,则会忽略,当在等待资源中成功获取资源返回后在对中断进行操作,在AQS中也提供了响应中断的获取方法。
public final void acquireInterruptibly(int arg) throws InterruptedException { //首先判断线程是否被中断,如果被中断则直接抛出异常 if (Thread.interrupted()) throw new InterruptedException(); //如果没有被中断过,则尝试获取资源 if (!tryAcquire(arg)) //说明获取资源失败,则需要加入等待队列,在等待队列中获取资源 doAcquireInterruptibly(arg); }
和acquire方法原理类似,只不过会首先判断是否被中断过,如果中断则抛出异常,然后获取资源,如果失败,则添加到等待队列中,从等待队列中获取资源,接下来我们看一下在等待队列中获取资源是如果线程被中断,则会响应中断。
大家可以看一下上图标红的地方,这个和上面acquire中有所区别,这个方法检测到被中断过,则直接抛出异常。
忽略中断和响应中断的获取资源方法都说了,还有一种就是超时的情况,看看AQS又是怎样处理的。
public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException { //判断是否被中断过,如果中断过直接抛异常 if (Thread.interrupted()) throw new InterruptedException(); //尝试获取资源,如果失败添加到等待队列中,从等待队列中获取资源 return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout); }
如下图标红的地方就是判断超时的处理步骤。
上面讲解了三种关于获取共享资源的方法,接下来继续分析资源是怎样释放的。
public final boolean release(int arg) { //尝试释放资源 if (tryRelease(arg)) { //走到这一步:说明释放资源成功 Node h = head; if (h != null && h.waitStatus != 0) //唤醒等待队列中的后继节点 unparkSuccessor(h); return true; } return false; }
从上面源码中,可以总结释放资源的两个步骤:
第一个步骤:调用tryRelease()方法尝试释放资源
第二个步骤:释放资源成功,如果等待队列还有节点,则需要唤醒当前头结点head的后继节点
我们开始一个步骤一个步骤的分析,和获取资源一样,tryRelease()方法在AQS中并没有实现,需要子类去实现,其实就是对state的释放
protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
第二个步骤调用unparkSuccessor()方法唤醒后继节点。
//参数node:表示当前的头结点head private void unparkSuccessor(Node node) { //获取头结点的状态 int ws = node.waitStatus; if (ws < 0) //如果状态小于0,通过CAS将状态改成0,因为此时头结点释放资源 compareAndSetWaitStatus(node, ws, 0); //找到下一个需要唤醒的节点,默认下一个唤醒的节点是head的后继节点 Node s = node.next; //如果后继节点为null,或者被取消了,则继续查找 if (s == null || s.waitStatus > 0) { s = null; //则从尾节点tail开始查找,直到查找到等待队列中最前面状态小于0的节点,然后唤醒这个节点 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) //唤醒查找到的节点 LockSupport.unpark(s.thread); }
唤醒方法非常的简单,其实就是查找到下一个需要唤醒的节点,首先就是判断头结点head的后继节点,如果后继节点为null或者后继节点被取消,则从尾节点tail往前查,直到查到最前面那个小于0的节点
本篇文章主要分析了独占模式下资源的获取与释放,这是独占锁的获取和释放的基础,大家需要真正的去理解,可能对于一部分同学来说资源的获取和释放还是比较的抽象,下一篇文章我将用图解的方法结合例子在讲解以下,如果对您有所帮助,请关注我。