多线程同步工具类的使用

507 阅读9分钟

一、CountDownLatch

场景介绍:日常开发中经常会遇到需要在主线程中开启多个线程去并行地执行任务,并且主线程需要等待所有子线程执行完毕后再进行汇总的情景。

在CountDownLatch出现之前一般都是使用线程的join()方法来实现这一点,但是join方法不够灵活(因为项目实践中一般都避免直接操作线程,而是使用ExecutorService线程池来管理,而ExecutorService传递的参数是Runable或者Callable对象,这个时候没有办法调用线程的join方法),不能够满足不同场景的需要,所以JDK开发提供了CountDownLatch这个类,通过CountDownLatch可以控制某个线程的执行时机。

CountDownLatch构造函数如下:

该类还有两个关键方法:

public void await() throws InterruptedException ;// 若计数值不为0,则阻塞等待,直到计数值为0,线程中断后执行该操作会抛异常。
public void countDown(); //计数值减1

看一个代码例子:

public class UtilsTest {
    public static void main(String[] args) throws InterruptedException {
        final CountDownLatch latch = new CountDownLatch(3);//因为下面模拟三个线程,因此创建一个计数值为3的CountDownLatch对象。
        //循环执行并打印3个线程,依次将计数值减1
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {
                public void run() {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "countdown");
                    latch.countDown();
                }
            }).start();
        }
        //计数值不为0时将阻塞,为0时才执行
        latch.await();
        System.out.println("main function execute");
    }
}

线程A执行await,首先看下计数器的值,此时计数值为3,不为0,因此阻塞,线程1,线程2,线程3依次执行后countDown计数值最终变为0(执行countDown方法依次减1),线程A检查后发现为计数值为0了,恢复执行,因此最终效果就是3个线程执行完后,主线程才开始恢复执行。

再看一个例子,使用ExecutorService线程池:

public class UtilsTest {
    //创建一个CountDownLatch实例,计数值2
    private static CountDownLatch latch = new CountDownLatch(2);
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        //将线程A添加到线程池
        executorService.submit(new Runnable() {
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName()+" over!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {
                    latch.countDown();
                }
            }
        });
        //将线程B添加到线程池
        executorService.submit(new Runnable() {
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName()+" over!");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {
                    latch.countDown();
                }
            }
        });
        System.out.println("wait all thread over!");
        //等待线程A和线程B执行完毕
        latch.await();
        System.out.println("main thread over!");
        executorService.shutdown();
    }
}

输出结果:

看完上面两个例子,我相信你应该知道如何使用CountDownLatch类来处理多线程同步问题了!

总结下CountDownLatch和join方法区别:

1、调用一个子线程的join()方法后,该线程会一直被阻塞直到子线程运行完毕,而CountDownLatch则使用计数器来允许子线程运行完毕或者运行中递减计数,也就是CountDownLatch可以在子线程运行的任何时候让await方法返回而不一定必须等到线程结束。

2、使用线程池来管理线程时一般是直接添加Runable到线程池,这个时候就没有办法再调用线程的join方法了,就是说CountDownLatch相比join方法让我们对线程同步有更活的控制。

二、CyclicBarrier

上面的CountDownLatch的计数器是一次性的(计数器值初始化之后无法修改),只能等到调用countDown()方法之后将计数值递减至0再调用await()或countDown()方法才会立刻返回,那么这样就起不到线程同步的效果了。所以为了满足计数器可以重置的需要,JDK开发组提供了CyclicBarrier类,并且CyclicBarrier类的功能并不限于CountDownLatch的功能,从字面上理解,CyclicBarrier是回环屏障的意思,它可以让一组线程全部达到一个状态后再全部同时执行。

场景介绍:这里我们要实现的是,使用两个线程去执行一个被分解的任务A,当两个线程把自己的任务都执行完毕后再对它们的结果进行汇总处理。

我们先看看关键的方法:

public CyclicBarrier(int parties)//设置并发执行线程的个数
public CyclicBarrier(int parties, Runnable barrierAction);//设置并发执行个数;第二个Runable参数会在并发执行之前优先执行。
public int await()//执行后阻塞线程,直到阻塞的个数达到构造函数中设置的并发个数

看一个代码例子:

public class UtilsTest {
    // 创建一个CyclicBarrier实例,添加一个所有子线程全部达到屏障后执行的任务
    private static CyclicBarrier cyclicBarrier = new CyclicBarrier(2, new Runnable() {
        public void run() {
            System.out.println(Thread.currentThread().getName() + " task1 merge result");
        }
    });

    public static void main(String[] args) {
       ExecutorService executorService = Executors.newFixedThreadPool(2);
       //将线程A添加到线程池
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread().getName()+" task1-1");
                System.out.println(Thread.currentThread().getName()+" enter in barrier");
                try {
                    cyclicBarrier.await();
                    System.out.println(Thread.currentThread().getName()+" enter out barrier");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
        });

        //将线程B添加到线程池
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread().getName()+" task1-2");
                System.out.println(Thread.currentThread().getName()+" enter in barrier");
                try {
                    cyclicBarrier.await();
                    System.out.println(Thread.currentThread().getName()+" enter out barrier");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } catch (BrokenBarrierException e) {
                    e.printStackTrace();
                }
            }
        });
        //关闭线程池
        executorService.shutdown();
    }
}

如上代码创建了一个CyclicBarrier对象,其第一个参数为计数器初始值,第二个参数Runable是当计数值为0时要执行的任务。在main函数里面先创建了一个大小为2的线程池,然后添加两个子任务到线程池中,每个字线程在执行完自己的逻辑后会调用await方法。一开始计数值为2,当第一个线程调用方法时,计数器值递减为1。由于此时的计数器值不为0,所以当前线程就到了屏障点而被阻塞。然后第二个线程调用await时,会进入屏障,计数器值也会递减,现在计数器值就为0了,这时就会去执行CyclicBarrier构造函数中的任务,执行完毕后退出屏障点,并且唤醒被阻塞的第二个线程,这时候第一个线程也会退出屏障点继续向下执行。所以输出结果如下:

再看一个代码例子:

public class UtilsTest {
    public static void main(String[] args) throws InterruptedException, BrokenBarrierException {
        final CyclicBarrier barrier = new CyclicBarrier(4, new Runnable() {
            public void run() {
                System.out.println("barrier execute");
            }
        });
        for(int i=0;i<3;i++){
            new Thread(new Runnable() {
                public void run() {
                    try {
                        barrier.await();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } catch (BrokenBarrierException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName()+"countdown");
                }
            }).start();
        }
        Thread.sleep(1000);
        barrier.await();
        System.out.println("main thread execute");
    }
}

输出结果:

构造函数中传入了一个Runable,这个是并发之前优先执行的。for循环中创建的3个线程立刻执行,但是在barrier的作用下它们3个等到延时1s后的main方法执行await方法后再执行。最后的并发线程为4个

三、信号量Semaphore

Semaphore信号量也是Java中的一个同步器,与CountDownLatch和CyclicBarrier不同的是,它内部的计数器是递增的,并且一开始初始化Semaphore时可以指定一个初始值,但是并不需要知道同步的线程个数,而是在需要同步的地方调用acquire方法时指定需要同步的线程个数

在很多场景下都需要限制流量操作,Semaphore就是限制并发量,保护一个重要的(代码)部分防止一次超过N个线程进入。

先看看关键的方法:

public Semaphore(int permits);//设置最大阈值
public void acquire();//获取一个值
public void release()//释放一个值
public void acquire(int permits)//获取permits个值
public void release(int permits)//释放permits个值

只有获取值调用成功之后才会执行,否则阻塞,等待至获取成功。

//只允许一个线程同时进入
Semaphore semaphore = new Semaphore(1);
semaphore.acquire();
...
semaphore.release();

上面相当于一个锁,每次只允许一个线程进入。

//允许3个线程同时进入
Semaphore semaphore = new Semaphore(10);
semaphore.acquire(3);
...
semaphore.release(3);

上面构造函数参数设置为10,但是下面的acquire和release传入的都是3,实际效果就是中间的代码最多只能有3个线程进入。

看一个代码案例:

public class SemaphoreTest {
    // 创建一个Semaphore实例
    private static Semaphore semaphore = new Semaphore(0);
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(2);
        //将线程A加入线程池
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread().getName()+" over");
                semaphore.release();
            }
        });
        //将线程B加入线程池
        executorService.submit(new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread().getName()+" over");
                semaphore.release();
            }
        });

        semaphore.acquire(2);
        System.out.println("all child thread over!");
        //关闭线程池
        executorService.shutdown();
    }
}

输出结果:

上面的案例代码首先创建了一个信号量实例,构造函数的入参为0,说明当前信号量计数器的值为0,然后main函数向线程池添加了两个线程任务,在每个线程内部调用信号量的release方法,这相当于让计数器的值增1。最后在main线程里面调用信号量的acquire防范,传参为2说明调用acquire方法的线程会一直阻塞,直到信号量的计数变为2才会返回。看到这里大家应该明白了吧,如果构造Semaphore时传递的参数为N,并在M个线程中调用了该信号量的release方法,那么在调用acquire使M个线程同步时传递的参数应该是M+N.

再看一个代码例子:

public class UtilsTest {
    private static Semaphore semaphore = new Semaphore(3);
    private static CyclicBarrier barrier = new CyclicBarrier(4);

    public static void main(String[] args) throws BrokenBarrierException, InterruptedException {
        for (int i = 0; i < 3; i++) {
            new Thread(new Runnable() {
                public void run() {
                    try {
                        barrier.await();
                        func();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } catch (BrokenBarrierException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
            barrier.await();
            func();
        }
    }

    public static void func() throws InterruptedException {
        semaphore.acquire();
        System.out.println(Thread.currentThread().getName() + " execute fun;T=" + System.currentTimeMillis() / 1000);
        Thread.sleep(5000);
        semaphore.release();
    }
}

上面的代码例子利用CyclicBarrier控制4个线程同时执行func函数,但是Semophore限制只能有3个线程同时访问。

输出结果:

总结:

这篇文章 介绍了多线程并发协作的一些重要的类。首先CountDownLatch通过计数器提供了更灵活的控制,只要检测到计数器的值为0,就可以往下执行,这相比使用join方法必须等待线程执行完毕后主线程才会继续向下运行更灵活。另外,CyclicBarrier也可以达到CountDownLatch的效果,但是后者在计数器值变为0后,就不能再被复用,而前者则可以使用reset方法重置后复用,前者对同一个算法但是输入参数不同的类似场景比较适用。而Semaphore采用了信号量递增的策略。一开始并不需要关心同步的线程个数,等调用acquire方法时再指定需要同步的线程个数,并且提供了获取信号量的公平性策略。

使用本篇介绍的类会大大减少你在Java中使用wait、notify等来实现线程同步的代码量,在日常开发中当需要进行线程同步时使用这些同步类会节省很多代码并且可以保证正确性。