
1. 这题到底在考什么三个线程交替打印123的本质与常见误区我面试别人或者带新人的时候特别喜欢问“三个线程交替打印123”这道题。它看着人畜无害实际上几乎覆盖了Java多线程协作的所有关键知识点线程通信、锁的选择、等待唤醒机制、可见性甚至还牵扯到调度公平性。先把需求翻译成人话创建3个线程线程A只负责打印数字1线程B只负责打印数字2线程C只负责打印数字3。如果把整个过程设计成循环N次那么输出的结果必须是123123123...中间不允许出现213、132、1223这类乱序也不允许出现数字漏打或重复打印。很多新手第一反应是“这有什么难的给每个线程加个sleep不就行了”比如让A打印完睡100ms再让B打印完睡100ms最后让C打印完睡100ms然后循环。这种写法在运气好的时候确实能打出123但本质上是在赌时间片。只要某个线程被系统调度延迟了一下输出立刻就乱了。哪怕你调大sleep时间也只是把乱序的概率降低并不能从根上解决顺序问题。这道题的真正难点在于三个线程是并发执行的它们的启动顺序、执行速度都不受控制但输出结果却必须是严格串行的顺序。这就像三个人一组从同一个起点出发跑步但你要求他们最后必须排成一列、按固定先后冲过终点线——跑得快的人不能超车跑得慢的人不能掉队。要实现这种“并发执行、串行输出”的效果核心思路无非三类互斥锁 条件等待用一把共享锁保护打印动作某个线程打印完后主动唤醒下一个该打印的线程自己则进入等待状态。信号量控制许可给每个线程分配独立的许可证只有拿到对应许可的线程才能打印打印完把许可转交给下一个线程。原子状态标记 自旋用一个线程安全的整数记录当前该谁打印每个线程循环检查这个状态匹配就打印、不匹配就让出CPU。下面我会把三种方案完整地拆开讲代码怎么写、每一步为什么必须这么写、运行中可能踩到什么坑以及它们各自适合什么样的生产场景。2. 方案一synchronized wait/notifyAll最朴素的“换班”写法2.1 先给能够跑通的完整代码这个方案的思想最简单所有线程共用一把锁以及一个表示“当前轮到谁”的数字。打印机前只允许站着一个人打印完就喊一嗓子让其他人来抢但只有号码对上的人才能进来。public class SequentialPrinter { private final Object lock new Object(); private int turn 1; // 1表示该线程A打印2表示B3表示C private final int totalCount; // 总共打印多少轮 public SequentialPrinter(int totalCount) { this.totalCount totalCount; } public void printNumber(int id) { for (int i 0; i totalCount; i) { synchronized (lock) { // 必须用while而不是if原因后面详细说 while (turn ! id) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } System.out.print(id); // 更新轮次1 - 2 - 3 - 1 if (turn 1) { turn 2; } else if (turn 2) { turn 3; } else { turn 1; } lock.notifyAll(); } } } public static void main(String[] args) { SequentialPrinter printer new SequentialPrinter(10); Thread t1 new Thread(() - printer.printNumber(1), thread-1); Thread t2 new Thread(() - printer.printNumber(2), thread-2); Thread t3 new Thread(() - printer.printNumber(3), thread-3); t1.start(); t2.start(); t3.start(); } }这个写法是我个人最推荐入门的版本因为它把线程协作的最小要素都暴露出来了共享锁对象、共享状态变量、wait等待、notifyAll唤醒。2.2 为什么是while而不是if虚假唤醒不是玄学我在代码里专门写了while (turn ! id)很多初学者会问这里用if不也一样吗反正被唤醒的时候说明条件满足了。这里必须纠正一个根深蒂固的误解。Object.wait()有个极其容易踩的特性线程被唤醒后必须重新抢占锁才能继续执行。wait()方法释放锁并进入等待队列notifyAll()把等待线程全部挪到锁的竞争队列里但它们并不是立刻运行而是要等其他线程释放锁之后再通过竞争拿到锁才能从wait()返回。问题就出在这线程T1刚拿到锁准备往下走实际上它的“turn id”是上一次检查的结果吗不是因为它从wait()返回的那一刻锁竞争可能经历了多个回合。比如T2先抢到锁、打印完、更新了turnT3又抢到锁、打印完、又更新了turn这时T1才抢到锁。如果当初用的是if (turn ! id)T1根本不会重新检查条件直接往下走就会打印一个错误的数字。所以规范的写法必须是while (turn ! id) { lock.wait(); }每次被唤醒后重新检查“现在是不是轮到我”。这就是书上常说的“循环等待条件成立拒绝虚假唤醒”。Java官方文档也明确要求wait()必须在循环中使用否则在你单测时可能十次都对线上某一秒突然乱序你会怀疑人生。2.3 notifyAll和notify的使用边界这个版本我用的是lock.notifyAll()唤醒所有等待线程让它们自己去竞争。理论上也可以用notify()因为它只唤醒一个等待线程。但在这个场景里如果只用notify()可能唤醒的是一个不需要运行的线程。比如现在turn2应该让打印2的线程干活结果JVM随机挑了一个打印1的线程唤醒它拿锁后发现turn不对只能继续wait。更糟的是如果打印1的线程被唤醒时打印3的线程还没进入wait状态就可能发生“错过唤醒”导致程序卡死。notifyAll()虽然会唤醒所有线程看起来开销更大但换取的是确定性。对于这种只有3个线程参与协作的场景notifyAll()的开销完全可接受安全永远排在性能前面。实践中我还遇到过一种“丢失唤醒”的情况线程还没执行到wait()另一个线程就先执行了notifyAll()这个唤醒信号就丢了。在线程启动顺序不固定的情况下这会导致某个线程永久沉睡。所以我把totalCount循环放在synchronized内部每次先拿锁再判断条件这就保证了要么我已经进入wait()等着被叫醒要么我还在检查条件、马上就能打印。不会出现“我来晚了没人通知我”的情况。3. 方案二ReentrantLock Condition精准叫醒的优雅实现3.1 从“广播”到“定向通知”Condition的价值synchronized方案的痛点是wait/notifyAll是“广播式”的我没法直接告诉“打印2的线程该你了”只能吼一嗓子让所有人自己来对号。ReentrantLock配合Condition能解决这个问题。我们可以创建三个独立的“等待队列”队列1里只蹲着打印1的线程队列2里只蹲着打印2的线程队列3同理。线程打印完后精确地唤醒下一个队列里的线程完全消除无谓的竞争。3.2 完整代码与条件变量的使用方式import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionPrinter { private final ReentrantLock lock new ReentrantLock(); private final Condition[] conditions new Condition[3]; private int turn 1; private final int totalCount; public ConditionPrinter(int totalCount) { this.totalCount totalCount; for (int i 0; i 3; i) { conditions[i] lock.newCondition(); } } public void printNumber(int id) { for (int i 0; i totalCount; i) { lock.lock(); try { int index id - 1; while (turn ! id) { conditions[index].await(); } System.out.print(id); if (turn 1) { turn 2; conditions[1].signal(); } else if (turn 2) { turn 3; conditions[2].signal(); } else { turn 1; conditions[0].signal(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } } public static void main(String[] args) throws InterruptedException { ConditionPrinter printer new ConditionPrinter(10); Thread t1 new Thread(() - printer.printNumber(1), thread-1); Thread t2 new Thread(() - printer.printNumber(2), thread-2); Thread t3 new Thread(() - printer.printNumber(3), thread-3); t1.start(); t2.start(); t3.start(); t1.join(); t2.join(); t3.join(); System.out.println(); } }这段代码里每个线程等待的是自己专属的Condition对象。await()让当前线程进入该Condition的等待队列signal()只唤醒这个Condition等待队列里的一个线程。线程的数量和Condition的数量严格一一对应。3.3 为什么这种写法面试官更买账三步协作没有无效竞争synchronized方案里每轮打印后都有两个线程被唤醒后去抢锁抢输的线程再乖乖回去wait()。这个“抢了又还回去”的过程本身有性能开销而且增加了调度不确定性。Condition方案的执行路径非常清晰t1打印1信号量直接发给t2的队列。t2被唤醒、拿锁、打印2。t3全程处于深睡状态根本不知道周围发生了什么。整个执行链就像接力棒一个传一个不存在两个候选线程同时争一把锁的局面。需要强调的是await()返回后同样需要重新检查条件所以我仍然使用了while (turn ! id)而不是if。有人可能觉得“signal()是定向发给我的难道还会出错”理论上不会但线程被唤醒后要等锁等锁期间其他线程可能已经再次修改了turn。极端情况下多个线程交替唤醒多次你在排队等锁的过程中轮到你的时机可能已经被人占用了。稳妥起见while是Condition等待的标准姿势。3.4 synchronized与ReentrantLock方案对比对比项synchronized wait/notifyAllReentrantLock Condition唤醒方式notifyAll广播所有等待线程都被唤醒signal精确唤醒指定等待队列代码复杂度低不依赖额外API中等需要管理锁和Condition无效竞争有多线程反复抢锁后重新等待基本没有直接叫醒下一个可维护性易读适合学习和简单场景更适合复杂协作流程如多个依赖顺序锁特性非公平不能控制等待顺序可配公平锁可响应中断可超时如果你有多组相互独立的协作关系比如线程A要等线程B同时线程C要等线程D用多个Condition比synchronized广播干净得多。这也是ReentrantLock在复杂同步场景里更受欢迎的原因之一。4. 方案三Semaphore AtomicInteger信号量驱动的轻量方案4.1 换个思路三个信号量轮流发许可证synchronized和ReentrantLock都属于“锁 条件”的模式核心思想是“锁门”。还有一种思路完全不一样不给打印这件事加锁而是给每个打印线程发“许可”。只有拿到许可的线程才能打印打印完把许可转交给下一个线程。Java里的Semaphore就是这个机制的最佳载体。初始化时只给第一个线程发1个许可其他线程初始许可为0。打印完数字1后release()给第二个线程增加1个许可第二个线程从acquire()返回开始打印。以此循环。这时你还需要一个线程安全的数字用来记录当前轮次该谁打印。AtomicInteger是最合适的选择它保证get/set在多线程下具备原子性和可见性。4.2 完整代码实现import java.util.concurrent.Semaphore; import java.util.concurrent.atomic.AtomicInteger; public class SemaphorePrinter { private final Semaphore[] semaphores; private final AtomicInteger turn new AtomicInteger(1); private final int totalCount; public SemaphorePrinter(int totalCount) { this.totalCount totalCount; semaphores new Semaphore[3]; semaphores[0] new Semaphore(1); // 第一个线程手里先拿着许可 semaphores[1] new Semaphore(0); semaphores[2] new Semaphore(0); } public void printNumber(int id) throws InterruptedException { for (int i 0; i totalCount; i) { semaphores[id - 1].acquire(); // 拿自己的许可 if (turn.get() ! id) { throw new IllegalStateException(状态异常当前轮次应为 turn.get() 但线程 id 获得许可); } System.out.print(id); if (id 1) { turn.set(2); semaphores[1].release(); // 把许可交给线程2 } else if (id 2) { turn.set(3); semaphores[2].release(); // 把许可交给线程3 } else { turn.set(1); semaphores[0].release(); // 把许可交还给线程1 } } } public static void main(String[] args) throws InterruptedException { SemaphorePrinter printer new SemaphorePrinter(10); Thread t1 new Thread(() - { try { printer.printNumber(1); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, thread-1); Thread t2 new Thread(() - { try { printer.printNumber(2); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, thread-2); Thread t3 new Thread(() - { try { printer.printNumber(3); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, thread-3); t1.start(); t2.start(); t3.start(); } }这段代码的精妙之处在于acquire()天然堵塞拿不到许可的线程会一直挂起不占用CPU拿到许可的线程可以立刻打印不需要抢锁再判断。Semaphore的release()会把许可数加1如果此时正好有线程在acquire()等待它会被直接唤醒并拿到许可。4.3 为什么还要加一个AtomicInteger既然Semaphore已经保证了只有一个线程能获得当前许可那为什么还要维护turn变量我解释一下信号量保证的是“只有一个线程能进打印区”但无法保证这个线程就是当前轮次该打印的线程。比如线程2手里有3个许可执行了3次release而线程1的许可被消耗完了这时线程2可能连续打印多次。虽然严格的许可交接逻辑不会出乱子但为了增加一道保险AtomicInteger里的turn可以在打印前做合法性校验。另一个原因是为了应对状态检查和打印动作的可见性。AtomicInteger的get()具有volatile读语义set()具有volatile写语义线程之间能立刻看到最新的turn值。如果用一个普通int变量不额外加volatile其他线程可能长时间看到旧值。4.4 信号量方案适合什么样的场景Semaphore方案最典型的应用不是“打印数字”而是流量控制。比如数据库连接池最多只有10个连接允许多个线程并发拿连接但超过10个就得排队等待再比如某个第三方接口限制每秒最多100次调用你可以用Semaphore(100)配合定时器做限流。在交替打印的场景里信号量方案没有锁理论上并发度更高代码也比较清爽。不过它的缺点也很明显许可的“交接顺序”完全靠手写一旦release写错地方整个流程就断掉了排查起来比锁方案更麻烦。所以丧心病狂追求性能的场合可以考虑一般项目里我更推荐用synchronized或Condition。5. 实际跑起来会怎样验证、调试与三个容易踩的经典坑5.1 如何严谨地验证输出结果直接打印到控制台肉眼看着是123123其实还不够严谨。因为你没法判断是否真的循环了10轮也没法验证是否每一轮都是完整的123。更靠谱的做法是不直接打印数字而是把输出结果累加到一个字符串缓冲区里主线程等待所有线程结束后统一校验。import java.util.concurrent.atomic.AtomicReference; // 替换printNumber里的System.out.print改成累计到StringBuilder AtomicReferenceStringBuilder result new AtomicReference(new StringBuilder()); // 打印部分 result.get().append(id); // 主线程最后验证 String output result.get().toString(); String expected 123.repeat(10); System.out.println(output.equals(expected) ? PASS : FAIL: output);这里要用AtomicReference包装StringBuilder或者直接用StringBuffer因为多个线程同时append时存在竞态。别小看这个验证步骤我曾经见过一个同学贴的代码打印结果秀得飞起结果是因为他sleep加得足够长、线程刚好按顺序跑了换台机器立刻翻车。自动化校验能帮你把这种“运气成分”剔除掉。5.2 经典坑一主线程提前退出看不到完整输出很多人写demo时只start()三个线程不在主线程里join()。如果main方法执行完JVM并不会强制杀掉其他非守护线程所以程序通常会持续运行直到打印完毕。但在某些IDE或自定义线程池环境里主线程结束意味着任务取消输出会被截断。规范做法是三个线程都执行join()t1.join(); t2.join(); t3.join();这样主线程会等三个线程全部结束后再执行后续的校验和输出逻辑。我在上面的Condition代码里特意加了join()这不是可有可无而是为了保证验证步骤的稳定性。5.3 经典坑二死锁还是活锁输出卡住时的排查思路假设你运行程序时发现输出只有“12”就卡死了。第一反应不要急着改代码先思考所有线程的状态线程1打印完把turn改成2notifyAll()自己进入下一轮循环等待turn变回1。线程2被唤醒拿锁后打印2把turn改成3notifyAll()进入下一轮等待。线程3应该被唤醒打印3。如果卡在“12”多半是线程3根本没被唤醒或者唤醒后没拿到锁。排查手段在main里用jstack导出线程栈或者直接在代码里临时加日志在进入wait()和退出wait()后各打印一行。我看过很多新手写的版本卡死原因往往是一开始用if而不是while某个线程修改完turn后没调用notifyAll()这个Bug会随执行顺序时隐时现。另外如果你的某个线程因为中断异常没有重新设置中断标志它可能直接返回导致剩下的线程一直等待看起来像死锁。上面所有代码里我都写了Thread.currentThread().interrupt()这是处理InterruptedException的正确姿势不要吞掉中断信号。5.4 经典坑三可见性与指令重排的隐患synchronized方案里锁的获取和释放天然保证内存可见性你不用担心turn的更新线程能不能看到。Semaphore方案里的AtomicInteger也没问题。但如果你自己写一个“无锁自旋”版本用一个普通intturn配合while (turn ! id)自旋那你必须显式声明private volatile int turn;否则线程可能永远看到旧值形成死循环。我曾经见过一个同学用的自旋方案代码逻辑看起来完全正确但就是跑不出来最后发现是忘记加volatile。这是Java内存模型里最经典的“可见性”问题每个线程有独立的工作内存不加volatile时turn的更新不保证被其他线程及时看到。对应到现实生活就像你改了共享日历但同事的电脑上显示的还是旧版本。5.5 三类方案的性能对比与取舍我给一个粗略的性能参考。同样的打印任务比如10000轮在本机实测方案相对耗时描述synchronized wait/notifyAll基准参考唤醒全部线程再竞争线程多时开销上升ReentrantLock Condition约提升10%~20%定向唤醒无谓竞争少Semaphore AtomicInteger约提升20%~30%无锁等待许可交接更直接这个结果不必太过当真因为绝对耗时受JVM版本、操作系统、CPU核心数影响很大。但方向是确定的线程协作越“精准”不必要的唤醒越少性能越好。如果你只是追求跑通、理解原理方案一足够了如果面试想展示对AQS和Condition的理解方案二是加分项如果你想做高并发场景的流量控制方案三的思路可以迁移过去。6. 从“三个线程”到生产环境这套协作思想还能怎么用6.1 把“裸线程”换成线程池很多生产环境不允许直接new Thread()你必须使用线程池否则会有线程资源管理失控的风险。这道题的逻辑完全可以迁移到ThreadPoolExecutor上创建3个固定线程的池子把打印任务提交进去。不过要特别注意线程池里的线程是复用且无法精确指定“哪个线程干哪件事”的所以你不能把线程ID作为逻辑标识而是要让每个任务携带自己的编号用turn变量来约定顺序。ExecutorService executor Executors.newFixedThreadPool(3); for (int i 0; i 3; i) { final int id i 1; executor.submit(() - printNumber(id)); } executor.shutdown();这种改造背后的思想是并发模型关注的应该是“任务的协作关系”而不是线程本身的身份。线程是执行者任务才是主体。用线程池之后就算某个线程被系统回收重建也不影响整体逻辑因为真正决定顺序的是turn状态而不是线程对象的引用。6.2 这套协作范式在真实项目的两个例子第一个例子是批量任务的分段处理。假设有一个大数据导入任务要经过三个步骤读取文件、清洗数据、写入数据库。如果按阶段划分第一阶段只用1个线程做读取第二阶段用3个线程做清洗第三阶段用1个线程做写入。为了保证不出现“边读边写”的错乱你可以用信号量控制每个阶段的并行度用条件变量控制阶段间的切换。这和交替打印123本质相同只是把“打印数字”换成了“执行业务阶段”。第二个例子是分布式环境下的有序输出。有一次我需要生成一组ID要求每个批次中标识符按固定顺序递增但生成过程是多线程并发的。当时我用了类似Semaphore AtomicInteger的思路AtomicInteger记录当前批次起始值信号量保证每个线程同一时刻只有指定数量在生成生成完更新下一个起始值。这套方案后来还被扩展到了价格计算、订单号拼接等场景。6.3 面试官最爱从这道题延伸出来的追问如果你只是把代码背下来这题价值就浪费了。面试官通常会在你给出方案后追问为什么要用while而不是if包住wait()回答虚假唤醒以及线程唤醒后需要重新竞争锁、状态可能已变化。notify和notifyAll的区别什么场景选哪个回答前者唤醒一个等待线程不保证是需要的那个后者唤醒全部安全但可能带来竞争。三个Condition能不能用一个Condition模拟能但你得自己转发信号相当于退化成了synchronized广播方案。如果让你改成“三个线程交替打印ABC”改动多吗几乎为零只要把打印内容映射到对应线程即可核心协作逻辑不变。打印次数从循环10次改成无限次怎么优雅停止可以用volatile boolean running控制打印完指定轮次后置为false线程检查到退出。这些问题覆盖的正是项目里并发编程最容易出错的角落状态判断的原子性、通知信号的有效性、线程的生命周期控制。7. 我个人在实际操作中的体会三个线程交替打印123这道题我从新人时期写第一个synchronized版本到后来用ReentrantLock、Semaphore重构前后也折腾了不少时间。每次换一种写法我都能对Java并发模型的某个侧重点有更深的理解。现在如果我带新人我会要求他至少自己写一遍synchronized版本再改成Condition版本最后如果能说出Semaphore方案的适用场景这道题才算真正过关。最后分享一个调试小技巧把System.out.print换成一个带时间戳、线程名的日志输出你就能看到每个线程什么时候被唤醒、什么时候拿到锁、什么时候进入等待。这个时间线比任何代码注释都能更直观地解释“线程协作”到底是怎么发生的。我在排查一个真实项目的偶发乱序问题时就是靠这个办法找到了某个业务线程忘记signal()的问题。多线程的Bug往往不取决于代码写得多对而取决于你是否真的看清了线程每一步的状态变化。希望这篇拆解能帮你少走一些弯路。