OO第二单元总结
OO第二单元总结
就像在电梯里一样,你在电梯里跟人说着话,一点没觉得有什么奇怪,一边说话一边升上了一层、十层、二十一层,城市落在你脚下,你进电梯时开始说的话现在说完了,开头和结尾的几个词之间隔了五十二层楼。我开始吹萨克斯风时就觉得自己进了一个电梯,不过是时间的电梯,如果可以这样说的话。
——科塔萨尔《追寻者》
1 第一次作业
本单元的第一次作业是模拟单部多线程电梯的运行。
1.1 大致思路
可以说,第一次作业是我在三次作业中耗费时间最长的,因为刚接触多线程,对多线程并不是非常的熟悉;再加之有了第一单元的经验,在第一次作业中我尽量尝试去设计一个能够沿用的架构,以避免接下来几次作业的重构。但在当时周六一整个白天的尝试中,我既没有想出一个合适的设计,也感觉没有能力去将想象中的设计用代码表现出来,更不用说在完成代码后进行多线程的 debug 了。最终,通过和同学的交流以及参考课上实验的代码,写出了一个与指导书中的提示稍微不同的设计——将电梯视为纯粹的硬件,由调度器线程直接对电梯进行控制;将各楼层的候乘乘客作为共享对象,由调度器线程和输入线程进行共享。
1.2 调度设计
类图如下所示。

由于接下来的两次作业我的大体设计架构均未发生变化,因此在此处将调度设计完整阐述一遍,接下来两次作业不再赘述,仅说明一些小的改动(主要是同步的设置以及上锁的问题)。
WaitQueue 为一层楼中候乘乘客的队列,将所有层的 WaitQueue 对象组合成一个 ArrayList 对象并封装成为一个新的类 FloorQueue,由于 ArrayList 类是线程不安全的,这样的设计有利于保证共享对象的访问安全。设置了一个输入线程 Input,通过调用课程组提供的接口向 FloorQueue 对象中相应楼层的 WaitQueue 对象添加读入的乘客请求。
Elevator 类用于存储电梯的各项属性,如在电梯中的乘客,电梯的上下移动、到达某一层楼、开关门、乘客的进出等操作,以及寻找电梯中乘客的下一个到达位置。在我的设计中,电梯是不作为一个线程的,它的各种操作都是受调度器控制的(这符合我的“软件控制硬件”思想),因此电梯对象不会访问共享对象(在本设计中为候乘的乘客队列)。
Scheduler 类另开一节进行阐述。
因此,总的调度逻辑是,在 main 主线程新开两个线程 input 以及 scheduler 线程,一个负责请求的读入,另一个负责对请求进行分析并控制电梯完成乘客的运输。乘客的请求都被放在 floorQueue 对象中,由两个线程进行共享。main 运行逻辑如下所示。

1.3 调度器的设计与交互问题
1.3.1 调度器设计
(由于三次作业中调度器的设置均未发生太大变化,因此仅在该处进行详细说明,后续不再赘述)
接下来就是重头戏 Scheduler 调度器类了,我视这个类为一个“软件”,它对电梯这个“硬件”进行控制,使其完成各种操作。这个类接受到达模式、候乘乘客队列以及一个电梯对象,并根据到达模式的不同调用不同类型的调度算法对电梯进行控制。具体如下:
Random模式:找到当前离电梯最近的有乘客的一层楼,电梯移向该层,并接收一个乘客;根据这个乘客的移动方向,找到这个方向上最近的一层楼候乘队列的第一位乘客,其具有相同的移动方向(如果有的话),并根据这两个(或一个)乘客的起始楼层与目标楼层的相对位置进行电梯的移动、接收乘客、释放乘客的操作。显然,这个算法看起来就非常的愚蠢,因为在第一次作业中,电梯的最大载客量为 6 人,但这个算法每次最多只能运载 2 人,这样非常容易导致电梯的总运行时间超过第一次作业的最大限时(在强测中确实有一些点超时了)。Morning模式: 电梯在一楼接收 MIN(电梯最大载客量 n,目前一楼候乘乘客数量 m) 个乘客,并不断移动至电梯内乘客最先到达的目标楼层进行乘客的释放,直到电梯为空。在输入结束之前重复这个操作。Night模式:搜索并让电梯移向当前有乘客的最高楼层,接收乘客,然后在电梯未满的情况下,不断寻找并移动到该层以下的最近的有乘客的楼层并接收乘客,最终回到一楼并释放乘客。在输入结束之前重复这个操作。
1.3.2 调度器与程序中的线程进行交互
在架构设计中,只有两个线程:Scheduler 线程和 Input 线程,这两个线程都是由 main 线程调用的,而调度器线程与输入线程进行交互的方式就是接收同一个候乘乘客队列 floorQueue ,并共享候乘乘客队列:input 线程向 floorQueue 中写入乘客请求,scheduler 线程读取 floorQueue 并取出乘客请求,也就是生产者-消费者模式。这也是两个线程之间唯一的交互方式。而在交互过程中,又涉及到了共享对象的上锁与同步问题,这一部分将另开一节进行阐述。
1.4 同步块的设置和锁的选择
前面已经提到,在设计中候乘乘客队列作为输入线程与调度器线程的共享对象,需要对其上锁;在调度类线程中一些方法需要对候乘乘客队列进行访问,因此也需要在调度器的方法中设置同步块,以保证在任何一个时刻只有一个线程能够对候乘队列进行访问。
在第一次作业中,由于我还不是太熟悉 wait() 与 notify()/notityAll() 的使用方法,因此在同步块的设置和锁的选择中我没有在方法中进行 synchronized 同步块的设置,而是直接在所有存在对候乘队列进行操作(无论是读还是写)的方法前加上 synchronized 声明,如 public synchronized void elevatorAdd(WaitQueue w)。这样既可以保证线程间的互斥访问,也可以避免由于 wait 的使用不当造成的死锁问题。
1.5 bug 的出现以及解决
在第一次作业的强测中,由于 Random 模式下调度算法过于拉跨,因此 Random 模式下的一些点运行时间过长,超出了限制的最大时间。不过并没有出现诸如死锁、输出错误等其他错误。因此在保证正确性的前提下对 Random 模式下调度算法进行优化就是这次 bug 修复的任务。
在经历了几天的思索后,我对 Random 调度算法进行了一些调整:电梯接收的乘客可以为多个,但乘客的运动方向必须相同;在电梯移动的过程中每移动一层就检查是否有新的同方向请求出现,若出现则移动至该层进行乘客的接收(也是多个乘客)。最终优化的效果还不错,至少我在后两次作业中都不再需要对调度算法进行优化了。
在互测中没有出现 bug,也没有发现他人的 bug。
2 第二次作业
本单元的第二次作业是模拟多部同型号电梯的运行,并要求能够响应输入数据的请求,动态加入电梯。
2.1 架构的拓展以及优化
架构设计并没有发生变化,仍然是输入线程与调度线程之间的交互,不同的是,在本次作业中为多个调度器线程,因此需要在同步块的设置以及共享对象的上锁上做一定的优化,以及处理在读入电梯请求后如何动态加入电梯、候乘乘客队列的分配、多部电梯的调度等问题。
在多部电梯的调度上,采取集中式调度,也就是所有的电梯共享同一个候乘队列,并对乘客请求进行竞争。这样的调度方法其实也符合我一开始所期望的架构,集中式分配也可以让乘客尽快被电梯接收,有助于性能的提升。(毕竟也不考虑电梯运行所消耗的电量)
首先,为了实现多部电梯的产生以及实现电梯的动态加入(在我的架构中,电梯即是调度器),我将调度器线程的生成位置从 main 线程更改到了 input 线程中来。这样可以将所有可能的电梯(调度器)的生成都封装到同一个类中。初始的三个调度器线程在 input 线程的开始就被初始化并开始运行,之后的新调度器则根据读入的指令动态生成并开始运行,如下所示:
ElevatorRequest eq = (ElevatorRequest) request;
Elevator e = new Elevator(Integer.parseInt(eq.getElevatorId()));
schedulers.add(new Scheduler(arrivePattern, floorQueue, e));
schedulers.get(schedulers.size() - 1).start();
其次是共享对象的互斥访问及同步问题。这一部分另开一节。
最后是对调度算法进行优化以及对一些小的细节进行增加与修改。调度算法的优化在第一次作业的 bug 修复中已经完成,那么也只是需要对电梯类内部加入 id 的成员以及在输出中加上 id 编号即可。
经过修改和优化后类图如下所示:

2.2 多调度器线程/输入线程之间的交互
由于在架构中,共享对象有且始终只有一个,那就是候乘乘客队列。调度器线程与输入线程之间的交互仍然是生产者-消费者模式,不同的是这次作业中存在多个消费者,它们之间虽然不存在直接意义上的交互,但它们需要访问同一个候乘队列,因此多调度器线程对候乘队列的访问必须是互斥的,也就是不能让调度器之间产生任何交互。
2.3 同步块的设置和锁的选择
在课下的调试过程中,我意识到在方法前加上 synchronized 声明已经不再适用,因为可能有多个线程同时想要对 floorQueue 进行访问;解决的方法是在 Scheduler 类的一些方法中加上 synchronized 同步块,直接将方法内的所有内容加入同步块内,这样可以保证在同一时间有且仅有一个 Scheduler 类对象与 floorQueue 对象进行交互,其中一个方法的代码如下所示:
public void elevatorAdd(int floor) {
synchronized (floorQueue) {
if (!elevator.isFull() && !floorQueue.getWaitqueue(floor).noWaiting()) {
elevator.open();
WaitQueue w = floorQueue.getWaitqueue(floor);
for (int i = 0; i < w.getNum() && !elevator.isFull(); i++) {
elevator.inPerson(w.remove(i));
i--;
}
elevator.close();
}
}
}
同时,针对 ArrayList 类线程不安全的问题,在 WaitQueue 类中对 ArrayList 的操作全部用新的带有 synchronized 声明的方法实现,如 get() 和 remove() 方法,如下所示:
public synchronized PersonRequest get(int i) {
return requests.get(i);
}
public synchronized PersonRequest remove(int i) {
return requests.remove(i);
}
通过直接对 WaitQueue 对象直接进行操作,而非从 WaitQueue 对象中取出其中的 ArrayList 成员再对其进行操作,可以提高线程的安全性,保证共享对象的互斥访问。
3 第三次作业
本单元的第三次作业要求模拟多部不同型号电梯的运行(移动速度、限载人数、可停靠楼层的不同)。
3.1 针对作业的拓展
由于第三次作业出现了可停靠楼层不同的电梯,也就出现了是继续使用不换乘的集中式调度还是可实现换乘的分布式调度的问题;由于我不想对原来的架构进行重构,因此继续保留不换乘的集中式调度,只有乘客的起始楼层与目标楼层都满足某类型电梯的可停靠楼层要求,该乘客才能进入该类型电梯。这样做使得完成拓展要求所用的时间也少了许多。
首先是对电梯类进行修改,使其满足能够生成不同型号电梯的要求。我没有使用继承的方法,而是直接在电梯类的构造方法中新增了一个参数 type,根据类型的不同对电梯的各种参数进行不同的赋值。同时在电梯类新写了一些方法,用于判断电梯当前所在楼层是否属于可停靠楼层。
其次是对 Scheduler 类进行一定的修改,电梯的搜索以及载客都是在电梯的可停靠楼层中进行的。实现的方法只需在判断条件中与上一个电梯能否在此处停靠的判断条件。在乘客进入电梯前,也会对乘客的目标楼层进行判断,只有目标楼层属于电梯的可停靠楼层,乘客才能进入电梯。多调度器线程/输入线程的交互与第二次作业完全一致,因此不再赘述。
3.2 同步块的设置和锁的选择
在 Schduler 类中一些与 FloorQueue 对象进行交互的方法仍然与第二次作业的做法相同。
避免轮询
在第三次作业的中测中,我发现对于 Random 模式下的数据,总会出现 CPU 超时的情况。经过思考后,我认为是调度器线程在 Random 模式下在没有适合乘客的情况下仍然对 floorQueue 候乘队列进行轮询,导致 CPU 使用过多。因此,对 FloorQueue 类中的 noWaiting 方法进行了修改,对于给定的电梯进行查询,即使电梯的某个可停靠楼层有待乘乘客,但若其目标楼层不在电梯的可停靠楼层内,这个乘客便不会被电梯识别到。这样一来就把线程占用 CPU 资源却不运行的问题给解决了。
3.3 架构设计的可拓展性
- 类图

- UML协作图

此处 FloorQueue 并不是一个线程,仅仅为了展示其与其他线程之间的交互情况。
对比三次作业的类图可以看出,三次作业所用到的类是完全一致的,不同的只是根据作业要求的不同将调用顺序作了一些修改。每一个类都有其负责的特定功能,在拓展时只需根据要求不同对不同的类中的方法或属性做出一些修改即可,而不必对原有的架构进行大幅修改,因此有一定的可拓展性。但在第三次作业中,我原本想设置一个工厂类,但考虑到电梯种类较少,因此最后便不了了之。如果要增加程序的可拓展性,那么在应对越来越多的电梯种类的话,建立起一个工厂类进行不同类型电梯的生成是非常有必要的。
4 bug 分析
4.1 自己程序的 bug
在第一次作业的强测中因算法性能过差问题出现了 bug,已经在前文中说明。在第二三次作业的强测和互测中均未出现 bug。主要原因是在第二三次作业不用思考架构设计的问题,只需要对作业的要求进行一定的拓展,因此检查与 debug 的时间多了许多,自然就不容易出现 bug 了。
4.2 互测找 bug
在三次作业中我都没有找到他人程序中的 bug,一是因为不太了解自动评测要如何实现,二是自己完成电梯作业后精疲力尽,没有精力再去 hack 别人。虽然没有去 hack 别人,但我也下载了一些他人的代码进行阅读和学习,帮助我更好地完成电梯作业。
5 心得体会
5.1 线程安全
对我来说,这三次作业在除架构设计外最大的难点就是保证共享对象不被多个线程同时访问,后两次作业的重心也是放在了同步块的设置以及对象的上锁上。同时,也要注意避免死锁的情况,在这一方面我掌握的不是很好,因此没敢使用 wait/notify 写法,只能对同步块进行认真研究,并确定线程的运行、休眠等条件。
为了解决 java 的一些内部类线程不安全的问题(如 ArrayList 类),我在使用了这些内部类的方法中都人为加上了同步声明,这也再次让我体会了课上老师所说的,要使一个类实现线程安全与同步,这个类所管理的对象也必须实现同步。
总的来说,这三次作业让我在线程安全上有了更加深刻的体会,自己也在多线程的调试中发现了自己程序中的线程安全问题,并尝试去分析和解决;在对作业进行拓展时也全方面地去分析程序中的哪个部分需要进行同步,这对我的多线程编程能力的提高有了很大的帮助。
5.2 层次化设计
所谓的层次化设计,就是不同的类相互之间进行调用以实现不同的功能。经历了第一单元后,我在层次化设计上有了更良好的认识,在第一次作业就主动有意识地去构造一个可以沿用的架构设计;同时在写代码之前,先思考哪些类要完成怎样的功能,不同的类之间调用关系是怎样的,这个类能否进行多种情况的拓展;这些问题能够帮助我更快地完成设计,并提升了写代码的速度。这种思想也逐渐渗透到我的其他编程以及学习中去,个人认为收获是巨大的。

浙公网安备 33010602011771号