【搞定Java并发编程】第24篇:Java中的并发工具类之CountDownLatch

上一篇:Java中的阻塞队列 BlockingQueue 详解

本文目录

1、CountDownLatch的基本概述

2、CountDownLatch的使用案例

3、CountDownLatch的源码分析


1、CountDownLatch的基本概述

CountDownLatch允许一个或多个线程等待其他线程完成操作。

CountDownLatch又称为“闭锁”,它是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待,直到等待的线程操作完成或者等待超时,自己再开始执行。

CountDownLatch可以延迟线程的进度直到其他线程到达终止状态,它可以用来确保某些任务在其他任务都完成后再继续进行:

  • 确保某个计算在其需要的所有资源都被初始化之后再继续执行;
  • 确保某个服务在其依赖的所有其他服务都已经启动之后才启动;
  • 等待直到某个操作所有参与者都准备就绪后再继续执行。

另外它还提供了一个countDown方法来操作计数器的值,每调用一次countDown方法计数器都会减1,直到计数器的值减为0时就代表条件已成熟,所有因调用await方法而阻塞的线程都会被唤醒。

这就是CountDownLatch的内部机制,看起来很简单,无非就是阻塞一部分线程让其在达到某个条件之后再执行。但是CountDownLatch的应用场景却比较广泛,只要你脑洞够大利用它就可以玩出各种花样。最常见的一个应用场景是开启多个线程同时执行某个任务,等到所有任务都执行完再统计汇总结果。下图动态演示了闭锁阻塞线程的整个过程。

上图演示了有5个线程因调用await方法而被阻塞,它们需要等待计数器的值减为0才能继续执行。计数器的初始值在构造闭锁时被指定,后面随着每次countDown方法的调用而减1。


2、CountDownLatch的使用案例

【案例1】

我们看下 Doug Lea 在 java doc 中给出的例子,这个例子非常实用,我们经常会写这个代码。

假设我们有 N ( N > 0 ) 个任务,那么我们会用 N 来初始化一个 CountDownLatch,然后将这个 latch 的引用传递到各个线程中,在每个线程完成了任务后,调用 latch.countDown() 代表完成了一个任务。调用 latch.await() 的方法的线程会阻塞,直到所有的任务完成。

package com.zju.CountDownLatch;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;

public class Driver {

	public static void main(String[] args) throws InterruptedException {
		
		CountDownLatch doneSignal = new CountDownLatch(10);
		Executor e = Executors.newFixedThreadPool(4);
		
		// 创建10个任务,提交给线程池来执行
		for (int i = 0; i < 10; i++) {
			e.execute(new WorkerRunnable(doneSignal, i));
		}
		
		// 等待所有的任务都完成了,这个方法才会返回
		doneSignal.await();
	}
}

class WorkerRunnable implements Runnable{

	private final CountDownLatch doneSignal;
	private final int i;
	
	WorkerRunnable(CountDownLatch doneSignal, int i){
		this.doneSignal = doneSignal;
		this.i = i;
	}
	
	@Override
	public void run() {
		try {
			doWork(i);
			// 这个线程的任务完成了,调用 countDown 方法
			doneSignal.countDown();
		} catch (Exception e) {
		}
	}
	
	public void doWork(int i){
		System.out.println(i);
	}
}

所以说 CountDownLatch 非常实用,我们常常会将一个比较大的任务进行拆分,然后开启多个线程来执行,等所有线程都执行完了以后,再往下执行其他操作。这里例子中,只有 main 线程调用了 await 方法

我们再来看另一个例子,这个例子很典型,用了两个 CountDownLatch:

class Driver { 
    void main() throws InterruptedException {
        CountDownLatch startSignal = new CountDownLatch(1);
        CountDownLatch doneSignal = new CountDownLatch(N);
 
        for (int i = 0; i < N; ++i) 
            new Thread(new Worker(startSignal, doneSignal)).start();
 
        // 这边插入一些代码,确保上面的每个线程先启动起来,才执行下面的代码。
        doSomethingElse();            // don't let run yet
        // 因为这里 N == 1,所以,只要调用一次,那么所有的 await 方法都可以通过
        startSignal.countDown();      
        doSomethingElse();
        // 等待所有任务结束
        doneSignal.await();           
    }
}
 
class Worker implements Runnable {
    private final CountDownLatch startSignal;
    private final CountDownLatch doneSignal;
 
    Worker(CountDownLatch startSignal, CountDownLatch doneSignal) {
        this.startSignal = startSignal;
        this.doneSignal = doneSignal;
    }
 
    public void run() {
        try {
            // 为了让所有线程同时开始任务,我们让所有线程先阻塞在这里
            // 等大家都准备好了,再打开这个门栓
            startSignal.await();
            doWork();
            doneSignal.countDown();
        } catch (InterruptedException ex) {
        } 
    }
 
    void doWork() { ...}
}

这个例子中,doneSignal 同第一个例子的使用,我们说说这里的 startSignal。N 个新开启的线程都调用了startSignal.await() 进行阻塞等待,它们阻塞在栅栏上,只有当条件满足的时候(startSignal.countDown()),它们才能同时通过这个栅栏。

5

如果始终只有一个线程调用 await 方法等待任务完成,那么 CountDownLatch 就会简单很多,所以之后的源码分析读者一定要在脑海中构建出这么一个场景:有 m 个线程是做任务的,有 n 个线程在某个栅栏上等待这 m 个线程做完任务,直到所有 m 个任务完成后,n 个线程同时通过栅栏。

【案例2】

应用场景:在玩欢乐斗地主时必须等待三个玩家都到齐才可以进行发牌。

package com.zju.CountDownLatch;

import java.util.concurrent.CountDownLatch;

public class Player extends Thread {

	private static int count = 1;
	private final int id = count++;
	private CountDownLatch latch;
	
	public Player(CountDownLatch latch){
		this.latch = latch;
	}
	
	@Override
	public void run() {
		System.out.println("玩家" + id + "已入场");
		latch.countDown();
	}

	public static void main(String[] args) throws InterruptedException {
		CountDownLatch latch = new CountDownLatch(3);
		System.out.println("牌局开始,等待玩家入场...");
		
		new Player(latch).start();
		new Player(latch).start();
		new Player(latch).start();
		
		latch.await();
		System.out.println("玩家已到齐,开始发牌!");
	}
}

运行结果:

运行结果显示发牌操作一定是在所有玩家都入场后才进行。我们将23行的latch.await()注释掉,对比下看看结果:

可以看到在注释掉latch.await()这行之后,就不能保证在所有玩家入场后才开始发牌了。


3、CountDownLatch的源码分析

因为CountDownLatch的源码比较少,这里直接全部贴出来,方便直观感受下CountDownLatch的内部结构。可以发现CountDownLactch是基于AQS共享式锁基础上实现的。

public class CountDownLatch {
    
    private static final class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = 4982264981922014374L;

        Sync(int count) {
            setState(count);
        }

        int getCount() {
            return getState();
        }

        protected int tryAcquireShared(int acquires) {
            return (getState() == 0) ? 1 : -1;
        }

        protected boolean tryReleaseShared(int releases) {    
            for (;;) {
                int c = getState();
                if (c == 0)
                    return false;
                int nextc = c-1;
                if (compareAndSetState(c, nextc))
                    return nextc == 0;
            }
        }
    }

    private final Sync sync;

    public CountDownLatch(int count) {
        if (count < 0) throw new IllegalArgumentException("count < 0");
        this.sync = new Sync(count);
    }

    public void await() throws InterruptedException {
        sync.acquireSharedInterruptibly(1);
    }

    public boolean await(long timeout, TimeUnit unit)
        throws InterruptedException {
        return sync.tryAcquireSharedNanos(1, unit.toNanos(timeout));
    }
	
    public void countDown() {
        sync.releaseShared(1);
    }

    public long getCount() {
        return sync.getCount();
    }

    public String toString() {
        return super.toString() + "[Count = " + sync.getCount() + "]";
    }
}

首先来看下CountDownLatch的构造方法:

public CountDownLatch(int count) {
    if (count < 0) throw new IllegalArgumentException("count < 0");
    this.sync = new Sync(count);
}

CountDownLatch只有一个带参构造器,必须传入一个大于0的值作为计数器初始值,否则会报错。可以看到在构造方法中只是去new了一个Sync对象并赋值给成员变量sync。和其他同步工具类一样,CountDownLatch的实现依赖于AQS,它是AQS共享模式下的一个应用。CountDownLatch实现了一个内部类Sync并用它去继承AQS,这样就能使用AQS提供的大部分方法了。下面我们就来看一下Sync内部类的代码。

//同步器
private static final class Sync extends AbstractQueuedSynchronizer {

    // 构造器
    Sync(int count) {
        setState(count);
    }

    // 获取当前同步状态
    int getCount() {
        return getState();
    }

    // 尝试获取锁
    // 返回负数:表示当前线程获取失败
    // 返回零值:表示当前线程获取成功, 但是后继线程不能再获取了
    // 返回正数:表示当前线程获取成功, 并且后继线程同样可以获取成功
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    // 尝试释放锁
    protected boolean tryReleaseShared(int releases) {
        for (;;) {
            // 获取同步状态
            int c = getState();
            // 如果同步状态为0, 则不能再释放了
            if (c == 0) {
                return false;
            }
            // 否则的话就将同步状态减1
            int nextc = c-1;
            // 使用CAS方式更新同步状态
            if (compareAndSetState(c, nextc)) {
                return nextc == 0;
            }
        }
    }
}

可以看到Sync的构造方法会将同步状态的值设置为传入的参数值。之后每次调用countDown方法都会将同步状态的值减1,这也就是计数器的实现原理。在平时使用CountDownLatch工具类时最常用的两个方法就是await方法和countDown方法。调用await方法会阻塞当前线程直到计数器为0,调用countDown方法会将计数器的值减1直到减为0。下面我们来看一下await方法是怎样调用的。

// 导致当前线程等待, 直到门闩减少到0, 或者线程被打断
public void await() throws InterruptedException {
    // 以响应线程中断方式获取
    sync.acquireSharedInterruptibly(1);
}

// 以可中断模式获取锁(共享模式)
public final void acquireSharedInterruptibly(int arg) throws InterruptedException {
    // 首先判断线程是否中断, 如果是则抛出异常
    if (Thread.interrupted()) {
        throw new InterruptedException();
    }
    // 1.尝试去获取锁
    if (tryAcquireShared(arg) < 0) {
        // 2. 如果获取失败则进人该方法
        doAcquireSharedInterruptibly(arg);
    }
}

当线程调用await方法时其实是调用到了AQS的acquireSharedInterruptibly方法,该方法是以响应线程中断的方式来获取锁的,上面同样贴出了该方法的代码。我们可以看到在acquireSharedInterruptibly方法首先会去调用tryAcquireShared方法尝试获取锁。

我们看到Sync里面重写的tryAcquireShared方法的逻辑,方法的实现逻辑很简单,就是判断当前同步状态是否为0,如果为0则返回1表明可以获取锁,否则返回-1表示不能获取锁。如果tryAcquireShared方法返回1则线程能够不必等待而继续执行,如果返回-1那么后续就会去调用doAcquireSharedInterruptibly方法让线程进入到同步队列里面等待。这就是调用await方法会阻塞当前线程的原理,下面看看countDown方法是怎样将阻塞的线程唤醒的。

// 减少门闩的方法
public void countDown() {
    sync.releaseShared(1);
}

// 释放锁的操作(共享模式)
public final boolean releaseShared(int arg) {
    // 1.尝试去释放锁
    if (tryReleaseShared(arg)) {
        // 2.如果释放成功就唤醒其他线程
        doReleaseShared();
        return true;
    }
    return false;
}

可以看到countDown方法里面调用了releaseShared方法,该方法同样是AQS里面的方法,我们在上面也贴出了它的代码。releaseShared方法里面首先是调用tryReleaseShared方法尝试释放锁,tryReleaseShared方法在AQS里面是一个抽象方法,它的具体实现逻辑在子类Sync类里面,我们在上面贴出的Sync类代码里可以找到该方法。

tryReleaseShared方法如果返回true表示释放成功,返回false表示释放失败,只有当将同步状态减1后该同步状态恰好为0时才会返回true,其他情况都是返回false。那么当tryReleaseShared返回true之后就会马上调用doReleaseShared方法去唤醒同步队列的所有线程。这样就解释了为什么最后一次调用countDown方法将计数器减为0后就会唤醒所有被阻塞的线程。

private void doReleaseShared() {
       
    for (;;) {
        Node h = head;    // 设置头节点为h
        if (h != null && h != tail) {
            int ws = h.waitStatus;   // 获取头节点的同步状态
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;    // 如果CAS失败,就不停的尝试        
                unparkSuccessor(h);  // 唤醒其他节点
            }
            else if (ws == 0 &&
                    !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                
        }
        if (h == head)                   
            break;
    }
}

全文完!

上一篇:Java中的阻塞队列 BlockingQueue 详解


推荐两篇文章:

1、Java并发系列 | CountDownLatch源码分析【本文源码分析部分参考于此】

2、AQS共享模式与并发工具类的实现

猜你喜欢

转载自blog.csdn.net/pcwl1206/article/details/85059522