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 的写法,很多东西在线程池里都会变得更自然。