3. Thread 基础:从一个线程处理一个 PDF 开始
发布于 • 阅读量 0
前面已经把 PDF 水印的单文件处理跑通了。接下来就可以开始引入线程。
我不打算一上来就用线程池。虽然真实项目里直接 new Thread() 的机会不多,但学习并发时,Thread 这一层还是绕不开。因为线程池、CompletableFuture 底层最终也是把任务交给线程去执行。
所以这一节先写最原始的版本:创建一个线程,让它去处理一个 PDF 文件。
先看最简单的写法
新建一个类:
com.succos.thread.ThreadWatermarkDemo
代码如下:
package com.succos.thread;
import com.succos.dto.FileItemContext;
import com.succos.service.PdfWatermarkService;
public class ThreadWatermarkDemo {
public static void main(String[] args) {
System.out.println("main 方法开始执行:" + Thread.currentThread().getName());
Thread thread = new Thread(() -> {
System.out.println("子线程开始执行:" + Thread.currentThread().getName());
try {
FileItemContext fileItemContext = new FileItemContext();
fileItemContext.setSourcePath("input/test1.pdf");
fileItemContext.setTargetPath("output/test1-watermark.pdf");
fileItemContext.setWaterMakeText("上下文网");
PdfWatermarkService service = new PdfWatermarkService();
service.addWaterMakerOfPDF(fileItemContext);
System.out.println("PDF 水印处理完成:" + Thread.currentThread().getName());
} catch (Exception e) {
e.printStackTrace();
}
}, "pdf-thread-1");
thread.start();
System.out.println("main 方法继续往下执行:" + Thread.currentThread().getName());
}
}
这段代码里,我只创建了一个线程:
Thread thread = new Thread(() -> {
// 这里是真正要执行的任务
}, "pdf-thread-1");
后面的 "pdf-thread-1" 是线程名。
这个名字不是必须写,但我建议写上。因为后面观察日志时,线程名非常有用。尤其是并发任务多了以后,如果日志里全是默认的 Thread-0、Thread-1,看起来会比较乱。
Thread 里面放的是什么?
这里容易混淆的一点是:Thread 不是 PDF 处理逻辑本身。
真正的 PDF 处理逻辑是这里:
service.addWaterMakerOfPDF(fileItemContext);
Thread 只是负责开一个新的执行路径,让这段逻辑不要在 main 线程里执行,而是交给另一个线程执行。
也就是说,这里可以简单理解成:
main 线程:负责启动程序、创建子线程;
pdf-thread-1:负责处理 PDF 文件。
程序启动时,默认会有一个 main 线程。
当我调用:
thread.start();
Java 才会真的启动一个新的线程。这个新线程启动以后,会去执行 new Thread(...) 里面的那段代码。
运行时大概会看到什么?
控制台输出可能是这样:
main 方法开始执行:main
main 方法继续往下执行:main
子线程开始执行:pdf-thread-1
PDF 水印处理完成:pdf-thread-1
也可能是这样:
main 方法开始执行:main
子线程开始执行:pdf-thread-1
main 方法继续往下执行:main
PDF 水印处理完成:pdf-thread-1
顺序不一定完全固定。
这就是多线程刚开始比较容易让人不适应的地方。调用了 thread.start() 以后,子线程什么时候真正开始跑,是由 JVM 和操作系统调度的,不是我们肉眼看到代码顺序就能完全决定的。
也就是说,下面这两行:
thread.start();
System.out.println("main 方法继续往下执行:" + Thread.currentThread().getName());
不是说 thread.start() 里面的任务全部执行完,才会打印下一行。
start() 的意思更像是:
我把这个线程启动起来了,至于它什么时候抢到 CPU 去执行,由调度器决定。
所以 main 线程不会在 start() 这里等子线程处理完 PDF,它会继续往下走。
这一点后面会引出 join()。
为什么 main 线程会继续执行?
因为 thread.start() 不是一个阻塞方法。
它只是告诉 JVM:
我要启动一个新线程了。
启动以后,main 线程和 pdf-thread-1 就是两个相对独立的执行路径。
可以理解成:
main 线程:
打印 main 开始
创建 thread
启动 thread
打印 main 继续执行
pdf-thread-1:
打印子线程开始
处理 PDF
打印 PDF 处理完成
这两条执行路径会交叉执行。
如果 PDF 处理很慢,main 很可能早就执行到最后了,而子线程还在处理 PDF。
这也是为什么在并发程序里,单纯看代码上下顺序是不够的,还要看任务到底在哪个线程里执行。
currentThread() 的作用
代码里我用了好几次:
Thread.currentThread().getName()
这行代码的作用是拿到当前正在执行这行代码的线程名称。
比如:
System.out.println("main 方法开始执行:" + Thread.currentThread().getName());
这行是在 main 方法里执行的,所以线程名一般是:
main
而下面这行:
System.out.println("子线程开始执行:" + Thread.currentThread().getName());
是在 new Thread 里面执行的,所以线程名是:
pdf-thread-1
我觉得学并发时,多打印线程名很有必要。
因为很多时候代码看起来是一段连续逻辑,但实际上已经换线程了。如果不打印线程名,很容易误判执行流程。
Thread 适合怎么理解?
现在先不要把 Thread 想得太复杂。
我更愿意这样理解:
Thread = 一个执行任务的工人。
任务本身是:
service.addWaterMakerOfPDF(fileItemContext);
线程是:
new Thread(...)
启动线程是:
thread.start();
所以完整过程就是:
准备一个任务;
创建一个线程;
让这个线程去执行这个任务。
这一节只处理一个 PDF,所以看不出并发带来的效率变化,但能先看清楚一件事:PDF 处理逻辑已经不在 main 线程里执行了,而是在 pdf-thread-1 里执行。
这一版代码有什么问题?
这段代码只是为了学习 Thread,不适合直接作为最终方案。
问题至少有几个。
比如,它只处理了一个文件。如果有 10 个 PDF,就要创建 10 个线程,写法很快就会变乱。
再比如,main 线程并没有等待子线程完成。虽然很多时候普通子线程没结束,JVM 不会马上退出,但从代码结构上看,main 并不知道 PDF 到底什么时候处理完。
还有,如果每个 PDF 都手动 new Thread(),线程数量也不好控制。文件一多,很容易开太多线程。
所以这只是第一步。
后面会继续往下改:
先理解 Thread;
再理解 start() 和 run();
再用 join() 等待子线程;
再处理多个 PDF;
最后用线程池替代手动创建线程。
这一节先记住什么?
这一节我主要记住三点:
1. Java 程序启动后,main 方法本身就是在 main 线程里执行的;
2. new Thread(...) 只是创建线程对象,真正启动线程要调用 start();
3. start() 之后,main 线程不会等子线程处理完,它会继续往下执行。
这几个点看起来基础,但后面理解线程池和 CompletableFuture 时都会用到。
因为不管写法怎么变,本质上还是那句话:
任务最终一定是由某个线程执行的。