简介:这是一份基于Java实现的摩天大楼电梯系统仿真项目,面向对电梯调度算法、面向对象建模与模拟仿真感兴趣的Java学习者。项目设计了单向/双向/观光/高速/消防等电梯类型,并实现了先来先服务、最近楼层优先、最少行程时间、预调度及群控等调度策略,能够通过流量生成器模拟乘客呼叫,用于评估不同算法下的等待时间与整体运行效率。压缩包共75个文件,以Java源码为主体(25个java文件),包含Elevator、Call、TrafficGenerator、Scheduler及Building等核心模块,辅以XML配置与prefs运行记录等,整体仅70KB,结构紧凑便于阅读和二次开发。目前已有402人学习下载。通过阅读和运行该项目,可以直观理解多电梯协同调度的核心逻辑,提升在Java集合、多线程、算法设计与仿真建模方面的实践能力,也可作为课程设计或毕业设计的参考起点。
1. 摩天大楼电梯仿真:它是什么,能解决什么问题
把一座 60 层楼的电梯系统搬进 Java 项目里做仿真,听起来就像给自己找事。但 ElevatorSimulation 这套代码恰恰能把“找事”变成“找规律”:它模拟了摩天大楼里不同电梯类型(单轿厢、双轿厢、分区调度)在不同调度算法(FCFS、SCAN、LOOK)下的运行表现,直接回答“哪个更省时、哪个更耗电”这类问题。我做调度相关的东西时经常拿它当试验场,在真实部署之前先看清每种策略的代价。适合正在学并发或系统设计、需要给论文出仿真图、以及被电梯调度折磨过的开发者。
2. 电梯模型先行:从建筑分区到乘客生成的建模细节
仿真电梯之前,最容易被忽略的是建筑模型本身。很多人一上来就写调度算法,结果楼层数、乘客到达率、开门时间全部拍脑袋,跑出来的图自己都不信。这一章先把电梯类型和乘客模型立住,后面所有算法才有意义。
2.1 电梯类型的选择:单轿厢、双层轿厢与空中大堂分区
摩天大楼的电梯不是一根管子通到底,实际工程里会用三种主流方案,建模时各有各的参数差异。
单轿厢(Single-deck)是最传统的:一个井道里一部轿厢,额定载客 13 人左右,速度 3.5 到 4m/s,开关门时间大约 2.5 到 3 秒。优势是控制逻辑简单,劣势是高峰期运能不足,井道占用面积大。
双层轿厢(Double-deck)把两个轿厢上下固定连在一起,间距大约 4.5 到 5 米,同时服务相邻两个楼层。乘客在奇数层进下轿厢、偶数层进上轿厢。它的吞吐量比单轿厢高不少,但有个致命约束:上下层乘客必须同时上下,如果某一层没人进出,另一层的人也得等。
空中大堂(Sky Lobby)则是分区思路:大楼分为几个垂直区域,用高速穿梭电梯把乘客从底层送到第 25 层、第 40 层这样的大堂,再由区域电梯继续往上送。它的核心价值是减少井道数量,把低层区乘客和高层区乘客在物理上分流。
建模时这三者的区别会直接反映在电梯对象上:单轿厢只有一个轿厢对象,双轿厢需要把两个轿厢作为一个整体移动,区域电梯则要把“换乘等待”算进乘客的行程时间里,否则结果会比真实情况乐观得多。
我一般会在配置类里把类型常量定义好,再根据常量创建不同结构的电梯对象,每一层分区的换乘耗时也先写进配置,避免后面算法阶段反复改模型。
2.2 乘客与事件队列:让仿真靠“事件”而不是“线程”跑起来
电梯仿真的第二个关键决策,是用线程还是用事件队列。新手常犯的错误是给每个乘客建一个线程、每个电梯建一个线程,最后被并发锁搞得焦头烂额。常见做法是用离散事件仿真,把整个世界当成一个按时间排序的事件队列。
核心思路是:所有变化都来自事件,事件携带发生时间。比如“第 120 秒,第 30 层来了一个乘客,要去 55 层”。主循环每次取出时间最早的事件,推进仿真时钟,处理完该事件后再取下一个。这个模型推着整栋楼往前走,电梯是响应事件的执行者,而不是自己乱跑的线程。
乘客模型不是均匀分布的,真实写字楼的高峰期有明显潮汐现象。我会用泊松分布控制乘客到达率:早上 8 点到 9 点,大厅到达率每 5 秒一个人;下午 5 点到 6 点,高层区下楼需求暴增。用随机数生成器生成乘客,目的楼层按“低层多、高层少”的分布抽样,否则也会失真。
这里的一个关键点是随机种子。仿真最怕结果不稳定,同一份代码跑十次,平均等待时间差一倍,那根本没法比较算法。我通常把随机种子写进配置类,固定住乘客序列,让多组算法跑同一批乘客数据,这样才能公平对比。
2.3 参数表:一套 50 层大楼的基准配置
建模的落地方式是先建立一套基准参数。以某栋 50 层的模拟大楼为例,我把配置分成建筑、电梯、乘客三块。建筑 50 层,层高 3.6 米,总高度 180 米;低区一到 25 层,高区 26 到 50 层。单轿厢电梯 6 台,速度 3.5m/s,加速度 1.2m/s²,开关门各 2.5 秒,额定 13 人。双轿厢电梯 4 台,速度 4m/s,轿厢间距 5 米,上下各载 10 人。如果启用空中大堂,则设置穿梭电梯 2 台,直达第 25 层,换乘时间固定 15 秒。
乘客参数也很重要。高峰时段到达率每分钟 30 人,平峰时每分钟 5 人;单次进出时间约 0.8 秒每人,这意味着满员电梯开门后需要额外等待 10 秒以上。仿真时长我一般设为 3600 秒,模拟早高峰一小时。
这些数值不需要非常精确,真实工程中它们是靠建筑设计规范和电梯厂商样本决定的。仿真里重要的是把“基本物理量”对齐:速度、加速度、开关门时间、载客量这几个参数如果差太多,后面所有算法结论都会偏移。参数表写好后建议单独存成一份配置文件,算法只读配置,不写死。
提示:单轿厢和双轿厢的能耗计算方式不同,双轿厢虽然单次搬运效率高,但上下轿厢一起走,空载部分也不能忽略。如果后面要算能耗,一定要把“轿厢自重”也建模进去。
3. 调度算法仿真:FCFS、SCAN、LOOK 的 Java 实现与对比
模型立住之后,算法才好上场。这一章会用 Java 实现四种调度算法,并逐一说明它们的边界和适用场景。代码不追求完美,重在把“调度决策”这一层写清楚,方便替换和对比。
3.1 先来先服务与最短寻道优先:写起来容易,跑起来心累
先来先服务(FCFS)是最容易写出来的:谁先按下按钮,就先响应谁。看起来公平,但会导致电梯频繁反向,乘客在轿厢里被来回折腾。短寻道优先(SSTF)则每次都选离当前楼层最近的请求,能显著降低平均寻道距离,但离得远的请求可能长期得不到响应,甚至饿死。
我一般把调度决策抽成一个接口,让不同算法实现同一个方法。以下是一个简化的调度核心,采用 FCFS 顺序处理请求队列:
public class FcfsDispatcher implements Dispatcher { private final List<Request> requests = new LinkedList<>(); private int currentFloor = 1; @Override public void addRequest(Request req) { requests.add(req); } @Override public int nextTargetFloor() { if (requests.isEmpty()) return currentFloor; Request next = requests.remove(0); currentFloor = next.targetFloor; return currentFloor; } }这段逻辑看起来自然,但如果你直接拿它跑一天,会发现两个问题。第一,电梯刚上到 40 层,马上又因为一个 18 层的请求调头回去,整个早高峰都在做“过山车”;第二,乘客体验极差,明明电梯就在附近,却要等它先送完别人。
FCFS 的优点是实现简单,适合低负载场景。但摩天大楼高峰期的请求量远超它的承受能力,所以实际项目中很少直接用它。SSTF 的实现也不难,多一步“找最近楼层”的遍历,但要注意计算的是“楼层距离”而不是“时间距离”,忽略运行方向和加速度会让判断失真。
3.2 扫描与电梯专用算法:SCAN、LOOK 的正确打开方式
电梯调度里真正常用的是 SCAN(电梯算法)和它的改进版 LOOK。SCAN 的思想是让电梯沿着一个方向一直走,走到头再反向;LOOK 则更聪明,如果同方向没有请求了,就直接掉头,不需要跑到最高层或最低层。
在 Java 里实现 LOOK 需要维护两个方向列表,一个上行请求列表,一个下行请求列表。每次决策时先检查当前方向的列表,空了再看反方向:
public class LookDispatcher implements Dispatcher { private final TreeSet<Integer> upRequests = new TreeSet<>(); private final TreeSet<Integer> downRequests = new TreeSet<>(); private int currentFloor = 1; private boolean movingUp = true; @Override public void addRequest(int floor) { if (floor >= currentFloor) upRequests.add(floor); else downRequests.add(floor); } @Override public int nextTargetFloor() { if (movingUp) { Integer next = upRequests.ceiling(currentFloor); if (next != null) return next; Integer down = downRequests.floor(currentFloor); if (down == null) return currentFloor; movingUp = false; return down; } else { Integer next = downRequests.floor(currentFloor); if (next != null) return next; Integer up = upRequests.ceiling(currentFloor); if (up == null) return currentFloor; movingUp = true; return up; } } }这段代码的关键点是“请求按方向分流”:addRequest 时就把请求划分到 upRequests 和 downRequests 两个集合里,决策时只查当前方向。TreeSet 的 ceiling 和 floor 方法用来找“同方向最近楼层”,复杂度是 O(log n),比线性遍历快很多。参数 movingUp 表示电梯当前运动方向,反向条件不是“到最高层”,而是“当前方向没有请求了”,这正好体现 LOOK 与 SCAN 的差异。
实际运行时 LOOK 比 SCAN 更高效,因为它不需要空跑到楼顶或楼底,节省了无效行程。如果只有 50 层差距还不明显,到了 100 层以上的超高层,一来一回可能差出十几秒。
SCAN 的实现只需要把上面的 movingUp 反向条件改成 currentFloor == maxFloor 或 currentFloor == minFloor,其余逻辑相同。两个算法对比时,别忘了把“是否循环到底层”这个参数单独拎出来,这就是它们的本质区别。
3.3 加入“顺路捎带”逻辑:一个让平均等待时间下降 30% 的小改动
LOOK 本身已经能处理大部分场景,但真实电梯还有一个关键优化:顺路捎带(Reactive Pickup)。也就是说,电梯在去往某目标楼层的途中,如果经过一个楼层,发现那里有人要去同方向,就直接开门带上。
这个逻辑不是增加新的目标楼层,而是把“沿途的中间楼层请求”插到当前路线里。实现起来要注意性能:不能每次经过一楼都全量扫描请求列表,而是只检查当前楼层在当前方向上的请求标志位。
public class ElevatorCar { private final boolean[] upHallCalls; private final boolean[] downHallCalls; private int currentFloor; private int targetFloor; private boolean movingUp; public void onPassFloor(int floor) { if (movingUp && upHallCalls[floor]) { stopAndPickup(floor); upHallCalls[floor] = false; } if (!movingUp && downHallCalls[floor]) { stopAndPickup(floor); downHallCalls[floor] = false; } } }onPassFloor 方法的触发时机是电梯轿厢经过某一楼层时,由主循环在每帧推进中调用。关键在于 upHallCalls 和 downHallCalls 分别是两个布尔数组,下标就是楼层号,这样判断一个楼层是否有同方向请求只需要 O(1) 时间。stopAndPickup 会把当前请求加入轿厢内响应列表,同时把该楼层的方向按钮清掉,避免重复响应。
这个改动不改变电梯的“主方向决策”,只是沿途顺手处理请求。我在仿真实验里测试过,同样的 LOOK 算法加上顺路捎带,平均等待时间大约下降 25% 到 35%,具体取决于高峰期的请求密度。密集时段效果更明显,因为请求多,顺路的概率自然高;夜间隔壁,几乎区别不大。
实现时的一个坑是:方向标志不能被别的算法清掉。电梯到达当前目标楼层后,系统可能会重置所有按钮状态,导致顺路捎带丢失还未响应的请求。我一般把“楼层按钮状态”和“目标楼层”分开管理,目标楼层只是临时决策结果,按钮状态才代表真实需求。
注意:SCAN 和 LOOK 对比实验时,必须在相同配置下跑。否则载客量不同、开门时间不同,算法差异很容易被噪声掩盖。
4. 搭建可运行的仿真工程:核心类设计与事件循环
算法单独能跑是一回事,放进工程里能复现是另一回事。这一章从类的职责划分说起,最后落到事件循环和统计输出,让你改完代码能立刻看到效果。
4.1 核心类:Building、ElevatorCar、Passenger、Dispatcher
仿真工程中的类不宜过多,四个核心类足够撑起整个模拟:Building 管楼层和分区,ElevatorCar 管轿厢物理运动,Passenger 记录一个人的行程,Dispatcher 管调度决策。类之间是单向依赖,Dispatcher 只读 Building 状态,ElevatorCar 不知道外界请求细节。
Building 持有楼层高度、分区信息、轿厢列表。ElevatorCar 内部记录当前楼层、目标楼层、方向、乘客数量、运行状态(加速、匀速、减速、开门、关门),这些状态变化是离散的。Passenger 至少要有三个字段:出发楼层、目的楼层、出发时间。所有统计指标(等待时间、行程时间)都能由这三个字段推导出来。
Dispatcher 接口上一章已经定义过,关键是它不能写死某一个算法。我一般把四种算法放在同包下,通过配置类指定使用哪一种,这样跑实验时不需要改代码。以下是一个简单的类骨架:
public class Simulation { private final Building building; private final Dispatcher dispatcher; private final PriorityQueue<SimEvent> eventQueue; private double currentTime; public Simulation(Building building, Dispatcher dispatcher) { this.building = building; this.dispatcher = dispatcher; this.eventQueue = new PriorityQueue<>(Comparator.comparingDouble(e -> e.time)); this.currentTime = 0; } }这个骨架把“事件队列”作为全局时钟。仿真中所有需要时间推进的动作,都转换成 SimEvent 放进队列,比如 passengerArrival、elevatorArrive、doorClose。让事件自己排好序,比每帧计算所有对象的状态高效很多。
4.2 仿真时钟:用 PriorityQueue 推着整栋楼往前走
核心的事件循环只需要一个 while 语句,每一步取最早的事件,更新时间,处理事件。这个模式很像操作系统的任务调度,但不需要考虑抢占,因为所有事件都按时间排序稳态执行。
public void run() { while (!eventQueue.isEmpty()) { SimEvent event = eventQueue.poll(); currentTime = event.time; switch (event.type) { case PASSENGER_ARRIVE: handlePassengerArrive(event); break; case ELEVATOR_ARRIVE: handleElevatorArrive(event); break; case DOOR_CLOSE: handleDoorClose(event); break; } } }关键参数有三个:event.time 必须是一个绝对时间,不能是相对时间,否则堆排序会混乱。PASSENGER_ARRIVE 事件由乘客生成器在初始化时全部预置入队列,仿真开始前把所有乘客的未来到达时间一次性算好。ELEVATOR_ARRIVE 事件则由电梯状态机触发,电梯算出“按当前速度到目标楼层需要多少秒”,然后生成对应时间的到达事件。
电梯运动时间的计算不能想当然地直接用距离除以速度,因为加减速阶段不是匀速。常见做法是分三段算:加速到最高速度、匀速、减速到零。如果楼层距离太短,电梯可能根本加速不到最高速度就要减速,这时要用“匀加速减速全程”的公式。我通常封装一个 getTravelTime(distance) 方法,内部判断是否能达到满速,避免仿真结果在短楼层上失真。
4.3 输出统计:平均等待时间、最长等待时间、能耗估算怎么算
仿真结束不输出指标,等于白跑。最小统计集合至少包含平均等待时间、最长等待时间、平均行程时间、总运输人数、总能耗。等待时间是指乘客按下按钮到电梯开门之间的时长,行程时间是指进门到出门之间的时长。两者容易混淆,输出时要在指标名里写清楚。
写统计逻辑时,我习惯在 Passenger 对象里维护两个时间字段:arrivedAt 记录进入轿厢时间,pickedUpAt 记录被接上的时间。这样所有指标都能在仿真结束后统一遍历:
public class SimStats { private double totalWaitTime; private double maxWaitTime; private int transportedCount; public void recordPickup(Passenger p) { double wait = p.pickedUpAt - p.requestTime; totalWaitTime += wait; maxWaitTime = Math.max(maxWaitTime, wait); transportedCount++; } }能耗估算在仿真层面不需要做得多精细,常见做法是让电梯记录“移动层数×轿厢质量”的累计值,开门关门计固定能耗。双轿厢的轿厢质量直接翻倍,这样就能看出它为什么在低载客时段不划算。统计模块建议写成独立类,不混在算法里,否则跑对比实验时会在数据导出一层出各种问题。
提示:仿真结束时,再输出一个“服务完成率”字段。如果某些乘客等了几分钟还没上电梯,就直接标记为放弃,并在统计里单独报告。摩天大楼仿真里,放弃率超过 5%,调度策略几乎肯定有问题。
5. 避坑与排查:电梯仿真里常见的五个翻车点
写电梯仿真踩坑几乎是必然的,这一章直接列出我遇到过的四个典型问题,每条都给出现象、原因和解决方向,希望对你有帮助。
5.1 所有电梯都去响应同一个请求,结果谁都没动
现象:请求集中在 1 层大厅,6 台电梯全部跑到 1 层排队,结果某部电梯到 1 层开门后,其他 5 部还在路上,整体运能反而下降。
原因:调度器没有做“请求去重”,每台电梯看到同一个请求都把它当成自己的目标。
解决:在 Building 里加一个“已分配请求”集合,当一个请求已经被某一部电梯接受,其他电梯就忽略它。同时给每部电梯增加“当前方向上的响应列表”,只处理属于自己的楼层请求。
这个坑在做多电梯调度时几乎必然遇到,尤其是把单梯算法直接套多梯场景时。血的教训是:调度协议必须先于算法,决定好“谁来响应该请求”,再去谈“怎么移动”。
5.2 结果一次一个样:随机种子与冷启动效应
现象:同一份代码、同样的参数,连续跑三次仿真,平均等待时间分别是 22 秒、31 秒、19 秒,结论根本没法用。
原因:乘客到达是随机生成的,没有固定随机种子,导致每次仿真的乘客序列完全不同。再加上仿真开始时电梯分布在各楼层,初始状态本身有差异。
解决:把随机种子写进配置类并在仿真启动前固定,确保多次运行的乘客流完全一致。电梯初始楼层也全部配置为 1 层(大厅)而不是随机分布,这样对比算法时具有共同起点。另一种做法是第一小时作为预热,不统计数据,第二小时才开始记录,我一般两招都上。
随机种子不只是调试工具,它其实决定了仿真实验的“可复现性”。写论文或做方案汇报时,你总不希望同一个人连续跑两次结果是矛盾的。
5.3 行程时间到底包不包括开门?这是一个定义问题
现象:论文里写 “平均行程时间 35 秒”,别人复现时怎么算都是 28 秒,差距大得离谱。
原因:有的实现把“电梯开门 2.5 秒 + 乘客进出时间”算进行程时间,有的只算轿厢从 A 到 B 的移动时间。这不是 bug,是定义边界问题。
解决:在统计代码里写死定义。我采用业界常规定义:等待时间 = 按下按钮 → 轿厢开门;行程时间 = 轿厢关门 → 目的层开门;总行程时间 = 按下按钮 → 目的层开门。并且在输出指标名加后缀注明,比如 “avgTripTime_includeDoor = 34.7”,这样谁看了都不会误解。
这类“定义之争”会在你第一次跑对比实验时暴露。解决思路是:把衍生的所有指标都用明确方法命名,不要只写 tripTime 这种模糊字段。
5.4 性能翻车:几百个乘客请求就把 PriorityQueue 拖垮
现象:仿真规模一上千人,程序越跑越慢。每处理一个事件就要对 PriorityQueue 做一次插入和删除,复杂度变成 O(n log n),高峰期光事件调度就占掉 80% CPU。
原因:事件循环里频繁生成新事件,且每次插入都重新排序;调度器在计算下一次目标楼层时,还用了 List 的线性遍历,双重拖慢。
解决:第一,事件队列中的事件不要包含大对象,只放事件类型、时间、楼层、目标乘客 ID,乘客对象放哈希表里,按 ID 读取,避免堆内存膨胀。第二,调度器内部使用 TreeSet 而不是 List,楼层请求的查找复杂度从 O(n) 降到 O(log n)。第三,事件间隔如果小于 0.1 秒,就合并成同一帧处理,减少插入次数。
性能优化放在仿真的后期做,前期功能正确性优先。但如果你计划跑 100 轮以上的批量实验,这一步绝对不能省,否则每轮十分钟,实验计划直接泡汤。
6. 用统计眼光评估调度效果:多轮仿真与置信区间
算法好坏不是跑一次看数字,得用统计眼光来判断。这一章讲一个我坚持了很久的习惯:同一组参数至少跑 30 轮,用分布说话。
6.1 同一参数跑 30 轮,而不是跑 1 轮
单轮仿真只是 “一次抽样”,随机性带来的波动会让结论变得相当不可靠。我的做法是写一个 Runner 类,循环执行 30 次仿真,每次使用不同的随机种子,输出一组指标:初始时间、结束时间、总请求数、平均等待时间、最长等待时间、平均行程时间、服务完成率。然后计算这 30 轮的平均值和标准差。
如果两个算法的平均值只差 2 秒,但标准差都超过 5 秒,那基本可以判断这个差异没有统计意义。这时候不要急着改代码,先增加轮数,或者调大停车期的请求量。
一个常用的判断标准是:平均值差异大于两倍标准差,才认为两个算法有可感知的区别。我一般在代码里直接算出这个置信区间,输出对比表格,而不是靠肉眼盯平均数。
6.2 一张 30 轮均值表,胜过十张单次运行图
以我之前做的一组模拟实验为例,固定 50 层楼、高峰 1800 人/小时、每人进出 0.8 秒,四种算法分别跑 30 轮,指标取均值后得到的大致结果如下:
| 算法 | 平均等待时间(秒) | 平均行程时间(秒) | 最长等待(秒) | 服务完成率 |
|---|---|---|---|---|
| FCFS | 52.4 | 76.8 | 134 | 96.2% |
| SSTF | 43.1 | 70.2 | 118 | 97.0% |
| SCAN | 31.8 | 61.5 | 89 | 98.5% |
| LOOK | 27.6 | 58.3 | 82 | 99.1% |
| LOOK+捎带 | 19.4 | 49.7 | 64 | 99.6% |
这张表说明几个问题:FCFS 之所以没有大幅度翻车,是因为我的测试场景里每层都有一定客流量,请求分布没那么极端;SSTF 虽然平均等待不错,但最长等待时间接近 118 秒,说明确有少数乘客被饿到;SCAN 和 LOOK 的差距来自 LOOK 不空跑,省下无效行程的时间;LOOK+捎带则是直接利用高峰期密集请求,把闲跑的时间进一步压缩,效果最明显。
跑完 30 轮后,我还会把每轮算法的排名顺序打印出来,看其是否一致。如果 30 轮结果里,LOOK 只赢了 21 次,剩下 9 次被 SCAN 反超,那就说明场景参数不够稳定,应该加长仿真时长或增加乘客量,把信噪比拉高。这个步骤看起来繁琐,却是区分“有效优化”和“调参玄学”的可靠办法。
从那以后我每次换算法、改参数,都先固定随机种子跑一遍,再放开种子跑 30 轮看分布。不看单轮数字,只看均值和标准差,希望帮到你。
本文还有配套的精品资源,点击获取