持续创作,加速成长!这是我参与「掘金日新计划 · 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先获取head节点值为null;
- 线程2再初始化head和tail节点;
- 线程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个线程由于未抢占到锁时加入到双向链表中,流程图如下:
所以当我们在唤醒队列中节点时,如果从head节点开始,那么在时刻1,由于链表不完整,势必导致后续的节点无法被唤醒从而造成一直等待下去;
思考:
能否先设置next节点再设置prev节点呢,那这样就可以从前往后唤醒了?
3 解决bug引入的PROPAGATE状态
jdk1.8版本中Node状态分为
- Cancel 1 取消状态
- 0 默认状态
- Signal 待唤醒状态 -1
- Condition condition队列中 -2
- 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;
- t1释放锁,,先将head的waitstatus重置为0,再将t3唤醒t3,t3抢占锁后,propagate代表剩余可获取的数量此时为0,那么不会唤醒t4,,t3先设置自己为head,此时head的waitStatus仍为-1,t3暂停操作;
- t2释放锁,将头结点即t2的waitstatus置为0,还是唤醒t3,无效唤醒;
- t3释放锁时,由于head的waitStatus为0,所以不会唤醒t4;
jdk1.6版本共享模式下释放锁往后传播的源码:
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);
}
}