通过CounDownLatch的源码,来了解AQS的共享锁

5 篇文章 1 订阅

点击了解AbstractQueuedSynchronizer

CountDownLatch

CountDownLatch的使用很简单, 构造CountDownLatch时需要传入一个int变量, 表示需要调用10次CountDownLatch#countdown()之后, 调用CountDownLatch#await() 才不会阻塞.

public static void main(String[] args) {
    CountDownLatch countDownLatch = new CountDownLatch(1);
    for (int i = 0; i < 2; i++) {
        new Thread(() -> {
            try {
                countDownLatch.await();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread() + "I get it.");
        }).start();
    }
    new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        countDownLatch.countDown();
    }).start();
}

上面的代码输出如下:

Thread[Thread-0,5,main] I get it.
Thread[Thread-1,5,main] I get it.

上面的代码, 并不会马上输出, 而是需要等待大约10秒, 也就是第三个线程进行CountDownLatch#countdown().

获取共享锁

共享锁, 顾名思义就是可以由多个线程共享的锁. 比如读写锁中的读锁, 读锁允许多个线程同时访问受保护的资源.

获取共享锁需通过以acquireShared开头的方法. 以acquireShared开头的方法公有两个:

  1. acquireShared(int)
  2. acquireSharedInterruptibly(int)

CountDownLatch#await()中是获取共享锁的方法. 他会以一种可打断的方式获取共享锁.

// CountDownLatch#await 方法
public void await() throws InterruptedException {
    sync.acquireSharedInterruptibly(1);
}

不过两个获取共享锁的方法核心一致. 在 acquireShared 方法里面全有, 因此分析该方法.

acquireShared方法

// 获取共享锁, 并且会忽视interrupt.
public final void acquireShared(int arg) {
    // tryAcquireShared 方法需要被框架的子类实现. 
    // 并且tryAcquireShared 返回小于0, 代表获取锁失败.
    // CountDownLatch实现tryAcquireShared方法的逻辑如下:
    //   如果countdown已经减到0, 则返回1, 否者返回-1.
    if (tryAcquireShared(arg) < 0)
        // 以不可打断的方式获取共享锁
        doAcquireShared(arg);
}

doAcquireShared方法

以不可打断的方式获取共享锁

private void doAcquireShared(int arg) {
    // 创建共享类型Node. 并且会把Node加入到队尾.
    final Node node = addWaiter(Node.SHARED);
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            // 获取node的前任
            final Node p = node.predecessor();
            // 如果前任是链表头节点,则进行尝试获取共享锁
            if (p == head) {
                // 尝试获取共享锁, 成功则返回值大于等于0
                int r = tryAcquireShared(arg);
                if (r >= 0) {
                    // 共享锁获取成功
                    // 设置头节点,并且进行传播
                    setHeadAndPropagate(node, r);
                    p.next = null; // help GC
                    if (interrupted)
                        selfInterrupt();
                    failed = false;
                    return;
                }
            }
            // 获取共享锁失败,判断是否要进入暂停
            // 如果 shouldParkAfterFailedAcquire 返回true, 则进入暂停
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

🤔思考: 与独占锁相比, 这里出现了传播, 传播又是什么呢?
setHeadAndPropagate(Node,int)方法共两个参数, 第一个参数是当前node, 第二个参数是一个int值,该值是从tryAcquireShared方法中返回的参数.

private void setHeadAndPropagate(Node node, int propagate) {
   // 记录下旧的头节点
   Node h = head;
   // 设置node为头节点
   setHead(node);
   // 满足一下条件会试图去唤醒双向链表中的下一个节点:
   //   1. 调用者传入的propagate大于0
   //   2. 传播标识被前一个操作设置(在setHead操作之后,判断h.waitStatus操作之前)
   // 满足上面的条件之外,还需满足下面的条件之一:
   //   1. 下一个等待中的节点以共享模式.
   //   2. 有一个特殊的情况, 下一个节点为null.
   // 下面的多个检查可能会引起不必要的唤醒, 但是只有在同时acquires和releases是才会出现.
   // 因此大多数情况都需要立即唤醒.
   if (propagate > 0 || h == null || h.waitStatus < 0 ||
       (h = head) == null || h.waitStatus < 0) {
       Node s = node.next;
       if (s == null || s.isShared())
           doReleaseShared();
   }
}

释放共享锁

在AQS中, 释放共享锁的方法有 releaseShareddoReleaseShared 两个. 但是对外暴露的只有releaseShared方法.

releaseShared方法

// 释放共享锁. 如果tryReleaseShared方法返true, 则会唤醒至少一个被阻塞的线程.
public final boolean releaseShared(int arg) {
    // 如果使用AQS的共享锁功能, 则需要实现tryReleaseShared方法.
    if (tryReleaseShared(arg)) {
        // AQS提供释放锁的实现
        doReleaseShared();
        return true;
    }
    return false;
}

doReleaseShared方法

在AQS提供的doReleaseShared方法中, 保证释放共享锁, 并且会唤醒后继和进行传播. 这里相对于release方法多了传播.

private void doReleaseShared() {    
    // 因此在释放时, 有其他线程也在进行获取和释放操作, 因此要保证释放的传播性.
    // 头节点通常会尝试调用`unparkSuccessor`去唤醒后继.
    // 但是如果上面操作失败了(并发导致), 则在release之前设置status为PROPAGATE
    // 此外,在进行操作时以防止新节点加入,我们必须一直循环.
    // 并且, 这里也需要对CAS的返回值进行判断, 因为并不能向以往一样简单unparkSuccessor.
    for (;;) {
        // 记录头节点
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            // 唤醒后继
            if (ws == Node.SIGNAL) {
                // 如果CAS设置状态失败,则再次循环.
                // 可能会因为多线程并发释放导致失败.
                // 假设T1和T2两个线程同时调用到doReleaseShared方法. 
                // T1线程设置头节点状态 SIGNAL -> 0, 他会唤醒下一个节点
                // T2线程则设置失败. 则进入下一次循环.
                // 🤔 思考: 假设T2线程为什么设置头节点状态为PROPAGATE, 而不是其他?
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;
                // 唤醒后继
                unparkSuccessor(h);
            } 
            // 状态是0的话, 则设置节点状态传播状态, 若设置不成功则再次循环.
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;
        }
        // 退出的唯一出口.
        if (h == head)
            break;
    }
}

深入CountDownLatch

public static void main(String[] args) {
    // 创建state为1的CountDownLatch
    CountDownLatch countDownLatch = new CountDownLatch(1);
    // 创建thread, 10秒之后 将 CountDownLatch 的 state - 1
    Thread thread = new Thread(() -> {
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        countDownLatch.countDown();
    }).start();
    
    // 创建三个线程, 等待CountDownLatch的 state的状态为0, 才能执行
    int waitThread = 3;
    for (int i = 0; i < waitThread; i++) {
        new Thread(() -> {
            try {
                countDownLatch.await();
                System.out.println(Thread.currentThread() + " get it.");
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
    System.out.println(countDownLatch);
    for (int i = 0; i < 5; i++) {
    try {
        System.out.println("one second past.");
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

上面代码输出内容如下:

java.util.concurrent.CountDownLatch@4dd8dc3[Count = 1]
one second past.
one second past.
one second past.
Thread[Thread-1,5,main] get it.
Thread[Thread-3,5,main] get it.
Thread[Thread-2,5,main] get it.
one second past.
one second past.

输出的第一行中的Count是构造CountDownLatch传入的参数,也是AQS中的state变量, 表示只要有一个线程调用 CountDownLatch#countdown , 其余线程调用 CountDownLatch#await 就不会阻塞.

在第一到三秒中, AQS中的双向链表如下所示.
同步队列状态

上图中, 第一个节点是初始新建双向链表的哨兵节点, 不封装任何线程. 而后续的三个节点, 则分别代表等待的三个线程. 节点标记为S, 则表示后继需要唤醒(unpark).

在第三秒的时候, 另外一个线程会调用CountDownLatch#countdown使state变为0.

// 实际调用的是下面的方法.
public void countDown() {
    sync.releaseShared(1);
}

// 释放共享锁
public final boolean releaseShared(int arg) {
    // 尝试去设置state, 并且会影响去释放共享锁
    if (tryReleaseShared(arg)) {
        // 释放共享锁.
        doReleaseShared();
        return true;
    }
    return false;
}

// tryReleaseShared 方法比较特殊, 是CountDownLatch实现.
// AQS中 该方法直接抛出异常, 需要使用者覆写.
protected boolean tryReleaseShared(int releases) {
    // 递减count; 当减到0的时候,进行唤醒等待队列.
    for (;;) {
        int c = getState();
        if (c == 0)
            return false;
        int nextc = c-1;
        if (compareAndSetState(c, nextc))
            return nextc == 0;
    }
}

根据代码,可以画出下图:

当有线程调用CountDownLatch#countdown, 该线程会唤醒队列中等待的有效第一个节点. 调用countdown方法的线程会将当前head的状态设置为0,并且唤醒下一个线程.
第一次唤醒

被唤醒的线程,发现自己可以获取到锁, 于是把自己设置成头节点, 断开与源头节点的联系.
设置头节点

但是被唤醒的节点, 发现自己处于共享模式, 于是把当前head的状态设置为0,并且唤醒下一个节点.
唤醒第二个节点

重复第一个节点被唤醒的操作.
第二个节点

第三个被唤醒的节点, 发现自己处于共享模式, 想去唤醒下一个节点, 但是发现没有下一个节点, 于是流程到此结束.
唤醒结束

结束语

  1. 了解CountDownLatch锁的释放和获取流程
  2. 了解CountDownLatch的底层逻辑
  3. 了解AQS底层的共享锁模式
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值