BUAA OO 第二单元总结
BUAA OO 第二单元总结
写在前面
本单元作业是OO经典的“夺命电梯”,主要考察的是有关于多线程的知识,所以相较于第一单元的表达式求导题目,笔者并没有再次经历反复重构的折磨,三次作业在大体的代码架构上并没有很大的差别,所以笔者在下面只着重分析第三次的代码架构,前两次的作业就暂且放下不表,并会将大部分笔墨用来介绍有关多线程的知识和电梯调度算法。
调度器设计和电梯算法
知道读者大大们都很关注这个,所以笔者将其放在第一个介绍。笔者采用的是单电梯look算法和多电梯自由竞争的调度算法,故并没有用到调度器,下面笔者会详细介绍所用到的算法。
由于请求列表类是加锁的共享对象,因此线程安全问题在此暂不考虑。
-
Look算法:算法核心是根据当前电梯所在的楼层和前进方向,来对总的乘客请求列表进行分析,判断电梯按当前方向运行是否还会遇见乘客(乘客前进方向是否和电梯当前运行方向相同无需考虑),如果仍有乘客,或者电梯内部仍有乘客的话(即为满足运行条件),电梯就会继续沿当前方向运行,遇见乘客后,如果乘客前进方向和电梯相同,且电梯可以继续搭载乘客,则该乘客进入电梯;如果在该方向上没有乘客,且当前电梯内部为空,则改变电梯方向再次进行判断,如果仍然不满足运行条件,则让该电梯线程
wait
(当然,如果此时输入已经结束,则让该电梯的线程直接结束)。 -
自由竞争:每一部电梯都通过自身的Look算法来决定运行状态,当电梯可以搭载乘客时,就把该乘客的信息从
ProcessQueue
类(总请求列表)中移除,并将其加入自身的乘客列表中,即该电梯成功争抢上了该乘客,那么其他电梯就会在下次进入临界区后变化状态。值得一提的是,自由竞争算法可以在任意电梯模式下使用,各个模式只需根据题目的具体要求而稍微改动电梯的启动条件即可,笔者的做法如下:在
Night
模式下,在检测到输入全部结束后再启动电梯线程;而Morning
模式下,则让电梯启动前进行判断,如果电梯此时满载,则直接启动;否则的话等待2秒钟后把能带上的乘客带上后启动。可能读者大大们会感到疑惑:这样自由竞争的话,不是会有很多的电梯进行“陪跑”么?没错,是会有这种情况,但从实际测试效果来看,完完全全地“陪跑”行为很难发生,大多数的电梯虽然没有抢上自己原本的目标乘客,但仍然有乘客会被其他电梯剩下,从某种程度上也避免了一部电梯搭载过多的乘客,而其他电梯空闲的问题;退一万步讲,就算同方向上的乘客被其他电梯抢光了,但仍有反方向的乘客等着你。因此,除个别测试数目过于少的情况下性能不太理想,其他情况下性能均能到达差强人意的效果。
-
换乘策略:由于在第三次作业中各个电梯的属性不同,因此涉及到了换乘的问题。笔者的思路是在各自电梯的Look算法判断中加入一定的限制条件:将乘客的起始楼层作为第一考虑,如果电梯无法到达该楼层,则忽略此乘客的请求;也将乘客到达楼层纳入请求,如果可以直达则正常判断,如果无法直达,则检测最近的换乘站,如果从换乘站到终点站的距离小于起始站到终点站,则进行正常的Look算法判断。
还有一点小变换是有关于把需要换乘的乘客的信息重新加入请求列表:在乘客进入电梯时进行判断,如果无法直达,则
new
一个新请求,把原请求的终点楼层取负后赋给新请求的起始楼层,用作换乘的标志位,终点信息也对应发生改变,然后将该新请求存入电梯内部的乘客列表,最后在该乘客下电梯时再判断一下起始楼层的正负看是否需要将其再次加入总的请求列表,即可实现完整的换乘信息交互。
第三次作业架构分析
- 总体架构
本单元的作业在总体上采用了生产-消费者的模式,即通过Producer类来获取用户的请求,先将请求存储在总的请求列表RequestQueue
中,然后通过Scheduler
类来对请求类进行调度,即将请求从RequestQueue
类分到ProcessQueue
中,最后再由Elevator
类来进行电梯的进程。
如果读者大大细心的话,也许会疑惑那为什么笔者还要写调度器,即
Scheduler
类,下面笔者会进行详细地解释,这里就不作叙述啦~
底下附上了笔者第三次作业的类图和UML协作图。
相信大家也不喜欢看类图,这里只是贴一下,在下面会有各个部分的详细介绍,请读者大大耐心读下去。
-
代码详细架构
-
Main
类:Main
类是程序开始的地方,主要负责的是各个线程的初始化和start
,以及负责时间戳的初始化。 -
Producer
类:实现了Runnable
接口,内含属性RequestQueue
(共享对象,即用户请求队列)、number
(电梯的个数,用以程序结束的判断)。主要职责是负责整个程序的读入工作,通过官方包来将请求进行分类并执行相应的操作(例如添加新电梯和添加新的请求到RequestQueue
中)。从算法的度量分析来看,总体上复杂度还可以接受,只有一项爆红,但仍然有部分无用的部分,可以进行更进一步的优化。
-
RequestQueue
类和Scheduler
类:笔者的初衷:笔者本来想在每一个电梯进程中新建一个只属于该电梯的
ProcessQueue
,用来储存每一台电梯需要完成的请求任务,这样就可以将多部电梯完全分成独立的进程。进一步分析我们可以知道,这样做的话需要一个调度器来将原来的用户请求按照调度算法分到各个电梯中,也就对应要一个类来储存用户的请求列表,所以,RequestQueue
类和Scheduler
类就诞生了。本来是很自然的思路,但是,笔者最终采用的是自由竞争算法,即让各个电梯自由地在请求队列中争夺乘客。这是因为电梯调度问题不存在最优解,也就是笔者不知道哪一种调度算法的性能更加好一些(笔者嫌写调度算法过于复杂),加上害怕大删减会招致不知名bug(才不是笔者想偷懒),最终导致了ProcessQueue
类成为了所有电梯访问的共享对象,而原先的这两个类也完全失去了作用,仅仅将请求从Producer
类传到ProcessQueue
类中。总结来说就是:只是将用户请求队列从
Producer
类引入共享对象ProcessQueue
中,其余的属性和方法一概没有,加上和删除对结果都没有任何的影响,完完全全的无用类。。。 -
ProcessQueue
类:电梯们访问的共享对象,用来储存所有的用户请求,内含的属性中除包含PersonRequest
的容器外,含有一个boolean
类型的变量end
,用来判断是否全部输入都已经结束,用来结束Producer
进程和Night模式下电梯的启动;内含的方法包含用来判断look算法中当前方向是否还有乘客的算法、电梯进程的wait
和notify
。从算法的度量分析来看,该类的复杂度显然爆表了。。。主要原因是构思的时候收到了调度算法的影响,在方法里面不停地对请求队列进行遍历,然后按照FromFloor
楼层进行排序,导致了复杂度过大。 -
Factory
类:按照输入的模式来生成对应的电梯线程,由于第一单元出现过所以这里就不加赘述了。 -
Elevator
类:本次作业的绝对核心!实现了
Runnable
接口,拥有属性除了包括类型,ID,运行速度,开关门速度等基本属性外,笔者加入了personRequests
,储存当前该电梯内的乘客信息和preList
,储存该电梯在运行路程上可以接上的乘客的信息,相当于是该电梯自由竞争得来的乘客,可以更快地使其他电梯转换自由竞争的目标。由复杂度分析可知,代码耦合严重,复杂度也普遍较为复杂,仍有很大的修改空间。
-
-
可拓展性
笔者代码中的电梯进程都是继承自
Elevator
这一父类,且在Elevator
类中包含了电梯的所有可能用到的属性,在调度算法上使用了look算法和自由竞争来作为主要的调度方法,所以说可以应对大部分的题目变动;笔者的三次作业都是在第一次的架构上做的小修改,即每一次添加一些小的函数,所以说会产生像上面RequestQueue
类和Scheduler
类多余的情况。总的来说,相比于第一单元作业的三次重构,本单元作业的可拓展性有较大的提升。
同步块和锁
笔者的架构中,一共有三个线程,分别是负责读入的Producer
线程,负责中间储存(和并不存在的调度)的Scheduler
线程和负责运行的电梯线程;共享对象有两个,一个是负责储存乘客请求的ProcessQueue
类,一个是负责结束整体程序的Producer
类。之所以这样设计的原因是:Producer
类可以检测输入是否停止,且添加电梯的请求是从Producer
类中产生的,所以说令程序结束的工作直接放在Producer
类中是最方便的;而ProcessQueue
类不仅需要被多台电梯访问,也要被Scheduler
线程访问,所以自然也是一个共享对象。
而有关同步锁的设计:三次作业中,笔者的同步锁都是使用的synchronized
语句,但在细节上又有不同。在第一次作业中,笔者的同步锁全部是写在ProcessQueue
类中的方法里,如下图:
像这样用synchronized
修饰共享对象的原因是因为,在第一次作业中只有一部电梯,所以共享对象只会在电梯和Scheduler
类中被引用,进一步分析可以知道,笔者的电梯是采用的look算法,即只需要判断当前方向上是否有乘客和到达新的一层后,判断是否有乘客可以上电梯,所以笔者的电梯进程中绝大部分的时候是用不到ProcessQueue
类的,且用到的地方相隔比较远(判断是否还有乘客在run
方法开头,而判断是否可以上电梯则在run的末尾)。因此,如果笔者用synchronized
来修饰电梯对象的代码块的话,会使得电梯空占着锁对象的时间变得无比巨大,所以Scheduler
类肯定会感到不满。而只修饰方法的话,就会使得电梯只在需要查看锁对象的时候占有锁,其他时间都留给了调度器来添加新的请求,这样两者都觉得好像自己独占了锁对象。
而在第二、三次作业中,除了用synchronized
修饰ProcessQueue
类中的所有方法外,还在电梯类中加入了用synchronized
修饰的代码块,笔者的想法如下:
- 在第二、三次作业中,出现了多部电梯的情况以及换乘的情况,所以笔者的代码中,在到达新楼层后,既要判断是否有人要上电梯(从请求列表中移除对象),又要判断是否有换乘的乘客需要下车(向请求列表中新增对象),所以如果还是锁方法的话,会使得出现电梯错过乘客的情况,还不如先让一部电梯在该层的工作结束后,再让下一部电梯开始进入临界区。
- 还有一点就是,在后两次作业中加入了用
synchronized
修饰的Producer
对象,因为笔者设计时出现了一点点小问题,就是没有让Producer
类进行程序结束,而是通过最后一个电梯进程访问Producer
类中的电梯数量的属性来进行程序结束。这样就导致了必须在电梯进程判断是否结束的时候加上锁,不然会导致多部电梯同时访问Producer
类,都得到了程序不终止的条件,最终都结束了电梯自己的进程后,总程序却没有停止的情况,即RTLE。
BUG与Hack
终于说到了本单元笔者最大白给的地方了(哭唧唧)。。。
写在前面,在要提交的原代码中随意添加调试语句真是毒瘤做法。
-
第一次作业
- 在第一次的作业中,笔者本来信心满满,结果强测WR,仔细一看原代码,发现憨憨笔者在调试bug时写入了一条
print
语句,不仅提交时忘记将其注释掉,而且print
语句满足的条件还正好比较诡异:说它条件强吧,中测硬是一个没有;说它条件弱吧,强测爆了四个。。。 - hack:由于不停地被同组攻击(再次哭唧唧),导致笔者在互测期间癫狂地找自己原代码的bug而放弃了hack别人(再次白给)。。。
- 在第一次的作业中,笔者本来信心满满,结果强测WR,仔细一看原代码,发现憨憨笔者在调试bug时写入了一条
-
第二次作业
-
在第二次的作业中,笔者本来再次信心满满,结果再次白给。。。同样的错误,同样的酸爽。。。憨憨笔者在经历了第一次忘记注释print语句后,第二次调试用CTRL+F把所有调试用的
print
语句全删了,但是调试写的if
忘删了(会让全部线程wait
)。。。不过好在也是因为这次的条件语句及其***钻,(不仅对测试数据有要求,对电梯和Scheduler
的线程顺序也有要求),导致笔者和强测数据点都没有注意到,倒是挺佩服同组大佬的,这都能找出来然后hack我两个点(含泪给同组大佬点赞)。还有一个值得一提的东西,笔者的第二次算法评测结果中有两个点性能为80,其他均为99+(疑惑.jpg),差距过大的原因至今不明,目前猜测是由于数据点过于独特,大量同层请求在同一时间进入,使请求还没全部进入
ProcessQueue
类电梯就未载满客出发,导致需要跑第二遍相同的路程,因此性能较差,但总体来看效果差强人意。好的解决办法暂无,因为如果需要判断没有输入后才启动电梯,对其他点的性能会产生较大负影响。 -
hack:同组的大佬实在是强,笔者用了一些自己写的强测点和从第一次作业强测里面的测试点都没有测试出bug,结果最后同组只有笔者一人被hack(不停地哭唧唧)。
-
-
第三次作业
- 这次笔者学聪明了,新建了一个文件来进行调试,果然没有再次白给。
- hack:依旧被分到了大佬云集的地方(
再次怀疑分配算法出现bug:我为什么会在里面),依旧未能找到大佬的bug。
心得体会
- 在原代码中随意添加调试语句是个毒瘤做法,不仅会使你的代码变得一团糟,还可能删不干净。如果要添加语句进行调试,不妨新建一个备份文件,找出bug后记得同步就好。
- 好的架构和设计模式受益无穷:笔者在第一次作业架构的时候费尽了心思,思前想后了好久之后才开始动手,使得在后续的作业中每次只需要稍微加加改改。
- 学习新知识的时候最好跟着教程搭个小样例试一试:笔者在最开始接触多线程的时候感觉一阵头大,因为网上的教程有很多写的晦涩难懂(
粗看好似人言,认真读起来又好似不是人言),但是在自己稍微写了个小线程后突然茅塞顿开,到后来也就渐渐理解了相关知识。