简介:这是一份基于Java的摩天大楼电梯系统仿真项目,面向学习调度算法与面向对象设计的开发者。项目涵盖单向、双向、观光、高速、消防等电梯类型,并实现先来先服务、最近楼层优先、最少行程时间、预调度及群控等多种调度策略,通过随机流量生成器模拟乘客呼叫,便于对比不同算法下的等待时间与运行效率。资源共75个文件,以Java源代码为主(包含电梯实体、呼叫对象、流量生成、调度器及大楼管理等核心类),辅以XML配置、prefs参数及MD/TXT说明文档,整体压缩包仅70KB,结构清晰。已有402人学习。通过学习可掌握电梯调度算法的实现思路,理解Building、Elevator、Call、Scheduler、FlowGenerator等模块的协作关系,提升Java编程与算法优化能力。
1. 摩天大楼电梯类型与算法仿真:为什么调度比电梯本身更值钱
在超高层建筑里,电梯从来不是「把轿厢从 A 送到 B」这么简单。一栋 60 层的写字楼,早高峰 15 分钟内可能有上千人同时涌向电梯厅,调度算法直接决定平均候梯时间是 30 秒还是 3 分钟。这台 ElevatorSimulation 仿真的核心价值,就是让你用纯 Java 把「电梯类型」「停靠策略」「调度算法」拆开来看,跑出可对比的量化指标——平均等待时间、最长等待时间、轿厢满载率、能耗模拟。它不依赖真实硬件,一套代码就能在本地把 FCFS、SSTF、SCAN、LOOK 这些算法反复对照。适合正在做操作系统课程设计、Java 并发练习、或者想搞懂调度策略差异的开发者。下面我按自己拆这套仿真代码的顺序,把模型、算法、运行、调参、排错一次性讲透。
2. 调度算法怎么选:FCFS、SCAN、LOOK 到目的楼层控制
2.1 先搞清楚电梯调度的性能指标是什么
判断调度算法好不好,不是「能不能把人送到」,而是要看一组可量化的指标。我在跑仿真实验时,最关注四个数据:平均等待时间(乘客按下按钮到轿厢开门)、最长等待时间(最差情况下的饥饿程度)、总运行距离(能耗和机械磨损的代理指标)、轿厢平均载客率(资源利用效率)。这四个指标之间经常互相打架——追求最短平均等待时间,可能会让某些极端请求等很久;追求满载率,又会让空驶率上升。仿真项目的意义就在于把这种「算法 trade-off」变成数字,而不是停留在感觉层面。
仿真模型里时间单位统一用秒。每个电梯都要配置基础参数:加速度、最大速度、开门时间、单乘客进出时间。我常用的基准配置是最大速度 2.5 m/s、加速度 1.0 m/s²、开关门 1.5 秒、每人进出 0.8 秒。这些参数直接决定「电梯从这里到那里到底需要几秒」——如果只按楼层差 × 固定速度来算,仿真结果会失真,后面避坑章节我会专门讲。
2.2 四种基础算法怎么在代码层面区分
FCFS(先来先服务)是最直观的:把请求按到达时间排成队列,一部电梯按顺序逐个执行。它的优点是不会饿死任何请求,缺点是没有方向概念——电梯明明在 10 层往上走,为了接 3 层的下行请求可能先下去,导致所有乘客都陪跑。
SSTF(最短寻道时间优先)来自磁盘调度思想,每次选离当前楼层最近的请求。它在电梯场景的问题是:电梯在 15 层,10 层有个上行请求、3 层有个下行请求,它选了 10 层,但完事后可能又跑去 3 层,中间不断改变方向,轿厢里的人会很难受。
SCAN(电梯算法)才是给电梯设计的:电梯保持当前方向一直走,走到底(或到达当前方向最远请求)才掉头。仿真项目里 SCAN 往往作为默认比较基准,因为它兼顾了「方向确定性」和「公平性」。一个常见误区是把 SCAN 误解为「必须开到顶层或底层才回头」,实际工程中一般用 LOOK 变体——只走到当前方向上最远的请求楼层就反向,不是死板地顶到底。
LOOK可以理解为 SCAN 的改进版。它不预设必须到达端点,而是动态看当前方向还有没有请求,没有就立刻转向。在摩天大楼场景里,LOOK 能显著减少无意义的长距离空驶。这套仿真项目里 LOOK 是默认算法,我的经验也是从 LOOK 开始实验,再换回 SCAN 做对比。
表:四种算法的典型表现特征
| 算法 | 平均等待时间 | 最差等待 | 运行距离 | 适用场景 |
|---|---|---|---|---|
| FCFS | 较高 | 中等 | 高 | 低楼层、请求极稀疏 |
| SSTF | 中等 | 高(易饿死) | 低 | 不推荐用于电梯 |
| SCAN | 中等 | 低 | 中 | 中高楼层通用基准 |
| LOOK | 低 | 低 | 中低 | 高楼层、请求密集 |
2.3 分区调度:摩天大楼的真正解法
单台电梯再怎么优化算法,在 50 层以上的大楼里都有物理极限。项目源码里还包含「分区调度」逻辑:把楼层拆成低区、中区、高区,每个区分配给特定电梯组。比如 4 部电梯,可以分成高区 2 部、中区 1 部、低区 1 部,每部电梯只响应自己区内的请求。这种方式在早高峰效果惊人——因为真实早高峰的请求模式高度集中(所有人都从大堂出发去各楼层,然后从各楼层回大堂)。
实现分区调度时,核心是「请求归属判断」:外部请求按下按钮时,系统要根据目标楼层判断这单该由哪个区的哪部电梯接手。我在项目代码里看到的设计是维护一个静态分区表,按楼层阈值切分,每部电梯启动时注册自己的服务区间。这样调度的逻辑就从「选一部空闲电梯」变成「选一个区、再看区内哪部电梯合适」,复杂度立刻降了一个维度。
不过分区调度有个明显的副作用:跨区请求要换乘。如果你的仿真目标是模拟真实大楼,就必须把「换乘罚时」也建模进去——大概每次换乘加 15 到 20 秒(等梯 + 步行 + 开关门延时)。如果只看单梯指标,分区方案会显得不如全局调度,但放在真实场景里体验反而更好。仿真项目里调这种参数时,我一般会用一组「楼层分布矩阵」来描述请求流向,而不是随机均匀分布,这样能让分区调度的优势显形。
3. 揭开仿真内部:核心类、事件循环与可插拔调度器
3.1 核心对象建模:Elevator、Request、Passenger 各自管什么
拆这套仿真代码时,我第一个关注的是对象边界。合理的电梯仿真必须把三个概念分开:Elevator是物理实体,管当前位置、当前方向、当前载客量、运行状态(静止/加速/匀速/减速/开门);Request是一条调度指令,记录请求楼层、目标楼层、请求时间、请求方向;Passenger是关联在 Request 上的乘客模型,负责统计等待时长与乘梯时长。
我一般会先去读 Elevator 类的状态机,因为它基本决定了整个仿真的复杂度。一个合格的电梯模型至少要包含以下状态:IDLE(无请求待机)、ACCELERATING、CRUISING、DECELERATING、DOOR_OPENING、DOOR_CLOSING、LOADING_UNLOADING。如果代码把电梯状态只用「静止/移动」二值描述,那这个仿真跑出来的时间数据基本不具备参考价值——因为真实电梯的加减速和开关门占了单次运行时间的 30% 左右。
Request 类的设计里也有一个关键决策:内部请求(轿厢内按钮)和外部请求(厅站按钮)是否分队列管理。我的建议是分开。外部请求会被调度器分配给某部电梯,分配之后才映射为该电梯的内部停靠目标。如果一个仿真系统在分配后还要维护一个全局外部请求池,那就意味着请求可以从一部电梯转移给另一部——现实中这意味着乘客被「甩客」了,不应该允许。
乘客行为建模同样值得看。仿真项目里一般对乘客做两种建模:一种只关心起终点和时间,叫「流量型乘客」;另一种还关心「耐心」,比如等待超过 60 秒后可能离开或改走楼梯,叫「行为型乘客」。前者适合算系统吞吐,后者适合算真实体验。我对这套代码的建议是:先用流量型跑通主流程,加行为型时注意它会影响请求重分配,不要和 FCFS 这类无重分配算法产生逻辑冲突。
3.2 调度器接口:如何让十种算法共用一套框架
调度器的抽象是这类仿真项目质量的分水岭。一套好的框架,FCFS、SSTF、SCAN、LOOK 不应该各自写一套事件处理逻辑,而应该都实现同一个接口。源码里我看到的核心接口一般长这样:
public interface Dispatcher { // 外部请求到达时,决定由哪部电梯处理 Elevator dispatch(Request request, List<Elevator> elevators); // 电梯在某楼层停靠完成,决定下一步方向 int decideNextDirection(Elevator elevator, List<Request> pending); }dispatch决定「谁来接」,decideNextDirection决定「往哪走」——这两个方法足够覆盖所有单梯调度算法。FCFS 的 dispatch 逻辑是挑一个「当前 IDLE 时间最早」的空闲电梯;LOOK 的 decideNextDirection 要判断「当前方向上还有没有请求」,没有就反向。
为了让不同算法能相互对照,调度框架还需要一个「事件钩子」(hook)。我建议每部电梯完成一次「到达楼层并开门」动作时,触发一个onFloorArrived(elevator, floor)事件。SCAN 这类方向优先算法依赖这个钩子来判断是否到达当前方向的最远请求;SSTF 则要在钩子里重新扫描所有未处理请求的相对位置。把钩子设计成同步调用,能让实现在事件循环里保持状态一致性,避免异步回调带来的并发竞态。
一个我见过的反面设计是把算法选择写成if (algorithmName.equals("SCAN"))散落在各个类里。这种写法一旦加第六种算法,改动面会爆炸。正确做法是可以配置的:
Dispatcher dispatcher = DispatcherFactory.create("LOOK"); Simulation sim = new Simulation(building, elevators, dispatcher, seed);DispatcherFactory内部用简单工厂模式按字符串返回实例,同时可以在构造时注入运行参数,如 LOOK 的「同方向顺路捎带」「反向顺路捎带」开关。这样新增算法时只需扩展工厂,不动仿真框架。参数说明:seed是随机数种子,控制乘客生成序列;固定 seed 之后每次仿真结果完全一致,这直接决定了实验可复现性,我在第 6 章会专门讲怎么用它做 A/B 测试。
3.3 事件循环构造:为什么用「下一事件推进」而不是「逐帧驱动」
仿真系统的推进方式有两种主流选择:固定时间步长(每 100ms 更新一次所有状态,类似游戏循环)和下一事件推进(按事件发生时间跳跃式推进,模拟时钟直接跳到下一个事件点)。这套项目的核心循环用的是「下一事件推进」,这是对的。
固定时间步长对电梯仿真的问题是:电梯在 95% 的时间里处于匀速运行,逐帧更新会产生大量「电梯什么都没做」的空计算;而开门、加速这些关键行为又被粒度限制在 100ms 内,精度不够。下一事件推进的精髓是:把整个仿真拆成一串事件节点——「电梯到达 12 层」「乘客按下 35 层按钮」「门完全关闭」。每个事件带一个时间戳,循环从优先队列里取出最早的事件,把模拟时钟拨到该时间,然后执行事件回调,再插入后续事件。
public void run() { while (!eventQueue.isEmpty()) { SimulationEvent evt = eventQueue.poll(); currentTime = evt.time; evt.execute(this); } }这段代码虽然简洁,却是整个仿真的引擎。我给这套仿真扩展新功能时,唯一需要做的就是往eventQueue里插入新事件,而不是修改循环本体。比如我想在仿真里加入电梯困人故障,只需在任意事件回调里插入一个FaultEvent,到点触发故障逻辑,主循环根本不用改。
参数说明:currentTime是全局模拟时钟,不要用真实系统时间替代,否则无法复现。事件队列里每个事件必须携带时间戳与执行回调,优先队列按时间戳排序。如果一个事件的处理过程中产生了「当前时间」而不是「未来时间」的新事件(比如乘客到站后立即发新请求),说明逻辑有 bug——事件处理器绝不能回头造过去的事件,这会破坏因果链。
4. 项目怎么跑:环境准备、启动命令、场景配置与指标解读
4.1 环境与编译:JDK 版本和构建工具怎么选
拿到这套仿真代码,第一步不是急着读源码,而是先把运行环境配好。我平时用 JDK 11 编译运行,构建工具首选 Maven。如果你的机器上还没有 Maven,直接装一个带mvn命令的发行版就行。项目里没有第三方依赖也能编译,但 Maven 结构更方便后续跑批量实验。
# 克隆/解压项目后,在根目录执行 mvn clean package java -jar target/elevator-simulator-1.0.jar如果环境里只有javac没有 Maven,也可以用最原始的编译方式:把所有.java文件直接编一遍,然后指定主类运行。常见做法是:
javac -encoding UTF-8 src/main/java/**/*.java java -cp src/main/java com.elevator.sim.Main --config sim_config.json这里--config参数指向仿真配置文件,项目里已有默认的sim_config.json,一般不用改就能直接跑出结果。注意javac如果报编码错误,多半是 Windows 控制台代码页问题,加-encoding UTF-8能绕过去。这是新手最容易在第一步翻车的地方。
4.2 配置文件字段逐个拆:楼层数、电梯数、载客量、时间分布
启动后你会发现,仿真报告的差异全部来自配置文件。以下是核心字段及其取值边界:
{ "floors": 40, "elevators": 4, "capacityPerElevator": 20, "maxSpeedMps": 2.5, "accelerationMps2": 1.0, "doorOpenCloseSeconds": 1.5, "passengerBoardSeconds": 0.8, "simulationDurationSeconds": 3600, "requestGeneration": { "distribution": "POISSON", "meanIntervalSeconds": 5.0, "seed": 20240820 }, "dispatcher": { "type": "LOOK", "zoneBased": true, "zoneSplit": [13, 26] } }floors与elevators决定系统的基础规模。capacityPerElevator是轿厢限员,超过后不能继续载客,这对早高峰仿真影响很大。requestGeneration里的distribution支持 POISSON 和 UNIFORM 两种:POISSON 模拟「随机到达但平均间隔稳定」的真实客流;seed固定后每次乘客请求序列完全一样,是做不同算法对照实验的前提。
zoneBased和zoneSplit是分区调度开关。zoneSplit: [13, 26]表示楼层 1~13 为低区、14~26 为中区、27~40 为高区,4 部电梯默认平均分配。如果你把zoneBased打开但电梯数量不能整除分区数,调度器会自动给前几个区多分电梯,这个行为我是通过读源码确认的——不要依赖直觉去猜分配逻辑。
4.3 跑完之后报告怎么读:四项指标的对照意义
仿真结束后控制台会输出一段 CSV 格式的统计信息,我每次实验都把它复制到表格工具里做矩阵对比。报告核心如下:
total_requests=1420, served=1403, rejected=17 avg_wait_sec=18.4, p95_wait_sec=41.2, max_wait_sec=88.5 avg_travel_sec=31.7, avg_occupancy_rate=54.2% total_distance_m=41260, total_energy_kwh=18.3重点看rejected:如果乘客等待太久放弃,会产生被拒绝记录,这个数字在任何算法下都应该保持在很低水平。avg_occupancy_rate是平均载客率,太低说明电梯空跑严重;太高(接近 100%)说明乘客可能等了很多趟才挤上去。我在早高峰场景下见过载客率 95% 但 waiting 数据也很差的报告——这说明「满载运力」没有匹配请求分布,此时应该调分区策略而不是算法。
total_energy_kwh是能耗模拟值,通常按「运行距离 × 重量修正系数 + 启停次数 × 固定损耗」计算。它主要用来检验「省距离」和「省能耗」是否一致——实际中频繁启停带来的能耗惩罚往往超过距离收益,这也是 LOOK 优于 SCAN 的重要佐证。每次改完参数跑完一轮,我建议把四列核心指标连同配置 hash 存进一个 CSV 文件,方便最后汇总。
4.4 场景化实验:早高峰、午餐、夜间三种模式的配置差异
调度算法在不同场景下的优劣排序会反转,这是仿真最有价值的部分。早高峰的特征是「大量请求从 1 层出发,目标均匀分布在各楼层」;午餐的特征是「各楼层互访,请求方向杂乱」;夜间的特征是「请求极少,空驶成本占比高」。
针对早高峰,我会把requestGeneration.meanIntervalSeconds调小到 2.0 秒、把capacityPerElevator调到 20,并打开zoneBased;针对午餐,区间互访多,建议关掉zoneBased,把 LOOK 的「反向顺路捎带」打开;夜间则要调高meanIntervalSeconds到 30 秒以上,此时 FCFS 和 LOOK 的表现几乎没差别,但能耗差异会被放大——FCFS 的空驶距离明显偏长。
要注意的是,distribution如果一直是 POISSON 就无法体现「瞬时大客流」冲击。我一般会在仿真开始后的第 300 秒手动注入一批突发请求来模拟活动散场,具体做法是在源码里找到请求生成器,追加一个定时批量插入逻辑。这个改动对 SCAN 影响不大,但对 SSTF 会造成极强的饥饿效应——正好用来验证理论推导。
5. 避坑:仿真实现里最容易翻车的五个细节
5.1 现象:多部电梯同时响应同一外部请求
跑仿真时会经常看到报告里两台电梯都停到了 20 层,但那一层只有一个外部请求,其中一台完全是空跑。原因:外部请求被广播给所有电梯,而不是被调度器分配给某一个具体实例。解决:在dispatch方法里把「请求分配给电梯」和「电梯内部记录停靠目标」解耦——请求一旦分配,就写入该电梯的内部目标队列,其他电梯不可见。我一般还会在电梯状态里加一个assignedRequestIds集合,避免重复分配。
5.2 现象:LOOK 算法在楼层中间「犹豫不决」
仿真跑着跑着,某部电梯在 12 层和 14 层之间来回折返,但两个方向都只有零星请求。原因:decideNextDirection在判断「当前方向是否还有请求」时,把「同方向顺路请求」和「当前楼层新到达请求」混在一起,导致方向频繁反转。解决:把方向判断逻辑收紧——新到达的请求先进入「待分配池」,下一轮调度器统一分配;同方向捎带只针对已经在本电梯目标队列里的请求。我习惯加一个「最小方向保持时间」,比如电梯确定方向后至少往前走 3 层才能改变方向,这在仿真里能明显降低折返抖动。
5.3 现象:仿真时间越走越慢但模拟时钟没有推进
有时报告里的total_requests非常大,但仿真时长只有几十秒。原因:某个事件处理器里产生了「当前时间」的新事件,插入优先队列后马上又被取出执行,形成事件风暴。解决:检查所有eventQueue.offer()调用点,确保新事件的时间戳严格大于当前事件的时间戳。门诊式排查方法是在事件循环里加一个保护变量:同一模拟时刻最多执行 100 个事件,超过立即抛异常。这个保护逻辑我建议保留在正式代码里,发现卡死时它能直接定位问题事件类型。
5.4 现象:换了随机种子后,算法排序结论反转
同一批配置只改了 seed,LOOK 和 SCAN 的排名就变了,看起来「算法谁更好」完全凭运气。原因:请求总量太少,随机波动盖过了算法差异。解决:不要用单 seed 跑单组实验,而是固定一组 seed(如 5 个),每个 seed 下都跑所有算法,取平均值再比较。我在本地跑对比实验时,至少用 5 个 seed,每组跑 3 次剔除异常波动。另一个思路是把仿真时长加到 2 小时以上,请求样本量大了之后,算法差异才会稳定显形。
5.5 现象:分区调度开启后,跨区乘客的等待时间暴涨
配置里zoneBased: true后,报告显示 20 层到 30 层的请求要等两趟电梯才能到达。原因:跨区请求只分配给了高区电梯,但高区电梯在高峰期忙于响应区内请求,跨区乘客的优先级被压到最低。解决:分区调度的核心必须配「跨区请求换乘逻辑」。我一般会在 13 层和 26 层设置换乘站,乘客先在低区电梯到达换乘站,再步行进入中区或高区电梯。要精确模拟这种体验,需在报告里单独统计「包含换乘的总行程时间」,否则拿分区调度和全局调度直接比 average_wait,分区被低估是必然的。
6. 进阶用法:用同一组请求序列做算法横向基准
当你想真正说服别人「LOOK 比 FCFS 好」,靠跑几次仿真是不够的,必须做一次控制变量的横向对比。具体步骤是:固定文件配置里的 seed 和 simulationDurationSeconds,生成一组请求序列,然后让所有算法在同一序列上运行。代码结构可以这么组织:
List<Integer> seeds = Arrays.asList(1001, 2002, 3003, 4004, 5005); Map<String, List<double[]>> results = new LinkedHashMap<>(); for (int seed : seeds) { for (String algo : new String[]{"FCFS", "SSTF", "SCAN", "LOOK"}) { SimulationConfig cfg = loadConfig("sim_config.json"); cfg.setSeed(seed); cfg.setDispatcherName(algo); Simulation sim = new Simulation(cfg); sim.run(); results.computeIfAbsent(algo, k -> new ArrayList<>()) .add(new double[]{sim.getAvgWait(), sim.getMaxWait(), sim.getEnergyKwh()}); } }这段代码的意义是把「随机性」从对比中剥离。每个 seed 下,乘客到达时间和目标楼层完全一致,唯一变量是调度算法,跑完之后按算法分组算均值即可。
汇报结果时,不要只贴平均等待时间,要同时列出 p95 等待时间和能耗。我在实际跑早高峰 40 层、4 部电梯的配置下,看到过 FCFS 的 avg_wait 是 42 秒、p95 是 88 秒,LOOK 的 avg_wait 是 19 秒、p95 只有 36 秒——看起来 LOOK 全面占优。但夜间低客流场景下两者差距缩到 2 秒以内,这时能耗反而成为关键决策指标。只有把这种「算法优势随场景漂移」的结论讲清楚,横向基准才有说服力。
另外还有一个值得做的验证:把仿真结果回放成楼层-时间图。我一般会在代码里加一个debugTrace选项,输出每部电梯在每个时刻的楼层和方向,然后用表格软件生成折线图。图中看到电梯频繁往返,几乎可以断定调度器有方向反转问题;看到某部电梯长时间停在底层不响应高层请求,则是高区电梯分配不足的典型症状。从现象反推配置问题,比盲改参数高效得多。
这套仿真项目的最大价值在于把调度算法的「理论差异」变成「可复现的数字实验」。我从拆源码到做完横向基准,一共花了一个周末,过程中最大的教训是:不要一上来就改算法,先固定 seed 跑一遍默认配置,把每个报告字段的含义搞清楚,再做第一组对比实验。从那以后,每改一处参数,我都强制走一遍「备份配置 → 固定 seed → 跑全部算法 → 记录四指标」的流程,再也没被偶然结果带偏过。希望帮到你,也欢迎你在自己的实验里加上爬楼层策略或动态分区,跑出更贴近真实大楼的数据。
本文还有配套的精品资源,点击获取