Java多线程实现的十字路口红绿灯调度模拟(含可运行源码与课程设计文档)
简介:这是一个用Java编写的轻量级交通信号灯调度模拟程序,核心功能是模拟真实十字路口南北向和东西向车流的交替通行逻辑。程序通过多线程分别控制两个方向的车辆队列,并使用信号量(Semaphore)协调红绿灯状态切换:绿灯方向允许车辆按序出队通行,红灯方向则阻塞等待;状态周期性轮转,直到所有预设车辆全部通过。代码结构清晰,src目录下包含TrafficLight、Vehicle、Direction等关键模型类,Test.java为统一启动入口,支持一键运行与参数观察。配套提供README.md说明文档,涵盖编译方式、运行步骤、设计思路及调试建议;LICENSE文件明确开源许可;.gitignore和IDEA工程配置(.idea、TrafficLight.iml)确保开箱即用。整个项目无需额外依赖,JDK 8+即可编译执行,适合操作系统课程中关于进程同步、线程调度、信号量应用等知识点的实践验证与教学演示。
1. 项目概述:为什么一个十字路口值得用多线程重写一遍?
你有没有在早高峰的十字路口等过红灯?车流像被无形的手掐住咽喉,南北向排成长龙,东西向空荡荡却只能干等——这背后不是魔法,是一套精密的时序调度逻辑。而今天我要讲的,不是交通局的后台系统,而是一个用Java写的、跑在你笔记本上的“袖珍版路口大脑”。它不接摄像头,不连GPS,但把操作系统课上那些抽象得让人打哈欠的概念——信号量(Semaphore)、线程竞争、临界区保护、状态轮转——全塞进了一个不到500行核心代码的模拟器里。
这个项目叫“Java多线程实现的十字路口红绿灯调度模拟”,关键词很直白:Java信号灯、多线程调度、信号量同步、交通模拟、课程设计。但它真正的价值,不在于“模拟得像不像”,而在于“能不能让你亲手拧开调度逻辑的盖子,看清齿轮怎么咬合”。我带过三届操作系统课程设计,见过太多学生抄完代码却答不出“为什么这里要用acquire()而不是wait()”;也见过调试到凌晨两点,就因为忘了Semaphore的许可数初始值设成了0而不是1,导致所有线程永远卡在红灯前——这种痛,只有亲手踩过才记得住。
它解决的不是一个工程问题,而是一个认知问题:当多个执行流(南北/东西车流)共享同一资源(路口通行权)时,如何避免撞车(竞态)、死锁(四辆车同时停在路口中央互不让)、饥饿(某个方向永远等不到绿灯)?答案就藏在Semaphore那几行看似简单的API背后。程序里没有UI动画,没有3D渲染,只有控制台里跳动的[N] Vehicle #3 passed和[E] Waiting for green...——但正是这些朴素输出,映射着真实世界里每一秒都在发生的资源协调。
适合谁?如果你是计算机专业本科生,正在啃《现代操作系统》第三章,手边有JDK 8+环境,想找个能编译、能打断点、能改参数、能亲眼看到线程阻塞与唤醒全过程的实例,那它就是为你准备的。它不炫技,不堆砌设计模式,所有代码都压在最贴近OS原理的层面:一个TrafficLight类封装状态机,两个VehicleQueue线程各自维护队列,一个全局Semaphore作为通行许可证发放中心。你可以把它当成一张可拆解的电路图,每个元件(类、方法、变量)都有明确的物理意义。接下来,我会带你一层层剥开它的实现肌理,不只是告诉你“怎么写”,更要解释“为什么非得这么写”。
2. 整体架构与设计思路:为什么选信号量,而不是synchronized或wait/notify?
2.1 核心矛盾:路口通行权的本质是“有限许可”的资源分配
先抛开Java语法,回到现实路口:一个十字路口,同一时刻最多只允许一个方向的车辆通过(忽略黄灯过渡和左转专用道等复杂情况)。这意味着“通行权”是一种数量固定为1的稀缺资源——它不能被复制,不能被共享,必须由某个权威机构(交通信号灯)统一发放和回收。当南北向获得通行权,东西向就必须等待;反之亦然。这种“此消彼长、互斥占用”的特性,正是信号量(Semaphore) 最擅长描述的场景。
我们来对比几种常见的同步机制,看看为什么Semaphore是这里的最优解:
-
synchronized块:它能保证同一时刻只有一个线程进入临界区,但它无法表达“当前谁拥有通行权”这个状态。synchronized像一把独占的门锁,谁拿到锁谁进门,但门后是什么、门开多久、下次该谁进——它一概不管。而我们的需求是:不仅要锁门,还要让南北线程知道“现在是绿灯,可以出队”,让东西线程知道“现在是红灯,给我老实排队”。synchronized本身不携带状态信息,你需要额外用volatile boolean isNorthGreen变量配合,再加一堆if-else判断,代码立刻臃肿且易错。 -
wait()/notify()机制:它能实现线程间的通信,比如南北线程完成通行后notify()东西线程。但问题在于,notify()是随机唤醒一个等待线程,而我们需要的是严格轮转:南北→东西→南北→东西……如果只用notify(),可能出现南北线程刚放行完,notify()恰好唤醒了另一个南北线程(假设队列里还有车),导致东西向永远等不到机会——这就是饥饿(Starvation)。要避免它,你得自己维护一个唤醒顺序队列,逻辑陡然复杂。 -
Semaphore(信号量):它天生就是为“许可(permit)”而生的。我们初始化一个Semaphore(1),代表路口只有1个通行许可。南北线程想通行,先semaphore.acquire()——如果许可可用,立刻拿到并通行;如果已被东西向占用,就自动阻塞等待。东西向线程同理。当一方通行完毕,调用semaphore.release(),归还许可,此时Semaphore内部会公平地唤醒一个等待线程(默认公平模式下按FIFO顺序)。你看,轮转逻辑、阻塞唤醒、许可计数,全被Semaphore封装好了。你不用操心“谁该醒”“醒几个”,只需专注“我拿许可→干活→还许可”这三步。
提示:项目中
TrafficLight类里的semaphore = new Semaphore(1, true),第二个参数true就是开启公平模式(Fairness),确保等待队列里的线程按到达顺序被唤醒,彻底杜绝饥饿。这是课程设计里一个关键细节,很多学生忽略它,结果调试时发现东西向永远等不到绿灯,百思不得其解。
2.2 线程职责划分:两个独立队列,一个共享状态机
整个系统由三个核心线程协同工作:
- 主控线程(Main Thread):负责启动一切,创建
TrafficLight实例,启动南北/东西两个车辆队列线程,并监听全局结束条件(所有车辆通行完毕)。 - 南北向车辆队列线程(NorthSouthQueue):它是一个
Thread子类,内部维护一个Queue<Vehicle>。它的任务很单纯:只要TrafficLight当前状态是NORTH_SOUTH_GREEN,就从队列头部取出一辆车,模拟通行耗时(Thread.sleep()),打印日志,然后继续;如果状态是EAST_WEST_GREEN,就调用semaphore.acquire()阻塞自己,直到被唤醒。 - 东西向车辆队列线程(EastWestQueue):职责与南北向完全对称,只是操作自己的队列和响应相反的状态。
注意:这两个队列线程是完全独立、并发执行的,它们之间没有直接调用关系。它们唯一的交集,就是那个全局的
Semaphore对象和TrafficLight的状态变量。这种设计符合“高内聚、低耦合”原则——每个线程只关心自己的队列和信号灯状态,不干涉对方逻辑。你在src/models/目录下看到的VehicleQueue.java,就是这个线程的实现,里面run()方法的核心就是一个while(!allVehiclesPassed()) { checkLightAndProceed(); }循环。
2.3 状态机设计:红绿灯不是开关,而是一个有生命周期的实体
TrafficLight类不是简单地存一个boolean isNorthGreen。它是一个完整的小型状态机,包含:
- 状态枚举(TrafficLightState):
NORTH_SOUTH_GREEN,EAST_WEST_GREEN,TRANSITION(过渡态,用于模拟黄灯时间)。 - 状态切换逻辑(switchState()):不是简单取反,而是按预设周期(如南北绿灯30秒,黄灯3秒,东西绿灯30秒)定时切换。切换时,先置为
TRANSITION,休眠黄灯时长,再切到下一个方向。这个设计模拟了真实信号灯的渐进式变化,避免了状态突变带来的逻辑跳跃。 - 状态查询接口(isNorthGreen(), isEastWestGreen()):供车辆队列线程安全读取当前通行权归属。
这种状态机设计,让“红绿灯”从一个被动的布尔值,变成了一个主动管理调度节奏的“指挥官”。你在Test.java里看到的trafficLight.startCycle(),启动的就是这个状态机的后台守护线程,它默默计时、切换、通知,而车辆线程只管响应。
3. 核心细节解析与实操要点:从模型类到线程启动的每一步
3.1 模型类设计:Vehicle、Direction、TrafficLightState 的语义精准性
src/models/目录下的三个基础类,是整个模拟的语义骨架。它们的设计,直接决定了代码的可读性和可扩展性。
-
Vehicle类:它不只是一个ID。除了id字段,它还包含direction(所属方向)、arrivalTime(到达队列时间,用于后续计算平均等待时间)、passTime(模拟通行耗时,可设为随机值增加真实性)。构造函数强制传入Direction,杜绝了“一辆车既往北又往东”的逻辑错误。toString()方法重写为[N] Vehicle #5格式,让控制台日志一眼可辨方向。 -
Direction枚举:只有NORTH_SOUTH和EAST_WEST两个值。它比用字符串"NS"或整数0/1安全得多。所有涉及方向判断的地方(如if (vehicle.getDirection() == Direction.NORTH_SOUTH)),编译器都能帮你检查拼写错误。更重要的是,它为未来扩展预留了接口——如果哪天要加左转专用车道,只需新增NORTH_LEFT_TURN枚举值,相关逻辑就能自然延伸。 -
TrafficLightState枚举:同理,NORTH_SOUTH_GREEN,EAST_WEST_GREEN,TRANSITION三个状态,清晰表达了信号灯的全部可能行为。TrafficLight类内部用volatile TrafficLightState currentState存储,并提供synchronized的setState()和getState()方法,确保多线程读写安全。这里volatile关键字很关键:它保证了状态变更对所有线程的立即可见性,避免了因CPU缓存不一致导致的“某个线程还看到旧的绿灯状态”的诡异bug。
实操心得:我在指导学生时,常强调“枚举是类型安全的第一道防线”。曾有个学生把
Direction写成String,结果在VehicleQueue.run()里写了if (dir.equals("north")),结果因为大小写不一致("North"),导致南北向车辆永远无法通行。用枚举,这种错误在编译期就被拦住了。
3.2 信号量同步的关键位置:acquire() 和 release() 的“配对哲学”
Semaphore的使用,精髓在于acquire()和release()必须严格配对,且位置精准。项目中,它们出现在两个关键位置:
-
车辆队列线程的通行入口(
VehicleQueue.java的run()方法内):java while (!allVehiclesPassed()) { if (trafficLight.isNorthGreen() && this.direction == Direction.NORTH_SOUTH) { // 南北向绿灯,且本线程负责南北向 try { semaphore.acquire(); // 关键!申请通行许可 // 此处开始模拟车辆通行... Vehicle vehicle = queue.poll(); System.out.println("[" + direction + "] Vehicle #" + vehicle.getId() + " passed"); Thread.sleep(vehicle.getPassTime()); // 模拟通行耗时 } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } finally { semaphore.release(); // 关键!归还通行许可 } } else if (trafficLight.isEastWestGreen() && this.direction == Direction.EAST_WEST) { // 同理,东西向逻辑 try { semaphore.acquire(); // 申请许可 Vehicle vehicle = queue.poll(); System.out.println("[" + direction + "] Vehicle #" + vehicle.getId() + " passed"); Thread.sleep(vehicle.getPassTime()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } finally { semaphore.release(); // 归还许可 } } else { // 当前方向非绿灯,短暂休眠避免忙等 Thread.sleep(100); } }
这里acquire()放在if分支内部,确保只有真正要通行的线程才会去争抢许可。finally块中的release()是铁律——无论通行过程是否抛异常(比如sleep()被中断),许可都必须归还,否则系统会永久死锁。这就是为什么try-finally结构在这里不可替代。 -
交通灯状态切换时的许可回收(
TrafficLight.java的switchState()方法):java private void switchState() { // 切换前,先释放当前许可,让等待线程有机会竞争 semaphore.release(); // 然后设置新状态 currentState = nextDirection == Direction.NORTH_SOUTH ? TrafficLightState.NORTH_SOUTH_GREEN : TrafficLightState.EAST_WEST_GREEN; // 记录切换时间,用于统计 lastSwitchTime = System.currentTimeMillis(); }
这个release()容易被忽略,但它至关重要。想象一下:南北向通行完毕,状态要切到东西向。如果不先release(),东西向线程还在acquire()上阻塞,而南北向线程已经退出,许可无人归还,东西向将永远等待。所以,状态切换的瞬间,必须主动release()一次,把许可“扔”回池子里,让等待队列里的线程(必然是东西向的)能抢到它。
提示:
Semaphore的许可数是动态的。初始为1,acquire()减1,release()加1。任何时候,availablePermits()方法都能返回当前可用许可数。你可以在调试时加一句System.out.println("Available permits: " + semaphore.availablePermits());,实时观察许可的流转,这是理解同步逻辑最直观的方式。
3.3 工程结构与IDEA配置:为什么.gitignore和.iml文件不可或缺
项目目录里那些看似“多余”的文件,恰恰是工程化思维的体现:
-
.gitignore:它明确告诉Git哪些文件不该纳入版本控制。里面包含了/out/(IDEA编译输出目录)、*.iml(模块配置文件)、.idea/(IDEA工作区配置)。为什么重要?因为这些是环境相关文件。你的同学用Eclipse,他的.project文件和你的.iml完全不同;他用Maven,他的pom.xml和你的build.gradle也不同。把这些文件加入Git,会导致协作时无谓的冲突和混乱。一个干净的.gitignore,是团队协作的基石。 -
TrafficLight.iml和.idea/目录:这是IntelliJ IDEA的专属配置。.iml文件定义了模块的源码路径(<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false"/>)、依赖库(JDK版本)、编译输出路径。.idea/目录则保存了代码风格、运行配置(Run Configuration)、断点设置等个性化偏好。有了它们,你的同学双击TrafficLight.iml,或者用IDEA打开项目根目录,就能零配置启动——JDK自动识别,源码路径自动加载,Test.java的绿色三角形运行按钮直接点亮。这省去了新手面对“Cannot resolve symbol ‘Vehicle’”时手足无措的半小时。 -
README.md:它不是摆设。里面清晰写着: - 编译命令:
javac -d out src/*.java src/models/*.java - 运行命令:
java -cp out Test - 参数调整说明:如何修改
Test.java里的GREEN_DURATION_NS = 30000(毫秒)来改变绿灯时长。 - 调试建议:推荐在
VehicleQueue.run()的acquire()前后加日志,观察线程阻塞/唤醒时机。
这份文档,把“开箱即用”从口号变成了现实。我见过太多课程设计,代码写得漂亮,但README里只有一句“运行Test.java”,结果助教花了一小时才搞懂怎么编译。
4. 实操过程与核心环节实现:从零开始搭建可运行环境
4.1 环境准备与项目导入:JDK 8+ 是底线,IDEA 是利器
第一步,确认你的开发环境。项目要求JDK 8或更高版本。为什么不是JDK 17?因为课程设计面向教学,JDK 8是目前高校实验室最普及的版本,语法稳定,文档丰富,兼容性最好。你可以通过命令行验证:
java -version
# 输出应类似:java version "1.8.0_391"
第二步,获取项目源码。资源包里有一个DsSM77ZvHa5Wtck2HhRf-master-d2932b3bb808a9e93a7fb3df5d40beebea2b3243目录,这其实是GitHub仓库的压缩包(master分支,commit ID d2932b3...)。把它解压到任意目录,比如~/traffic-light-sim。
第三步,用IntelliJ IDEA导入。打开IDEA,选择Open,导航到解压后的根目录(即包含.gitignore、src、Test.java的那个文件夹)。IDEA会自动识别这是一个Java项目,并读取.iml和.idea/配置。几秒钟后,项目结构树就会显示出来:src是源码根目录,models是包名,Test.java是入口。此时,右键点击Test.java,选择Run 'Test.main()',你应该能看到控制台输出:
Starting traffic light simulation...
[N] Vehicle #1 passed
[N] Vehicle #2 passed
[E] Vehicle #1 passed
...
如果报错Error: Could not find or load main class Test,大概率是IDEA没识别到源码根目录。这时右键src文件夹 → Mark Directory as → Sources Root,即可修复。
实操心得:我让学生第一次运行前,务必先删掉
out/目录(如果存在)。因为旧的编译class文件可能和新代码不匹配,导致NoSuchMethodError等诡异错误。养成“clean build”的习惯,是调试的第一步。
4.2 核心代码剖析:以Test.java为起点,逆向追踪执行流
Test.java是整个系统的总开关,只有20多行,但它是理解全局流程的钥匙。我们逐行解读:
public class Test {
public static void main(String[] args) {
// 1. 创建交通灯控制器
TrafficLight trafficLight = new TrafficLight(30000, 30000, 3000); // NS绿, EW绿, 黄灯时长(毫秒)
// 2. 创建两个方向的车辆队列,各预设5辆车
Queue<Vehicle> northSouthQueue = new LinkedList<>();
Queue<Vehicle> eastWestQueue = new LinkedList<>();
// 3. 初始化车辆(简化版,实际可读取文件或随机生成)
for (int i = 1; i <= 5; i++) {
northSouthQueue.add(new Vehicle(i, Direction.NORTH_SOUTH, 1000 * i));
eastWestQueue.add(new Vehicle(i, Direction.EAST_WEST, 1000 * i));
}
// 4. 创建并启动两个车辆队列线程
VehicleQueue nsQueue = new VehicleQueue(northSouthQueue, Direction.NORTH_SOUTH, trafficLight);
VehicleQueue ewQueue = new VehicleQueue(eastWestQueue, Direction.EAST_WEST, trafficLight);
nsQueue.start(); // 启动南北向线程
ewQueue.start(); // 启动东西向线程
// 5. 启动交通灯状态机
trafficLight.startCycle();
// 6. 主线程等待,直到所有车辆通行完毕
try {
nsQueue.join(); // 等待南北向线程结束
ewQueue.join(); // 等待东西向线程结束
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Simulation completed.");
}
}
- 第1行:
TrafficLight构造函数接收三个参数:南北向绿灯时长、东西向绿灯时长、黄灯时长(单位毫秒)。这体现了设计的灵活性——你可以轻松改成“南北绿灯45秒,东西绿灯15秒”的不对称配时,模拟真实路口的车流量差异。 - 第3-4行:车辆初始化用了最简方式(for循环)。但在实际课程设计中,我鼓励学生升级:比如从
vehicles.csv文件读取,每行包含id,direction,arrival_time,pass_time,这样能模拟更真实的随机到达模式。 - 第4行:
VehicleQueue构造函数传入了队列、方向、以及TrafficLight引用。注意,两个线程共享同一个TrafficLight实例,这是状态同步的基础。 - 第5行:
trafficLight.startCycle()启动了一个后台线程,它内部是一个while(true)循环,按设定周期调用switchState()。这个线程和两个车辆线程是并行的,共同构成三线程协作模型。 - 第6行:
nsQueue.join()和ewQueue.join()是关键。join()让主线程暂停,等待子线程执行完毕。只有当两个队列里的所有车辆都poll()完毕,VehicleQueue.run()循环才会退出,join()才返回。这保证了“Simulation completed.”总是在最后打印。
4.3 参数调整与效果观察:改变绿灯时长,看系统如何响应
课程设计的价值,在于你能动手“拧螺丝”。项目提供了多个可调参数,让我们试试改变它们,观察系统行为:
-
修改绿灯时长:打开
Test.java,找到new TrafficLight(30000, 30000, 3000)。把第一个30000(南北绿灯)改成60000(60秒),第二个30000(东西绿灯)保持不变。重新运行,你会看到控制台输出明显变化:连续出现十几条[N] Vehicle #X passed,然后才出现[E] Vehicle #1 passed。这直观展示了“配时方案”对通行效率的影响——南北向车流大,就给它更长绿灯。 -
修改车辆数量:把
for循环的i <= 5改成i <= 10,南北和东西各增加5辆车。运行后,你会发现总模拟时间变长了,但更重要的是,观察[E] Waiting for green...的日志出现频率。当东西向车辆增多,而绿灯时长不变时,它们在红灯期间的等待队列会变长,这引出了“平均等待时间”的计算需求——你可以扩展Vehicle类,记录waitStartTime,在通行后计算waitTime = passTime - waitStartTime,再求平均值。 -
关闭公平模式:回到
TrafficLight.java,把new Semaphore(1, true)的true改成false。重新运行,多次测试。你会发现,有时东西向会连续获得两次通行权(如果它在南北通行完毕后恰好“抢”到了许可),打破了严格的轮转。这就是不公平模式下的随机性。这个实验,能让你深刻理解fair参数的实际意义。
提示:为了量化效果,我建议你在
Test.java末尾加一段统计代码:java long totalWaitTime = nsQueue.getTotalWaitTime() + ewQueue.getTotalWaitTime(); long totalVehicles = nsQueue.getVehicleCount() + ewQueue.getVehicleCount(); System.out.printf("Average wait time: %.2f ms%n", (double) totalWaitTime / totalVehicles);
这需要你在VehicleQueue类里添加totalWaitTime累加器和getTotalWaitTime()方法。一个小小的扩展,就把模拟从“能跑”升级到了“可分析”。
5. 常见问题与排查技巧实录:那些让我熬夜调试的坑
5.1 经典死锁:所有线程都卡在 acquire() 上,控制台静音
现象:运行Test.java,控制台只打印Starting traffic light simulation...,然后一片寂静,程序不退出,也不报错。
排查思路:
1. 首先,用jps命令(JDK自带)查看Java进程:jps -l。你应该看到一个类似12345 Test的进程ID。
2. 然后,用jstack 12345(替换为你的实际PID)导出线程堆栈。在输出中搜索WAITING或BLOCKED,重点关注Semaphore.acquire()相关的堆栈。
3. 如果发现两个VehicleQueue线程都停在acquire(),而TrafficLight的状态机线程(名字通常是Thread-0或TrafficLight-Cycle)也处于WAITING,那基本可以断定是死锁。
根本原因与修复:
最常见的原因是TrafficLight的状态切换线程没有正确release()许可。回顾switchState()方法,如果漏掉了semaphore.release(),或者release()被写在了if条件分支里(比如只在某种状态下才释放),那么许可就会永远被“锁死”。修复方法很简单:确保switchState()方法体的最开头,就有一行无条件的semaphore.release()。
另一个隐蔽原因是VehicleQueue线程在acquire()前就抛出了异常(比如NullPointerException),导致finally块里的release()从未执行。检查你的queue.poll()是否可能返回null(队列为空时),并在acquire()前加空指针检查:
if (queue.peek() != null) {
semaphore.acquire();
// ... 通行逻辑
} else {
Thread.sleep(100); // 队列空,短暂休眠
}
5.2 线程饥饿:东西向车辆永远等不到绿灯
现象:控制台持续输出[N] Vehicle #X passed,[E] Waiting for green...偶尔出现一两次,但很快又消失,东西向大部分车辆始终无法通行。
排查思路:
1. 在TrafficLight.switchState()方法里,加一行日志:System.out.println("Switching to: " + nextState);。
2. 运行程序,观察日志。如果发现Switching to: EAST_WEST_GREEN只出现一次,之后就再也不切换了,说明状态机卡住了。
3. 检查TrafficLight的cycleDuration计算逻辑。项目中,状态切换是基于System.currentTimeMillis()的差值计算的。如果lastSwitchTime被错误地重置(比如在switchState()里又赋了一次值),或者GREEN_DURATION_EW被设为0,都会导致切换失败。
根本原因与修复:
最可能的原因是Semaphore的公平模式被禁用,且东西向线程的acquire()调用时机不佳。当南北向通行完毕,switchState()调用release(),此时东西向线程可能还没来得及执行到acquire(),而南北向线程(如果队列还有车)又抢先执行了acquire()并拿到了许可。解决方案有两个:
- 首选:确保Semaphore构造时fair参数为true(项目默认就是)。
- 备选:在VehicleQueue.run()的else分支(即非绿灯状态)里,增加一个Thread.yield(),主动让出CPU,给其他线程(尤其是刚被release()唤醒的东西向线程)更多执行机会。
5.3 日志混乱:控制台输出顺序错乱,分不清哪个线程在说话
现象:控制台输出像这样:
[N] Vehicle #3 passed
[E] Waiting for green...
[N] Vehicle #4 passed
[E] Vehicle #1 passed
看起来逻辑正常,但当你仔细看,发现[E] Vehicle #1 passed出现在[N] Vehicle #4 passed之后,而东西向绿灯应该在南北之后才亮起——这说明日志打印没有原子性,被其他线程的输出打断了。
原因与修复:System.out.println()本身不是线程安全的。多个线程同时调用它,输出会交织。这不是功能错误,但严重影响调试。修复方法是用synchronized块包裹日志:
synchronized (System.out) {
System.out.println("[" + direction + "] Vehicle #" + vehicle.getId() + " passed");
}
或者,更优雅的方式是使用java.util.logging.Logger,它天生支持多线程安全输出。在VehicleQueue类顶部加:
private static final Logger logger = Logger.getLogger(VehicleQueue.class.getName());
然后用logger.info("[" + direction + "] Vehicle #" + vehicle.getId() + " passed");替代System.out.println()。Logger会自动序列化输出,保证每条日志完整、有序。
实操心得:我给学生的硬性要求是,所有调试日志必须用
Logger,禁止System.out。因为一旦项目规模变大,System.out的混乱会成为调试噩梦。这个习惯,能让你在未来的分布式系统开发中少吃大亏。
5.4 编译错误:找不到符号 Vehicle 或 TrafficLight
现象:IDEA里Test.java报红,提示Cannot resolve symbol 'Vehicle'。
排查与修复:
这是新手最常见的问题,根源在于包路径(package)声明与目录结构不匹配。打开src/models/Vehicle.java,第一行应该是:
package models;
而src/TrafficLight.java(如果它在src/下)应该是:
// 没有package声明,即默认包(default package)
但Test.java在src/目录下,它要引用models.Vehicle,就必须写:
import models.Vehicle;
import models.Direction;
如果Vehicle.java里写了package com.example.traffic;,而你的目录是src/models/,那就必然报错。修复方法只有两个:
- 方案A(推荐):统一包名。把Vehicle.java、Direction.java、TrafficLight.java、VehicleQueue.java的package声明都改为package models;,然后把它们全部移到src/models/目录下(Test.java留在src/)。这是标准Java工程结构。
- 方案B:删除所有package声明,让所有类都在默认包。但这不推荐,因为默认包在大型项目中无法被其他包引用。
6. 课程设计升华:从模拟器到可扩展的交通仿真框架
这个十字路口模拟器,绝不仅仅是一个课程作业的终点。它是一块坚实的跳板,可以支撑你跃向更复杂的系统设计。我在指导高年级学生时,常会提出几个“下一步”挑战,它们都源于项目现有的坚实骨架:
-
挑战一:引入左转专用车道。真实路口有左转箭头灯。这需要扩展
Direction枚举,增加NORTH_LEFT_TURN、SOUTH_LEFT_TURN等;TrafficLightState也要增加对应状态;Semaphore可能需要升级为Semaphore[]数组,为每个车道分配独立许可。这会让你深入理解资源粒度细化——把“一个路口”拆成“四个车道”,同步策略必须随之演进。 -
挑战二:接入真实数据流。把
Test.java里的静态for循环,替换成一个KafkaConsumer,从消息队列实时接收车辆到达事件(JSON格式:{"id":123,"direction":"NS","timestamp":1712345678})。这会迫使你学习异步事件处理和背压(Backpressure) 控制——当车辆涌入速度超过模拟处理速度时,如何优雅降级(比如丢弃过期车辆)? -
挑战三:可视化监控面板。用JavaFX或Web技术(Spring Boot + Thymeleaf),做一个实时仪表盘:显示当前绿灯方向、南北/东西队列长度、平均等待时间曲线、历史通行热力图。这会带你接触前后端分离和实时数据推送(WebSocket),让枯燥的控制台日志变成生动的运营视图。
所有这些扩展,都建立在一个稳固的基础上:清晰的模型(Vehicle, Direction)、健壮的同步(Semaphore)、可插拔的线程(VehicleQueue)。你不需要推倒重来,只需在现有类上叠加新功能。这正是优秀软件设计的魅力——它不追求一步到位的宏伟,而专注于每一块砖的严丝合缝。
我个人在实际教学中发现,那些能把这个小模拟器玩透的学生,后续学分布式锁(Redisson)、学微服务熔断(Hystrix)、学Kubernetes调度策略时,理解速度会快很多。因为底层逻辑是相通的:如何在不确定的并发环境中,用确定的规则,保障资源的公平、高效、安全分配。 这个十字路口,是你通往更大世界的第一个路标。
简介:这是一个用Java编写的轻量级交通信号灯调度模拟程序,核心功能是模拟真实十字路口南北向和东西向车流的交替通行逻辑。程序通过多线程分别控制两个方向的车辆队列,并使用信号量(Semaphore)协调红绿灯状态切换:绿灯方向允许车辆按序出队通行,红灯方向则阻塞等待;状态周期性轮转,直到所有预设车辆全部通过。代码结构清晰,src目录下包含TrafficLight、Vehicle、Direction等关键模型类,Test.java为统一启动入口,支持一键运行与参数观察。配套提供README.md说明文档,涵盖编译方式、运行步骤、设计思路及调试建议;LICENSE文件明确开源许可;.gitignore和IDEA工程配置(.idea、TrafficLight.iml)确保开箱即用。整个项目无需额外依赖,JDK 8+即可编译执行,适合操作系统课程中关于进程同步、线程调度、信号量应用等知识点的实践验证与教学演示。
更多推荐


所有评论(0)