BUAA_OO_第二单元总结
BUAA_OO_第二单元总结
0. 概论
第二单元的三次作业都是有关电梯调度的,主要区别是:第一次限定乘客请求只能是在一个楼座上下移动;第二次引入了横向电梯,允许乘客请求的横向移动和纵向移动,但不能斜向移动;第三次作业对乘客的请求做了强化,允许乘客斜向移动,即出发楼座和终点楼座不同,且出发楼层和重点楼层不同,且允许自定义设置电梯的速度、停靠楼座(横向)、最大容纳人数。这就使得一些乘客请求不能一次完成,需要安排换乘。在三次作业中我的电梯策略均为ALS策略。
1. 架构设计
第一次作业我的架构如下图所示:

- 分为主线程、输入线程、调度线程、电梯线程四种线程。
- 线程的对资源的互斥访问主要通过RequestQueue队列类内部synchronized的方法实现,简化了外部线程共享访问时的互斥实现。
- 采用ALS基准策略,定义了主请求mainRequest。电梯有人时,主请求是最先进入电梯的请求;电梯无人时,主请求是等待队列中最早的请求。在电梯运行的过程中,若符合捎带条件,则捎带,在此过程中主请求也会发生变化。
第二次作业的架构:

- 新增横向电梯线程。
- 实现了随输入的电梯增加请求,动态添加电梯的机制。为防止运行在同一楼座或同一楼层的电梯产生竞争现象,引入了电梯预约乘客的机制。具体来说,就是当电梯为空时,扫描电梯队列,获取到最早的一个请求,记为预定请求(reservedRequest),同时记为主请求,然后从队列中把预定请求删除,防止另一个电梯拉该乘客,造成混乱。
- 此时,预定请求只存在于该电梯中,不存在于其他电梯或共享队列中。如果电梯前往接该请求的过程中捎带了几个乘客,其中最早进电梯的成为主请求,如果在路上乘客都下完了,主请求就应当设为预定请求。换言之,电梯有人时,主请求是电梯里的人,电梯无人时,主请求必定是预定请求。
第三次作业的架构

- 引入控制器类,作为全局调度的中介,同时保留了第二次作业的调度器-电梯线程的架构,实现了代码的重用。
- 控制器类采用了一种类似流水线的架构。具体为:输入线程将乘客请求投送到控制器类中,然后控制器根据横向电梯的排布将请求分拆为1~3个只包含横向或者纵向的请求,保存在nextReqMap中,表示此请求下一阶段的请求是什么,若key对应的value为NULL,表示流水线到达最后一个阶段,已结束。然后控制器将分拆后的第一个请求投送到waitQueue,按第二次的策略调度;若电梯完成了请求,会调用Controller的finishRequest,表示已经执行完某个请求,Controller会查询nextReqMap将下一阶段的请求投送到waitQueue中。这样,既保证了分拆后的请求在流水线上有序执行,又有效重用了原来的调度代码。
- 加入横向电梯全局管理类,为调度提供决策上的辅助。
时序图:
2. 同步块和锁的设置
2.1 RequestQueue
RequestQueue是我的程序中用来管理请求队列的一个共享对象,内部实现了互斥访问的方法。
对于请求在输入线程InputThread和调度线程Schedule的传送,使用waitQueue进行交互。
对于请求在调度线程Schedule和电梯线程ElevatorThread之间的传送,我为每个楼层(横向)和每个楼座(纵向)都设置了队列,通过分配在各个位置上的队列进行交互。
在请求的传送和查询过程中,主要的同步操作在RequestQueue对象内完成,RequestQueue对外暴露的每一个访问的方法都加synchronized,保证同一时刻只有一个线程能够访问。对于需要遍历RequestQueue内部队列找出符合条件的请求的操作,在具体的线程中实现,每次访问时,都对RequestQueue对象加锁(synchronized)。
具体可看4.1.1的代码。
2.2 Controller类
在内部实现了互斥的请求添加与请求完成操作,具体的方案是为每一个方法都加锁,保证同一时刻只有一个线程能够得到访问权。
Controller类:
public class Controller {
private static final Controller INSTANCE = new Controller();
private HashMap<PersonRequest, PersonRequest> nextReqMap = new HashMap<>();
private RequestQueue waitQueue;
private boolean inputEnd = false;
private Controller() {
}
public synchronized void setInputEnd() {
this.inputEnd = true;
// 拆分完的请求全部分派好后,选择标记waitQueue为结束
// System.out.println(nextReqMap.size());
if (nextReqMap.size() == 0) {
waitQueue.setEnd();
}
}
public static Controller getInstance() {
return INSTANCE;
}
public void setWaitQueue(RequestQueue waitQueue) {
this.waitQueue = waitQueue;
}
// 接收所有的请求并进行拆分
public synchronized void addRequest(PersonRequest req) {
/* 拆分请求, 并把拆分好的第一个请求放入waitQueue */
if (req.getFromBuilding() == req.getToBuilding()) {
// 单独的纵向电梯
nextReqMap.put(req, null);
waitQueue.addRequest(req);
}
else {
int floor = ElevatorManage.searchNearestFloor(req);
// 只用横座电梯即可运输
if (req.getFromFloor() == req.getToFloor() && floor == req.getFromFloor()) {
nextReqMap.put(req, null);
// 注意写回的时间
waitQueue.addRequest(req);
}
// 分拆为3个请求
else {
/* 1. 纵向电梯:可以没有 */
PersonRequest request1;
if (floor != req.getFromFloor()) {
request1 = new PersonRequest(req.getFromFloor(), floor,
req.getFromBuilding(), req.getFromBuilding(), req.getPersonId());
}
else {
request1 = null;
}
/* 2. 横向电梯 :一定有 */
PersonRequest request2 = new PersonRequest(floor, floor,
req.getFromBuilding(), req.getToBuilding(), req.getPersonId());
if (request1 != null) {
nextReqMap.put(request1, request2);
}
/* 3. 纵向电梯 :可以没有 */
if (floor == req.getToFloor()) {
// 第三步不需要再坐电梯了
nextReqMap.put(request2, null);
}
else {
// 第三步还需要坐一下电梯
PersonRequest request3 = new PersonRequest(floor, req.getToFloor(),
req.getToBuilding(), req.getToBuilding(), req.getPersonId());
nextReqMap.put(request2, request3);
nextReqMap.put(request3, null);
}
waitQueue.addRequest(request1 != null ? request1 : request2);
}
}
}
public synchronized void finishRequest(PersonRequest request) {
PersonRequest nextReq = nextReqMap.get(request);
if (nextReq != null) {
waitQueue.addRequest(nextReq);
}
nextReqMap.remove(request);
// System.out.println(inputEnd);
// 已经空了
if (nextReqMap.size() == 0 && inputEnd) {
waitQueue.setEnd();
}
}
}
3. 调度器的设计
在第二次作业中,我采用电梯线程自由竞争的策略,调度器Schedule在分派请求的时候不考虑电梯的忙闲,直接把请求推送到对应位置的队列中,由共享这一队列的电梯线程自由竞争。事实证明自由竞争策略实现简单,且能达到较为出色的运送性能。因为在一个电梯处于忙碌状态,另一个处于闲置状态时,若有新请求,因为此时电梯1忙于运输,不能捎带,电梯2恰好能赶上并捎带。这样的安排有效平衡了电梯间的负载,提升了效率。
在第三次作业中,对于乘客请求的分拆,我采用了如下的分拆策略:
预设最多换乘3次,若安排更多次换乘,则调度策略会比较复杂,难于分析。对于起始座和目标座相同的请求,坐纵向电梯一次就能到位;对于起始座和目标座不同的请求,我的分拆策略是选取离当前层最近且沿着纵向方向(fromFloor->toFloor方向)的层作为中转层,先坐纵向电梯到中转层,再乘坐横向电梯到目标座,最后乘坐纵向电梯到目标层。在分派请求时,先分派第一阶段的请求,待第一阶段的请求完成并反馈时,分派第二阶段的请求,直到所有阶段请求都被分派并完成。
4. BUG分析
4.1 轮询
轮询的bug主要出现在电梯竞争资源,在得不到资源的时候反复等待的状况。我总共发现了两个轮询bug。
4.1.1 RequestQueue轮询
RequestQueue队列代码:
public class RequestQueue {
private ArrayList<PersonRequest> requestQueue;
private boolean isEnd;
public RequestQueue() {
this.requestQueue = new ArrayList<>();
this.isEnd = false;
}
public synchronized void addRequest(PersonRequest request) {
requestQueue.add(request);
notifyAll();
}
public synchronized ArrayList<PersonRequest> getRequestQueue() {
return requestQueue;
}
public synchronized PersonRequest getOneRequest() {
if (!this.isEnd && requestQueue.isEmpty()) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (requestQueue.isEmpty()) {
return null;
}
PersonRequest request = requestQueue.get(0);
requestQueue.remove(0);
notifyAll();
return request;
}
public synchronized PersonRequest getTopRequest() {
if (requestQueue.isEmpty()) {
return null;
}
else {
PersonRequest request = requestQueue.get(0);
requestQueue.remove(0);
return request;
}
}
// 每次更新状态都要提醒等待者
public synchronized void setEnd() {
notifyAll();
this.isEnd = true;
}
public synchronized boolean isEmpty() {
notifyAll();
return requestQueue.isEmpty();
}
public synchronized boolean isEnd() {
notifyAll();
return isEnd;
}
}
判定等待条件的代码:
synchronized (horizontalQueue) {
if (horizontalQueue.isEmpty() && reservedRequest == null && !running) {
if (horizontalQueue.isEnd()) {
return;
} else if (!horizontalQueue.isEnd()) {
try {
horizontalQueue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
在此版本的RequestQueue中,我们在isEmpty和isEnd两个方法中都添加了notifyAll, 而实际上这两个方法并不需要添加notifyAll。因为在代码中我们等待的情况只有队列为空或者队列中的元素均不满足要求,而访问这两个函数并不会使情况改观。而且,如果仔细分析的话,若有两个电梯线程都依赖于这个队列,那么在等待的过程中会发生尽管队列为空,但是互相唤醒的问题。

从上图可以看出,实际上,两个线程一直处在一种 A判定等待(唤醒B)->A等待->B判定等待(唤醒A)->B等待->A判定等待(唤醒B)->... 的循环中,实际上时间片被两个线程轮流占据,根本没有等待。我们想要达到的效果是两个线程一起等待,共同等待输入方的唤醒。
解决方法:删除isEmpty和isEnd的notifyAll。
4.1.2 不恰当的等待条件轮询
问题描述: 在第三次作业中,引入了横座电梯停靠机制。我的横座电梯还沿用了之前的等待条件,即判断队列为空以及其他条件也满足就等待。这样的判断导致了我强测和互测出现了很多ctle的bug。
解决方案: 横座电梯能从队列中接乘客当且仅当队列中有满足停靠要求的乘客请求,所以队列为空的等待条件应当换成队列中没有满足停靠要求的乘客请求。
4.2 纵向电梯在一层空转
问题描述:当电梯上完人达到轿厢最大人数限制后,此时就在该层的主请求上不来(之前默认到达了主请求所在的楼层就能接上主请求的),而电梯仍然把主请求设为上不来的那个人,因此电梯一直在该层空转。
解决方法:电梯外的人上完电梯后,检查电梯是否为空,若不为空,将主请求设为电梯内最先上电梯的那个人。
5. 评测机的构造
在三次作业中,我构建了 数据生成器、自动化运行工具、结果评测器 三个评测部件,用于对自己的程序进行检测和Hack他人的代码。
其中,在结果评测器中,我把电梯的运行看作一个有限状态机,分为OPEN-CLOSE-ARRIVE三个状态,只有在OPEN和CLOSE之间才能上下乘客,同时监测其中人员的变化,检查有无不合法情况。若在检测过程中发现问题,即时抛出异常,便于查错。结果评测器中涵盖的异常种类如下:
- 输出序列时间不合法
- 输出序列时间不递增
- 所请求的电梯不存在
- 当前楼座不是停靠楼座
- 此时不应开门
- 开关门时间过短
- 此时不应关门
- 电梯非横向或者纵向移动
- 电梯横纵向跳层
- 爬层时间过短
- 人未全部运输
- ......
6. 心得
- 多线程的调试明显比单线程的调试更具有挑战性。多线程不仅涉及到了在一个线程内代码的相互关系,还涉及到了多个进程之间的同步与交互问题,后者更复杂,更难于调试。
- 轮询的问题不只是在不用wait-notifyAll的情况下才会发生,在一些程序逻辑设计不好的情况下也会发生。想要查出轮询的bug需要仔细检查自己的代码逻辑,确保在多线程的环境下能够表现出正确的行为。
- 写自动评测非常重要!!! 在第一单元的后两次作业中,我没有写自动评测,导致互测和强测都出现了很多bug;而第二单元我几乎每次作业都针对性地写了自动评测工具,所以强测和互测几乎没有检出什么bug,仅有的ctle bug是因为我评测机没有检测CPU时间而导致没有发现的。而且在写数据正确性检测时,我能发现很多程序设计时没有考虑到的细节问题,实际上是对题目设计和自身代码的一次很好的OverView。
- 因为在写三次作业的时候时间略紧,我没有探索电梯运输的优化和其他调度策略,也算是一个遗憾吧。之后的单元有机会的话我想跳出自己的框架,去尝试探索更多的可能性。
- 在写博客时花了很多时间画UML类图、UML时序图,发现画这些图对于理清自己的设计逻辑和运行逻辑很有帮助,眼中不只有局部的代码了,而是看到了多线程程序的整体行为和整体逻辑。相信以后回来看的时候也会有所启发。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享一个免费、快速、无限量使用的满血 DeepSeek R1 模型,支持深度思考和联网搜索!
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· ollama系列1:轻松3步本地部署deepseek,普通电脑可用
· 按钮权限的设计及实现
· 25岁的心里话