AQS的小知识点

194 阅读4分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

AQS小知识点

对于aqs,我们必须熟悉其原理,因为在juc包中大部分的工具类都会基于aqs来实现;在aqs中存在很多小的知识点,我们只有理解了它们,才能全方位的了解aqs的设计思想;

1 不注意的空指针

在aqs中,对于公平锁而言,在抢占锁时需要先判断队列中是否存在等待的节点,若存在则加入队列,否则才进行cas设置state变量;那么如何判断队列中是否存在等待的节点呢?

public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
    Node t = tail; // Read fields in reverse initialization order
    Node h = head;
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

以上代码先获取tail节点,再获取head节点,然后判断两者不相同时;

Read fields in reverse initialization order

public final boolean hasQueuedPredecessors() {
    // The correctness of this depends on head being initialized
    // before tail and on head.next being accurate if the current
    // thread is first in queue.
     Node h = head;
    Node t = tail; // Read fields in reverse initialization order
 
    Node s;
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

如果先获取head,再获取head时;head和tail的初始化过程如下

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

上面代码先初始化head,再初始化tail;

  1. 线程1先获取head节点值为null;
  2. 线程2再初始化head和tail节点;
  3. 线程1执行s = h.next时,由于head节点为null,此时发生空指针异常;

通过上述分析,我们发现某一个属性的前提就会出现异常情况,Doug Lea在代码中页注释了Read fields in reverse initialization order,不得不佩服他老人家的技术功底;

2 从后往前找可唤醒的节点

在aqs中,当某一个持有锁的线程释放锁时,需要唤醒队列中第一个未中断的线程来获取锁,但是我们发现代码实现上是从tail往前找,并不是直接从head处找;那么这是为什么呢?

head和tail的初始化过程
private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

上述代码我们发现节点的指向是先设置prev指针,当cas替换tail节点成功后,再将原来的tail节点的next指针指向自己;假设此时存在3个线程由于未抢占到锁时加入到双向链表中,流程图如下:

image.png 所以当我们在唤醒队列中节点时,如果从head节点开始,那么在时刻1,由于链表不完整,势必导致后续的节点无法被唤醒从而造成一直等待下去;

思考:

能否先设置next节点再设置prev节点呢,那这样就可以从前往后唤醒了?

3 解决bug引入的PROPAGATE状态

jdk1.8版本中Node状态分为

  1. Cancel 1 取消状态
  2. 0 默认状态
  3. Signal 待唤醒状态 -1
  4. Condition condition队列中 -2
  5. PROPAGATE -3 传播 然而再jdk1.6版本以前(包含)是不存在PROPAGATE状态的.引入这个PROPAGATE状态的目的是解决在共享锁Semphore中的一个bug;
场景再现

在Semphore实现aqs的共享锁时(共享次数为2);假设t1.t2都获取到锁,t3.t4未获取到锁时,t3.t4都会加入到clh队列中;其中head的waitStatus=-1,t3结点的waitStatus为-1,t4结点的waitStatus为0;

  1. t1释放锁,,先将head的waitstatus重置为0,再将t3唤醒t3,t3抢占锁后,propagate代表剩余可获取的数量此时为0,那么不会唤醒t4,,t3先设置自己为head,此时head的waitStatus仍为-1,t3暂停操作;
  2. t2释放锁,将头结点即t2的waitstatus置为0,还是唤醒t3,无效唤醒;
  3. t3释放锁时,由于head的waitStatus为0,所以不会唤醒t4;

jdk1.6版本共享模式下释放锁往后传播的源码: image.png

4 线程都要抢占两次锁

再独占锁模式下,当某一个线程抢占锁失败后,加入到clh队列中后,为什么不立刻被park,而是还要执行一次循环,这样是否会造成浪费;由于业务加锁的时间间隔比较短,当某一个线程由于未抢占到锁后被阻塞了,但是再很短的时间内立刻被唤醒了.这么会造成大量的上下文切换.对系统的开销很大; 针对这种场景牺牲一定的cpu时间进行空轮询;

private void doAcquireInterruptibly(int arg)
    throws InterruptedException {
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        for (;;) {
            final Node p = node.predecessor();
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}