java多线程系列:一 并发工具类的使用_1 ( CountDownLatch 、 CyclicBarrier、 Semaphore 、 Exchanger )

  • 前言- 本系列随笔 会深入浅出,解析java多线程的各种技术及实现。
  • 随笔主要根据 《java并发编程的艺术》一书作为参考。 本系列以使用为主要目的,本人理解有限,还望读者辩证采纳,没有过多涉及源码的讨论,重在初学者的使用,理解伪码。
  • 预备知识:1. volatile 关键字需要有一定理解  2. AQS 队列同步器有一定认知 (后续我会专门讲解AQS,先瞎听就够了,不懂就装懂!)
  • 可能新手对下面部分内容完全不理解,我后续会继续更博,并最终将该系列排序。
  • 全文 根据常用的 CountDownLatch 、 CyclicBarrier、 Semaphore 、 Exchanger 进行讲述如何使用

 一:CountDownLatch   等待多线程完成的工具类

    CDL使用场景很多,比如赛马,必须所有马都准备好了,说了 OK!才能发枪并计时。
    使用在代码中就类似于线程间协作, A B 两个线程需要准备(执行代码), C需要等他俩给了通知之后,才能执行。
    CountDownLatch提供了一个有参构造和两个方法:
    public CountDownLatch(int count)  : 填入一个参数count, 此参数被volatile 修饰,保证多线程下的安全性。
    await():使当前线程进入同步队列中阻塞,知道有通知 或者中断才能返回 继续执行。
    countDown():每调用一次  count 减1,直到减到0, 就会让所有正在执行 await()方法的线程从同步队列中返回,继续执行方法。
        

  1. 其实死板的 join就能完成     先上代码

 1 package E08工具类;
 2 
 3 public class JoinCountDownLatchTest {
 4     public static void main(String[] args) throws InterruptedException {
 5         Thread parser1 = new Thread(new Runnable() {
 6             @Override
 7             public void run() {
 8                 System.out.println("parser1 finish"); //A
 9             }
10         });
11 
12         Thread parser2 = new Thread(new Runnable() {
13             @Override
14             public void run() {
15                 System.out.println("parser2 finish");  //B
16             }
17         });
18         parser1.start();  
19         parser2.start();  
20         parser1.join();
21         parser2.join();
22         System.out.println("all parser finish"); //C
        
23 } 24 }

    上述代码,当 thread.join() 时 会阻塞调用 join方法的线程(main线程), 此处main方法 需要等待 parser1、parser2 都执行完毕,才能继续,输出如下:

  但是很明显, join是非常不灵活的,实际使用也早被 wait/notify 等待-通知机制给取代了。

parser1 finish
parser2 finish
all parser finish

  2、 使用CountDownLatch 代替 join

package E08工具类;

import java.util.concurrent.CountDownLatch;

/**
 * 等待多线程完成的工具类  可以取代  thread.join();
 */
public class CountDownLatchTest {
    static CountDownLatch c = new CountDownLatch(2);

    public static void main(String[] args) throws InterruptedException {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("1号马准备完毕");
                c.countDown();
                System.out.println("2号马准备完毕");
             
                c.countDown();
            }
        }).start();
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    System.out.println("先无视我"); //请先无视此线程,后续给您讲解我设置的意义
                    c.await();
                    System.out.println("");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }).start();
        System.out.println("等待马儿准备完毕");
      
        c.await();
        System.out.println("计时完毕,比赛结束");
        System.out.println("3");
    }

}

 结果如下:  很明显  countdownlatch 就是这么简单

1号马准备完毕
2号马准备完毕
等待马儿准备完毕
计时完毕,比赛结束
3
先无视我

 3、解析源码的实现原理 

  1、CountDownLatch (CDL) 是根据队列同步器 AbstractQueuedSynchronizer 的要求来实现的,我后续会专门讲解。  其实就是个锁

  AQS就是实现java1.5以后Lock的核心类,强调使用者(你)只需要会调用几个方法(lock  unlock等)即可, 实现者(CDL的作者)也只需要写几个实现方法(tryAcquire tryRelease tryAcquireShared等)即可,其余细节的AQS全部帮忙搞定!

   CDL是个比重入锁 ReetrantLock更简单的结构! 

  CDL 整体的结构 如下:

public class CountDownLatch {  // 类!

  //1、关键! CDL下有一个静态类,Sync继承了AQS,不要激动,每个锁其实都是这么搞的,这是AQS的要求! 你需要实现部分方法! 继续阅读
private static final class Sync extends AbstractQueuedSynchronizer { private static final long serialVersionUID = 4982264981922014374L; Sync(int count) { //构造器,填入 count具体数值 setState(count); } int getCount() { return getState(); }
     //2、重要! 需要CDL开发者自己实现的方法,目的是申请加锁(说白了就是自定义一个关于state变量的规则,state被volatile修饰)。
     //此方法也很简单,CDL的意思是, state==0,就返回1,否则返回0,很重要,记住!
protected int tryAcquireShared(int acquires) { return (getState() == 0) ? 1 : -1; }      //3、重要,需要开发者实现的方法,目的是 解锁(其实也是围绕state进行的逻辑)
     //此方法,只有一个情况会返回true,就是state原来是1,被改成了0,其他情况一律返回false,这是有用意的!
protected boolean tryReleaseShared(int releases) { // Decrement count; signal when transition to zero for (;;) { int c = getState(); if (c == 0) return false; int nextc = c-1; if (compareAndSetState(c, nextc)) return nextc == 0; } } }

 

2、研究具体的方法 

上面只是让CDL开发者实现的俩方法,接下来我们看AQS怎么调用,

你没听错, 实现的 tryAcquireShared 是AQS 抽象方法 abstract tryAcquireShared  的重写,具体的逻辑都是AQS在执行,调用关于加锁、解锁的判断时,使用上面的方法。

 

2.1、await()方法,实际上是一个加锁方法,原理说的浅白点就是: 如果更改state值失败,就会被放入一个同步队列,阻塞,等待别人来通知它。

public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
-->>>
进入sync.acquireSharedInterruptibly(1);看一眼,它是AQS写好的方法,sync只是继承了而已。
public final void acquireSharedInterruptibly(int arg)
throws InterruptedException {
if (Thread.interrupted())
throw new InterruptedException();
if (tryAcquireShared(arg) < 0)
doAcquireSharedInterruptibly(arg);
}

看到没,如果 CDL开发者写的 加锁方法 返回值<0,则进入doAcquireSharedInterruptibly,这方法目的是把 当前线程给放入同步队列,让他等待。
如果 >=0,等于啥也没发生。由于state基本上都会设置为正整数N,
不管是哪个线程,只要调用了c.await();通通进入同步队列里候着。

继续分析:
 private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.SHARED);   //由于锁分为 独占锁(只有一个线程能成功获取锁)和共享锁, 这里使用的是Shared,共享模式(允许多个线程加锁成功)  后续文章我再慢慢解读差别。
                               //AQS有一个 静态类,Node,形成了链表,也就是我们说的同步队列,没获取锁成功的线程都会被包装到这个链表里候着。
boolean failed = true; try { for (;;) {   //无限循环,每次被唤醒 都会来获取一次,称之为自旋。 final Node p = node.predecessor(); //查看自己的前驱结点,AQS的逻辑是,当自己的前驱结点是头节点,就去获取一次锁,否则继续睡觉。 if (p == head) { int r = tryAcquireShared(arg); // 当前驱结点是头节点,获取,CDL开发这自己写的,state=0,则1,否则-1 ,所以说基本上刚开始都是 -1,调用await()的线程都去睡觉。 if (r >= 0) { setHeadAndPropagate(node, r); //成功!将自己设置为头节点,唤醒后续的兄弟,这里很重要,我下面分析, 这里解决了一个问题! p.next = null; // help GC failed = false; return; } } if (shouldParkAfterFailedAcquire(p, node) && //睡觉了,这里比较复杂,你权当在阻塞即可。并且此处响应中断,如果中断了当前线程,那么就不睡觉了,直接退出 抛异常。 parkAndCheckInterrupt()) throw new InterruptedException(); } } finally { if (failed) cancelAcquire(node); } }

 

理一下逻辑:
1、state被我们设置成了2, 所以调用c.await()的线程都会进入同步队列,阻塞。
2、当自己被唤醒,检查下前驱结点,如果为链表的头节点,则获取一次锁,tryAcquireShared,将自己设置为头节点 ;失败继续睡觉。
3、setHeadAndPropagate 中
 if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
            if (s == null || s.isShared())
                doReleaseShared();   //居然有 释放锁的方法!理解这里非常重要  这和接下来要说的 c.countDown() 是一个意思。
        }

 

4、当countDown方法把 state给减到了0,那么 队列里的线程再tryAcquireShared 就是1了,所以会从队列中返回,结束阻塞。
至此 c.await()解析了90% 结束了
  
2.2 解析 c.countDown
其实就是个解锁方法,用来减少state的值,直到0。
 public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {   //CDL 自己写的解锁方法,之前提到过,只有 state被减到了0,才能返回true,其他情况都是false.
            doReleaseShared();
            return true;
        }
        return false;
    }

-->

 private void doReleaseShared() {    //此方法也比较复杂,我没有完全理解,大概意思就是对下一个节点进行唤醒,让它去尝试获取锁,当然下一个节点也一定是成功的,最后传播下去,调用了c.await()的线程都会从队列中返回
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

 

2.3* 可以看到 由于releaseShared 的逻辑,只有在state = 1时, c.dountDown才回去真正解锁一次,唤醒同步队列的首节点,其他时候都是直接返回了false。
那么问题来了,解锁一次,如何让我队列里那么多的节点都能解锁呢?

解决的方法正是 setHeadAndPropagate(node, r); 此方法不仅把自己(A)设置为头节点,让原来的头节点被GC回收,并且进行了传播,唤醒下一个节点B。
      下一个节点B会继续把自己(B)当作头节点, 唤醒C。

整个 CountDownLatch就解析完毕了。

tips:实际上 CountDownLatch的源码解析就是 java1.5 Lock共享锁的实现原理的简化,甚至获取和释放锁的过程一模一样,ReetranLock只是多了重入的过程,以及它是独占锁,
而CDL是共享的。







     

posted @ 2020-06-18 16:29  edisonhou  阅读(105)  评论(0)    收藏  举报