面向对象程序设计第二单元总结
“清明时节雨纷纷,电梯行人欲断魂”。从清明到五一,电梯系列问题陪伴我走过了一个月。在这一个月的学习中,我初步学习掌握了多线程编程的技巧,也在不断的试错中认识到多线程编程可能出现的各种问题。无论如何,顺利度过电梯月还是一件值得欣喜的事。在此,谨以本文作为电梯系列的收尾,也借此机会总结回顾过去一个月的工作并和大家交流一下对多线程学习的体会。
本文正文分为四个部分,分别介绍在本单元中我对同步块的设置和锁的选择、调度器设计及线程协同的架构模式、bug分析及hack策略以及心得体会。
二、同步块的设置和锁的选择
在本单元的设计中,我在synchronized
锁和读写锁之间做过很长时间的权衡,最后还是全程使用synchronized
锁完成了作业。
2.1 第一次作业
第一次作业简单描述就是一个生产者负责产生乘客请求、一个缓冲区负责作为临界区、还有一个电梯消费者来处理请求。
由于对多线程还不够熟悉,我暂且使用了安全实用的synchronized
锁,为了便于管理,我将所有的锁都安在了生产者—消费者模型
中托盘(缓冲区)对象的方法上。同时对 notifyall
的使用比较随意,几乎所有加锁的方法都加了一句notifyall
,这一点在第一单元单部电梯的情况下不会有大锅,但在之后的迭代中便会暴露出问题。(这一点会在第四部分详细说明)
public synchronized void setEnd(boolean end);
public synchronized boolean isEnd();
public synchronized boolean isEmpty();
public synchronized void put(Schedule other);
public synchronized Schedule get();
//以上五个方法都有notifyall
2.2 第二次作业
第二次作业在第一次作业的基础上新增加了同一缓冲区面临多部电梯、电梯种类新增横向电梯等需求。由于可能存在多个读者同时读取缓冲区,因此在本次作业中尝试读写锁是一个很自然的想法。
我在完成第二次作业需求的迭代开发后也尝试了读写锁,但是效果和 synchronized
区别不大,分析原因可能是在本作业中读操作的数量并没有比写操作的数量多太多,因此没有体现出读写锁高效的优势。最后,考虑到 synchronized
锁虽然没有读写锁灵活轻量,但是更加简洁安全,我还是选择了synchronized
。
2.3 第三次作业
第三次作业在第二次作业的基础上新增加了电梯属性定制化、乘客请求存在换乘等需求。由于第三次作业新增的需求并没有涉及到对加锁逻辑的修改,因此我还是沿用了前两次作业的 synchronized
锁。
总的来说,在本单元的学习中,我在同步块设置方面主要集中在共享对象的缓冲区的方法上,而对锁的选择主要尝试了 synchronized
和读写锁,最后真正应用到作业中的是 synchronized
。
三、调度器设计及线程协同的架构模式
在本单元的设计中,我主要采用的是生产者—消费者模型
。在前两次作业中并没有设置调度器,所有电梯直接和托盘(缓冲区)进行共享对象的交互。到了第三次作业,我参考了课上实验中的 流水线模型
,新增加了RequestCounter
类用于判断任务的结束、 Controller
类用于实现乘客请求的调度。以下是我三次作业的协作图 (sequence diagram)
,其中红色部分为第一次作业,橙色部分为第二次作业迭代开发新加部分,绿色部分为第三次作业迭代开发新加部分。
3.1 第一次作业
3.1.1 设计架构
第一次作业我采用了生产者——消费者
的模型。比较有特点有以下两点:
-
共享对象的定义形式:HashMap<Integer, Passengers>,其中Key是楼层,Value是一个存储乘客的对象,这个乘客对象内部存储了两个乘客集合,分别是上行集合和下行集合。这样存储的很大原因是希望在电梯做决策时能够减少遍历,不过在数据量小的情况下和用
ArrayList
直接存储PersonRequest
并没有太大区别,但在大数据方面会表现出优势,因此 该设计使得我的程序在应对数据规模方面有较高的可扩展性。 -
处理共享对象(下面就称为请求队列)的方法:电梯是消费者,输入线程是生产者,中间有托盘作为缓冲。电梯内部有策略类对象,其作用是根据整个楼座的请求队列和电梯自身的信息决定电梯的运行目标。为了保证线程安全,电梯每次都会直接从托盘中获取所有的请求队列并存储到自身属性中。
3.1.2 电梯策略
在电梯运行策略方面,我采用了look
策略:
-
当电梯有乘客时,以乘客中目标楼层距离当前楼层最远的请求为主请求确定目标楼层
-
当电梯没有乘客时,首先按照原方向,寻找距离当前楼层最远的有请求的楼层,找到则确定为目标楼层,这次寻找最终结果有可能会是当前层。如果寻找无果(沿方向的所有层包括本层都没有请求)则改变方向,继续寻找。若最后没有找到,则可以返回一个标志结束的值表示电梯进入空闲。
3.1.3 UML类图
3.2 第二次作业
3.2.1 设计架构
第二次作业仍然采用生产者——消费者
模型。对于横向电梯,我直接新加了一个类,没有和原来的纵向电梯合并,我的考虑主要有两点:一是由于之前纵向电梯的共享对象是按层分的,横向电梯则希望按楼座分;二是考虑到如果加一块会让电梯类过于复杂,所以想要分两个对象分摊复杂度。
3.2.2 电梯策略
第二次作业的多部电梯让我第一次作业中对共享对象的处理方法不再适用。由于按照算法分配和让电梯自由竞争都不是全局最优解,所以我选择了比较简单的自由竞争。
-
关于自由竞争的实现:对于缓冲区对象新加入了线程安全的查看方法,每部电梯都可以同步查看缓冲区内的请求队列并作出目标决策。在电梯询问是否有乘客需要上电梯时,电梯会调用缓冲区对象的查询方法,该线程安全的方法会返回一个符合条件的乘客请求(没有则返回null),并将其从缓冲区请求队列删除,保证不会出现一个乘客上多部电梯的情况。
-
关于横向电梯的调度策略:横向电梯我仍然使用了类似
look
的策略。假设ABCDE依次编号为01234,按顺时针排列,且顺时针规定为正向。-
当电梯有乘客时,依然希望寻找其中目标楼座距离当前楼座最远的请求为主请求,但是这里的距离不应该是简单的加减法,可以加上模运算:dis = (target - nowBuilding + 5) % 5;
-
当电梯没有乘客时,原则上仍然按照第一次的方法寻找,这里我在实现的时候出现了巨大的bug,好在在同学的帮助下及时修正,这点会在第四部分详细说明。
-
3.2.3 UML类图
3.3 第三次作业
3.3.1 设计架构
第三次作业我采用了流水线架构
。新建了Controller
类,并采用RequestCounter
来控制任务的结束。我将请求进行静态划分,分为三个阶段(纵向——横向——纵向
),对请求的处理逻辑修改如下:
如果是阶段1:判断是否为纵向可直达?若是则直接跳到阶段3,把纵向请求移交给对应tables
若不是,判断是否本层可以转移?若是则直接跳到阶段2,
若不是,则执行阶段1,从起始层送到中转层,且目前在起始座。
执行阶段2,从起始座,送到目标座,且目前在中转层
执行阶段3,从中转层送到目标层,且目前在目标座(若中转层等于目标层,则阶段3直接结束)
3.3.2 电梯策略
对于电梯自身的运行策略并没有大的改变,为了满足第三次作业的需求做出了微调。对于中转层的确定,我尝试使用dijistra
算法计算最短路径,但是本人实现效果一般,最后还是采用了基准思路:
M >>(P-'A') & 1+ M >>(Q-'A') & 1 = 2
且 (|X-m| + |Y-m|)min
3.3.3 UML类图
四、bug分析及hack策略
4.1自身bug
本人在三次互测中均没有被测出bug,但是在自我测试时出现的bug感觉把所有能遇见的bug都体验了一遍,下面进行总结:
4.1.1第一次作业
第一次作业正值清明节,当我完成了基础功能之后,我兴致勃勃地尝试了学长的量子电梯
,但是最后实现的量子电梯比较畸形,最后果断放弃了。在自我测试时,发现自身电梯运行策略(run方法)有很大的问题,因此我在清明节进行了重构,以下是我重构之后的简要代码思路:
public void run() {
while (true) {
if (table.isEmpty() && table.isEnd()
&& schedule.getSchedule().isEmpty() && passengers.isEmpty()) {
return;//结束线程
}
if (schedule.getSchedule().isEmpty() && passengers.isEmpty()) {
//进入等待
}
target = updateTarget();//获取目标
getDown();//下车
check();//检查缓冲区
boolean change = getIn();//上车
target = updateTarget();//更新目标
changeDir(change);//判断换向
closeDoor();//关门
makeMove();//移动
}
}
修改整理成以上逻辑之后感觉瞬间舒爽不少。
4.1.2 第二次作业
第二次作业我成型比较早,在作业公布当天晚上就写出了第一版,但是也遇到了很多bug:
-
横向look死循环
[0.0]ADD-floor-7-2 [1.5]1-FROM-A-2-TO-B-2 [1.5]2-FROM-B-2-TO-A-2 [1.5]3-FROM-C-2-TO-B-2 [1.5]4-FROM-D-2-TO-C-2 [1.5]5-FROM-E-2-TO-D-2
这一点是我横向转向逻辑的错误导致的,之前我当前进方向有请求时便会维持当前方向,同时采用同向捎带策略,这就导致我面对上述样例时会产生无限循环的bug。最后我修改了换向逻辑,使得电梯在第一次同向捎带失败时就主动尝试换向。
-
轮询
照应开篇所说的 notifyall
满天飞的恶果,我的程序在公测开始前一晚爆出了轮询的bug,最后分析原因就是当多个电梯同时可以访问一个托盘时,如果托盘的方法中 notifyall
过多则会导致多个电梯被唤醒,这会造成电梯持续访问,浪费CPU资源。最后我删去了部分不必要的 notifyall
,解决了这一问题。
4.1.3 第三次作业
第三次作业正值体测周,我身心俱疲(还是太颓了),最后在周四中午才完成了初版,并在之后两天陆续de出以下bug:
-
无法到达目的地的乘客仍然上了横向电梯
解决方式:getIn
增加条件
-
捎带出现错误导致效率低
解决方式:将乘客的起始和目标均设为动态的
-
乘客难以同时上电梯
解决方式:将 synchronized
锁住的方法尽量改小。
-
轮询问题
解决方式:将横向电梯等待的条件设置的更加严格
4.2 他人bug
由于本人自己在做作业时出现过很多bug,因此在互测时也比较有经验,所以这一单元的hack也算是战果累累,总共发现了他人三个bug。
-
第一次作业:主要是输出线程不安全的bug,房内很多同学输出的时间戳都没有满足严格递增。
-
第二次作业:主要是我自己测试时发现的那个横向电梯死循环的bug。
-
第三次作业:主要也是死循环。
互测hack策略
本单元我的互测策略是首先使用自身出现过问题的数据进行hack,接着阅读他人代码,寻找容易出错的位置(比如锁的设置、线程安全等),最后尝试使用随机数据测试。实践下来,感觉最有效的还是使用自身出现过问题的数据和阅读他人代码,而随机数据hack的成功并不高。
五、心得体会
-
预备知识学习及基础习题练习:
在预习阶段,我了解了多线程编程的基础概念、创建新线程的方法(实现Runnnable
接口、Callable
接口、继承 Thread
类等)、线程的各种状态(可运行、阻塞、无限期等待、限期等待、死亡等)、中断线程的方法( interrupt
、 InterruptedException
、共享标志位等)、守护线程、线程同步方法、线程互斥同步的大小设置(同步代码块、同步方法、同步一个类、同步一个静态方法等)、死锁的概念、使用wait
和 notifyall
、使用 ReentrantLock
、使用 condition
、使用 ReadWriteLock
及 Atomic
的概念等等。
除此之外,我还用过练习H2O的生成、哲学家问题等习题初步体会了多线程编程的技巧。
-
线程安全:在作业过程中,线程安全是首先需要解决的问题,我主要是通过将加锁方法集中在一个对象中来保证线程安全的管理更加清晰。
-
层次化设计:在本单元的练习中,我认为自己进一步体会了层次化设计的重要性,首先是设计模式的选择,从单例模式、观察者模式等众多设计模式中选择生产者—消费者模式及流水线模式;其次是乘客请求的分解,将转乘的请求分为三个阶段,对于横向请求按楼层分割,对于纵向请求按楼座分割;最后才是电梯的运行逻辑及策略。
本单元的学习让我更深刻理解了面向对象的程序设计思想以及多线程编程的方法技巧,希望未来能再接再厉!