跳到正文
hello world

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-0Thread-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 时都会用到。

因为不管写法怎么变,本质上还是那句话:

任务最终一定是由某个线程执行的。
3. Thread 基础:从一个线程处理一个 PDF 开始 - Java并发之从Thread到CompletableFuture - 上下文网