恒美微站 Logo 恒美微站
  • 首页
  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心
  • 联系我们

Java信号量Semaphore实战:三线程交替打印ABC原理与实现

  • 首页
  • 资讯中心
  • /
  • Java信号量Semaphore实战:三线程交替打印ABC原理与实现

相关资讯

Hermes WebUI 主题与皮肤定制指南:12 种内置外观 + 3 条路径打造专属界面 2026/9/9 15:54:09
Windows 10/11 跑 Android 子系统:WSABuilds 从零到跑通的手把手安装手册 2026/9/9 15:54:09
WSABuilds 30 分钟上手:在 Windows 上装一个带 Google Play 和 Root 的安卓环境 2026/9/9 15:54:09

最新资讯

旧 Mac 装新系统:OpenCore Legacy Patcher 从 0 到 1 实操笔记
Ray分布式计算框架深度解析:从任务调度到集群部署实战
论文降重与降AIGC双重达标:实操流程、工具逻辑与避坑指南
Playwright错误处理与重试机制实战:从崩溃到稳定采集
【滚雪球学数学建模】第4.3节·概率与统计建模,一文搞懂!
Python socket编程详解:TCP与UDP选型、实现与实战

今日推荐

基于MongoDB的图书管理系统:数据建模与Spring Boot+Vue实战
Claude Code安装配置全攻略:从零开始用上终端AI编程助手
tmux 会话管理与终端复用:AI 编程工作流的调度中枢实战

本周热门

超人会飞不算本事:系统稳定依赖清晰规则与边界设计
超人VS蜘蛛侠:拆解超级IP的影响力与传播方法论
基于CNN的调制信号识别:MATLAB实现时频图分类实战

本月精选

自研推理加速器Redwood:两周内实现PyTorch模型高效部署的实战教程
V4L2摄像头采集实战:从camera_client.rar到出图全流程解析
从“谁发明了钢琴键”到知识问答智能体:RAG与记忆工程实践

Java信号量Semaphore实战:三线程交替打印ABC原理与实现

发布时间:2026/9/9 15:54:09
Java信号量Semaphore实战:三线程交替打印ABC原理与实现 三线程交替运行这个问题基本是Java并发编程面试里的常客。很多同学第一反应是synchronized加wait/notify或者是用ReentrantLock加Condition但用信号量Semaphore来做其实是另外一种非常轻巧的思路。这篇文章我就以“并发信号量的使用”为主线完整拆解一下怎么用Java的Semaphore实现三个线程按顺序交替运行顺便把信号量底层的机制、常见误区和排查方法一并讲透。先说清楚这篇文章适合谁。如果你是准备Java面试的开发者或者工作中正在写多线程协作代码、却总觉得对Semaphore理解停留在“计数器”层面那这篇可以直接收藏。我会从设计思路、源码逻辑、完整代码到问题排查一步步展开保证你看完之后不仅会写还能讲清楚为什么这么写。1. 整体设计思路拆解1.1 先理解信号量是什么信号量本质上是一个带计数器的“许可证管理员”。你可以把Semaphore想象成一个停车场门口的电子显示屏上面显示着剩余车位。线程要执行某段代码得先看剩余车位是不是大于0足够则扣减一个车位然后进去执行执行完出来的时候再归还一个车位。这个“扣减”和“归还”在Java里对应的就是acquire()和release()两个方法。这样说可能稍微简单了点但方向是对的。信号量的核心价值在于它可以控制同时访问某个资源的线程数量。比如数据库连接池限制最多10个连接那一个Semaphore初始化为10每个线程拿连接前acquire一下用完release一下就能把并发量钉死在10这个上限。注意一个容易混淆的细节信号量本身不负责“公平”地把许可分给哪个线程。谁抢到就是谁的没有所谓的“轮流”逻辑。所以我们用信号量做“三线程交替运行”本质上不是让信号量自己去调度顺序而是要通过多个信号量的许可流转人为地制造出一个“环形链条”让线程A拿到许可后只唤醒BB只唤醒CC只唤醒A这样才能形成严格的交替。1.2 为什么是三个信号量而不是一个很多人第一反应是“用一个Semaphore初始值为1不就行了一个线程拿到锁其他线程等着释放后再抢不就能交替运行吗”听起来有道理但实际上这种方案只能保证同一时刻只有一个线程在执行临界区不能保证执行的顺序是A-B-C。为什么因为Semaphore的acquire()是竞争式的。假设A运行完了执行release()把许可归还这时B和C都在阻塞等待中两个线程会同时去抢这个许可谁抢到谁运行。如果这次是B抢到了输出B没问题但如果下次A释放许可后B和C又同时抢结果C抢到了那输出顺序就变成了A-C这就不符合“交替”的语义了。所以一个信号量做不到“指定下一个谁运行”。它只能保证“最多一个线程运行”保证不了“哪个线程运行”。要严格实现A线程运行完必须轮到BB运行完必须轮到CC运行完必须轮到A就得给每个线程安排一个专属的信号量而且这些信号量的许可要定向释放不能放到“公共池子”里让大家抢。1.3 拼接成一个环形依赖链核心思路是先定义三个信号量命名上直观点semaphoreA、semaphoreB、semaphoreC。初始许可数量分别是semaphoreA为1semaphoreB为0semaphoreC为0。为什么要这样初始化因为我们要保证整个程序一启动只有线程A能立刻拿到许可往下走B和C即使启动得再早也只能阻塞在acquire()上等待许可。然后每个线程的逻辑是固定的三步A线程semaphoreA.acquire()拿许可执行打印最后semaphoreB.release()释放B的许可B线程semaphoreB.acquire()拿许可执行打印最后semaphoreC.release()释放C的许可C线程semaphoreC.acquire()拿许可执行打印最后semaphoreA.release()释放A的许可看到没有这其实是一个环A释放BB释放CC释放A。许可像接力棒一样在三个信号量之间循环传递。因为每一轮只有一个信号量持有1个许可并且许可只流向唯一指定的下一个信号量所以顺序一定不会乱。思考一下每个线程在打印完之后release的是别的线程的信号量而不是自己的这是整套方案最关键的设计。如果A执行完释放了自己的许可那就又回到“竞争模式”了。这个方案的巧妙之处在于信号量在这里不是用来“限制并发数”而是当作一个“接力棒”传递顺序令牌。这也回答了网上很多人的疑问“信号量不是管并发数量的吗为什么能用来做线程顺序控制”原因就在这里——用多个信号量组成依赖链利用许可的定向释放天然就能把无序竞争变成有序传递。2. 核心细节与底层原理2.1 Semaphore源码层面的工作逻辑要真正理解这套方案不能只停留在API调用层。我建议你把Semaphore的几个关键方法在源码里过一遍这里面有很多值得琢磨的细节。Semaphore内部有一个继承自AbstractQueuedSynchronizer也就是常说的AQS的同步器Sync它维护了一个state变量这个state就是当前可用的许可数量。acquire()方法本质上是尝试用CAS操作把state减1如果state大于0说明有许可减1成功线程继续执行如果state等于0说明没有许可了线程就会被封装成Node节点放进AQS的同步等待队列里进入阻塞状态。release()方法则反过来用CAS把state加1并且唤醒等待队列里的一个线程。注意这里的唤醒是有讲究的。如果Semaphore创建时指定了公平模式那释放许可后会唤醒等待时间最长的那个线程基本遵循先来后到如果是非公平模式释放后新来的线程可能直接和队列里的等待线程抢许可这就是非公平竞争。我们这个“三线程交替”的场景其实并没有依赖公平模式来保证顺序。顺序是靠许可的计数状态天然保证的A释放B的许可之前semaphoreB.state一定是0B一定阻塞着A一释放state变成1B被唤醒后acquire成功于是只有B能继续。这就是设计思路上的巧妙之处——你不需要依赖AQS队列的公平性因为整个系统里每次只有一个许可被释放且接收方是唯一确定的。2.2 二值信号量与互斥锁的区别很多人会把Semaphore(1)和一把互斥锁画等号其实不太严谨。互斥锁的特点是“持有者才能释放”我lock了如果想要解锁那必须由同一个线程unlock这是一种所有权机制。而二值信号量Semaphore(1)不关心所有权线程A可以acquire线程B可以release信号量本身允许这种跨线程的“转让式”释放。这个特性对很多人来说是反直觉的但恰好是我们这个三线程方案能成立的基础。正是因为Semaphore不校验“acquire和release必须是同一个线程”我们才能让A线程去release B线程的信号量。如果你的代码模拟的是“A释放自己的锁让B执行”那用synchronized或者ReentrantLock就要麻烦得多因为它们天然就不允许跨线程解锁。不过副作用也有。信号量没有所有权机制意味着任何线程只要拿到了Semaphore对象的引用理论上都可以随意调用release()导致许可数量增加这很危险。比如B线程正在等待semaphoreB的许可结果另一个无关线程不小心多release了一次B就会被提前放行整个顺序就错乱了。所以用信号量实现顺序控制时一定要确保release()只出现在正确的位置避免无关线程触碰。2.3 公平模式与非公平模式的坑这个案例里不是核心但如果你用Semaphore做其他并发场景一定要知道公平模式的区别。Java里创建Semaphore可以传第二个参数new Semaphore(permits, fair)。fair为true时内部会用FIFO队列保证先等待的线程先获得许可整体更公平但吞吐量低一些fair为false时采用非公平策略释放许可后所有等待线程一起去抢性能好但可能出现“线程饥饿”边缘case。在“三线程交替”这个例子中由于我们依赖的是“唯一许可投递”而不是“多线程抢许可”所以fair与否对最终输出顺序没有影响。但如果你把方案改成“用一个Semaphore控制多个工作线程”那非公平模式可能会导致某些线程长时间拿不到许可运行次数严重不均这时可以考虑开公平模式。记住一点公平模式的开销比非公平模式高不要无脑全开。3. 实操过程与完整实现3.1 实战三线程循环打印ABC下面直接上完整可运行的代码我用最直白的方式写让第一次接触信号量的同学也能跟得上。import java.util.concurrent.Semaphore; public class ThreeThreadAlternateDemo { public static void main(String[] args) { // 初始只有A的信号量有1个许可B和C都是0 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 10; i) { semaphoreA.acquire(); // A拿许可 System.out.print(A); semaphoreB.release(); // 释放B的许可 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread threadB new Thread(() - { try { for (int i 0; i 10; i) { semaphoreB.acquire(); // B拿许可 System.out.print(B); semaphoreC.release(); // 释放C的许可 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread threadC new Thread(() - { try { for (int i 0; i 10; i) { semaphoreC.acquire(); // C拿许可 System.out.print(C); semaphoreA.release(); // 释放A的许可 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); threadA.start(); threadB.start(); threadC.start(); } }运行这段代码输出结果是一串严格的“ABCABCABCABC...”不会有任何乱序。这里我循环了10次输出30个字符。你可以自己改成20次、100次逻辑完全不用动。这段代码里有几个细节值得单独说明第一每个线程内部都有一个for循环表示这个线程要重复执行多少轮。acquire放在循环体的开头release放在循环体的结尾这个顺序不能乱。如果release不在循环内比如写在了for外层那线程只释放一次许可整个流程就断掉了。第二System.out.print这个方法本身是线程安全的吗严格说System.out是一个PrintStream它的print方法内部有同步块单次print不会出现字符穿插问题。如果我们改成System.out.print(A)每次输出一个字符没问题。但如果你在真实项目里打印更复杂的日志建议用日志框架避免并发打印导致日志串行混乱。第三线程的启动顺序threadA.start()、threadB.start()、threadC.start()并不决定实际执行顺序。因为即使我们让C先启动它执行到semaphoreC.acquire()时也会因为没许可而阻塞。阻塞后线程进入WAITING状态不占用CPU等A跑完再被唤醒。这也是三个信号量初始值“一高一低”设计带来的天然约束。3.2 指定轮数的写法如果面试官问“我要让三个线程交替输出10轮怎么改”直接把我上面的循环次数改成10就行。但有的场景要求“A跑完指定次数后整个程序退出”这时要注意主线程或者守护线程的管理。有一个很容易踩的坑线程跑完后JVM不会立刻退出。因为main线程已经执行到末尾但线程A、B、C都还存活。如果你用非守护线程的方式创建主线程结束后JVM会等待这三个线程全部执行完才退出这没问题因为三轮线程正常跑完自己的for循环后都会终止。但如果你在代码里自己创建了线程池或者额外的调度线程记得要shutdown否则程序会一直挂在那里不退出在命令行看起来就像是“卡死了”。控制轮数时我建议把for循环里的次数单独抽成一个常量比如private static final int TOTAL_ROUNDS 10;这样代码语义更清晰后续调整轮数也方便面试官也会觉得你代码习惯好。另外补充一种需求变体如果希望三个线程不是从A开始而是要求“从B开始”那只改初始许可即可。把semaphoreB初始化为1semaphoreA和semaphoreC初始化为0输出的顺序就会变成BCABCA。这种“谁初始有许可谁第一个跑”的设计非常直观也是很多人喜欢用信号量做顺序控制的另一个原因——改个初始值就能改变起点。3.3 原理解读一次完整的调度过程我用一个时间线来模拟一下程序刚开始运行时的调度过程这样对理解状态流转会有非常大的帮助。假设线程A、B、C都已经启动但还没有任何人acquire成功。初始状态semaphoreA 1semaphoreB 0semaphoreC 0线程A执行semaphoreA.acquire()state从1变为0A获得许可继续执行打印A随后调用semaphoreB.release()state从0变为1。此时B因为semaphoreB.acquire()阻塞在队列里release会唤醒B。线程B被唤醒后semaphoreB.acquire()尝试把state从1扣减为0成功B打印B随后semaphoreC.release()C的state从0变为1唤醒线程C。线程C被唤醒后semaphoreC.acquire()把count从1扣减为0成功打印C随后semaphoreA.release()A的state从0变为1唤醒线程A。至此完成一轮完整的ABC循环。接下来第二轮的流程完全一样A再去acquire自己的信号量此时它的state刚刚被C重新置为1所以A能再次获得许可。这个流程里最核心的点是整个系统中同一时间只有“一个确切的线程”拥有可用的许可而且这个许可只定向给下一个线程。所以绝对不可能出现两个线程同时拿到许可的情况也不存在“拿了许可但不想执行”的浪费。注意三个线程都会在acquire()处阻塞等待如果有一个线程因为异常挂掉了那后续线程永远等不到许可程序会一直停在那里。所以生产环境里一定要在finally块或者异常处理里保证release()一定会被执行。关于这一点我在下一节的排查技巧里会展开讲。4. 常见问题与排查技巧4.1 面试官会追问的问题既然这个题目高频出现在Java面试里我就把面试官大概率会追着问的问题一起整理出来大家直接当速查表用。第一个问题为什么不用synchronized加notifyAll这个问题的标准回答是notifyAll会唤醒所有等待线程但只有一个线程能抢到锁继续执行其他线程要重新竞争。虽然通过一个共享变量加状态判断也能实现交替但代码量明显更多而且状态变量一旦没处理好容易出现死锁或者重复唤醒。信号量的核心优势是“定向唤醒”因为每个线程等待的是自己专属的信号量release哪个信号量就等于精准通知哪个线程不需要额外的状态判断。第二个问题Semaphore和CountDownLatch、CyclicBarrier有什么区别CountDownLatch是一次性的await等待计数归零后所有线程一起放行不能复用CyclicBarrier可以让多个线程互相等待到齐后同时继续可以循环使用。而Semaphore是“令牌式”的许可证可以不断申请和归还适合做资源池和信号控制。三者侧重点不同不能互相完全替代。第三个问题如果semaphoreA、B、C初始值都设置成1会怎样那程序一启动三个线程都能拿到许可就会同时打印完全失去顺序性。设置成A1、B0、C0本质上是在“选出一个第一个执行的线程并且给其他线程设置起步门槛”。第四个问题如果我把release()写成了acquire()会怎样如果A线程在打印后调用semaphoreB.acquire()而不是release那B的许可永远不可能增加程序会直接死锁——A在等一个永远得不到的新许可而B又在等A释放许可互相卡死。这个错误在写代码时很容易犯尤其要注意acquire和release是成对出现但跨线程的。第五个问题信号量怎么配合线程池使用最经典的就是限流。比如写一个Semaphore(5)每个任务执行前acquire()执行完release()就能保证同时最多5个任务在执行。配合自定义线程池做并发控制时注意acquire应该放在任务提交前还是任务执行时这个顺序会影响池子里等待的任务数和实际并发执行的任务数。4.2 实战中容易踩的坑先讲一个我实际调过的问题程序输出了几个ABC之后突然卡住不动了看起来像死锁。排查后发现是某个release()少写了或者被if条件包起来了导致某个信号量的许可归零后再也没有人给它补充。这种问题很难通过日志发现因为线程不会报异常只会安静地WAITING。排查这类问题建议先做两件事第一用jps找到进程号再用jstack导出线程栈看哪个线程阻塞在哪个acquire()方法上第二在release()前后加日志打印每个信号量的剩余许可数量和线程名很快就能定位到断链的位置。再讲一个经典错误在try块里acquire但没有在finally里release。如果线程执行到acquire之后的业务逻辑时抛了RuntimeExceptionrelease就不会执行许可证就永久丢了。这个问题的修复方式其实很简单把release放到finally里。但要注意如果acquire本身失败了也就是线程没拿到许可就异常退出那finally里的release就不能乱放否则会把许可数量“虚增”导致信号量计数错乱。semaphore.acquire(); try { // 业务逻辑 } finally { semaphore.release(); }上面这种写法才是更稳妥的先acquire成功再进try-finally。如果acquire放在try内部而acquire抛异常了finally里的release会被执行等于凭空归还了一个不属于自己的许可在资源池场景下这是很严重的bug。还有一个和本案例相关的坑在主线程里创建线程A、B、C最后没有让主线程等待其执行完毕程序可能会提前结束。我们的例子里因为线程是普通非守护线程JVM会等待所有非守护线程执行完才退出所以没问题。但如果你设置了daemon(true)或者使用了某些线程池默认的守候策略主线程一结束其他线程可能被强制终止输出结果就不完整了。4.3 扩展思考能不能用单个信号量实现“N线程循环”很多人会问如果线程数不是3个而是5个、10个是不是就得写10个信号量其实有一种更通用的思路用一个Semaphore加上一个AtomicInteger计数器每次线程执行完自增计数器然后根据计数的取模结果决定接下来释放哪个线程。但这种做法本质上还是需要多个条件或者多个信号量单靠一个Semaphore的“唯一许可”无法定向唤醒某个特定线程因为Semaphore自己不认得线程。所以如果你的场景是固定数量线程按固定顺序循环执行那“每线程一个信号量”的环状设计是最简单直观的。如果线程数是动态的或者顺序需要动态变化建议直接转向ReentrantLock加多个ConditionCondition可以精确地signal指定的等待线程设计上更灵活。这也是我在实际编码中会综合权衡的一点信号量做“固定顺序循环”非常漂亮但一旦条件复杂了还是要及时换工具。收尾一点个人的实践体会信号量这个类平时在限流场景里用得最多但很多人忽略了一个关键特性它不要求acquire和release必须是同一个线程也正因为这个特性它能被用来做线程间的“许可转交”和顺序控制。我在多次使用中最大的感受是相比synchronized那套“锁”的心智模型信号量更像是在“传递令牌”设计代码的时候脑子里要多一条“这条链子下一棒交给谁”的思路。如果你第一次写三线程交替运行可以照着上面的代码自己敲一遍再把初始值改成不同的搭配跑一跑比如试试B一开始就有许可会是什么效果或者故意少写一个release看卡顿现场。多踩几次坑之后对信号量、AQS等待队列、线程状态流转这些概念的理解会远比背八股文来得扎实。

关于恒美微站

恒美微站专注于为个体商户、工作室提供极简自助建站服务,让每个人都能轻松拥有专业网站。

快速链接

  • 关于我们
  • 建站服务
  • 主题模板
  • 案例展示
  • 资讯中心

服务项目

  • 可视化建站
  • 拖拽编辑
  • 主题定制
  • SEO 优化
  • 网站托管

联系方式

  • 📍 地址:北京市朝阳区建国路 88 号
  • 📞 电话:400-888-8888
  • ✉️ 邮箱:info@hmyw.cn
  • 🕐 时间:周一至周日 9:00-18:00

© 2024 恒美微站 hmyw.cn 版权所有 | 京 ICP 备 12345678 号