跳到正文
hello world

18. 为什么工作中不建议一直 new Thread

发布于阅读量 0

前面为了学习线程基础,我一直在手动 new Thread()

比如:

Thread thread = new Thread(() -> {
    // 处理 PDF
}, "pdf-thread-1");

thread.start();

这种写法很直观,也很适合刚开始理解线程。

但是如果放到真实项目里,我一般不会这样写。

不是说 new Thread() 不能用,而是它不适合大量任务、批量任务和长期运行的服务。

尤其像 PDF 批量处理这种场景,如果每个 PDF 都创建一个线程,文件一多,问题很快就会暴露出来。


new Thread 的问题在哪里

假设 input 目录下有 100 个 PDF。

如果我这样写:

for (File file : files) {
    new Thread(() -> {
        processPdf(file);
    }).start();
}

代码看起来很简单。

但是它的意思是:

100 个 PDF;
创建 100 个线程;
这些线程几乎同时启动;
每个线程都要占用系统资源。

如果文件再多一点,比如 1000 个 PDF,那就是 1000 个线程。

这时候问题就来了。

线程不是免费的。

每创建一个线程,操作系统都要给它分配资源,比如线程栈、调度信息等。线程多了以后,程序不一定更快,反而可能更慢。


线程太多会带来什么问题

线程太多,最明显的问题是 CPU 上下文切换变多。

CPU 同一时间能真正执行的线程数量是有限的。

比如我的机器是 8 核,那并不代表我创建 1000 个线程以后,1000 个线程都能同时跑。

实际情况更像是:

线程1 执行一会儿;
切换到线程2;
线程2 执行一会儿;
切换到线程3;
……

线程越多,CPU 花在切换线程上的时间就越多。

这些切换本身不产生业务价值。

也就是说,程序可能不是在认真处理 PDF,而是在不停切换线程。

这就很亏。


内存也会被占用

线程还会占内存。

每个线程都有自己的线程栈。

如果线程数量很大,内存压力也会变大。

PDF 处理本身可能还会读取文件、生成图片水印、写出新 PDF,这些操作本来就可能占用不少内存。

如果再创建大量线程,风险更高。

可能出现:

内存占用持续升高;
GC 频繁;
程序响应变慢;
严重时直接 OutOfMemoryError。

所以不能只看“多线程能不能跑”,还要看“机器能不能扛住”。


文件处理还会带来 IO 压力

PDF 水印处理不是纯计算。

它涉及文件读取和文件写入。

如果几十个甚至几百个线程同时读写磁盘,磁盘 IO 压力会明显上升。

这时候即使 CPU 没满,程序也可能慢。

因为大家都在等磁盘。

所以 PDF 处理这种任务,并不是线程越多越好。

更合理的方式是控制并发数量。

比如:

同一时间处理 3 个 PDF;
或者同一时间处理 5 个 PDF;
剩下的任务排队等待。

这比一下子全部冲上去更稳。


new Thread 没有任务队列

手动 new Thread() 还有一个问题:没有统一的任务队列。

比如现在来了 100 个任务,我直接创建 100 个线程。

如果又来了 100 个任务,我再创建 100 个线程。

程序没有一个地方统一管理这些任务。

也没有办法很自然地表达:

最多同时执行 3 个;
剩下的先排队;
前面的任务完成后,再执行后面的任务。

虽然我可以用 Semaphore 限制同时进入处理逻辑的数量,但线程本身还是创建出来了。

这就不是很理想。

我更希望任务是这样处理的:

任务来了,先放进队列;
线程池里有固定数量的工作线程;
哪个线程空闲了,就从队列里取一个任务执行;
线程执行完任务后,不销毁,继续处理下一个任务。

这就是线程池的思路。


new Thread 不能复用线程

手动创建的 Thread,执行完一次任务后就结束了。

比如:

Thread thread = new Thread(() -> {
    processPdf(file);
});

thread.start();

这个线程执行完 processPdf(file) 后,生命周期就结束了。

如果还要处理下一个 PDF,就要再创建新的线程。

这会带来额外开销。

线程池不一样。

线程池里的线程可以复用。

比如有 3 个工作线程:

pdf-thread-1
pdf-thread-2
pdf-thread-3

它们处理完第一批 PDF 后,不会马上销毁。

它们会继续从任务队列里取下一个 PDF 任务。

这样就避免了反复创建和销毁线程。


new Thread 不好统一管理

还有一个实际问题:不好管理。

如果我到处都写:

new Thread(() -> {
    // 任务逻辑
}).start();

项目大了以后,很难知道:

现在到底创建了多少线程;
这些线程叫什么名字;
哪些线程还活着;
哪些任务失败了;
程序关闭时这些线程有没有结束;
异常怎么统一处理。

线上排查问题时,如果日志里全是:

~~~text
Thread-1
Thread-2
Thread-3

很难判断这些线程到底属于哪个业务。

线程池至少可以统一命名、统一配置、统一关闭。

比如后面可以把线程名改成:

pdf-watermark-thread-1
pdf-watermark-thread-2
pdf-watermark-thread-3

一看日志就知道是 PDF 水印线程池里的线程。

这个对排查问题很重要。


new Thread 异常不好统一处理

手动 new Thread() 时,如果线程里的任务抛异常,一般只能在任务内部自己捕获。

比如:

new Thread(() -> {
    try {
        processPdf(file);
    } catch (Exception e) {
        e.printStackTrace();
    }
}).start();

如果每个地方都这么写,异常处理会比较散。

有的地方记录日志,有的地方只打印堆栈,有的地方可能直接吞掉异常。

后面维护起来会比较难。

线程池虽然也不是自动帮我解决所有异常问题,但它至少提供了更统一的任务提交和结果获取方式。

比如后面用:

Future<String> future = executor.submit(() -> {
    processPdf(file);
    return "处理成功";
});

就可以通过 Future 拿到结果,也能在 get() 时感知异常。

再往后用 CompletableFuture,异常处理会更灵活。


new Thread 不方便控制关闭

普通 Java 程序里,如果我手动创建很多线程,程序什么时候结束、线程什么时候退出,都要自己处理清楚。

在 Spring Boot 这种长期运行的服务里,这个问题更明显。

比如我在接口里随手写:

new Thread(() -> {
    processPdf(file);
}).start();

请求返回了,但后台线程还在跑。

如果应用准备关闭,这些线程怎么办?

任务要不要继续?

要不要等待它们处理完?

如果任务卡住了怎么办?

如果到处都是手动创建的线程,这些问题很难统一处理。

线程池至少可以通过:

executor.shutdown();

或者:

executor.shutdownNow();

来统一控制关闭。

这就是工程化上的差别。


PDF 项目里会怎么演进

现在回到 PDF 水印项目。

最开始我可能是单线程:

一个一个处理 PDF。

然后为了学习线程,我写了:

一个 PDF 一个 Thread。

接着为了控制并发数量,我用了:

Thread + Semaphore。

再后来为了等待所有任务结束,我用了:

Thread + Semaphore + CountDownLatch。

这些写法都能帮助我理解并发工具,但还不是最适合生产代码的写法。

更自然的演进方向是线程池:

把每个 PDF 封装成一个任务;
提交到线程池;
线程池控制最多几个线程同时处理;
其他任务排队;
线程处理完一个任务后继续处理下一个。

也就是:

executor.execute(() -> {
    processPdf(file);
});

线程由线程池管理,任务由队列管理。

这就比手动 new Thread() 更稳。


线程池解决了哪些问题

线程池主要解决这几个问题:

1. 控制线程数量;
2. 复用线程;
3. 管理任务队列;
4. 统一处理任务提交;
5. 支持拒绝策略;
6. 方便统一关闭;
7. 可以自定义线程名称,方便看日志。

这些都是 new Thread() 很难自然处理的。

比如我希望:

同一时间最多 3 个 PDF 正在处理;
最多排队 100 个任务;
超过以后让调用方自己执行,或者直接拒绝。

这在线程池里可以通过参数配置。

但如果手写 new Thread(),就要自己写一堆控制逻辑。

没必要。


那 new Thread 就完全不能用吗?

也不是。

学习阶段,用 new Thread() 非常合适。

它能让我看清楚:

线程怎么创建;
start() 和 run() 的区别;
join() 怎么等待;
线程之间怎么并发执行。

一些非常简单的一次性小程序,也可以用。

但在长期运行的业务系统里,尤其是 Web 服务、批量任务、定时任务、文件处理服务里,我一般不会大量手动创建线程。

这个时候应该优先考虑线程池。

所以我会这样区分:

学习线程原理:可以 new Thread;
真实批量任务:优先用线程池。

这一节小结

这一节我主要记住几点:

1. Thread 适合学习线程基础,但不适合大量任务;
2. 每个任务都 new Thread,会导致线程数量不可控;
3. 线程太多会带来 CPU 切换、内存占用和 IO 压力;
4. new Thread 没有任务队列,也不能复用线程;
5. 手动创建线程不好统一命名、关闭和管理异常;
6. 批量 PDF 处理更适合用线程池。

用一句话总结:

new Thread 能让我理解线程,但真正做批量任务时,线程池才是更稳的做法。

下一节开始进入 ThreadPoolExecutor

前面那些 Thread + Semaphore + CountDownLatch 的写法,很多东西在线程池里都会变得更自然。

18. 为什么工作中不建议一直 new Thread - Java并发之从Thread到CompletableFuture - 上下文网