跳到正文
hello world

5. join:主线程怎么等待子线程结束

发布于阅读量 0

前面已经能用 start() 启动一个子线程了。

不过这里还有一个问题:main 线程不会自动等子线程执行完。

也就是说,我调用了:

thread.start();

只是把子线程启动起来。至于子线程什么时候执行完,main 线程默认是不关心的。

但是在批量处理 PDF 这种场景里,我肯定会遇到这种需求:

所有 PDF 都处理完以后,再统计总耗时;
所有 PDF 都处理完以后,再输出成功和失败数量;
所有 PDF 都处理完以后,再执行下一步逻辑。

这个时候就要用到 join()


先看不使用 join 的情况

新建一个类:

com.succos.thread.ThreadJoinDemo

先写一个不使用 join() 的版本:

package com.succos.thread;

public class ThreadJoinDemo {

    public static void main(String[] args) {

        System.out.println("main 开始:" + Thread.currentThread().getName());

        Thread thread = new Thread(() -> {
            System.out.println("子线程开始处理 PDF:" + Thread.currentThread().getName());

            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            System.out.println("子线程处理 PDF 完成:" + Thread.currentThread().getName());
        }, "pdf-thread-1");

        thread.start();

        System.out.println("main 继续执行:" + Thread.currentThread().getName());

        System.out.println("main 结束:" + Thread.currentThread().getName());
    }
}

运行后,输出可能是这样:

main 开始:main
main 继续执行:main
main 结束:main
子线程开始处理 PDF:pdf-thread-1
子线程处理 PDF 完成:pdf-thread-1

也可能子线程会先打印一部分。

这个顺序不是固定的,因为 thread.start() 只是启动子线程,不代表子线程已经执行完。

我这里要注意的是:main 线程启动子线程以后,会继续往下走。它不会停下来等 pdf-thread-1 把 PDF 处理完。

这在一些真正的异步场景下没问题,但如果我想统计总耗时,或者想在最后打印“全部处理完成”,那就不合适了。


加上 join

现在把代码改一下:

package com.succos.thread;

public class ThreadJoinDemo {

    public static void main(String[] args) throws InterruptedException {

        System.out.println("main 开始:" + Thread.currentThread().getName());

        Thread thread = new Thread(() -> {
            System.out.println("子线程开始处理 PDF:" + Thread.currentThread().getName());

            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            System.out.println("子线程处理 PDF 完成:" + Thread.currentThread().getName());
        }, "pdf-thread-1");

        thread.start();

        System.out.println("main 准备等待子线程执行完成");

        thread.join();

        System.out.println("main 等到了子线程完成,继续执行");

        System.out.println("main 结束:" + Thread.currentThread().getName());
    }
}

这次输出大概是这样:

main 开始:main
main 准备等待子线程执行完成
子线程开始处理 PDF:pdf-thread-1

等待 3 秒...

子线程处理 PDF 完成:pdf-thread-1
main 等到了子线程完成,继续执行
main 结束:main

关键是这行:

thread.join();

它的意思是:

当前线程等待 thread 这个线程执行结束。

在这个例子里,执行 thread.join() 的是 main 线程,所以就是:

main 线程等待 pdf-thread-1 执行结束。

join 等的是谁?

这个地方我之前也容易绕。

比如这行代码:

thread.join();

不是 thread 自己在等待。

而是执行这行代码的线程在等待 thread 执行完。

在当前例子里,执行这行代码的是 main,所以就是:

main 等 pdf-thread-1。

所以我更愿意这样理解:

谁执行 join() 这一行,谁就停下来等目标线程结束。

这个理解比较直接。


用 join 统计处理耗时

join() 一个很常见的用途,就是等子线程执行完以后,再统计耗时。

比如:

package com.succos.thread;

public class ThreadJoinTimeDemo {

    public static void main(String[] args) throws InterruptedException {

        long start = System.currentTimeMillis();

        Thread thread = new Thread(() -> {
            System.out.println("开始处理 PDF:" + Thread.currentThread().getName());

            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }

            System.out.println("PDF 处理完成:" + Thread.currentThread().getName());
        }, "pdf-thread-1");

        thread.start();

        thread.join();

        long end = System.currentTimeMillis();

        System.out.println("全部处理完成");
        System.out.println("耗时:" + (end - start) + " ms");
    }
}

如果不写 join()end 很可能在 PDF 还没处理完时就已经计算出来了。

那统计到的就不是 PDF 处理耗时,而只是 main 线程启动子线程的耗时。

所以只要我想表达“任务真正处理完用了多久”,就必须让主线程等到任务结束。


多个线程怎么 join?

一个线程用一次 join() 很简单。

如果我有两个 PDF,用两个线程处理,可以这样写:

package com.succos.thread;

public class ThreadJoinTwoDemo {

    public static void main(String[] args) throws InterruptedException {

        long start = System.currentTimeMillis();

        Thread thread1 = new Thread(() -> {
            processPdf("test1.pdf");
        }, "pdf-thread-1");

        Thread thread2 = new Thread(() -> {
            processPdf("test2.pdf");
        }, "pdf-thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();

        long end = System.currentTimeMillis();

        System.out.println("两个 PDF 都处理完成");
        System.out.println("总耗时:" + (end - start) + " ms");
    }

    private static void processPdf(String fileName) {
        System.out.println(Thread.currentThread().getName() + " 开始处理:" + fileName);

        try {
            Thread.sleep(3000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }

        System.out.println(Thread.currentThread().getName() + " 处理完成:" + fileName);
    }
}

这里的顺序很重要。

我先启动两个线程:

thread1.start();
thread2.start();

然后再等待它们结束:

thread1.join();
thread2.join();

这样两个线程才是并发执行的。


不要 start 一个 join 一个

这里有个坑。

如果我这样写:

thread1.start();
thread1.join();

thread2.start();
thread2.join();

代码也能运行,但并发效果基本就没了。

执行流程会变成:

启动 thread1
main 等 thread1 执行完
thread1 执行完以后
再启动 thread2
main 再等 thread2 执行完

这就接近顺序执行了。

所以多个线程时,我一般会按这个习惯写:

先把线程都 start;
再统一 join。

这个习惯后面也会用到。

比如学习 CompletableFuture 的时候,也不能在循环里提交一个任务就 join() 一个任务。那样也会让异步任务变成接近顺序执行。


多个 PDF 的写法

如果 PDF 文件数量不固定,我不会手写 thread1thread2

更自然的写法是把线程放到集合里。

package com.succos.thread;

import java.io.File;
import java.util.ArrayList;
import java.util.List;

public class ThreadJoinBatchDemo {

    public static void main(String[] args) throws InterruptedException {

        File inputDir = new File("input");

        File[] files = inputDir.listFiles(file ->
                file.isFile() && file.getName().toLowerCase().endsWith(".pdf")
        );

        if (files == null || files.length == 0) {
            System.out.println("input 目录下没有 PDF 文件");
            return;
        }

        long start = System.currentTimeMillis();

        List<Thread> threadList = new ArrayList<>();

        for (File file : files) {
            Thread thread = new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " 开始处理:" + file.getName());

                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    return;
                }

                System.out.println(Thread.currentThread().getName() + " 处理完成:" + file.getName());
            }, "pdf-thread-" + file.getName());

            threadList.add(thread);
        }

        for (Thread thread : threadList) {
            thread.start();
        }

        for (Thread thread : threadList) {
            thread.join();
        }

        long end = System.currentTimeMillis();

        System.out.println("全部 PDF 处理完成");
        System.out.println("总文件数:" + files.length);
        System.out.println("总耗时:" + (end - start) + " ms");
    }
}

这段代码的流程比较清楚:

先遍历 PDF,创建线程;
再统一启动所有线程;
再统一等待所有线程结束;
最后统计结果。

不过这还不是最终写法。

因为如果 input 目录下有 1000 个 PDF,这段代码就会创建 1000 个线程。线程太多以后,CPU 切换、内存占用、磁盘 IO 都可能出问题。

所以这一节只是为了理解 Threadjoin()

真实批量处理,后面还是要过渡到线程池。


InterruptedException 怎么处理

join() 会抛出 InterruptedException

练习代码里,我可以直接在 main 方法上写:

public static void main(String[] args) throws InterruptedException

这样比较省事。

但在真实项目里,如果捕获到 InterruptedException,最好不要直接吞掉。

一般会这样写:

try {
    thread.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
}

这里的:

Thread.currentThread().interrupt();

是把当前线程的中断状态重新设置回去。

因为 InterruptedException 被捕获以后,中断标记会被清除。如果不恢复,外层代码可能就不知道当前线程曾经被中断过。

这个点现在先记住就行。后面学线程池、Future.cancel(true) 的时候还会遇到。


join 适合什么场景?

join() 适合用来理解线程之间的等待关系,也适合少量手动线程的等待。

但如果任务很多,我不会用大量 Thread + join 去写生产代码。

原因很明显:

线程数量不好控制;
任务失败不好统一收集;
处理结果不好管理;
没有任务队列;
没有拒绝策略;
没有线程复用。

所以 join() 是一个基础工具。

它解决的是一个很具体的问题:

当前线程怎么等待另一个线程执行完?

后面真正做批量 PDF 处理时,会逐步换成:

CountDownLatch;
ThreadPoolExecutor;
CompletableFuture.allOf()。

这些工具更适合批量任务。


这一节小结

这一节我主要记住几点:

1. start() 只负责启动子线程,不负责等待子线程结束;
2. join() 可以让当前线程等待目标线程执行完成;
3. thread.join() 的意思是:当前线程等 thread 结束;
4. 多个线程时,应该先全部 start,再统一 join;
5. start 一个 join 一个,会让并发效果变差。

用一句话总结:

join 解决的是“主线程什么时候知道子线程已经干完活”的问题。

下一节继续看 Runnable

因为现在虽然能创建线程了,但任务和线程还是混在一起。理解 Runnable 以后,再看线程池为什么提交的是任务,就会顺很多。