java并发包的核心就是AbstractQueueSynchronizer,理解了它,就能够理解锁,理解了锁,再看各种同步容器,并发工具的核心方法时就会非常容易。本文主要分析获取锁的整个流程,队列的节点,队列的状态,cas操作等基础知识并未展开。
因为AQS只是构建锁的基抽象类,单独拿出来不能够完整分析,所以我们以最常见的ReentranceLock的lock和unlock为例进行分析,这里选取默认的非公平模式。
为便于理解,有3个注意点。 1,锁的语义级别是线程,当看见一个上锁的代码段时,首先应该找出这段代码里哪些字段是共享变量,他们往往是上锁的原因。这样再去搜索这个变量还在其他哪些方法中使用了,结合多个方法一起看对理解代码含义很有帮助。2,因为队列节点持有线程,所以当看到本文中的字眼【线程】和【节点】时,他们也许代表同一个意思。3,节点类有一个字段waitStatus,AQS队列有一个字段state,前者用来节点间通信,后者表示锁的持有状态,不要混淆。
接下来就从官方给的使用例子开始吧。
class X {
private final ReentrantLock lock = new ReentrantLock();
// ...
public void m() {
lock.lock(); // block until condition holds
try {
// ... method body
} finally {
lock.unlock()
}
}
}
从第5行的lock()方法开始。
final void lock() {
if (compareAndSetState(0, 1)) //1
setExclusiveOwnerThread(Thread.currentThread()); //2
else
acquire(1); //3
}
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread; //4
}
1 先进行一次cas操作,这里不管等待队列是否有等待线程,直接尝试获取锁,未跟等待队列交互,体现非公平性的一个点。 2 把当前线程赋给独占线程变量 3 若锁被持有,进acquire(1)方法,尝试进队
/**
* 独占获取,忽略中断 至少调用一次tryAcquire方法,成功自然返回,不成功则线程入队,期间会多次调
* 用tryAcquire直到成功获取。
*/
public final void acquire(int arg) {
if (!tryAcquire(arg) && //5
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) //6
selfInterrupt(); //7
}
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
5 先看tryAcquire方法,它调用nonfairTryAcquire()方法。这个方法的核心思想就是对state字段进行cas操作,成功则拿到锁并返回true,失败则返回false。我们可以发现这个方法也不管等待队列有没有等待节点,直接插队获取,非公平性的又一体现。
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread(); //拿到当前线程
int c = getState(); //查看state字段的值
if (c == 0) {
if (compareAndSetState(0, acquires)) { //cas
setExclusiveOwnerThread(current); //设置独占线程
return true;
}
}
else if (current == getExclusiveOwnerThread()){ //若持有锁的就是当前线程(可重入的体现)
int nextc = c + acquires; //state字段加上想获取的个数
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded"); //溢出
setState(nextc);
return true;
}
return false; //获取失败返回false
}
6 tryAcquire失败了才会进acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) ,addWaiter把当前线程放到等待队列的队尾,acquireQueued是对队列内节点获取锁的操作以及挂起操作的实现 ,这里可以说是队列外尝试获取和队列内尝试获取的分割点了。 6.1先看addWaiter() 双向链表的正常入队操作,结果是把持有当前线程的节点放到队尾。
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode); //包装当前线程作为节点
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred; //将当前节点与队尾节点连接
if (compareAndSetTail(pred, node)) { //cas重置队尾节点
pred.next = node; //将旧队尾节点与当前节点连接
return node;
}
}
enq(node); //队尾为空则当前节点做队头,构造新队列
return node;
}
需要注意的是倒数第二行的enq()方法,在单线程确实是作为对头入队,但考虑多线程的话,有可能别的线程也发现对头为空,同时也调用了enq()方法,所以enq()方法内部还是通过CAS操作来保证只有一个线程的设置对头操作可以成功。 而且这里的入队节点不会被置为队头,队头是一个空线程的节点。还在尝试获取锁的节点不能作为队头,队头要么是空节点,要么是持有锁的节点,
结论: 即等待队列的真正头节点是第二个节点。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // 必须初始化
if (compareAndSetHead(new Node())) //队头节点
tail = head;
} else { //双向链表正常入队
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
6.2 承上,节点入队之后,再看acquireQueued() 我们前面再6提过一句,acquireQueued是对队列内节点获取锁的操作实现.,它规定了线程何时挂起何时尝试获取锁。
/**
* 为在队列中的线程以独占非中断模式获取锁,该方法也被condition wait方法使用
*/
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; //⑦锁获取成功,失败标志位置false
return interrupted; //⑧返回是否中断过的标志位 ,函数唯一出口
}
if (shouldParkAfterFailedAcquire(p, node)&& //⑨获取锁失败,判断挂起还是再次重试
parkAndCheckInterrupt()) //⑩挂起该节点,同时检测中断
interrupted = true; //⑪中断发生则标志位置true
}
} finally {
if (failed)
cancelAcquire(node); //⑫失败获取,取消该节点的获取锁资格
}
}
该方法是核心逻辑,注意点较多。首先该方法只有一个出口,即11行,要到达这里必须获取锁【即第7行的tryAcquire】成功,否则无限循环。然而若要走到11行,就必须设置failed字段为false,那finally的if语句岂不是永远不会成立。想来想去,只有一种可能,即抛出RuntimeException,然而究竟有什么错误会抛出目前不得而知。
其次,第7行的p==head保证了:如果线程入队,则会遵循先进先出原则来tryAcquire,避免出现孤儿节点。然后,第8行会把拿到锁的节点放到队头,为释放锁时唤醒后继节点做准备(unparkSucessor方法会唤醒头节点的后继节点,可结合等待队列的真正头节点是第二个节点这一结论来理解)。 接下来到shouldParkAfterFailedAcquire方法,如注释所说,他是节点间信号控制的核心方法。
/**
* 检查并且更新一个失败获取锁的节点的状态。
* 返回TRUE如果当前线程应该中断。 这是主要的信号控制方法在所有获取锁的循环中
* 要求参数满足 pred == node.prev.
*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; //拿到前节点的状态
if (ws == Node.SIGNAL)
/*
* 这个节点已经通知过前节点在它释放锁后,告诉这个节点可以尝试获取。所以可以安全挂起
* 当前节点,返回TRUE */
return true;
if (ws > 0) {
//前节点被取消,跳过前节点并重试
do {
node.prev = pred = pred.prev; //链表删除节点操作
} while (pred.waitStatus > 0); //直到找到前节点不是取消状态,停止
pred.next = node;
} else {
//等待状态一定是0或者PROPAGATE。表明我们需要一个信号,但是还不能挂起。
// 调用者需要重试来确保在它挂起之前真的不能获得锁。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false; //当前节点还不能挂起。
}
这里还是把节点类的可能状态补上,便于分析。
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should
* unconditionally propagate
*/
static final int PROPAGATE = -3;
当一个节点被挂起,它想要被唤醒的话,必须在前节点释放锁之后通知他,我们在后面看unparkSucessor方法会发现,只要后继节点的waitStatus不是1,就会被唤醒。这里只有当ws == Node.SIGNAL时shouldParkAfterFailedAcquire[失败获取后应该挂起]方法才能返回TRUE只是代码的一种规定。若ws>0 [代码第8行],则前节点的state是取消,跳过前节点,返回false。若等待状态是0或者PROPAGEATE,表明 可以重试,还不能挂起,所以把前节点的等待状态置-1,返回false。PROPAGATE的注释可以发现,它用在共享模式,所以在独占模式把waitStatus的值置为Signal。
让我们再次回到acquireQueued方法的14行,只有当shouldParkAfterFailedAcquire方法返回true,即允许当前线程挂起的时候,才能调用parkAndCheckInterrupt方法。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted(); }
这个方法虽然只有两行,但是park方法是线程挂起的核心,我们可以看到它没有捕获InterruptedException,而是在线程再次启动时,判断现成的中断状态并返回。这是他与sleep,wait方法的不同,他能够响应中断,但并不抛出异常,所以需要代码来判断究竟是中断唤醒了该线程,还是unpark方法。 一般线程会挂起在park方法,当被唤醒后回到acquireQueued方法继续循环,再次尝试获取锁,失败的话再次挂起,直到成功为止。
https://blog.csdn.net/aitangyong/article/details/38373137可以看看这篇文章关于park和unpark的说明。
假如parkAndCheckInterrupt真的返回了true,那么我们回到acquireQueued方法15行,设置中断标志位为true。这个值最终会作为返回值传递到上层,让上层代码来决定这个迟来的中断信号究竟处理不处理。这也是acquireQueued方法注释所写“为在队列中的线程以独占非中断模式获取锁”的意思所在。然后代码回到第5行继续循环,直到成功获取锁为止。
lock方法告一段落,让我们看一下unlock方法。前文提到,挂起节点的唤醒需要前节点为头节点。所以该方法有两块逻辑,1是cas设置独占线程和state的值。二是唤醒头节点的后继节点。以ReentrantLock的unlock方法为例
public void unlock() {
sync.release(1);
}
/**
* 独占模式的释放锁. 如果tryRealease返回true的话,非阻塞一个或多个线程
* 该方法被unlock方法调用。
*/
public final boolean release(int arg) {
if (tryRelease(arg)) { //1 尝试释放锁
Node h = head;
if (h != null && h.waitStatus != 0) //2 队头状态不是0
unparkSuccessor(h); //3 唤醒后继节点
return true;
}
return false;
}
先看1的tryRelease()方法
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
protected final void setState(int newState) {
state = newState;
}
protected final void setExclusiveOwnerThread(Thread thread) {
exclusiveOwnerThread = thread;
}
这个方法就是前文提到的unlock方法两大逻辑中的释放逻辑。释放独占线程,设置state的值。这个方法唯一值得思考的点在为什么共享变量state和ExclusiveOwnerThread的设置没有使用CAS操作。
这里尝试分析为什么没有使用CAS操作,可以跳过。
分析到现在,我们发现修改state和ExcluesiveOwnerThread的值的方法有两类,一类Acquire,一类Release。
多线程可能发生值覆盖的情况 | A线程 | B线程 |
情况1 | tryRelease | tryRelease |
情况2 | tryAcquire | tryRelease |
情况3 | acquireQueued | tryRelease |
因为持有锁的线程只有一个,所以Release方法在同一时刻只会被一个线程调用,不会产生多线程覆盖值的操作,情况1不会发生。Acquire修改这两个变量的值的方法可以分为未入队线程直接获取和入队节点尝试获取两类,分别为情况2和情况3。
未入队线程直接获取的方法tryAcquire内部其实是nonfairTryAcquire方法。所以我们先看一下情况2.
如下图,左边是线程A,尝试不进队列直接获取锁,右边是B线程,尝试释放锁(证明B线程现在持有锁,走到setState(c)之前,state的值都不是0)。线程B的问题是红框setExclusiveOwnerThread(null)到红框setState(c)之间,OS切换到线程A的话否会调用setExclusiveOwnerThread(current)。若线程A要走到setExclusiveOwnerThread(current),state必须为0才有可能,state要是0,线程B必须走到末尾的setState,竞争1不会发生。
线程A要进setState必须是线程A是持锁线程,即重入的情况,此时线程B持有的锁,线程A不会走到setState,竞争2不会发生。
再看情况3.道理相同,acquireQueued线程未挂起的时候,第7行还是走tryAcquire,跟情况2相同。acquireQueued线程挂起的时候,需要前节点的唤醒,前节点的tryRelease成功后才会调用unparkSuccessor唤醒后节点。也不会产生冲突,这里就不展开分析了。关于多线程竞态变量的赋值,例如ConcurrentHashMap的put和get,CountDownLatch的await,countdown,各种锁的加锁和上锁等成对的方法的分析,要结合happen-before原则来分析。happen-before是java内存模型的高度概括,大家可以找资料自己看一下。
final boolean acquireQueued(final Node node, int arg) { public final boolean release(int arg) {
boolean failed = true; if (tryRelease(arg)) {
try { Node h = head;
boolean interrupted = false; if (h != null && h.waitStatus != 0)
for (;;) { unparkSuccessor(h);
final Node p = node.predecessor(); return true;
if (p == head && tryAcquire(arg)) { }
setHead(node); return false;
p.next = null; // help GC }
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
release()释放锁内部的最后一个方法,概括一句话就是跳过被取消节点,唤醒后继节点。注意点:遍历时考虑到有null的情况,正序遍历会使null后的节点都断掉,倒叙会使null前的节点都断掉, 为了使队列走下去,舍弃null前比null后的节点要好,所以这里倒叙遍历。
private void unparkSuccessor(Node node) {
/*
* node是头节点。它的状态如果是负的(可能是signal)
* 尝试清楚这个状态值,这个值可能同时被等待线程改变,这里修改失败也不影响。
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* 启动后继节点,一般是下一个节点,但下一个节点明显null或者是取消状态的话
*倒序遍历队列,启动正数第一个非取消状态的节点并唤醒它。
*/
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);
}
一点想法,
我们可以看出头节点的waitStatus并没有用来作为唤醒后继节点的条件。还记得获取锁的方法中shouldParkAfterFailedAcquire方法中节点想要挂起,必须值为SIGNAL,然而只要后继节点的waitStatus不为1就可以唤醒。SIGNAL这个值貌似并没有完成节点间通信这一功能。也就CANCELLED(对应 14行和17行)被用作删除节点的条件。
第7行和第8行的 if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);这一操作可以失败似乎也在说明这一值并没有那么重要。也许在其他模式中会用到这个值也说不定,或许仅仅是尽量保证多线程安全的一种操作。