Java的AQS详解1--独占锁的获取及释放

其实,很早之前就写过一篇浅谈Java的AQS,当时对AQS的运作机制有了大致的掌握,但可能源码还是没完全吃透,有些细节还是云里雾里。

最近又过了一遍AQS源码,觉得体悟更深了些,怕时间久了遗忘掉,记录下来方便后面查阅。

为避免篇幅较长,将这部分2篇来讲解,本文为第1篇,主要讲解AQS独占锁的获取及释放。

加锁

独占锁加锁的方法入口为acquire(int arg);

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

主要分成以下3个步骤:

  • 首先执行tryAcquire(arg)方法尝试获取锁,成功则当前线程获取到锁;
  • 若失败,则将当前线程封装成独占(EXCLUSIVE)节点追加到阻塞队列,并执行acquireQueued方法;
  • 执行acquireQueued方法过程中,若当前线程被标记为中断,则执行selfInterrupt()方法将中断响应掉。

tryAcquire方法是由AQS实现类来定义的,没啥可讲的。

addWaiter

首先看一下,独占节点加入阻塞队列的过程,即addWaiter方法:

private Node addWaiter(Node mode) {
    // 将当前线程包装成mode模式的Node节点
    Node node = new Node(Thread.currentThread(), mode);
    // 首先尝试快速入队
    Node pred = tail;
    // 如果队列的尾结点不为null
    if (pred != null) {
        // 当前节点的前置指向当前尾结点
        node.prev = pred;
        // 将当前节点设置为尾结点
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // 快速入队失败的话,则执行自旋入队
    enq(node);
    // 返回当前节点
    return node;
}

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        // 若尾结点为null,说明当前队列为空,需要初始化
        if (t == null) { // Must initialize
            // 设置头结点为new Node()
            if (compareAndSetHead(new Node()))
                // 尾结点为头结点
                // 注意,此处并没有返回,而是继续自旋,进入下次循环,也就是说,头结点是个空节点
                tail = head;
        } else {
            // 当前节点的前置指针指向当前的尾结点
            node.prev = t;
            // 将当前节点设置为尾结点
            if (compareAndSetTail(t, node)) {
                // 之前的尾结点next指针指向当前的尾结点
                t.next = node;
                return t;
            }
        }
    }
}

整个入队过程如下图所示:

1.jpg

acquireQueued

该线程封装成Node节点进入队列后,紧接着会执行acquireQueued方法。

final boolean acquireQueued(final Node node, int arg) {
    // 执行失败标志
    boolean failed = true;
    try {
        // 中断标志
        boolean interrupted = false;
        for (;;) {
            // 获取该节点的前继节点
            final Node p = node.predecessor();
            // 若该节点的前继节点为head节点,则执行tryAcquire方法尝试获取锁(head节点的状态未被设置为SIGNAL前,将一直自旋执行tryAcquire方法尝试获取锁)
            if (p == head && tryAcquire(arg)) {
                // 获取锁成功,则将该节点置为head节点
                setHead(node);
                // 原head节点的next置为null,以方便GC回收掉
                p.next = null; // help GC
                // 失败标志置为false,即执行成功
                failed = false;
                // 返回中断标志(阻塞被唤醒后会检查中断标志位,如果中断过,则为true,否则为默认值false)
                return interrupted;
            }
            // 2种情况: 前继节点为head结点,但尝试获取锁失败; 前继节点不为head节点
            // 首先执行shouldParkAfterFailedAcquire判断是否需要将该线程阻塞(注意执行完该方法,线程还未阻塞,只是判断时候可以阻塞而已)
            if (shouldParkAfterFailedAcquire(p, node) &&
                // parkAndCheckInterrupt将该线程阻塞
                // 阻塞被唤醒后,会检查阻塞挂起期间该线程是否被中断过,若中断过,则将interrupted置为true
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        // 若失败,当前节点放弃获取锁
        if (failed)
            cancelAcquire(node);
    }
}

可以看到,acquireQueued方法返回的是线程的中断标志,其有2个返回的时机:

  • 若该线程入队后,发现其前继节点为head,则会再次尝试获取锁,如果获取锁成功,则该节点升级为head,并返回中断标志false;
  • 线程入队之后,并被阻塞挂起,唤醒后将先执行Thread.interrupted()判断其阻塞期间该线程是否被中断过,若中断过,则返回true,否则返回false,所以,线程的中断是延迟响应的。

shouldParkAfterFailedAcquire

  • 如果其前继节点>0,则直接忽略,寻找前继的前继,直到前继节点的状态<=0;
  • 前继节点的状态<=0,则自旋CAS将其状态修改为SIGNAL;
  • 仅有其有效前继节点的状态为SIGNAL时,该方法才会返回true。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    // 第1个if分支
    // 若该节点的前继节点的状态为SIGNAL(-1),则代表可安全阻塞挂起(park),返回true
    if (ws == Node.SIGNAL)
        return true;
    // 第2个if分支
    // 若该节点的前继节点的状态>0,即CANCELLED(1),则不断往前遍历,直到某个节点的状态不大于0(<=0)
    if (ws > 0) {
        do {
            // 不断往前遍历
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
        // 将第1个符合条件的节点的next指针指向node
        // 此处会跳出if循环,然后返回false,再次进入到acquireQueued方法的自旋循环中(;;),再次执行shouldParkAfterFailedAcquire时,node节点的前继节点的状态肯定<=0,不会再进入该if分支,若前继节点的状态为SIGNAL(-1),进入第1个if分支,否则进入到第3个分支
    // 第3个if分支
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        // node前继节点的状态为0或者PROPAGATE(-3),则执行CAS将该前继节点的状态设置为SIGNAL(-1)
        // 主要前继节点未被CAS设置为SIGNAL(-1),则外层acquireQueued方法的自旋(;;)将一直跳转到该if分支中,直到前继节点被CAS设置为SIGNAL(-1)
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

parkAndCheckInterrupt

private final boolean parkAndCheckInterrupt() {
    // 将该线程挂起,执行到此处,线程就被阻塞挂起睡大觉去了,不会再往下执行;
    // 同时,最外层的自旋(;;)也停止了,只能等待被唤醒后再继续自旋
    LockSupport.park(this);
    // 线程在此处被唤醒,执行Thread.interrupted()判断阻塞期间是否被中断过
    return Thread.interrupted();
}

cancelAcquire

获取锁或者阻塞过程中,线程宕掉(系统异常或手动kill线程),则会进入到acquireQueued的finally代码里,并判断failed是否为true,若为true则执行cancelAcquire方法放弃获取锁。

private void cancelAcquire(Node node) {
    // 如果该节点为null,则直接返回
    if (node == null)
        return;
    // 将节点携带的线程置为null
    node.thread = null;

    // 从node往前遍历,找到第1个<=0(不为CANCELLED)的前置节点pred
    Node pred = node.prev;
    while (pred.waitStatus > 0)
        node.prev = pred = pred.prev;

    // 获取pred的直接后置节点(中间有CANCELLED节点的话,则predNext != node)
    Node predNext = pred.next;

    // 将该节点的状态置为CANCELLED
    node.waitStatus = Node.CANCELLED;

    // 如果node为尾结点,则将pred设置为新的尾结点,并将pred的后继指针置为null
    if (node == tail && compareAndSetTail(node, pred)) {
        compareAndSetNext(pred, predNext, null);
    // 若node不为尾结点
    } else {
        int ws;
        // 前继节点不是head、前继节点的状态为SIGNAL(-1)且pred.thread不为null
        if (pred != head &&
            // 会尝试1次将前继节点的状态修改为SIGNAL(-1)
            ((ws = pred.waitStatus) == Node.SIGNAL ||
             (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
            pred.thread != null) {
            Node next = node.next;
            // 如果node的后继节点不为null,且状态<=0,则将pred的next指针指向node的后继节点
            if (next != null && next.waitStatus <= 0)
                compareAndSetNext(pred, predNext, next);
        // 前继节点不是head节点,但是状态不为SIGNAL(-1)或者前继节点为head节点
        // 也就是前置节点没有“叫醒服务”,则node节点直接叫醒后继的线程
        } else {
            unparkSuccessor(node);
        }
        // 将node的next指针指向自己,方便被GC
        node.next = node; // help GC
    }
}

主要分成4种情况:

  • node节点为tail节点
2.jpg

当前节点已经为tail结点了,说明没啥后继节点需要去“唤醒”,直接执行出队操作即可。

  • node的前置节点为head节点(且node不为tail节点)
4.jpg

直接唤醒后继节点的线程。

  • node的前置节点不为为head节点,且状态值为SIGNAL(-1)(且node不为tail节点)
3.jpg

可以看到,该种情况下,cancelAcquire方法仅是将pred的后继指针指向next节点,并将node的状态标记为CANCELLED(1),但node及next节点的前继指针并没有改变,这样会导致出问题吗?答案是不会的。

当next节点被唤醒后(pred节点cancel获取锁、pred成为head节点并release锁),将重新进入到acquireQueued方法的自旋(;;)中,每次自旋均会先获取next的前继节点,此时为标记为CANCELLED(1)状态的node节点,显然不是head节点,将会接着执行shouldParkAfterFailedAcquire方法:

// 若该节点的前继节点的状态>0,即CANCELLED(1),则不断往前遍历,直到某个节点的状态不大于0(<=0)
if (ws > 0) {
    do {
        // 不断往前遍历
        node.prev = pred = pred.prev;
    } while (pred.waitStatus > 0);
    pred.next = node;

可以看到,此时next节点的前继指针直接指向了pred节点,完成了前继指针的调整。

  • node的前置节点不为为head节点,且状态值为不为SIGNAL(-1)(且node不为tail节点)
6.jpg

该种情况下,node会直接唤醒next的线程,唤醒之后的线程将进入shouldParkAfterFailedAcquire方法,在shouldParkAfterFailedAcquire方法未返回true之前,会一直自旋执行直到将next的前置节点pred的状态值设置为SIGNAL(-1)。

用个通俗的例子来概括整个过程:

相当于,我在队伍中间因为某种原因不想排队了,但是我有1个任务,就是需要叫醒排在我后面睡觉的人。

如果我已经是队尾了,没有排在我后面人需要被唤醒,那我直接离队就可以。

如果排在我前面的人已经是队头了,则直接叫醒排在我后面的人,让他去获取锁。

如果排在我前面的人不是队头,那么我会先尝试把这个任务交给排在我前面的人,如果前面的人同意,我就会把排在我后面的人告诉他(把他的后继指针指向排在我后面的人)。

如果不同意,那我直接叫醒排在我后面的人,让他自己去求排在我前面的人,直到前面的人同意叫醒他,他再次睡去。

自此,AQS独占锁的获取过程我们就讲解完了,代码不多,但其设计真的很精巧,值得反复咀嚼体会。

解锁

解锁的方法入口为release(int arg):

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方法由AQS继承类实现,没啥可说的。

如果head节点不为null且其状态不等于0,执行unparkSuccessor(h)唤醒head节点的后继节点:

private void unparkSuccessor(Node node) {
    // 获取当前节点的ws值
    int ws = node.waitStatus;
    // 将当前节点的ws值置0
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    Node s = node.next;
    // 若后继节点为null或者其ws值大于0(放弃状态),则从等待队列的尾节点从后往前搜索,
    // 搜索到等待队列中最靠前的ws值非正且非null的节点
    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;
    }
    // 如果后继节点非null,则唤醒该后继节点持有的线程
    if (s != null)
        LockSupport.unpark(s.thread);
}

线程被唤醒后,会进入到parkAndCheckInterrupt()方法的"return Thread.interrupted();"语句中修改线程中断标志,然后自旋(;;)再进入到acquireQueued()的if (p == head && tryAcquire(arg))的判断中,此时被唤醒的线程将尝试获取资源,同时原先的head节点出列,该节点成为新的head节点,相当于队列往前移动了。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,793评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,567评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,342评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,825评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,814评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,680评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,033评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,687评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,175评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,668评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,775评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,419评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,020评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,978评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,206评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,092评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,510评论 2 343