我在面试 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.CountD
