先交代一句,标题里这个“开启3个线程循环打印10遍ABC”,是并发入门里最经典的一道老题:三个线程分别负责打印A、B、C,每个线程各打印10次,最终输出必须是严格重复的“ABCABCABC...”,也就是总长度30个字符,一个顺序都不能乱。题目看着简单,但它把多线程里最容易被忽视的几件事全考了一遍:可见性、协作、等待和唤醒。今天我想把两个风格的实现摊开来讲——一个是我理解的“何氏硬刚版”,不用任何现成同步工具,拿volatile硬扛;另一个是网上流传最广的“网红版”,用信号量做精确交接。这两条路线我都实际跑过,踩过的坑也会一起拿出来。
适合看这篇的人很明确:准备Java后端面试的、刚接触多线程想找个题目练手的、以及那种“明明写对了但总觉得哪里不对”的同学。读完你至少能说出两套不同方案的代码、它们各自的原理、以及为什么有的写法能在面试里加分、有的写法只能留在笔记本里自嗨。
1. 经典题目到底在考什么
1.1 三线程接力赛:题目本质
把这道题理解成一场接力赛最顺手。跑道只有一条,三个队员A、B、C轮流跑,A跑完把接力棒交给B,B跑完交给C,C跑完再还给A。这里最关键的一点是:任何一个时刻,只能有一个队员在跑。如果三个人同时冲出去,输出就会变成“ABBAC...”这种鬼样子。
很多初学者第一次尝试是直接开三个线程,每个线程里面循环打印自己的字母,结果发现顺序完全不可控。原因也很简单:线程调度是抢占式的,A线程打印完一个“A”之后,时间片可能刚好用完,CPU切到B线程,B打印一个“B”,再切回来A又打印一个“A”,结果就出现了“ABA”。所以这道题的本质不是“如何创建线程”,而是“如何让多个线程协作,保证同一个时刻只有一个线程在执行打印动作”。
这个“同一时刻只有一个线程能打印”的约束,就是临界区保护。实现方式可以千奇百怪,但核心永远是那个接力棒:令牌(Token)。谁拿到令牌谁打印,打印完主动把令牌交给下一个人。
1.2 并发考点的四张牌:可见性、原子性、有序性、协作
我刷这道题最大的感受是,它能一口气把并发编程的四个基础考点全串起来。
第一张牌是可见性。B线程在自旋里等待状态变成1,凭什么能第一时间看到A线程把状态改成1?如果没有volatile,JVM和CPU可能让B线程一直读到自己线程栈里的旧值,于是B永远等不到,程序就卡死在循环里。volatile的作用就是保证一个线程的写,对其他线程的读是可见的。
第二张牌是原子性。state++这种操作,表面上是一行代码,底层其实是“读、改、写”三步。在多线程环境下,如果两个线程同时执行state++,可能互相覆盖。但有意思的是,这道题的硬刚版里state++并不会出错,原因是状态机天然保证了“同一个时刻只有满足条件的那个线程会去改state”,所以竞争窗口根本不存在。这个细节很多人没想明白,后面我会专门展开。
第三张牌是有序性。CPU和编译器为了性能会做指令重排,锁和volatile都带内存屏障,能在关键位置挡住重排。自旋版里如果state没有volatile,不光可见性出问题,重排也可能破坏“先打印后改状态”的顺序。
第四张牌是协作。线程之间怎么等待、怎么唤醒、谁来唤醒谁,这就是Semaphore、Condition、wait/notify这些工具存在的意义。没有协作机制,线程就是三个各说各话的广播喇叭。
1.3 两条路线的分水岭:等待方式不同
硬刚版和网红版最大的分水岭,不是代码长短,而是等待方式。
硬刚版的等待方式是自旋,也就是不睡觉、不挂起,在循环里持续检查状态。它一直在消耗CPU时间片,但换来的是极低的延迟——只要令牌一到位,当前线程可能正好在跑,立刻就能接上。网红版的等待方式是阻塞,拿不到信号量许可时线程会真正挂起,让出CPU,等别人release之后再由操作系统唤醒。这样CPU利用率低,但每次唤醒都有一次线程切换的开销。
理解了这个分水岭,你就能看懂为什么不同方案在不同场景下性能表现差异很大,也能明白面试官追问“你觉得哪种好”的时候,他想听的不是标准答案,而是你对这两种等待方式的理解。
2. 何氏硬刚版:不用锁也能转起来
2.1 核心思路:一个状态机转到底
“何氏硬刚版”这个名字,我理解成一种风格代号。谁写的已经不重要了,重要的是这种思路确实够“硬刚”:不借助synchronized、不用Lock、不用Semaphore,只用一个volatile的int变量,硬生生地把三个线程的顺序憋出来。
思路是这样的:定义一个state,0表示轮到A,1表示轮到B,2表示轮到C。A线程不断检查 state % 3 == 0,成立就打印A,然后让state加1;B线程检查 state % 3 == 1,成立就打印B;C线程同理。当state从2加到3之后,state % 3 又变回0,A线程接着打印,形成一个闭环。
这里有一个很容易被忽略的设计顺序:判断条件、打印、推进状态,这三步的顺序不能乱。必须先打印、再推进状态。为什么?因为如果先推进状态再打印,A把state从0改成1之后还没来得及输出“A”,线程就被切走了,B看到state等于1,打印了“B”,然后A恢复过来又打印“A”,顺序就变成了“BA”。反过来,先打印再推进,A即使打印完“A”就被切走,state仍然是0,B检查0 % 3 == 1不成立,只能继续等,直到A恢复执行state++,B才有机会。这就是“先检查、再打印、最后交棒”的意义。
2.2 完整代码:硬刚版初版
直接看代码,我加了完整的注释,跑的时候用JDK9以上,因为用到了Thread.onSpinWait()。
public class HardCoreABC { // 0 表示轮到A,1 表示轮到B,2 表示轮到C // volatile 保证state的修改对其他线程立即可见 private static volatile int state = 0; private static final int PRINT_COUNT = 10; public static void main(String[] args) { new Thread(() -> runTask('A', 0), "A").start(); new Thread(() -> runTask('B', 1), "B").start(); new Thread(() -> runTask('C', 2), "C").start(); } private static void runTask(char ch, int id) { for (int i = 0; i < PRINT_COUNT; i++) { // 自旋等待:一直等到 state % 3 == id while (state % 3 != id) { // 告诉CPU这里是一个自旋等待循环,可以优化功耗 Thread.onSpinWait(); } System.out.print(ch); state++; } } }跑完之后控制台应该打印出一串“ABCABCABCABCABCABCABCABCABCABC”,正好30个字符。
这个版本里Thread.onSpinWait()很关键。如果去掉它,程序也能跑,但CPU会以最高频率空转,风扇呼呼转。它实际上是在给处理器发一个PAUSE指令,告诉CPU“我在等一个东西,你不要把流水线塞满”,能明显降低功耗和总线竞争。JDK8及以下没有这个方法,可以退而求其次用Thread.yield(),但效果没有onSpinWait好。
2.3 硬刚版的三个致命伤
硬刚版看起来很酷,但它的问题也非常明显,我在实际跑的时候一一踩过。
致命伤一:CPU占用极高。三个线程同时运行,其中两个基本都在空转等状态。自旋不会让出CPU,所以它们会持续占用两个逻辑核心(如果线程被分配在不同核上)。我在一台8核笔记本上跑,任务管理器里直接看到两个线程把CPU拉满。题目只打印30个字符,刚够让风扇转一下,如果循环次数改成10万,电脑基本没法干别的。
致命伤二:依赖volatile的可见性,写错一个修饰符就死循环。我第一次写这个版本的时候,忘了在state前面加volatile,结果A打印了一个A之后,B线程一直读不到state更新,程序就卡死在B的自旋循环里。这个问题在本地跑的时候不是每次必现,因为有些环境恰好能读到新值,但一旦遇到就是“偶现卡死”,排查起来非常痛苦。
致命伤三:严格来说,它并不是真正的无锁。System.out.print内部是synchronized方法,PrintStream写字符的时候会加锁。也就是说,线程在打印字符的那一刻,实际上依然处于一个锁竞争里。这就让“硬刚版”的名号变得有点虚——三线程的协作确实没用锁,可最终的字符输出还是走了锁。
2.4 硬刚版的补救:CAS自旋版
如果你非要较真“无锁”这件事,可以换一种方式推进状态:用AtomicInteger + CAS,把“读state、判断、改state”这一串操作里的问题解决得更干净。
import java.util.concurrent.atomic.AtomicInteger; public class CasABC { private static final AtomicInteger state = new AtomicInteger(0); private static final int PRINT_COUNT = 10; public static void main(String[] args) { new Thread(() -> runTask('A', 0), "A").start(); new Thread(() -> runTask('B', 1), "B").start(); new Thread(() -> runTask('C', 2), "C").start(); } private static void runTask(char ch, int id) { for (int i = 0; i < PRINT_COUNT; i++) { while (true) { int cur = state.get(); if (cur % 3 != id) { Thread.onSpinWait(); continue; } // 状态唯一属于当前线程,此时打印不会被打乱 System.out.print(ch); // 打印之后再推进状态,交给下一个线程 if (state.compareAndSet(cur, cur + 1)) { break; } // 理论上不会走到这里 } } } }注意这段代码里,CAS放在打印之后。这不是写错了,而是故意保持“先打印、再交棒”的顺序。为什么CAS在这里不会失败?因为当前状态state % 3的值只对应一个线程,其他线程根本进不了打印分支,所以不会有人去并发推进state。加CAS的价值是让state从“普通int加法”变成“原子性推进”,以后如果逻辑扩展成多令牌场景,这个版本能扛更复杂的条件。
但说句实在话,这种版本属于“面试加分项”大于“工程实用项”。真到生产环境,我不会用这种自旋方案去控制业务线程的顺序,成本太高。
3. 网红版:信号量精确交接为什么最讨喜
3.1 为什么Semaphore适合接力赛
网上流传最广的版本,基本上是Semaphore版。这个方案之所以“网红”,是因为它把“令牌”这个抽象直接变成了代码:信号量就是一个令牌池,acquire是拿令牌,release是交令牌。
放到这道题里,结构非常自然:给A线程一个信号量,初始许可为1;给B和C各一个信号量,初始许可为0。只有A线程能拿到许可先打印,打印完之后release B的信号量,B线程就从阻塞中被唤醒,拿到许可打印。然后再release C,C打印,C再release A,完成闭环。
这个方案比硬刚版舒服在哪?最直观的一点是拿不到令牌的线程会真正阻塞,而不是空转。CPU利用率低,代码可读性也高,基本是字面意思:每个线程都在等自己的许可,拿到就干活,干完把许可交给下一个。面试官看一眼就能明白你的意图。
3.2 完整代码:Semaphore交接版
import java.util.concurrent.Semaphore; public class SemaphoreABC { private static final int PRINT_COUNT = 10; public static void main(String[] args) { Semaphore semA = new Semaphore(1); Semaphore semB = new Semaphore(0); Semaphore semC = new Semaphore(0); new Thread(() -> runTask('A', semA, semB), "A").start(); new Thread(() -> runTask('B', semB, semC), "B").start(); new Thread(() -> runTask('C', semC, semA), "C").start(); } private static void runTask(char ch, Semaphore current, Semaphore next) { for (int i = 0; i < PRINT_COUNT; i++) { try { current.acquire(); System.out.print(ch); next.release(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } }这个版本的输出结果和硬刚版完全一样,但代码里没有volatile、没有自旋、没有状态机。你只需要记得:三个线程各自持有一前一后两个信号量,前一个负责给自己发许可,后一个负责唤醒下一个线程。
InterruptedException的处理不能敷衍。虽然这个Demo里没人会去中断线程,但生产环境里线程池关闭、超时控制都可能触发中断。最标准的做法是catch之后重新设置中断标记位,然后结束任务,不能直接吞掉异常。
提示:Semaphore默认是非公平模式。在这道题里其实感知不到公平问题,因为每个信号量只有一个许可,而且只有一个线程在acquire它。但如果在别的场景里多个线程抢同一个信号量,非公平模式的“插队”行为就会被放大,需要new Semaphore(n, true)开启公平模式。
3.3 网红版进阶:ReentrantLock + Condition
比Semaphore更细一点的控制是ReentrantLock加三个Condition。每个线程有自己的等待队列,A唤醒B,B唤醒C,C唤醒A,精确到人,不会误伤。
import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class ConditionABC { 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 PRINT_COUNT = 10; public static void main(String[] args) { new Thread(() -> runTask('A', 0, condA, condB), "A").start(); new Thread(() -> runTask('B', 1, condB, condC), "B").start(); new Thread(() -> runTask('C', 2, condC, condA), "C").start(); } private static void runTask(char ch, int id, Condition current, Condition next) { for (int i = 0; i < PRINT_COUNT; i++) { lock.lock(); try { // 必须用while,不能用if while (state % 3 != id) { current.await(); } System.out.print(ch); state++; next.signal(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } } } }这段代码的核心是while (state % 3 != id)这一行。await被唤醒之后,线程不会自动判断“我该不该继续”,它只会回到await之后的下一行继续执行。如果这里用if判断,线程一旦被非正常唤醒(虚假唤醒),就可能在自己不该打印的时候继续往下走,导致顺序错乱。用while重新检查条件,是Condition和wait的标准写法,没有商量的余地。
3.4 为什么不推荐synchronized + notifyAll
如果你在网上搜这道题,还会看到很多用synchronized加wait/notifyAll的版本。思路是多个线程锁同一个对象,每次打印完notifyAll唤醒所有线程,让它们各自检查状态。
功能上它能跑通,但我不推荐用这个版本去面试或者写进代码。原因是它有一个很明显的性能缺陷:notifyAll会把所有等待线程全部唤醒,可实际条件只满足一个,其他线程被唤醒后发现不满足条件,又乖乖回去睡觉。每次打印都要经历一次“全体起床、检查、再睡回去”的过程,在线程多的时候会有明显的惊群效应。
如果用notify而不是notifyAll,又会遇到另一个问题:Java的对象锁只有一个等待集合,你没办法指定“只唤醒B线程”。万一唤醒的是C,C检查条件不满足就继续wait,而B永远不被唤醒,程序就死锁了。这个两难困境本质上是因为synchronized的等待机制太粗糙,它不区分等待的原因。ReentrantLock的多个Condition就是专门来解决这个问题的——每个Condition一个队列,signal只唤醒对应队列的第一个线程。
4. 硬刚版与网红版的正面对比
4.1 可读性、代码量与门槛
单看代码行数,硬刚版和Semaphore版差不多,都不长。但代码行数少不等于容易懂。硬刚版的理解门槛主要在那个抽象的状态机上:你得先把state从0到2的循环想清楚,还得理解“先打印后推进状态”的必要性,还得意识到volatile承担了什么责任。如果哪一环没想透,代码写出来就是“看起来能跑,但不知道什么时候会翻车”的状态。
Semaphore版则几乎是“所见即所得”:线程acquire自己的许可,不行就等,拿到了就干活,然后release给下一个。这种直白感是它成为网红版本的重要原因之一。我给初学者讲这道题,也从Semaphore版开始讲,讲明白之后再问一句“如果不允许用Semaphore,你还能怎么写”,再引出硬刚版的状态机。
Condition版的可读性居中,但它的价值在于让你理解精确唤醒和惊群效应的区别。三套方案如果都能写出来,你对线程协作的认知就不是“背了一段代码”,而是真的理解了不同抽象层次的控制手段。
4.2 性能和资源占用实测对比
我实际测试的时候发现一个很尴尬的事实:在Java里用System.out.print做控制台输出,瓶颈根本不在线程同步,而在I/O。30个字符的输出量太小,两种方案跑起来时间都差不多,肉眼完全看不出差别。
想测同步开销,就得把打印换成纯内存操作。比如每个线程打印完之后做一个普通的整数自增,最后校验总数。我在一台Linux服务器上大致跑过100万轮,规律是:自旋版总耗时比Semaphore版少一些,但CPU占用率明显高,有一个核基本被吃满;Semaphore版的等待线程会挂起,CPU很安静,但每次acquire/release都伴随一次线程唤醒,整体耗时多了大概两到三成。
注意这个结果只能看趋势。自旋在临界区极短、竞争极低的时候确实有性能优势,但它把“等待成本”转移到了CPU上。Semaphore把等待成本转给了操作系统的线程调度。没有哪个方案绝对更好,只有合不合适。
4.3 选型速查表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 面试讲思路 | Semaphore版 | 清晰、好写、不容易出错 |
| 追求底层原理解释 | volatile自旋版 | 能讲清楚可见性和状态机 |
| 复杂业务精确唤醒 | ReentrantLock + Condition | 多条件队列,signal精确 |
| 超短临界区、性能敏感 | 自旋 + CAS | 减少上下文切换,但注意CPU占用 |
| 长等待、大量线程阻塞 | Semaphore / Lock | 让出CPU,避免空转 |
| 简单场景、快速实现 | synchronized | 代码少,但不要用notifyAll |
5. 现场翻车实录:这些问题我都踩过
5.1 输出顺序乱套、一直打印同一个字母
我第一次写硬刚版,跑出来的结果是“AAAAAAAAAA...”,B和C全程沉默。原因非常蠢:我给state加的不是volatile,而是普通int。A线程打印完之后修改state,B线程在另一个核心上读到的state永远是0,所以B以为自己永远没轮到自己。加上volatile之后问题立刻消失。
还有一种情况是条件写反了,比如B线程写成state % 3 != 0,结果B抢在A前面打印,输出顺序全乱。排查这类问题最笨也最有效的办法,是在每个线程打印之后把当前线程名、字符、state值都打出来看一眼,顺序对不对一目了然。
注意:验证并发程序的正确性,别只盯着控制台看。把输出重定向到文件,然后用diff和预期的“ABC”重复串比对,干净利落。
5.2 自旋把CPU飙到100%
这是硬刚版最容易劝退人的地方。程序明明跑得很快,结果正确,但任务管理器里CPU已经红了。原因就是自旋等待时线程不休息,持续执行while循环里的状态检查。
解决思路有两个方向。一个是在循环体里让出一下,Thread.onSpinWait()或Thread.yield()能明显缓解。另一个更彻底——直接放弃自旋,换Semaphore或Condition。如果只是写Demo玩,加onSpinWait就够了;如果代码要部署到生产环境,我建议选阻塞方案,别拿业务线程的CPU空转开玩笑。
5.3 虚假唤醒导致顺序断裂
Condition版的经典翻车现场是:用if替代while检查条件。某个线程被signal之后,恰好不是在正常状态下醒来,而是因为某些底层原因被唤醒,它直接往下执行打印,结果输出顺序就乱了。
虚假唤醒在Java文档里专门有说明:线程可能在没有被signal、没有被中断、没有超时的情况下醒来。所以await之后必须重新检查条件。这不是理论问题,是实际会发生的问题。我写这段代码时吃过亏,后来养成了习惯,凡是await/wait后面跟条件判断,一律用while,不用if。
5.4 System.out这个隐藏的锁
硬刚版标题写的是“不用锁”,但实际上System.out.print本身就是synchronized的。也就是说,哪怕你的线程协作机制完全无锁,字符真正输出到终端的那一步还是要进锁。这个事实有两层影响。
第一,严格抠“无锁”这个概念时,硬刚版站不住脚;第二,做性能测试时,System.out会掩盖真实的同步开销,导致自旋版和Semaphore版测出来的时间几乎一样。想测真实差异,就得把打印换成纯内存操作,比如AtomicLong计数。
5.5 常见问题速查表
| 症状 | 可能原因 | 解决办法 |
|---|---|---|
| 只打印一个字母然后卡死 | volatile漏写 | 状态变量加volatile |
| 输出顺序错乱 | 条件判断方向写反 | 检查state % 3和id的对应关系 |
| CPU占用100% | 自旋空转不让步 | 加Thread.onSpinWait或换阻塞方案 |
| 偶发多打印了同一个字母 | 用if而不是while等待 | 改成while循环重新检查条件 |
| 进程不退出 | 信号量release错了对象 | 检查next是否指向正确下一个线程 |
| 性能测试看不出区别 | System.out是瓶颈 | 换成内存计数再测 |
6. 从这道题延伸到工程与面试的思考
6.1 面试官问这道题,到底想听什么
这道题在面试里出现率极高,但面试官通常不是只要一个能跑的代码。他更想看到的是:你脑子里有没有一套“多线程协作工具箱”。
能写出Semaphore版,说明你至少会用工具;能接着说出Condition版和wait/notifyAll的差异,说明你理解等待队列和精确唤醒;能主动提起自旋和阻塞的区别、CPU占用和上下文切换的取舍,那就是在秀内功了。我遇到过不少候选人,代码写得很顺,但问到“为什么这里用while不用if”就卡住了——这其实就是考察你有没有真正理解await之后的执行路径。
另一个加分点是主动聊测试和验证。你能在写完代码之后说一句“我会把输出重定向到文件,用diff校验结果”,面试官对你的工程素养评价会明显高一层。
6.2 自旋锁在真实项目里的生存空间
把自旋锁写进业务代码,我个人非常不推荐。业务逻辑的临界区通常不是简单几行指令,可能是数据库操作、RPC调用、文件读写,这种长临界区一旦自旋,CPU浪费是灾难性的。
但自旋思想在底层框架里很常见。JUC里很多并发容器和工具类都用了自旋加CAS的组合,比如一些并发队列的入队出队、FutureTask的状态流转。这些场景的特点是临界区极短:就是几个字段的比较和赋值,几十纳秒。当临界区这么短时,线程挂起再唤醒的开销反而比重试几次CAS更大,自旋成了更经济的选择。
HardCore版的自旋用在这里也算一个教学案例,它让你理解为什么JDK内部会设计自适应自旋:JVM会根据过去一段时间自旋是否成功来动态调整自旋次数,避免无意义的空转。
6.3 泛化思考:3个线程变成N个线程,M遍
如果想把这道题吃透,我建议你顺手做一个泛化版本:N个线程,循环打印M遍,顺序依次是线程0、线程1、线程2……线程N-1,然后回到线程0。
硬刚版思路直接改成state % N == id;Semaphore版做成一个数组,每个线程acquire自己的信号量,然后release下一个;Condition版用循环创建N个Condition,signal下一个下标。抽象出来之后你会发现,这三种做法本质上是同一个逻辑的不同表达:判断“谁当前拥有令牌”,以及“把令牌交给谁”。
这种泛化能力很值钱。面试时你说出“我还能把三线程扩展成N线程”,比死记硬背三线程代码要有说服力得多。
我个人在实际操作中的体会是:这道题最好的学习方式不是抄一遍标准答案,而是把三种方案都写一遍、都跑一遍、都故意改错一遍。改错的过程才是真正长本事的过程。比如你亲手把volatile删掉,看一次卡死;把while改成if,看一次乱序;把Semaphore的release目标写错,看一次死锁——以后再遇到类似问题,你脑子里会立刻浮现出“这场景我见过”的直觉。
最后再分享一个小技巧:写完这类并发Demo,别直接看控制台输出,先准备一个期望串,比如"ABC".repeat(10),然后把程序输出重定向到文件,用diff一比对,秒出结论。这个习惯帮我省下的时间,早就超过写这篇总结花的时间了。