我在面试 Java 候选人时,最喜欢拿“java多线程打印”来摸底。不是让人背线程池参数,而是直接要求手写一段代码:for 循环里开多个线程去打印任务,再回答三个问题——为什么输出顺序是乱的?怎么让主线程等所有任务打印完再继续?想让三个线程交替打印 ABC 又该怎么控制?这一题基本能看出一个人是真碰过并发问题,还是只在死记面试题。
这篇文章我就按这条线去写。不绕概念,直接从“多线程打印”这个看起来很小、实际能把线程调度、线程安全、等待机制、线程池、锁与条件变量全部串起来的话题展开。你如果刚学多线程,或者正在准备面试,又或者工作中写过“for 循环内 new Thread”但总觉得哪里不对,那这篇文章应该是能帮到你的。
1. 多线程打印出来的乱序,不是玄学:先理解线程调度与输出边界
1.1 从一段“看起来应该顺序执行”的代码说起
先看初学者最常见的写法。需求很简单,开 5 个线程,每个线程打印自己的任务编号,最后主线程打印“提交完毕”。
java复制public class LoopThreadDemo {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
new Thread(() -> {
System.out.println("任务 " + i + " 开始");
}).start();
}
System.out.println("所有任务提交完毕,主线程结束");
}
}
这段代码有两个问题。第一,如果你真跑一遍,大概率会看到“所有任务提交完毕”先打印出来,然后才是各个线程的输出,而且顺序每次都不一样。第二,代码里 i 在 lambda 表达式里使用会直接编译报错,因为 lambda 捕获的循环变量必须是 effectively final,这里需要先复制一份局部变量。
java复制public class LoopThreadDemo {
public static void main(String[] args) {
for (int i = 0; i < 5; i++) {
int taskId = i;
new Thread(() -> {
System.out.println("任务 " + taskId + " 开始");
}).start();
}
System.out.println("所有任务提交完毕,主线程结束");
}
}
跑一次输出大概是:
code复制所有任务提交完毕,主线程结束
任务 3 开始
任务 0 开始
任务 4 开始
任务 1 开始
任务 2 开始
这不是程序写错了,而是线程调度的本来面目。Java 的线程最终由操作系统调度,操作系统给每个线程分配时间片,谁先拿到 CPU、谁先执行完全不确定。main 线程启动完子线程后,并没有“等它们执行完”的机制,它会继续往下走,所以主线程自己的打印语句经常抢先完成。
1.2 println 的线程安全能保护什么,保护不了什么
很多人会问:System.out.println 不是线程安全的吗?如果线程安全,为什么打印出来还是乱序?
这里有个容易混淆的点。PrintStream.println 方法内部确实上了同步锁,所以并发调用时,单次 println 的内容不会出现“一半来自线程 A、一半来自线程 B”的撕裂情况。比如多个线程同时打印“AAA”和“BBBB”,你不会看到“AABBA”这种单个字符串内部混合的输出。
但同步锁管不到“顺序”。线程 A 先抢到锁打印了一行,释放锁之后,线程 B 完全可能抢在 A 的下一次打印之前执行。也就是说,println 保证的是“一次 println 调用的完整性”,不是“多个线程之间的输出顺序”。如果要保证顺序,必须在业务逻辑层面做同步控制,也就是后面要讲的锁和状态协调。
1.3 为什么用“打印”这种看似无聊例子来学多线程
因为打印是观察并发现象最直观的手段。你不需要依赖数据库、网络、文件这些外部环境,只要几行 System.out,就能立刻看到线程安全性、可见性、等待协作这些概念在真实执行中是什么效果。
把并发任务换成写日志、生成 PDF、批量导出报表、执行异步任务,底层逻辑其实一模一样。理解了多线程打印中的等待和顺序问题,你再去看“多线程同时执行 SQL 查询,等全部返回后再汇总”这类生产需求,会发现框架都是现成的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. for 循环里开多线程:先想清楚“等不等”和“怎么等”
2.1 最典型的错误:主线程已经跑完了,任务还在后台运行
回到开头那个问题:如果 for 循环里有 20 个打印任务,每个任务需要执行几秒,我们要求所有任务完成后主程序再退出,很多人的第一版代码是这样的:
java复制public class WaitDemoWrong {
public static void main(String[] args) {
for (int i = 1; i <= 20; i++) {
int taskId = i;
new Thread(() -> {
try {
Thread.sleep(500);
System.out.println("打印任务 " + taskId + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
System.out.println("全部任务已提交,程序结束");
}
}
真实开发里,这段代码会造成很隐蔽的问题。比如在定时任务里调用了这样的方法,结果外层判断“任务已结束”就返回了,而线程池/后台线程还在运行,日志输出和数据入库发生在主流程完成之后;如果是 Web 应用,每次请求都裸 new 线程,并发一高,系统能创建上千个线程,内存和上下文切换开销直接拉满。
所以写多线程代码之前,必须先确定一个核心问题:这一段主逻辑需不需要等待子线程完成?如果需要,就别用裸 new Thread 一把梭。
2.2 等待所有线程完成的四种方式对比
我把常用的等待方式整理成一张对照表,按复杂度从低到高排列。
| 方式 | 核心 API | 能否拿到任务结果 | 适用场景 | 注意点 |
|---|---|---|---|---|
| 线程对象.join() | Thread.join() |
不能,只能感知线程执行完毕 | 临时少量线程 | 需要保存 Thread 对象,逐个 join |
| 门闩 | CountDownLatch.await() |
不能,只能等计数归零 | 多个线程并发完成后主线程继续 | 必须在 finally 中 countDown |
| 线程池 + Future | ExecutorService.submit() + Future.get() |
能,get 返回结果或异常 | 需要任务返回值,需要按提交顺序拿结果 | get 是阻塞操作,要处理超时 |
| CompletableFuture | CompletableFuture.allOf().join() |
能,可配合 join 聚合 | 任务间可并行,最后聚合结果/回调 | 默认公共线程池要慎用 |
简单场景用 join 没有问题。但 join 有一个别扭的地方:需要把每个 Thread 对象保存下来,主线程一个一个 join,如果一个任务长期不结束,主线程就一直阻塞。想做到“任何一个任务失败了,其他任务取消”这种精细控制,join 也做不了。
2.3 用 CountDownLatch 做“闸门”:一份可直接用的模板
CountDownLatch 的思路很好理解:初始化一个计数器,主线程 await 等待计数器归零;每个子线程执行完后调用 countDown() 减一。当最后一个任务 countDown 后,计数器变成 0,主线程从 await 恢复执行。
java复制import java.util.concurrent.CountDownLatch;
public class WaitWithCountDownLatch {
public static void main(String[] args) throws InterruptedException {
int taskCount = 5;
CountDownLatch doneLatch = new CountDownLatch(taskCount);
for (int i = 1; i <= taskCount; i++) {
int taskId = i;
new Thread(() -> {
try {
System.out.println("打印任务 " + taskId + " 开始");
Thread.sleep(100);
System.out.println("打印任务 " + taskId + " 完成");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
// 无论任务成功还是异常,都必须 countDown,否则主线程永远等
doneLatch.countDown();
}
}).start();
}
doneLatch.await();
System.out.println("所有打印任务已完成,主线程继续");
}
}
这里最关键的一点是 countDown() 必须放在 finally 块中。如果任务执行过程中抛异常,没有执行 countDown,计数器永远到不了 0,主线程会在 await() 那里一直阻塞,程序看起来就像“卡死”了一样。这是实际项目里最常见的坑之一,尤其容易出现在捕获异常后直接 return 的代码里。
CountDownLatch 的局限性也很明显:它只能告诉你“任务执行完了”,不能告诉你“任务的结果是什么”。如果任务需要返回计算结果,或者其中一个任务抛出异常需要传播到主线程,就得换线程池加 Future 的方式。
3. 并发打印任务后的结果收集:线程池与 CompletableFuture 才是常规解法
3.1 别用裸线程跑批量任务:线程池解决的不只是“等待”
我见过很多项目的代码,处理一批任务时会这样写:
java复制for (任务 : 任务列表) {
new Thread(() -> process(任务)).start();
}
这个写法在任务量小于 10 的时候好像没什么问题,但一旦任务量到几百、几千,问题就暴露了。线程的创建和销毁本身有开销,而且操作系统能支撑的线程数是有限的。一台普通机器上创建几千个线程,内存可能先撑不住,然后 CPU 大量消耗在线程上下文切换上,真正的业务执行时间反而变长了。
线程池在这里的价值是:用固定数量的工作线程去消费任务队列。任务多的时候,线程池把多余的任务排队,而不是无限创建新线程。
3.2 线程池 + Future:等结果,但要注意 get 会阻塞
用线程池改写批量并发打印任务,可以这样写:
java复制import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
public class ThreadPoolFutureDemo {
public static void main(String[] args) throws Exception {
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<String>> futures = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
int taskId = i;
Future<String> future = pool.submit(() -> {
// 模拟耗时的打印任务
Thread.sleep(100);
return "任务 " + taskId + " 打印完成";
});
futures.add(future);
}
// future.get() 会阻塞,直到对应任务完成
for (Future<String> future : futures) {
String result = future.get();
System.out.println(result);
}
pool.shutdown();
System.out.println("全部任务已结束");
}
}
这段代码有两个细节要说明。
第一,submit() 可以传入 Callable,任务执行后会返回一个 Future。通过 future.get() 能拿到任务结果;如果任务内部抛了异常,get() 会把异常包装成 ExecutionException 重新抛出来。这就解决了 CountDownLatch 无法传递异常的问题。
第二,future.get() 是阻塞方法。上面的例子是按提交顺序逐个调用 get(),如果第 3 个任务跑得特别慢,主线程会卡在第 3 个获取结果那里,后面更快完成的任务结果也需要等。对“等全部完成再汇总”的场景来说没问题,但如果追求“先完成先处理”,应该用 CompletionService 或看下面的 CompletableFuture。
3.3 CompletableFuture 的 allOf 聚合:等待结果更优雅
如果你用的是 Java 8 以上,我更推荐用 CompletableFuture 来管理这种“多个并行任务,最后统一等待”的需求。代码可读性比 Future 列表加循环 get 高很多。
java复制import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class CompletableFuturePrintDemo {
public static void main(String[] args) {
ExecutorService pool = Executors.newFixedThreadPool(4);
List<CompletableFuture<String>> futures = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
int taskId = i;
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "任务 " + taskId + " 打印完成";
}, pool);
futures.add(future);
}
// allOf 等待所有 future 完成
CompletableFuture<Void> allDone = CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
);
// join 会等待 allOf 这个“聚合 future”完成
allDone.join();
// 如果需要每个任务的结果,再逐个 join 收集
List<String> results = new ArrayList<>();
for (CompletableFuture<String> future : futures) {
results.add(future.join());
}
pool.shutdown();
System.out.println("所有任务结果:" + results);
}
}
这里的核心是 CompletableFuture.allOf(...):它接收一批 CompletableFuture,返回一个新的 CompletableFuture,当所有子任务都完成时,这个聚合 future 才会完成。join() 的作用是阻塞等待完成。
为什么不直接对结果列表调用 CompletableFuture::join?因为你如果逐个调用 job1.join() 再 job2.join(),本质上和 Future 列表逐个 get 是一样的:如果第一个很慢,后面虽然完成了你也不会去读。先 allOf().join() 表示“所有任务都跑完了”,然后再从每个 future 里取结果,取的时候不会产生实际等待,只是顺手把已经算好的值拿出来。
supplyAsync 的第一个参数是任务逻辑,第二个参数是线程池。第二个参数其实很关键,如果不传,CompletableFuture 默认使用 ForkJoinPool.commonPool(),这是整个 JVM 共享的公共线程池。如果你的任务里有阻塞操作,比如等待远程接口、读写文件,可能把公共线程池的线程占满,影响其他使用 CompletableFuture 的代码。
4. 交替打印 ABC 这类面试题的底层逻辑:锁、状态与唤醒
4.1 “三个线程轮流打印 A、B、C”到底在考什么
前面聊的是“多线程各自打印,最后等结果”,属于并发任务的聚合。面试里还有另一类高频题:三个线程,线程 A 只打印 A,线程 B 只打印 B,线程 C 只打印 C,要求输出结果是 ABCABCABC…… 重复十次。这就是所谓的“多线程交替打印”。
这道题表面考的是打印顺序,实际考的是一个更底层的并发模型:多个线程要协作完成一件事,它们必须能看到共享状态,并且在合适的时机阻塞等待,被唤醒后再检查状态。
如果把条件放松到单线程按顺序打印 ABC,非常简单:
java复制public class SingleThreadABC {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
System.out.print("A");
System.out.print("B");
System.out.print("C");
}
}
}
放三个线程,难点就来了:每个线程打印完自己的字符后,怎么知道自己该停了?怎么通知下一个线程开始?这里不能靠 println 的内部锁,因为 println 锁无法表达“轮到谁”的业务顺序,必须引入业务状态变量。
4.2 synchronized + wait/notifyAll 的基本范式
一个最经典的实现:
java复制public class AlternatingPrint {
private static final Object lock = new Object();
private static int state = 0; // 0 表示轮到 A,1 表示轮到 B,2 表示轮到 C
private static final int TIMES = 10;
public static void main(String[] args) {
Thread a = new Thread(() -> printLetter('A', 0));
Thread b = new Thread(() -> printLetter('B', 1));
Thread c = new Thread(() -> printLetter('C', 2));
a.start();
b.start();
c.start();
}
private static void printLetter(char letter, int targetState) {
for (int printed = 0; printed < TIMES; ) {
synchronized (lock) {
// 使用 while 而不是 if,防止虚假唤醒
while (state % 3 != targetState) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.print(letter);
state++;
printed++; // 只有真正打印了才计数
lock.notifyAll();
}
}
}
}
思路拆开看其实很清晰:
state是共享状态,初始为 0,模 3 等于 0 时表示轮到 A 打印。- 每个线程在进入临界区后,先检查“当前是否轮到我”。如果没轮到,就调用
lock.wait()释放锁并进入等待。 - 某个线程打印完字符后,修改
state,再调用lock.notifyAll()唤醒所有等待中的线程。被唤醒的线程重新抢锁、重新检查条件。
有几个细节值得注意。
while 不能写成 if。因为 wait() 可能发生“虚假唤醒”,也就是线程没有收到任何 notify 就被唤醒。用 while 循环重新检查条件,才能保证条件不满足时继续等待。
notifyAll() 而不是 notify()。如果只唤醒一个线程,很可能唤醒的还是当前线程自己或者条件不匹配的线程,最终没有一个线程能继续推进,导致死锁。notifyAll 把所有线程都唤醒,它们会重新竞争锁并且重新检查条件,这样更稳妥。
printed++ 放在 synchronized 块内且打印后执行。如果把计数放在 for 的迭代位置,每个线程会因为没抢到锁而反复空转计数,最终打印次数根本不对。
4.3 状态变量能不能用 volatile 替代
有同学会问:state 变量可不可以修饰成 volatile,然后把 synchronized 去掉,改成自旋等待?
可以,但要理解适用边界。volatile 保证的是可见性和有序性,不能保证原子性。这里对 state 的操作是“读取、判断、打印、自增”,其中“自增”不是原子操作。用 volatile 自旋的版本里,如果多个线程同时读到 state=0 并都认为自己可以打印,就会输出多个 A,而不是一个 A。
比如这样写:
java复制public class AlternatingPrintSpin {
private static volatile int state = 0;
private static final int TIMES = 10;
public static void main(String[] args) {
new Thread(() -> {
for (int i = 0; i < TIMES; ) {
if (state % 3 == 0) {
System.out.print("A");
state++;
i++;
}
}
}).start();
// B、C 线程类似
}
}
在小任务量、低竞争的场景下,这段代码可能能跑对,因为它靠的是“操作足够快,碰巧没有并发冲突”。但在严格意义上,多个线程同时执行 if (state % 3 == 0) 时可能同时通过判断,然后同时执行 state++,最终输出结果不可预期。所以面试里如果把自旋版当作最终答案,容易被追问出问题。真正稳妥的方案还是 synchronized 或 ReentrantLock,把“判断 + 修改”做成原子操作。
4.4 ReentrantLock + Condition:从“全部唤醒”升级到“定向唤醒”
synchronized 方案只有一个等待队列,所以每次只能 notifyAll,唤醒后大家再去抢锁、检查条件。ReentrantLock 配合 Condition 可以把等待队列拆成多个,实现“只想唤醒 A 线程就只唤 A 线程”。
思路是创建三个 Condition,分别表示 A、B、C 三个线程各自的等待条件。A 线程等待 conditionA,打印完 A 后唤醒 conditionB;B 线程等待 conditionB,打印完 B 后唤醒 conditionC;C 线程同样。
java复制import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class AlternatingPrintLock {
private static final ReentrantLock lock = new ReentrantLock();
private static final Condition condA = lock.newCondition();
private static final Condition condB = lock.newCondition();
private static final Condition condC = lock.newCondition();
private static int state = 0;
private static final int TIMES = 10;
public static void main(String[] args) {
Thread a = new Thread(() -> run('A', condA, condB, 0));
Thread b = new Thread(() -> run('B', condB, condC, 1));
Thread c = new Thread(() -> run('C', condC, condA, 2));
a.start();
b.start();
c.start();
}
private static void run(char letter, Condition self, Condition next, int targetState) {
for (int i = 0; i < TIMES; i++) {
lock.lock();
try {
while (state % 3 != targetState) {
self.await();
}
System.out.print(letter);
state++;
next.signal();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}
}
Condition 方案更贴近生产环境里“精确通知”的需求,性能上也能避免无意义的竞争,缺点是代码复杂度高。面试中能先把 synchronized 版本写对,再说得出 Condition 的差异,已经很加分了。
5. 实战演练:用多线程“打印九九乘法表”该怎么做才对
5.1 需求拆解:并发的是计算,不是输出
热搜里总能看到“java for循环内的多线程”和“打印九九乘法表”这两个关键词连在一起,大概是被布置过这么一道练习:用多线程把九九乘法表打印出来。
如果你直接给每一行开一个线程,在线程里循环打印“1x1=1、1x2=2……”这种,最终控制台会出现行与行之间的交错,因为多个线程同时写 System.out,顺序完全不可控。有些行可能打成一行,有些行被切碎。
正确的思考方式是把任务拆成两个阶段:
- 阶段一(可并发):乘法表里的每一行计算之间没有依赖,第 i 行的结果不依赖第 j 行。所以可以让多个线程并发计算每个单元格的结果。
- 阶段二(必须串行):最终打印到控制台或写入文件时,需要按行列顺序规规矩矩输出。这个动作不能并发,应该放到一个收敛点,由主线程统一打印。
换句话说,多线程优化的目标是“计算并行”,输出环节不参与竞争。这也符合真实项目里的常见模式:多个线程并行处理数据,结果先放进共享容器,最后由单线程统一汇总呈现。
5.2 一份可运行的并行乘法表代码
用 Java 8 的 CompletableFuture 来实现,思路非常直接:
java复制import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class MultiplicationTableParallel {
public static void main(String[] args) {
int rowCount = 9;
int[][] table = new int[rowCount][];
for (int i = 1; i <= rowCount; i++) {
table[i - 1] = new int[i];
}
ExecutorService pool = Executors.newFixedThreadPool(4);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int row = 1; row <= rowCount; row++) {
final int currentRow = row;
futures.add(CompletableFuture.runAsync(() -> {
// 每个线程负责计算一行:currentRow x 1 到 currentRow x currentRow
for (int col = 1; col <= currentRow; col++) {
table[currentRow - 1][col - 1] = currentRow * col;
}
}, pool));
}
// 等所有行的计算线程都完成
CompletableFuture.allOf(
futures.toArray(new CompletableFuture[0])
).join();
pool.shutdown();
// 统一按顺序打印
for (int i = 1; i <= rowCount; i++) {
for (int col = 1; col <= i; col++) {
System.out.printf("%d*%d=%-2d ", col, i, table[i - 1][col - 1]);
}
System.out.println();
}
}
}
代码里有几个关键点值得展开解释一下。
table[currentRow - 1][col - 1] = currentRow * col; 是多线程写不同数组下标,每个下标只被一个线程写入一次,不存在写竞争。这比多个线程操作同一个共享变量安全得多。如果你让所有线程都更新同一个 state 变量,才需要考虑加锁。
二维数组在多个线程里分别写入不同位置,不需要额外同步。因为每个线程写的是自己那一行,行与行之间没有依赖,JMM 在 allOf().join() 之后建立 happens-before 关系:线程内的所有写操作对 join 返回后的主线程都可见。这是很多人忽略的地方,以为多线程改了共享数组之后必须加锁主线程才能看到完整结果,实际上只要有一个“让主线程等待所有线程结束”的同步点,就能保证可见性。
5.3 为什么用固定线程池而不是线程数等于任务行数
上面的例子有 9 行任务,用 4 个线程的线程池就够了。任务数大于线程数时,未被立即执行的任务会进入线程池的任务队列,等有空闲线程再执行。
如果直接开 9 个线程各自算一行,也不是不行,但这个练习题一旦扩大成“打印 10000 行的变种表”,一次性创建 10000 个线程会直接把程序拖垮。所以只要是批量循环任务,就应该养成用线程池的习惯。这不是教条,而是线程创建成本、内存消耗、上下文切换开销共同决定的工程选择。
线程池 + CompletableFuture + 最终统一打印,这套写法的好处是:无论你有 10 个任务还是 10000 个任务,主流程的代码几乎不用改,变化只是线程池参数的调整。
6. 写给自己的排查清单:多线程打印任务里的常见事故现场
6.1 任务永远不结束、进程却不退,怎么排查
最经典的表现是:主程序的所有业务逻辑都跑完了,控制台也打印了“结束”,但 JVM 进程就是退不出去。这种情况十有八九是还有非守护线程存活。
如果你用的是裸 new Thread,线程内的任务是个 while(true) 或者执行时间很长,主线程结束不影响它,JVM 要等所有非守护线程结束才退出。如果你用的是线程池,线程池里的核心线程默认会一直存活,即使空闲也不会自动销毁,必须在最后调用 shutdown(),让线程池在任务队列清空后关闭。
规范的收尾写法:
java复制pool.shutdown(); // 不再接受新任务,已有任务继续执行
try {
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
pool.shutdownNow(); // 超时未结束,强制中断
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
shutdown() 只是拒绝新任务,不是立刻杀掉已提交的任务。真正要强停的时候用 shutdownNow(),它会尝试向正在执行的线程发送中断信号。在业务代码里,如果线程正确处理了中断异常,就能及时结束。
6.2 并发一高,结果少了或者日志丢了,先查“任务是否真的提交成功”
有一次排查一个批量打印日志组件,系统压力一大,有些日志没有输出,也没有任何异常。后来发现代码写的是:
java复制CompletableFuture.runAsync(() -> {
// 打印逻辑
});
不传线程池时,任务默认走的是 ForkJoinPool.commonPool,它的大小是 CPU 核心数减 1。如果批量提交了几百个任务,但这些任务里又包含等待 IO 的逻辑,公共线程池很快被占满,后续任务全部排队,队列又长,处理速度跟不上,表现出来就是“日志晚了很多秒才输出,甚至进程退出后任务一起丢失”。
所以只要任务中可能包含阻塞操作,就应该用独立的线程池,并且给线程池一个有业务含义的名字。比如:
java复制ThreadFactory factory = new ThreadFactory() {
private final AtomicInteger seq = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "print-worker-" + seq.getAndIncrement());
t.setDaemon(false);
return t;
}
};
ExecutorService pool = new ThreadPoolExecutor(4, 8,
60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
factory,
new ThreadPoolExecutor.CallerRunsPolicy());
给线程命名这件事看着小,排查问题时能救命。线上出问题直接看线程 dump,一条 print-worker-37 比 pool-5-thread-3 能省掉大量猜测时间。
6.3 线程池核心参数怎么配,不是背公式而是看场景
我见过很多团队直接照搬面试题里的参数去建线程池:核心线程数 4、最大线程数 8、队列长度 100,但没有想过这个配置适不适合自己的业务。
这里给一个经验性参考:如果是 CPU 密集型任务,线程数建议在 CPU 核心数附近;如果是 IO 密集型任务,因为线程大部分时间在等待,可以调高线程数,一般用 CPU核心数 * (1 + IO等待时间 / CPU计算时间) 来估算。没有准确数据时,先压测再调优,不要拍脑袋。
队列长度的选择也要注意。Executors.newFixedThreadPool 默认用的是无界 LinkedBlockingQueue,意味着任务永远只进不出,如果任务提交速度长时间大于处理速度,队列里的任务会越积越多,最后很可能把内存撑爆。像第 3 节的例子我会换成有界队列,并配一个拒绝策略。生产环境宁可让任务提交方感知到压力而阻塞或降级,也不能让队列无限堆积。
6.4 我自己的一个经验:等待任务时永远要设超时
最后分享一个我自己踩了两回的坑:等待多线程任务结果时,不设超时。
CountDownLatch 的 await()、Future 的 get()、CompletableFuture 的 join(),这三个方法默认都能无限期等待。只要任务内部出现死锁或者某个远程调用一直不返回,主线程就会永远卡住。
我现在的习惯是能设超时的地方都设:
java复制// CountDownLatch
boolean finished = latch.await(30, TimeUnit.SECONDS);
// Future
String result = future.get(30, TimeUnit.SECONDS);
CompletableFuture 没有直接带超时的 join,可以改用 get(long timeout, TimeUnit unit),它同样支持超时。
设了超时之后,代码要多考虑一种情况:超时了怎么办?是根据当前已有的部分结果继续往下跑,还是发告警?还是做补偿?这不是个小问题,如果只是把超时时间一填,异常一吞,掩盖的故障比不设超时更危险。但无论如何,先把“不会无限阻塞”这层底线守住,再谈业务上的补偿策略,这个顺序不能反。
