java并发编程之主线程等待多个子线程执行

880 阅读8分钟

Offer 驾到,掘友接招!我正在参与2022春招打卡活动,点击查看活动详情

前言

今天在做信息导入,由于校验十分耗时,导致体验非常差,于是我想到了开启多个线程同时校验多条信息,但是这样产生了一个问题,就是怎样能够知道每一条校验的信息都是哪个地方出错了呢?所以需要返回每条信息校验的结果,把结果存到map中,等到都校验完了再判断有没有错误的,如果没有就校验成功,如果有返回错误信息。

下面一起来学习一下如何让主线程等待子线程执行完再继续执行吧。

模拟上面的需求

public class Main {
    public static void main(String[] args) throws InterruptedException {
        doSomeThing();
    }

    public static void doSomeThing() throws InterruptedException {
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            //循环校验,并且把校验结果加入到map中
            TimeUnit.SECONDS.sleep(1); //模拟校验耗时
            map.put("100" + i, "" + i);
        }
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start)+"ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }
}

上面for循环里面就是做校验的代码,我们运行可以得到下面的结果

校验耗时:10006ms
校验结果:
0 start
1008 8
1007 7
1006 6
1005 5
1004 4
-1 end
1003 3
1002 2
1001 1
1000 0
1009 9

Process finished with exit code 0

map里的元素并不是有序的

从结果看到校验一共用了10秒多,如果是100条数据,1000条数据呢?校验那么长时间,对于用户来说绝对不是一个好的体验,当然我们也可以选择异步导入的方式,这里我们是同步导入,需要等待导入返回结果。

实践过程

最开始我直接在for循环内创建线程,然后把for循环内的代码搬到run方法体中去,如下:

public static void doSomeThing1() throws InterruptedException {
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            new Thread(()->{
               //循环校验,并且把校验结果加入到map中
               try {
                   TimeUnit.SECONDS.sleep(1); //模拟校验耗时
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               map.put("100" + finalI, "" + finalI);
           });
        }
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start)+"ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:46ms
校验结果:
0 start
-1 end

从结果可以看出,没有打印校验结果,那么校验结果有没有被添加到map中呢?

我们将主线程睡眠2秒钟:

public static void doSomeThing1() throws InterruptedException {
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            new Thread(()->{
               //循环校验,并且把校验结果加入到map中
               try {
                   TimeUnit.SECONDS.sleep(1); //模拟校验耗时
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
               map.put("100" + finalI, "" + finalI);
            }).start();
        }
        TimeUnit.SECONDS.sleep(2); //睡眠2秒
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start)+"ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:2053ms
校验结果:
0 start
1008 8
1007 7
1006 6
1005 5
1004 4
-1 end
1003 3
1002 2
1001 1
1000 0
1009 9

Process finished with exit code 0

可以看到map中已经有了校验结果。这样看来,只要我主线程睡眠足够多的时间,就可以获取到所有的校验结果了,但睡眠多久合适呢?无法确定,所以还是再看看其他方法吧。

Thread.join()

我尝试使用线程的join()方法,但是这个方法会使得每一个线程都等待上一个线程执行完再执行下一个线程,所以耗时还是10秒多。

public static void doSomeThing2() throws InterruptedException {
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            Thread t = new Thread(()->{
                //循环校验,并且把校验结果加入到map中
                try {
                    TimeUnit.SECONDS.sleep(1); //模拟校验耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                map.put("100" + finalI, "" + finalI);
            });
            t.start();
            t.join();
        }
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start)+"ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:10056ms
校验结果:
0 start
1008 8
1007 7
1006 6
1005 5
1004 4
-1 end
1003 3
1002 2
1001 1
1000 0
1009 9

synchronized的等待唤醒机制

实际上在这里我的需求是多个子线程同时执行,主线程等待所有子线程执行完成,所以synchronized也不适用。

CountDownLatch

使用java.util.concurrent包下的CountDownLatch类,CountDownLatch类是一个计数器,可以设置初始线程数(设置后不能改变),在子线程结束时调用countDown()方法可以使线程数减一,最终为0的时候,调用CountDownLatch的成员方法await()的线程就会取消BLOKED阻塞状态,进入RUNNABLE从而继续执行

public static void doSomeThing3() throws InterruptedException {
        final CountDownLatch cdl = new CountDownLatch(10); //10个线程

        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            new Thread(()->{
                //循环校验,并且把校验结果加入到map中
                try {
                    TimeUnit.SECONDS.sleep(1); //模拟校验耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                map.put("100" + finalI, "" + finalI);
                cdl.countDown();
            }).start();
        }
        cdl.await(); //线程数量为0时继续下面的代码
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start)+"ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:1046ms
校验结果:
0 start
1008 8
1007 7
1006 6
1005 5
1004 4
-1 end
1003 3
1002 2
1001 1
1000 0
1009 9

这种方法是适用的,我们可以知道需要校验的数据有多少就初始化CountDownLatch的count大小。

线程池

上面介绍的方法CountDownLatch类,虽然可以使用,但是当需要校验的数据比较多,创建的线程多时,容易造成系统崩溃,每个线程的大小是1MB,过多的线程会使内存占用完而OOM。所以我们可以使用线程池来控制线程数量。

关于线程池的基础可以看我这篇文章Java并发编程:线程池的使用

public static void doSomeThing4() throws InterruptedException, ExecutionException {

        //创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                10,//核心线程数10
                10, //最大线程数100
                0L,TimeUnit.MILLISECONDS, //使用完即销毁
                new LinkedBlockingDeque<>(1000),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy()); //如果队列满了,并且运行中线程达到最大线程数,让创建者去执行
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 20; i++) {
            int finalI = i;
            Thread t = new Thread(() -> {
                //循环校验,并且把校验结果加入到map中
                try {
                    TimeUnit.SECONDS.sleep(1); //模拟校验耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                map.put("100" + finalI, "" + finalI);
            });
            executor.execute(t);
        }
        while (true){ //当活跃线程数为0时代表所有任务执行完
            System.out.println("线程存活数量:"+executor.getActiveCount());
            if (executor.getActiveCount() == 0){
                System.out.println("线程存活数量:"+executor.getActiveCount());
                break;
            }
        }
        executor.shutdown(); //线程池销毁
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start) + "ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }
线程存活数量:10
线程存活数量:10
线程存活数量:10
................
................
线程存活数量:3
线程存活数量:2
线程存活数量:2
线程存活数量:2
线程存活数量:0
校验耗时:2055ms
校验结果:
-1 end
1000 0
0 start
1008 8
10019 19
1007 7
10018 18
1006 6
1005 5
1004 4
1003 3
1002 2
1001 1
10011 11
10010 10
10013 13
10012 12
10015 15
10014 14
10017 17
1009 9
10016 16

可以看到也能校验,但是这种判断会使程序一直循环等待,导致cpu资源浪费。

CyclicBarrier类

CyclicBarrier类用于将当前线程挂起,直至所有线程都到达某个状态再同时执行后续任务。

public static void doSomeThing5() throws InterruptedException, ExecutionException {

        CyclicBarrier barrier = new CyclicBarrier(10);
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 10; i++) {
            int finalI = i;
            Thread t = new Thread(() -> {
                //循环校验,并且把校验结果加入到map中
                try {
                    TimeUnit.SECONDS.sleep(1); //模拟校验耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                map.put("100" + finalI, "" + finalI);
                try {
                    barrier.await();
                } catch (InterruptedException | BrokenBarrierException e) {
                    e.printStackTrace();
                }
            });
            t.start();
        }
        try {
            barrier.await();
        } catch (BrokenBarrierException e) {
            e.printStackTrace();
        }
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start) + "ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:1049ms
校验结果:
0 start
1008 8
1007 7
1006 6
1005 5
1004 4
-1 end
1003 3
1002 2
1001 1
1000 0
1009 9

CyclicBarrier的状态计数可以重用,而CountDownLatch不可以。

线程池 + CountDownLatch

为了解决循环等待和线程管理,我觉得可以使用线程池加CountDownLatch

public static void doSomeThing6() throws InterruptedException, ExecutionException {

        CountDownLatch cdl = new CountDownLatch(20); //20次线程操作
        //创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                10,//核心线程数10
                10, //最大线程数100
                0L,TimeUnit.MILLISECONDS, //使用完即销毁
                new LinkedBlockingDeque<>(1000),
                Executors.defaultThreadFactory(),
                new ThreadPoolExecutor.CallerRunsPolicy()); //如果队列满了,并且运行中线程达到最大线程数,让创建者去执行
        long start = System.currentTimeMillis();
        Map<String, String> map = new HashMap<>();
        map.put("0", "start");
        for (int i = 0; i < 20; i++) {
            int finalI = i;
            Thread t = new Thread(() -> {
                //循环校验,并且把校验结果加入到map中
                try {
                    TimeUnit.SECONDS.sleep(1); //模拟校验耗时
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                map.put("100" + finalI, "" + finalI);
                cdl.countDown(); //减
            });
            executor.execute(t);
        }
        cdl.await(); //等待计算器为0
        executor.shutdown(); //线程池销毁
        map.put("-1", "end");
        System.out.println("校验耗时:" + (System.currentTimeMillis() - start) + "ms");
        System.out.println("校验结果:");
        map.forEach((s, s2) -> System.out.println(s + " " + s2));
    }

输出结果:

校验耗时:2051ms
校验结果:
-1 end
1000 0
0 start
1008 8
10019 19
1007 7
10018 18
1006 6
1005 5
1004 4
1003 3
1002 2
1001 1
10011 11
10010 10
10013 13
10012 12
10015 15
10014 14
10017 17
1009 9
10016 16

Process finished with exit code 0

总结

综合上面的实例,我觉得 线程池 + CountDownLatch是解决主线程等待子线程比较好的方式,但是实际业务环境中并不是多线程并发执行效率更高,多个线程并发执行,线程间抢夺cpu资源,上下文切换都会消耗时间,还有可能会导致死锁等情况。

如有错误还请指出,同时欢迎大家讨论更多的方法。

参考

Java并发编程:CountDownLatch、CyclicBarrier和Semaphore