
搞Java并发这一块的朋友大概率都遇到过这么一道题用三个线程轮流打印A、B、C每个线程各打印三次最终输出必须是ABCABCABC。别小看这个题它考察的不只是你会不会写Thread和for循环更核心的是你怎么理解线程之间的“顺序协作”。类似的需求在实际项目里并不少见比如多模块按阶段执行、流水线任务轮转、客户端请求按序限流等。早期我做并发轮询模块的时候用的还是synchronized加wait/notify的组合代码写起来又长又容易出问题后来换成信号量Semaphore整个思路一下子就清晰了。这篇博文就把信号量实现三线程交替运行的完整方案写出来包含原理、代码、变体和排坑经验适合正在学Java并发、准备面试或者需要在项目里做多线程顺序控制的朋友参考。1. 三个线程交替运行的场景与方案选型1.1 题目解读一排字符背后的核心问题这道经典题的直接要求是三个线程T1、T2、T3分别只能打印A、B、C要求它们交替执行输出ABCABCABC而且每个线程打印的次数相等。再严格一点还会要求不能使用Thread.sleep这类靠时间凑顺序的写法。它本质上在考察三个层面的能力能定义“下一轮该谁执行”这个状态能让被阻塞的线程在正确时机被唤醒能保证整个执行链条不重复、不遗漏、不死锁。听起来不复杂但真写起来很多人第一步就栽在方案选择上。网上搜这道题解法五花八门有的用volatile共享变量轮询有的用wait/notify有的用Lock还有Condition有的甚至用AtomicInteger自旋。轮询方案最消耗CPUwait/notify方案代码量大且容易漏唤醒这些我都试过最后才稳定在信号量方案上。1.2 三种主流实现思路横向对比这里我先把三种常见实现拉出来做一个对比方便你看完心里有数。后面所有方案我基本都写过一遍踩过的坑会在第5节集中说。实现方式核心机制代码量死锁风险可读性synchronized wait/notify共享状态变量条件等待中高忘记notify或状态错乱一般ReentrantLock Condition精确唤醒指定条件队列中较低一般Semaphore许可证流转天然有序少低高wait/notify方案为什么容易出问题因为它依赖一个共享的int状态变量来标记“当前轮到谁”每个线程循环判断状态不是自己就wait。这个状态一旦写错比如A执行完忘了改成B的标识或者notifyAll时机不对轻则空转重则所有线程都挂起。Condition方案比wait/notify好一些能把A、B、C分别放到三个条件队列里精确唤醒但代码量依然不小。信号量方案则完全绕开了“共享状态变量”这个问题。它把“轮到谁执行”这句话直接翻译成了“谁手里有许可”谁有许可谁干活干完活把许可交给下一个。1.3 信号量为什么是最优解很多人对Semaphore的印象停留在“限流工具”上比如控制某个接口同时只有10个请求进来。实际上Semaphore在“线程协调顺序”这个方向上也极其好用原因有三个第一Semaphore天然支持“许可的转移”。锁synchronized、ReentrantLock要求谁加锁谁释放同一个线程必须成对操作但Semaphore允许线程A执行acquire之后由线程B执行release这种不对称性正好适合A释放给B、B释放给C的接力链条。第二不需要额外的共享状态变量。许可数本身就是状态线程之间不需要再通过volatile变量沟通“该我了没”少了一个最容易写错的地方。第三扩展性好。从两个线程交替改成三个、五个、十个只需要改信号量数组的长度和初始化逻辑代码结构几乎不变。后面第4节会专门讲这个通用模型。2. 信号量核心原理从一个简单类比说起2.1 停车场模型许可证的获取与归还理解Semaphore最简单的方式是把它想成一个停车场。停车场里有N个车位每辆车进来之前必须先拿到一个“停车许可”没有许可就得在门口排队等离开的时候归还许可后面排队的车才能进来。对应到代码里acquire()尝试拿一个许可拿不到就阻塞等待release()归还一个许可同时唤醒一个正在等待的线程。Semaphore内部维护了一个许可计数器在JDK里这个计数器就是AQS的state字段。acquire成功时state减1release时state加1。有一点要强调这个计数器可以大于1也可以等于1。等于1的时候就是我们常说的二值信号量也叫互斥信号量行为上和一把互斥锁非常接近大于1的时候就是计数信号量可以用来限制并发数。我在做jmeter压测确认系统并发数的时候脑子里就经常会拿这个模型去对照被测系统能稳定承受的最大并发本质上就是它能同时向外发放的“许可数”超过这个数就会排队或者拒绝。2.2 关键方法细节与AQS基础使用Semaphore之前几个核心方法必须弄清楚否则代码跑起来出了问题都不知道往哪查。acquire()方法签名是acquire() throws InterruptedException这说明它是可中断的。也就是说当线程在等待许可的时候如果被其他线程调用了interrupt()它会立刻抛出InterruptedException。很多人写代码时图省事直接catch住异常然后什么都不干这是很不好的习惯正确做法是在catch里调用Thread.currentThread().interrupt()把中断状态补回去。release()方法不会抛InterruptedException它只做一件事把许可数加1然后唤醒一个等待线程。正因为acquire和release的方法签名不对称所以它们天然可以被不同线程调用这也是它能做线程协作的基础。构造器上有一个容易被忽略的参数公平模式。new Semaphore(int permits, boolean fair)可以指定是否公平。默认非公平模式下许可被归还时等待线程和刚来的线程是竞争关系谁抢到算谁公平模式则严格按照FIFO顺序让等待最久的线程先获得许可。在三线程交替这个场景里因为每个信号量同一时刻最多只有一个线程在acquire公平性影响不大但在限流场景里如果希望排队顺序稳定建议显式加上true。从源码角度看acquire底层走的是sync.acquireSharedInterruptibly(1)release底层是sync.releaseShared(1)这两个方法都在AQS里实现了排队、阻塞、唤醒的完整逻辑。建议有时间去看一看这两个方法的实现理解AQS的CLH队列之后很多并发工具的底层逻辑就一通百通了。2.3 二值信号量、计数信号量与并发数控制结合搜索热度来看大家经常把互斥信号量、二值信号量、计数信号量混在一起讨论。简单区分一下二值信号量初始许可为1同一时刻最多只有一个线程能acquire成功语义上接近互斥锁计数信号量初始许可大于1同一时刻可以有多个线程同时acquire语义上接近限流器。注意二值信号量和synchronized还是有本质区别的。synchronized的锁是由持有锁的线程释放二值信号量则可以由任意线程release。这一点区别在“线程交替执行”里恰好是关键但在“保护共享资源”的场景里反而是隐患——如果某个线程acquire之后忘了release或者错误地多release了一次许可数就会失衡。数据库连接池、agent多并发配置、OCR并发设置这些场景底层思路其实都是计数信号量申请连接前acquire归还连接时release连接池的上限就是这个信号量的初始许可数。这个思维方式一旦建立看很多中间件的并发参数都会豁然开朗。3. 手写三线程交替打印的完整方案3.1 环形接力模型的设计思路核心思路一句话给每个线程单独配一个信号量第一个线程初始有1个许可其余线程初始0个许可每个线程执行前acquire自己的许可执行后release下一个线程的许可。三个线程对应三个信号量semaphoreA初始许可1线程A执行前acquire A执行后release BsemaphoreB初始许可0线程B执行前acquire B执行后release CsemaphoreC初始许可0线程C执行前acquire C执行后release A。这样许可永远只有1个在三个信号量之间流转形成A→B→C→A的闭环。把它想象成跑步接力赛只有手里拿着接力棒的运动员才能跑跑完必须把棒子交给下一个人。为什么初始许可证必须是1、0、0而不是1、1、1如果三个信号量初始都是1那么三个线程同时acquire都能成功顺序就完全随机了那是在用信号量限制并发数不是控制顺序。顺序控制的本质是“唯一许可在环中流转”任何多线程解法失去这个唯一性都会乱。3.2 完整代码ABC各打印三次直接上代码这个版本我加了线程名方便在日志里定位。import java.util.concurrent.Semaphore; public class ThreeThreadPrint { public static void main(String[] args) { Semaphore semaphoreA new Semaphore(1); Semaphore semaphoreB new Semaphore(0); Semaphore semaphoreC new Semaphore(0); Thread threadA new Thread(() - { try { for (int i 0; i 3; i) { semaphoreA.acquire(); System.out.print(A); semaphoreB.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-A); Thread threadB new Thread(() - { try { for (int i 0; i 3; i) { semaphoreB.acquire(); System.out.print(B); semaphoreC.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-B); Thread threadC new Thread(() - { try { for (int i 0; i 3; i) { semaphoreC.acquire(); System.out.print(C); semaphoreA.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-C); threadA.start(); threadB.start(); threadC.start(); } }运行结果就是一行干干净净的ABCABCABC3.3 执行时序拆解顺序为什么稳定很多第一次接触这个写法的人会有一个疑问代码里先start了threadA但如果线程B先被操作系统调度执行它不会先打印吗我们一步一步推演。假设main线程依次调用了threadA.start()、threadB.start()、threadC.start()但CPU调度的顺序并不一定按照start的顺序。极端情况下threadB最先获得CPUthreadB执行semaphoreB.acquire()此时semaphoreB的许可数为0acquire失败线程B进入阻塞等待threadC也尝试semaphoreC.acquire()同样因为许可为0进入阻塞threadA终于获得CPU执行semaphoreA.acquire()许可数从1变成0acquire成功threadA打印A随后semaphoreB.release()许可数从0变成1线程B被唤醒线程B从acquire处继续执行打印B然后semaphoreC.release()线程C被唤醒线程C打印C再semaphoreA.release()回到步骤3完成一个循环。这个过程里start的顺序只影响“谁先跑去acquire”而不管谁先跑最终能成功acquire的只有持有那个唯一许可的线程。许可状态变化可以用下面这个表格表示执行阶段当前持有许可的线程下一步可执行的信号量初始状态无A信号量有1个许可线程AA打印完成后线程B线程BB打印完成后线程C线程CC打印完成后线程A线程A这张表循环滚动顺序天然是稳定的。3.4 写代码时要注意的三个细节第一InterruptedException不能吞。所有acquire都要放在try-catch里catch住异常后至少执行Thread.currentThread().interrupt()恢复中断状态否则线程虽然被唤醒但中断标志丢了外层代码对“是否被打断”的判断会失效。第二循环次数必须和release次数匹配。上面例子循环3次那么A总共release B三次B总共release C三次C总共release A三次。如果A循环3次但B循环2次最后semaphoreB会残留一个许可下一次运行或者后续逻辑就会混乱。第三System.out.print本身不是线程安全的为什么这里直接用没问题因为信号量已经保证了同一时刻只有一个线程在执行打印逻辑不存在两个线程同时打印的情况。如果去掉信号量三个线程同时System.out.print就会看到字符穿插在一起的情况。4. 高频变体不同次数、多线程、两个信号量4.1 变体一每个线程打印不同次数面试官通常不会满足于最简单的版本紧接着就会加条件把题目改成A连续打印4次B连续打印5次C连续打印6次循环3轮要求输出AAAABBBBBCCCCCC再接着下一轮。这个变体其实很简单不需要改信号量的结构只需要在acquire成功之后用内层循环打印多次再release给下一个线程。import java.util.concurrent.Semaphore; public class ThreeThreadPrintExt { public static void main(String[] args) { int rounds 3; Semaphore semaphoreA new Semaphore(1); Semaphore semaphoreB new Semaphore(0); Semaphore semaphoreC new Semaphore(0); Thread threadA new Thread(() - { try { for (int i 0; i rounds; i) { semaphoreA.acquire(); for (int j 0; j 4; j) { System.out.print(A); } semaphoreB.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-A); Thread threadB new Thread(() - { try { for (int i 0; i rounds; i) { semaphoreB.acquire(); for (int j 0; j 5; j) { System.out.print(B); } semaphoreC.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-B); Thread threadC new Thread(() - { try { for (int i 0; i rounds; i) { semaphoreC.acquire(); for (int j 0; j 6; j) { System.out.print(C); } semaphoreA.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread-C); threadA.start(); threadB.start(); threadC.start(); } }运行结果是一串连续的字母组合如果想看得清楚可以在每组打印完之后加一个空格比如C线程的循环结束后System.out.print( )输出会变成AAAABBBBBCCCCCC AAAABBBBBCCCCCC AAAABBBBBCCCCCC轮次一目了然。另外注意一下JDK 11及以上可以直接用.repeat()配合String.repeat()简化内层循环JDK 8环境下就老老实实用for循环。4.2 变体二n个线程的通用环形模型学会了三个线程就该考虑更通用的场景了。如果面试官问五个线程交替打印ABCDE怎么办甚至n个线程交替打印该怎么设计我的做法是信号量数组加取模运算。import java.util.ArrayList; import java.util.List; import java.util.concurrent.Semaphore; public class MultiThreadRingPrint { public static void main(String[] args) { int threadCount 5; int rounds 3; Semaphore[] semaphores new Semaphore[threadCount]; for (int i 0; i threadCount; i) { // 第一个信号量初始给1个许可其余给0个 semaphores[i] new Semaphore(i 0 ? 1 : 0); } ListThread threads new ArrayList(); for (int i 0; i threadCount; i) { final int index i; Semaphore current semaphores[i]; Semaphore next semaphores[(i 1) % threadCount]; Thread thread new Thread(() - { try { for (int round 0; round rounds; round) { current.acquire(); System.out.print((char) (A index)); next.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, Thread- (char) (A index)); threads.add(thread); } threads.forEach(Thread::start); } }这里的核心代码就是semaphores[(i 1) % threadCount]用取模把最后一个线程的下一个指向第一个线程从而闭合整个环。把threadCount改成3就是基础版三线程交替改成10就是十线程交替。输出结果ABCDEABCDEABCDE这个通用模型是我在实际项目里做多阶段流水线时抽象出来的后来做任务编排也一直沿用这个思路强烈建议你把这个结构记住。4.3 变体三两个线程交替执行与生产者-消费者两个线程交替是这个模型在n2时的特例semaphoreA初始1semaphoreB初始0A acquire后release BB acquire后release A两个线程就可以严格按照ABABAB的顺序执行。这个特例在面试里常常被包装成“两个线程交替打印奇数和偶数”之类的题目。本质上和经典的单缓冲区生产者-消费者是同一种节奏生产者必须等消费者消费完当前数据才能生产下一个消费者必须等生产者生产完才能开始消费。信号量在这里扮演了“环节锁”的角色保证生产、消费严格交替不会出现空转或者重复消费。如果你后面要深入学习并发协作建议把两个线程、三个线程、n个线程、生产者-消费者这四个场景放在一起对比着看你会发现它们背后都是同一条“许可在环中流转”的规律。5. 常见问题与排查技巧实录5.1 程序卡死不输出的典型成因这类代码最常见的故障就是运行之后什么输出都没有或者打印了几个字符就停住不动了。说明白点就是信号量的许可在某一个环节丢了后面的线程永远等不到许可。我遇到过一个很典型的错误有人把三个信号量全部初始化为0然后想着等线程start以后再通过某个操作去“发许可”结果线程全部阻塞在acquire上控制台干干净净。还有一种情况是线程A的循环里只打印没release或者release写在了循环外面导致B只被唤醒一次。排查这种问题最快的方法是jstack。先用jps找到Java进程的PID然后执行jstack 会看到线程停在类似这样的位置java.lang.Thread.State: WAITING (parking) at sun.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175) at java.util.concurrent.locks.AbstractQueuedSynchronizer.parkAndCheckInterrupt(...) at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireSharedInterruptibly(...) at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireSharedInterruptibly(...) at java.util.concurrent.Semaphore.acquire(Semaphore.java:...)看到WAITING (parking)并且栈上有Semaphore.acquire基本可以确定是许可没到。接下来顺着代码检查每个线程release的信号量是不是正好是下一个线程acquire的信号量循环次数是不是一致哪里漏了就在哪补。5.2 输出乱序的排查方向输出乱序最常见的原因是初始许可设置错误。比如三个信号量都初始化成1那三个线程都能立刻acquire成功打印顺序完全由操作系统调度决定输出可能是ACBBCA这种毫无规律的序列。还有人会问我明明初始是1、0、0为什么还会乱一种可能是某个线程执行了多余的release比如循环里不小心写了两次release导致后续环节出现两把“接力棒”这时候顺序自然就乱了。另一种可能是线程内部在acquire之前还有其他逻辑改变了许可状态这种隐蔽bug要仔细检查。排查思路也不难给每个线程打印带上线程名和序号比如System.out.println(Thread.currentThread().getName() print A, round i)然后把日志拉出来看哪个环节多发了许可、哪个环节少发了许可一眼就能找出来。5.3 异常场景参数非法、中断、许可泄漏new Semaphore(-1)会直接抛IllegalArgumentException这个属于低级错误但真有人写动态配置时把并发数算成负数。还有两个隐蔽问题值得注意。第一个是许可泄漏。acquire成功之后如果线程在执行过程中抛了RuntimeException后面的release就不会执行这个许可就“丢”了。虽然在这个打印例子里不太可能抛异常但在真实项目中acquire和release之间可能是复杂的业务逻辑。我的习惯是把业务代码包在try-finally里在finally中release保证许可一定会归还。第二个是中断状态丢失。前面提过acquire会抛InterruptedException有人直接在catch里忽略这会导致线程中断状态被清除。如果外层逻辑依赖isInterrupted()判断是否退出结果就会不准确。正确做法是恢复中断状态try { current.acquire(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 根据业务决定是否return或者继续后续逻辑 return; }5.4 面试延伸问法该怎么接这道题在面试中出现频率极高面试官一般会连环追问我把自己被问过和模拟过的几个问法整理一下。问如果三个线程都要打印100次怎么改答案很简单把循环次数从3改成100其他什么都不用动模型完全不变。问能用两个信号量实现三个线程交替吗可以但是需要配合一个共享计数变量或者AtomicInteger来判断当前轮到谁代码会绕很多。面试时如果时间充裕可以推演实际工程中我不会这么写三个信号量是最直观清晰的方案。问Semaphore和synchronized有什么区别这是高频中的高频。核心区别在于锁的释放规则synchronized要求同一线程进入和退出临界区谁锁谁开而Semaphore的acquire和release可以由不同线程执行这个特性让它天然适合做线程之间的协作与交接。另一个区别是Semaphore支持多个许可可以控制并发数synchronized做不到这一点。问如果某些线程打印速度快、某些线程耗时长这个模型还成立吗信号量只约束“执行顺序”不约束“执行速度”。A线程acquire之后可以执行耗时操作B线程只能在A完成release之后才开始天然不会有“快线程抢先越界”的问题。说到底面试官真正想考察的是你有没有理解“线程交替执行”的底层机制而不只是背出某个答案。能把“为什么初始是1、0、0”和“为什么release由下一个线程执行”讲清楚基本上就能过关。最后分享一点我自己的体会。当初用wait/notify做这道题时我总纠结用什么状态变量、要不要notifyAll代码改来改去还会挂住换成信号量之后思路就变成“我只负责把许可交给下一个线程”整个人的状态都清爽了。后来在真实项目里做多阶段任务轮转我也是先从三线程的信号量模型验证通过再推广到n线程和动态配置的并发数控制这套思路一路都能用。如果你也在学Java并发建议把上面的代码亲手跑一遍然后自己改成不同打印次数、不同线程数量跑通之后再去看Semaphore的源码实现理解会深很多。遇到问题别急着翻答案先jstack再顺着许可链条找这是排查并发问题最快的方式。