ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java实现模拟电梯:状态机与调度算法实战

Java实现模拟电梯:状态机与调度算法实战 做课程设计或者面向对象编程练习时“模拟电梯”绝对是一个高频题目。很多人一开始会直接写一个while循环让电梯不停上下跑结果楼层一多、请求一多逻辑就乱成一团。这篇博客将以“模拟电梯7”为项目代号从状态机设计、调度策略、线程控制三个方面带大家用 Java 实现一个可运行的控制台版电梯模拟器。整个项目不依赖 Spring、不依赖数据库纯 JDK 开发适合用来巩固面向对象思想、集合类使用、多线程定时任务以及基础调度算法。阅读本文后你可以掌握如何用枚举管理电梯状态如何用队列保存停靠请求如何通过定时任务驱动电梯每秒“走一层”以及如何设计输入校验和退出机制。如果你正在找课程设计题目或者想理解电梯调度算法的落地逻辑这篇文章可以直接作为参考实现。1. 模拟电梯解决什么问题1.1 什么是电梯模拟系统电梯模拟系统就是用一个软件程序来模拟真实电梯的运行动作。真实电梯中乘客在某个楼层按下按钮电梯需要判断自己当前在几楼、是往上走还是往下走、下一站停哪里、到了之后要不要开门、门开多久再关上。这些动作看似简单但放在一个不断产生新请求的环境中就需要一套清晰的状态管理规则。在“模拟电梯7”这个项目中我们不需要真的控制硬件也不需要处理电梯门的光幕、超载、急停等物理逻辑只需要把核心的业务规则抽象成程序里的对象和方法。例如电梯当前楼层、电梯运行方向、电梯门开合状态、待停靠楼层列表等。当控制台输入一个请求“从 12 楼到 5 楼”模拟器会自动把 12 楼和 5 楼加入停靠列表然后让电梯一层一层移动并打印状态。1.2 项目要解决的核心问题这个项目主要解决三类问题第一电梯状态的表达。电梯不会一直“上上下下”它会有静止、上行、下行、开门、关门等状态。如果只用整数1和-1表示方向代码会很难读懂所以适合用枚举类型。第二停靠请求的排队。乘客按了 12 楼又有人按了 5 楼电梯应该先去哪一层这属于调度算法问题。最简单的方式是先来先服务也就是按请求顺序依次处理。更高效的方式可以模拟真实的电梯扫描算法。第三时间驱动。电梯是持续运行的每秒刷新一次状态。我们需要一个后台定时任务不断执行“电梯移动一层”的逻辑同时主线程继续等待用户输入新请求。1.3 本文适用的读者如果你已经学完 Java 基础语法正在找项目练手这个“模拟电梯7”项目非常适合。它不会用到太偏的知识点但会用到enum、List、Scanner、ScheduledExecutorService、synchronized等常用技术。如果你已经工作想快速了解电梯调度算法的入门实现也可以参考本文的思路。很多中间件中的任务分配、负载均衡算法本质上和电梯调度有相似之处有请求队列有服务节点有优先级。学会电梯调度对理解其他调度模型也有帮助。2. 环境准备与整体设计2.1 开发环境与版本说明本文示例使用纯 Java 开发编译运行不需要额外依赖。理论上 JDK 8 及以上版本都可以运行因为用到的ScheduledExecutorService在 JDK 5 就已经出现enum更是 JDK 5 就支持的功能。具体环境可以参考操作系统Windows / macOS / Linux 均可JDK 版本JDK 8 或更高版本开发工具IntelliJ IDEA、Eclipse 或者直接使用命令行构建方式不使用 Maven / Gradle直接使用javac编译如果你使用 IDEA新建一个 Java 项目即可然后把下面四个类的代码依次复制进去。如果你习惯命令行也可以按照下面的目录结构创建文件。2.2 模拟规则设定为了让项目可控我们先把模拟场景限定为单台电梯。规则如下楼层范围1 到 20 层电梯初始位置1 层初始状态静止用户输入格式起始楼层 目标楼层输入exit退出程序电梯每 1 秒执行一次移动逻辑例如控制台输入12 5表示有乘客在 12 楼要去 5 楼。电梯会先去 12 楼接人开门等待 3 秒然后继续去 5 楼到达后开门等待 3 秒再恢复静止状态。这里的“等待 3 秒”并不是让线程sleep3 秒而是用一个计数器记录剩余开门时间每次 tick 递减这样电梯调度线程不会被阻塞也方便后续扩展。2.3 项目结构规划我们先规划好包名和类避免写到一半结构混乱。项目可以放在src/com/example/elevator目录下。src/com/example/elevator/ ├── ElevatorState.java ├── Elevator.java ├── ElevatorController.java └── Main.java每个类的职责如下类名职责ElevatorState电梯状态枚举定义 IDLE、MOVING_UP、MOVING_DOWN、DOOR_OPENElevator电梯本体保存当前楼层、状态、停靠列表执行移动和开关门动作ElevatorController调度控制器负责接收请求、启动定时任务、停止定时任务Main程序入口读取控制台输入并调用控制器这样拆分的好处是电梯自己的动作和外部请求调度分离。未来如果要做多部电梯可以新增一个ElevatorGroup类来统一管理Elevator本身不需要大改。3. 状态机设计与核心模型3.1 电梯状态定义电梯在运行过程中必须处于一个清晰的状态。我们可以用枚举来定义。package com.example.elevator; public enum ElevatorState { IDLE, MOVING_UP, MOVING_DOWN, DOOR_OPEN }这里简单说明四个状态的含义IDLE电梯静止没有待停靠楼层MOVING_UP电梯正在向上运行MOVING_DOWN电梯正在向下运行DOOR_OPEN电梯门处于打开状态有些同学可能会问为什么需要单独定义DOOR_OPEN状态因为电梯到达目标楼层后不是立刻就走而是需要开门等待乘客进出。如果状态只区分“运行中”和“静止中”就没法表达“门开着但电梯没运行”这种情况。在实际项目中状态还可以继续细分比如DOOR_OPEN_MANUAL、FAULT、MAINTENANCE等。但作为课程设计四个状态已经足够。3.2 停靠请求模型停靠请求可以理解为一个楼层数字列表。比如用户输入12 5控制器会往电梯的停靠列表中添加两个数字12 和 5。这里需要区分“乘客在几楼等电梯”和“乘客要去几楼”。在简化实现中我们把两个数字都作为电梯的停靠点。电梯先去 12 层开门让乘客进入然后去 5 层开门让乘客出去。如果未来要更真实地模拟可以给每个请求增加方向属性例如public class Request { private int floor; private Direction direction; private int targetFloor; }不过本文为了把核心逻辑讲清楚先用ListInteger保存停靠楼层等代码跑通之后再考虑扩展成对象。3.3 为什么用状态机如果不用状态机很容易写出下面这样的代码if (elevator.isRunning()) { // ... } else if (elevator.isDoorOpen()) { // ... }这样的代码在功能简单时可以工作但一旦状态多了判断条件会越来越复杂而且容易出现“既在开门状态又在向上运行”这种矛盾。状态机的核心思路是在任意一个时刻对象只能处于有限个状态中的一个。我们检查当前状态根据状态执行特定逻辑并转移到下一个状态。电梯的 tick 方法其实就是一个状态转移函数。用状态机的另一个好处是方便调试。程序运行过程中我们只需要打印当前状态就能知道电梯到底卡在哪一步。真实电梯控制系统也是类似思路只是状态数量更多安全条件更严格。4. 调度算法拆解4.1 先来先服务 FCFS先来先服务是最容易理解的调度算法。用户发出请求后按顺序把楼层追加到停靠列表尾部电梯每次都取列表第一个楼层作为目标。这种算法的优点是实现简单不会出现某个请求长时间得不到响应的情况。缺点是效率不高。比如电梯正在 5 楼向上运行此时 6 楼有人要去 1 楼按照 FCFS 的处理顺序电梯可能先跑到 10 楼处理最早的请求再掉头回 1 楼6 楼的乘客只能多等一段时间。在“模拟电梯7”的入门版本中我选择使用 FCFS因为它的状态流转最容易理解代码也最直观。4.2 最短寻道时间优先 SSTFSSTF 的思路是每次从待停靠请求中选择距离当前楼层最近的一个作为下一站。比如电梯在 5 楼待停靠楼层有 10 楼和 6 楼那么优先去 6 楼。SSTF 的优点是平均响应时间短但存在一个经典问题饥饿。如果高层请求经常出现低层请求可能一直排不上队。真实电梯调度不会只用这个算法。4.3 扫描算法 SCAN 与 LOOKSCAN 算法又被称为电梯算法它的思想是电梯先朝一个方向运行处理沿途所有请求直到该方向最远的请求被处理完再反向运行。LOOK 算法是 SCAN 的改进版本不需要走到最远楼层只要该方向没有请求就立即反向。如果要实现 LOOK电梯需要维护一个方向属性并在每次取下一个停靠楼层时优先查找当前运行方向上的楼层。如果当前方向向上就找比当前楼层高的最近楼层如果找不到则切换方向找比当前楼层低的最高楼层。这种算法更贴近真实电梯但代码复杂度会高一些。我在后续扩展部分会给出思路完整代码仍以 FCFS 为主。4.4 三种算法对比算法实现难度平均等待时间是否有饥饿适用场景FCFS低较长无入门教学、简单模拟SSTF中较短有磁盘调度、单机任务分配SCAN/LOOK高较短基本无真实电梯系统对于课程设计而言建议先用 FCFS 跑通整个功能之后如果还有精力再把调度算法替换成 LOOK。算法替换不会影响电梯状态模型只需要修改“如何从停靠列表中选择下一站”的逻辑。5. 完整实战案例Java 控制台模拟电梯5.1 创建项目结构首先在任意目录下创建如下结构src/com/example/elevator/然后把四个文件依次创建。如果你使用 IDEA可以直接新建包com.example.elevator然后创建类。5.2 电梯状态枚举 ElevatorState.java文件路径src/com/example/elevator/ElevatorState.javapackage com.example.elevator; public enum ElevatorState { IDLE, MOVING_UP, MOVING_DOWN, DOOR_OPEN }这个文件很简单不需要任何额外方法。枚举类型在 Java 中是安全的不会出现状态值拼写错误的问题。5.3 电梯核心类 Elevator.java文件路径src/com/example/elevator/Elevator.javapackage com.example.elevator; import java.util.ArrayList; import java.util.List; public class Elevator { private final String name; private final int minFloor; private final int maxFloor; private volatile int currentFloor; private volatile ElevatorState state; private int doorOpenRemain; private final ListInteger stops new ArrayList(); public Elevator(String name, int minFloor, int maxFloor, int currentFloor) { this.name name; this.minFloor minFloor; this.maxFloor maxFloor; this.currentFloor currentFloor; this.state ElevatorState.IDLE; } public synchronized void addRequest(int floor) { if (floor minFloor || floor maxFloor) { System.out.println(非法楼层: floor); return; } stops.add(floor); System.out.println([ name ] 收到停靠请求: floor 层); } public synchronized void tick() { if (state ElevatorState.DOOR_OPEN) { doorOpenRemain--; if (doorOpenRemain 0) { closeDoor(); } printStatus(); return; } if (stops.isEmpty()) { state ElevatorState.IDLE; printStatus(); return; } int nextStop stops.get(0); if (currentFloor nextStop) { currentFloor; state ElevatorState.MOVING_UP; } else if (currentFloor nextStop) { currentFloor--; state ElevatorState.MOVING_DOWN; } else { stops.remove(0); openDoor(); } printStatus(); } private void openDoor() { state ElevatorState.DOOR_OPEN; doorOpenRemain 3; System.out.println([ name ] 到达 currentFloor 层开门); } private void closeDoor() { System.out.println([ name ] currentFloor 层关门); state ElevatorState.IDLE; } public boolean isValidFloor(int floor) { return floor minFloor floor maxFloor; } public String getName() { return name; } public int getCurrentFloor() { return currentFloor; } public ElevatorState getState() { return state; } public synchronized ListInteger getStopsSnapshot() { return new ArrayList(stops); } private void printStatus() { System.out.println([ name ] 当前楼层 currentFloor , 状态 state , 待停靠 stops); } }这段代码是整个项目的核心。我来解释几个关键点第一stops使用ArrayList保存待停靠楼层。tick方法每次只取第一个元素作为目标到达后就移除相当于队列的先进先出。第二addRequest和tick都是synchronized方法。因为电梯调度线程在后台运行主线程可能同时调用addRequest添加新请求如果不加锁可能出现并发修改异常。课程设计虽然并发量低但从一开始养成同步意识是好事。第三开门逻辑比较特殊。当电梯到达目标楼层后并不会直接调用Thread.sleep而是把doorOpenRemain设置为 3。下一次 tick 进入DOOR_OPEN状态时计数器减 1直到减到 0 再关门。这样做的好处是电梯调度线程不会被阻塞仍然可以处理新请求的加入。5.4 调度控制器 ElevatorController.java文件路径src/com/example/elevator/ElevatorController.javapackage com.example.elevator; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; public class ElevatorController { private final Elevator elevator; private final ScheduledExecutorService scheduler; private volatile boolean running true; public ElevatorController(Elevator elevator) { this.elevator elevator; this.scheduler Executors.newSingleThreadScheduledExecutor(); } public void start() { scheduler.scheduleAtFixedRate(() - { if (running) { elevator.tick(); } }, 0, 1, TimeUnit.SECONDS); } public void addRequest(int from, int to) { if (!elevator.isValidFloor(from) || !elevator.isValidFloor(to)) { System.out.println(输入楼层超出范围请重新输入); return; } if (from to) { System.out.println(当前楼层与目标楼层相同不需要乘梯); return; } elevator.addRequest(from); elevator.addRequest(to); } public void stop() { running false; scheduler.shutdownNow(); } }调度控制器的作用有两个一是定时驱动电梯执行tick二是接收外部请求并转换为电梯的停靠点。这里使用了ScheduledExecutorService它比直接创建Thread并循环sleep更优雅。scheduleAtFixedRate表示每秒钟执行一次任务。如果任务执行时间超过 1 秒它不会堆积任务而是等待当前任务执行完后再继续。5.5 入口 Main.java文件路径src/com/example/elevator/Main.javapackage com.example.elevator; import java.util.Scanner; public class Main { public static void main(String[] args) { System.out.println(模拟电梯7 启动); System.out.println(请输入: 起始楼层 目标楼层例如: 12 5); System.out.println(输入 exit 退出); Elevator elevator new Elevator(1号电梯, 1, 20, 1); ElevatorController controller new ElevatorController(elevator); controller.start(); Scanner scanner new Scanner(System.in); while (scanner.hasNextLine()) { String line scanner.nextLine().trim(); if (exit.equalsIgnoreCase(line)) { break; } String[] parts line.split(\\s); if (parts.length 2) { try { int from Integer.parseInt(parts[0]); int to Integer.parseInt(parts[1]); controller.addRequest(from, to); } catch (NumberFormatException e) { System.out.println(请输入数字楼层); } } else { System.out.println(输入格式: 起始楼层 目标楼层); } } controller.stop(); System.out.println(模拟电梯7 已退出); } }主程序的结构很清晰创建一部初始在 1 楼、可运行 1 到 20 层的电梯创建控制器并启动后台定时任务使用Scanner循环读取控制台输入解析输入格式调用控制器的addRequest方法输入exit后停止定时任务并退出5.6 编译与运行如果你在命令行环境下操作可以使用下面两条命令javac -encoding UTF-8 src/com/example/elevator/*.java -d out java -cp out com.example.elevator.Main如果使用 IDEA直接运行Main类即可。程序启动后控制台会输出模拟电梯7 启动 请输入: 起始楼层 目标楼层例如: 12 5 输入 exit 退出 [1号电梯] 当前楼层1, 状态IDLE, 待停靠[]此时输入12 5控制台会持续打印电梯状态大概每秒一行。由于电梯从 1 楼到 12 楼需要 11 秒中间的输出会比较多这里截取关键部分作为示例[1号电梯] 收到停靠请求: 12 层 [1号电梯] 收到停靠请求: 5 层 [1号电梯] 当前楼层2, 状态MOVING_UP, 待停靠[12, 5] [1号电梯] 当前楼层3, 状态MOVING_UP, 待停靠[12, 5] ... [1号电梯] 当前楼层12, 状态MOVING_UP, 待停靠[12, 5] [1号电梯] 到达 12 层开门 [1号电梯] 当前楼层12, 状态DOOR_OPEN, 待停靠[5] [1号电梯] 当前楼层12, 状态DOOR_OPEN, 待停靠[5] [1号电梯] 12 层关门 [1号电梯] 当前楼层11, 状态MOVING_DOWN, 待停靠[5] ... [1号电梯] 到达 5 层开门 [1号电梯] 当前楼层5, 状态DOOR_OPEN, 待停靠[] [1号电梯] 5 层关门 [1号电梯] 当前楼层5, 状态IDLE, 待停靠[]从输出可以看出电梯状态切换是完整的IDLE→MOVING_UP→DOOR_OPEN→MOVING_DOWN→DOOR_OPEN→IDLE。门开期间电梯不会继续移动这符合真实电梯的行为。6. 常见问题与排查思路6.1 电梯一直不动如果运行程序后发现电梯没有任何输出先检查是否调用了controller.start()。如果start()没有执行调度线程不会启动。另外一个常见原因是定时任务被意外关闭。可以检查stop()方法是否被提前调用或者主线程在执行scanner.hasNextLine()时直接退出了循环。6.2 输入请求后没有反应遇到这种问题优先检查输入是否被成功解析。例如输入了非数字字符Integer.parseInt会抛出异常虽然我们做了捕获但可能没有提示。还需要检查楼层范围。如果输入0 5或者21 5addRequest会判断为非法楼层并直接返回控制台会打印提示信息。6.3 电梯开门时间不对如果门刚开就关上说明doorOpenRemain的值设置太小或者tick的执行频率太快。当前实现中doorOpenRemain 3调度频率是每秒一次因此门大约开 3 秒。如果你改成doorOpenRemain 1门只会开 1 秒看起来就像刚开门就关门。这是正常现象不需要担心。6.4 电梯来回跑效率很低这是 FCFS 算法的固有缺陷。比如当前在 1 楼请求队列是[10, 2, 15, 3]电梯会先去 10 楼再去 2 楼再去 15 楼最后去 3 楼。从整体路径看电梯显然在来回折腾。如果你希望优化可以换成 LOOK 算法。具体做法是在每次取下一个停靠点前先根据当前电梯方向对stops进行排序和筛选而不是简单取第一个元素。6.5 多部电梯并发时出现数据错乱本文只实现了一部电梯。如果未来扩展为多部电梯要注意ListInteger不能被多个线程同时修改。建议在Elevator内部的方法上继续使用synchronized或者使用CopyOnWriteArrayList替代ArrayList。另外调度控制器和电梯本身不要共用同一个调度线程否则一部电梯卡住会拖慢所有电梯。最好每部电梯对应一个独立线程。6.6 常见问题汇总问题现象常见原因解决思路电梯没有任何输出定时任务未启动检查controller.start()是否调用请求楼层无效输入超出 1-20 范围在控制器中增加范围校验程序一输入就退出没有判断exit后退出循环检查Scanner循环逻辑门开时间太短doorOpenRemain太小调整开门剩余时间电梯路径效率低使用 FCFS 算法改用 SCAN 或 LOOK 算法多电梯数据错乱共享集合被并发修改使用同步或加锁7. 扩展思路图形界面与多电梯调度7.1 图形界面扩展控制台版本虽然能展示逻辑但不够直观。如果你想把项目做成课程设计展示可以在现有代码基础上增加 Swing 界面。基本思路是在主界面放一个JLabel显示电梯当前楼层放一个JTextField接收请求再放一个JButton提交请求。电梯状态变化后通过SwingUtilities.invokeLater更新界面。这里给出一个界面刷新的核心片段不是完整程序import javax.swing.*; import java.awt.event.ActionListener; public class ElevatorPanel extends JPanel { private JLabel statusLabel; public ElevatorPanel(Elevator elevator) { statusLabel new JLabel(elevator.getState().name()); add(statusLabel); Timer timer new Timer(1000, e - { statusLabel.setText(elevator.getState().name() elevator.getCurrentFloor()); }); timer.start(); } }javax.swing.Timer会在主线程中定时刷新 UI避免在多线程中直接操作 Swing 组件导致的线程安全问题。7.2 多电梯调度扩展现实中的写字楼通常有多部电梯多电梯调度会复杂很多。你需要增加以下内容电梯分组哪些楼层由哪些电梯负责空闲电梯选择当多个电梯都空闲时选择距离请求楼层最近的电梯方向匹配如果某部电梯正在向上运行那么它更适合响应上行的请求负载分配避免所有电梯都去响应同一个楼层多电梯调度是一个很经典的系统设计问题值得用一整个项目去练习。本文的代码结构已经为多电梯做了铺垫因为Elevator是独立对象只需在控制器中维护多个电梯实例即可。7.3 换用其他语言实现如果你熟悉 Python可以用threading.Timer或者sched模块实现定时任务逻辑基本一致。如果你熟悉 C 语言可以尝试用结构体表示电梯状态用链表保存停靠队列。核心的状态机设计思想是通用的。不过Java 在表达“状态枚举”和“并发控制”上更规范代码结构也更适合课堂讲解因此推荐初学者优先选择 Java 版本。8. 最佳实践与工程建议8.1 状态管理要集中电梯的状态变化应该集中在一个地方处理。不要在主程序里到处写elevator.setState(...)否则后期很难排查。建议像本文一样把状态变化收敛到Elevator内部的tick方法中。如果项目继续扩展还可以引入状态模式把“开门状态”和“运行状态”的处理逻辑拆成独立类这样每个状态类的代码会更短。但是对于课程设计枚举加方法判断已经足够。8.2 线程安全要提前考虑虽然单电梯项目并发问题不明显但一旦加入用户输入和定时任务就可能出现竞态问题。比如用户输入的请求和电梯 tick 同时修改stops列表。最简单的做法是使用synchronized修饰相关方法。如果追求更高的并发性能可以使用BlockingQueue代替ArrayList。BlockingQueue原生支持线程安全的入队和出队操作也很适合作为停靠请求队列。8.3 输入校验一定要做控制台输入是外部输入充满不确定性。用户可能输入负数、小数、超范围楼层甚至直接输入乱码。在解析请求时必须做好范围校验和格式校验。本文在控制器中做了两层校验第一层判断楼层是否在 1-20 范围内第二层判断起始楼层和目标楼层是否相同。同时捕获了NumberFormatException避免程序崩溃。8.4 日志不要滥用 System.out控制台版本使用System.out打印状态没问题但在生产系统中不建议这样做。频繁打印大量日志会拖慢性能。更好的做法是使用日志框架并区分debug、info、warn、error级别。对于模拟电梯项目一个简单的info日志打印楼层变化已经足够。8.5 调度算法要根据场景选择没有绝对最好的调度算法只有最适合场景的算法。如果项目目标是演示状态机FCFS 是最容易讲解的算法。如果项目目标是模拟真实电梯LOOK 算法更合理。如果你关注响应时间SSTF 值得参考但要注意饥饿问题。建议在代码中把调度算法的选择封装成一个方法例如public int selectNextStop(ListInteger stops, int currentFloor, ElevatorState state) { // 不同的策略对应不同实现 }这样后续替换算法时只需要修改一个方法不需要改动电梯状态机的整体结构。8.6 从课程设计到工业级系统的差距最后需要提醒一点本文的“模拟电梯7”是一个教学项目不是真正的电梯控制系统。真实电梯涉及门锁继电器、限位开关、超载检测、安全回路、故障诊断等大量硬件逻辑任何软件错误都可能导致安全事故。因此这个项目的价值在于帮助你理解状态机、队列、线程调度、算法设计这些通用思想而不是教你如何写一个能直接控制电梯的软件。在做其他类似模拟系统时也要先确认安全边界和验证方式。出现问题先在测试环境复现不要贸然放到真实设备上运行。你可以试着完成下面的小挑战将调度算法从 FCFS 替换为 LOOK然后模拟一批连续请求对比电梯总运行时间。这个练习会帮助你更深入理解电梯调度的本质。
返回列表