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 文件数量不固定,我不会手写 thread1、thread2。
更自然的写法是把线程放到集合里。
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 都可能出问题。
所以这一节只是为了理解 Thread 和 join()。
真实批量处理,后面还是要过渡到线程池。
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 以后,再看线程池为什么提交的是任务,就会顺很多。