OO_Unit2_Summary
心得体会
金钻以为他在学校或在给学生补习时,他正搭乘金属玻璃电梯直穿摩天建筑直达城市上空,脚下人间哆嗦的灯芒将明将灭,是他灵魂的夜景。事后降落回街上,人是个从母体脱落的空壳,从极高极高坠到了绝低绝低,走在茫茫人海里他是侥幸逃生的人。
丨钟晓阳《遗恨》
照例,我喜欢把心得体会写在一开头,让后来的读者先把我的情绪废话看完。引用这段文字一方面是表达电梯月期间自己的心境(与电梯作业无关),另一方面也是为了形象地表达自己作业架构的运行情况——喜欢在极高极高与绝低绝低之间扫荡来回。钟晓阳的文字很有意思,浅浅推荐。
相比上一次作业,我在本单元中更多地感受到了迭代开发的条理性与设计模式的工整性,虽然与真正的工业生产相比必然不足为道,但我似乎真的窥见了某种成体系、成规则的构筑代码的自然方法,某种将会贯穿于我未来职业生涯中的自然方法,“工厂模式”中工厂二字代表的标准化,正在逐渐被我体会。相当明显的一个表现就是,除了第五次作业,后两次作业几乎都只需要在前一次作业的基础上做出很小的改动与增量开发,就能够很快地“通关”(基本在半个小时左右),即使遇到了新需求,也能够相当自然地从已有模块或接口出发进行延展。这个开发节奏着实令人感到非常舒适。当然,我的迭代比较快也有可能是因为策略和架构都太简单了,不能登大雅之堂。
工厂这个词的表述可能会让一些同学感到不适,但这绝对不是一种所谓的“码农”思想——相信这次作业中许多同学都和我一样体会到并喜欢上了这种整齐感,自动化思想也一直是面向对象编程思想的其中一个核心。我绝对相信,它体现的设计思想会让我们在“降落回街上”之后,不尽受用。比起第一单元,我们离OOP也更进一步了。
引文中还有一个词语我很喜欢:金属玻璃电梯。是的,虽然架构严谨、模式工整,但这始终是一个令乘客与作为设计者的我都暗自心悸的透明玻璃电梯——线程安全的隐患藏匿自己如玻璃不显眼处细小的裂缝,同时诱发恐高与对意外的畏惧。不过,观看了其他同学的博客后,我也感受到这电梯的脆弱全然是我自己的设计责任,因此我也把有关内容放在了文章的最后一部分作力所能及的分析,希望后来看到这篇博文的人,能够为自己的电梯装上更可靠的保障,能够驱散玻璃外不怀好意地盘旋着的三朵叆靆黑云。
能够少一点遗恨。
三次作业架构设计
第五次作业
-
写在前面
老生常谈的部分是对电梯的实现作一个架构分析。第五次作业实现的是一个每座单部竖向电梯的电梯运行系统,实现和设计都比较简单,如果在这里能把代码架构修理得工整一些的话,对后续的迭代会很有好处。不知道怎么设计也没有关系,一般来说,照着实验课代码做是没错的。
比如第五次作业就可以直接采用实验代码中体现过的“生产者-消费者”模式。如果这个时候对锁机制等的了解还不够清晰,一般来说,照着实验代码也没有问题。但这个问题,始终需要搞清楚
,勿谓言之不预。 -
类图与策略描述
第一次作业的架构比较简单。
我做出的一个小改动是将所谓的调度器直接与输入线程合并为了InputThread类(也就是把托盘跟生产者放一起了,这样其实从架构上来说不太好),在输入乘客请求时,自动根据对其出发楼座的获取将其分配到对应楼座的等待队列waitingQueue中。由于考虑到后面每座可能会有多个电梯,因此我为每个电梯单独设置了电梯里外的队列 in 和 out,当然在这次作业中,waitingQueue几乎等同于out了。读取完所有的输入后,输入线程将五个楼座的等待队列都标志为结束(setEnd = true),当每座的等待队列已空且输入结束标志为真时,电梯线程就会自动结束。
电梯调度策略上,我采用的是标准的ALS策略,大致流程图如下:
一个能够提升性能的小改进是:在“上落客”中的上客环节(也即passengerIn方法),优先将需求方向(即Passenger类中的move属性)与电梯当前运行方向相同的乘客加入in队列中,如果此时in未满,才再扫描一遍out队列,将从当前座出发而方向不同的请求加入。
获取主请求并确定运行方向的标准是:先检查电梯内的队列,以队首乘客的情况确定运行方向;如果电梯内为空,则检查out队列,以队首乘客的情况确定运行方向;如果out也为空,则将运行方向设置为 wait (不代表此刻电梯真的停止,只是作为一个从等待队列中取请求的标志)。
提到未满,就要说明这里的一个小细节:开关门时务必要注意电梯的满员情况,如果当前电梯已满且内部没有要下的,直接跳过此层而不开门,以节省运行时间。
关于本单元作业的复杂度分析,由于处理的事情没有上单元复杂,方法分配也比较合理,因此三次作业复杂度都不是特别高,基本上一模一样,这里只放出第一次的,就不用三张大表格占据大量篇幅了。
Elevator.alsTragedy() 7.0 3.0 5.0 6.0 Elevator.down() 3.0 2.0 2.0 3.0 Elevator.Elevator(int, RequestQueue) 1.0 1.0 1.0 6.0 Elevator.getBuildingId() 0.0 1.0 1.0 1.0 Elevator.getBuildingName() 0.0 1.0 1.0 1.0 Elevator.getMainRequest() 3.0 3.0 4.0 4.0 Elevator.getNowFloor() 0.0 1.0 1.0 1.0 Elevator.getStatus() 0.0 1.0 1.0 1.0 Elevator.goGetMain(Passenger) 6.0 1.0 3.0 4.0 Elevator.goSolveMain(Passenger) 2.0 1.0 2.0 3.0 Elevator.hasIO() 6.0 5.0 3.0 5.0 Elevator.openClose() 7.0 2.0 5.0 7.0 Elevator.passengerIn() 6.0 1.0 4.0 4.0 Elevator.passengerOut() 3.0 1.0 3.0 3.0 Elevator.run() 8.0 3.0 6.0 9.0 Elevator.up() 3.0 2.0 2.0 3.0 InputThread.InputThread(ArrayList) 0.0 1.0 1.0 1.0 InputThread.run() 8.0 3.0 5.0 5.0 MainClass.main(String[]) 1.0 1.0 2.0 2.0 Passenger.getFromBuilding() 0.0 1.0 1.0 1.0 Passenger.getFromFloor() 0.0 1.0 1.0 1.0 Passenger.getId() 0.0 1.0 1.0 1.0 Passenger.getMove() 0.0 1.0 1.0 1.0 Passenger.getToBuilding() 0.0 1.0 1.0 1.0 Passenger.getToFloor() 0.0 1.0 1.0 1.0 Passenger.Passenger(int, int, int, int, int) 3.0 1.0 1.0 3.0 RequestQueue.getIndex(int) 0.0 1.0 1.0 1.0 RequestQueue.indexOf(Passenger) 0.0 1.0 1.0 1.0 RequestQueue.isEmpty() 0.0 1.0 1.0 1.0 RequestQueue.isEnd() 0.0 1.0 1.0 1.0 RequestQueue.put(Passenger) 0.0 1.0 1.0 1.0 RequestQueue.remove(int) 0.0 1.0 1.0 1.0 RequestQueue.RequestQueue() 0.0 1.0 1.0 1.0 RequestQueue.setEnd(boolean) 0.0 1.0 1.0 1.0 RequestQueue.size() 0.0 1.0 1.0 1.0 RequestQueue.take() 6.0 3.0 5.0 5.0 SafeOut.println(String) 0.0 1.0 1.0 1.0 Total 73.0 54.0 74.0 93.0 Average 1.972972972972973 1.4594594594594594 2.0 2.5135135135135136 -
线程协作图
第六次作业
-
写在前面
本次作业更新了要求:每座可以有多个电梯、在A座的每一层添加了一个环形电梯、可以通过电梯请求创建新电梯。正如助教所言,本质上就是电梯数量变多了,其实不用做多大的改进,我总共应该是花了差不多半个小时完成。
-
类图与策略描述
针对同座多部电梯的请求分配情况,我采取的是自由竞争——即依旧按出发楼座 / 楼层将请求丢到对应座 / 对应层的waitingQueue中,让电梯自己去抢请求。除此之外,唯一做出的改进就是增加电梯请求和增加了一个CircleElevator类,它几乎完全由Elevator类复制而来,只是修改了通过主请求确定当前运行方向的部分——因为基于ALS策略,我们需要实现一个寻最短路的功能(即选择顺时针和逆时针中较快的一个方法进行运动)。后来我发现这里抽象出一个父类或许更合适,因为相同的行为与属性实在是太多了。
//寻找最短距离以确定运行方向的过程,main即获取到的主请求 int disLong = (main.getFromBuilding() - this.nowBuilding + 5) % 5; int disLate = (this.nowBuilding - main.getFromBuilding() + 5) % 5; if (disLong < disLate) { status = 1; } else if (disLate < disLong) { status = 2; } else { disLong = (main.getToBuilding() - this.nowBuilding + 5) % 5; disLate = (this.nowBuilding - main.getToBuilding() + 5) % 5; if (disLong < disLate) { status = 1; } else { status = 2; } }
可以看到跟上一次作业的类图相比几乎没什么变化。
-
线程协作图
第七次作业
-
写在前面
本次作业新增了可换乘请求,并且支持定制电梯的容量、速度等。定制的地方好解决,只需要加几个属性表示电梯的容量和速度即可;至于换乘的部分,我改用ConcurrentLinkedQueue来管理乘客请求,在这里,我将所有的乘客请求都处理为一个Passenger的链表,并且在InputThread类中增加一个break Request方法来计算换乘策略。每次落客都删除乘客链表的表头,如果删除后链表为空,代表请求处理完毕。还有一个改动是针对横向电梯可达性的,我在RequestQueue里增加了一个takeCircle方法,使得横向电梯在楼层总等待队列中取请求时只能取到自己可达的请求。
public synchronized ConcurrentLinkedQueue<Passenger> takeToCircle(CircleElevator c) { while (!isValid(c)) { if (isEnd() && isEmpty()) { return null; } else { try { wait(); } catch (InterruptedException e) { e.printStackTrace(); return null; } } } ConcurrentLinkedQueue<Passenger> p = null; for (int i = 0; i < passengers.size(); i++) { Passenger ans = passengers.get(i).peek(); if (((c.getValidBuilding() >> (ans.getToBuilding() - 1)) & 1) == 1 && ((c.getValidBuilding() >> (ans.getFromBuilding() - 1)) & 1) == 1) { p = passengers.remove(i); i--; break; } } return p; }
这样可以最大限度地继续沿用前面的架构,其中最大的好处就是还可以沿用之前的线程结束逻辑(即等待队列为空且等待队列输入结束),不用像实验代码那样再设计一个计算请求数目的单例对象来计算请求完成情况,或者说采用流水线模式,直接把最朴素的生产者-消费者模式用到了尽头。
事实上,我一开始没有想到使用链表管理乘客请求的方式,而是在看了实验代码后对之前的作业代码做了一个大改动,自作聪明地为每个请求设置了优先级以模拟流水级,每次只处理优先级最高的请求,并及时更新后继请求的优先级,线程结束逻辑则采用了计算请求数目的单例对象,一旦所有请求处理完毕且输入结束,就结束线程。在这里我没有处理好线程安全问题,频频出现幽灵般不能复现的RTLE问题(测评机上随机出现线程不能正常结束的现象)。饱受其折磨数日且久久不能调试成功后,我选择了回到第六次作业代码,沿用它的架构,只是改用链表处理乘客,这样一来完成代码同样只用了不到半个小时,并以比较好的性能通过了所有的强测点。
所以,有一份自然迭代的甜头原放在那里,只是我没有及时醒悟,好好珍惜……sigh。
-
类图与策略描述
本次作业的代码与架构和第六次作业相比几乎没有任何变化,只是使用ConcurrentLinkedQueue<Passenger>代替了原来的Passenger,在定制电梯方面增加了几个属性以修改电梯的容量和速度,并且对横向电梯可达性做出了一些限制处理。因此就不再赘述类图和协作图了。
bug情况分析
-
第五次作业强测RTLE了两个点,后来发现是在电梯满员且没有乘客要下时仍然选择了开门,会导致大量的空开关,浪费运行时间。在开门前增加了相应的逻辑判断后解决该问题。
比较有意思的是,写代码的时候我其实发现了这个现象,甚至还觉得相当符合现实逻辑被逗笑了(想到了一些大运村宿舍电梯门在我面前徐徐打开而里面爆满的悲伤情形),忘了考虑这很浪费时间。
-
第六次作业互测中因为幽灵RTLE被hack了一下,代码原封不动交上去就又AC了,一直没有复现出错误。这是本单元我心中的一个“遗恨”。
关于互测
虽然有过一次性提交一大堆请求的朴素测试想法,但本人这单元没有hack人,故不在此叙述。
对于自己的本地测试,我采用的是白盒测试方法。
电梯与多线程
同步块与锁
-
synchronized
synchronized关键字,代表这个方法加锁,相当于不管哪一个线程运行到这个方法时,都要检查有没有其它线程B正在用这个方法(或者该类的其他同步方法),有的话要等正在使用synchronized方法的线程运行完这个方法后再运行此线程;没有的话,锁定调用者,然后直接运行。它包括两种用法:synchronized 方法和 synchronized 块。
在本单元中,我们很自然地会为所有的共享对象 / 共享方法加锁——防止一边修改,另一边又访问带来的错误,对于所有共享的东西,我们都要为之加锁,注意保证它的互斥性。
同样,我们也需要很自然地想到可能引发的死锁问题——如果一组互相需要竞争资源的线程现在因为锁机制在互相等待,就会导致永久阻塞下去,线程无法正常结束,本单元中的一些RTLE就是因为这个引起的。如果只限制在本单元中,可能的原因有嵌套锁,或者某个可逆请求的被锁(比如线程A锁住了1请求2,线程B锁住了2请求1的情形),如果出现死锁可以往这个方向排查。查看死锁的方法很简单,最直接的表现当然是终端线程无法正常结束,当然也可以运行jconsole来进行死锁检查。
解决死锁可以通过破坏等待关系等方法实现,但在实践中解决并不是那么容易,贸然破坏等待关系有可能引发新的安全问题,或者影响作业运行逻辑。因此还是在写代码时就需要考虑好哪些地方要加锁,而不是盲目地到处加,这是一种很危险的行为。最经典的死锁产生情景就是两类共享对象肆意加锁导致的死锁,所以尽量避免无谓的同步控制。
-
锁
虽然synchronized关键字对锁的实现很简单,由于本次作业中它的性能表现尚可,因此我只是简单在课下试验了一下读写锁,但并没有真正将完整的锁机制应用到代码中,而是一直保留使用同步块。
但是通过对lock的简单学习,我发现使用它可以更灵活地操作互斥行为,因而应该也比同步块机制更容易避免死锁的发生。
调度器
本次作业中我没有单独设计调度器,或者说,是把调度行为合并到了输入线程中。事实上将它单独拆出为一个调度器类也是可行的,架构上会体现得更优雅一些。但我考虑到我的调度行为只是将处理好的指令分派到对应楼座 / 楼层的总请求队列中,并不涉及具体电梯的行为(我全部采用了自由竞争策略,而且我觉得公开过多的私有队列修改权限也是一件难以保证线程安全的事),因此单独拆出的意义也就不十分大。
不过对于第七次作业,我后来想,如果能针对乘客做一个调度器,将 ta 分到相对比较空闲的电梯中,或许性能也可以更好,但想到做出的改动应该也比较大(毕竟需要把每个电梯的out队列修改权限都公开了),最后也就没有采用。(说到底是因为懒吧)
设计模式
在本次作业中我主要使用的是生产者-消费者模式。关于设计模式的部分,我想我在实验代码中反而体会得更多,比如标准的生产者-消费者模式、主从模式、流水线模式和单例模式等,它们也给了我的作业许多灵感。当然,《图解JAVA设计模式》一书也给了我不少帮助,尤其是在理解流水线模式的部分。希望今后自己可以更标准地利用相应的设计模式,以规范思维和代码行为。
三朵乌云
笼罩在看似祥和的电梯单元头上的无非是以下三朵乌云(虽然从气象学的角度它们有的时候并不是真正的乌云,此处只是做一个自以为形象的比喻),有的时候R麻了、C麻了,就连WA也是珍贵的幸福。
以下问题均可以通过思路清晰的remake解决
-
伪卷云:RTLE
-
策略问题
如果出现RTLE,首先应该检查策略问题,比如本人第五次作业中的电梯门空开关事件,还有我帮某同学看出的先到A层放人,再走到B层,再回到A层的反复横跳行为。这种情况下线程可以正常结束,但是明显超时(请利用定时投喂包进行本地测试),可以观看输出中有没有诡异的反复行为,从而消除错误。如果实在没有,不妨按照指导书的ALS策略标准地写下来——这是绝对不会超时的保险策略。
-
无法复现的幽灵
把本人、本人舍友和本人水友都弄麻了的错误——本地正常结束,评测机上随机RTLE。后来事实证明,并不是本地无法复现,而是测试的次数不够多,至少本人在反复提交同一组数据近1k次后终于复现出了不能正常结束的情况,并通过遍地埋伏的sout找到了线程不安全的地方,因此评测机的压力还是十分大的,我们需要尽力做好本地的工作。
这种情况的症结无非只有一个:线程不能正常结束,最直接的排除方法是肉眼debug,看看哪里有可能一直等待下去而无法被唤醒的可能(最可能的是线程结束逻辑设置得不对,建议首先查这里,比如之前有同学仅仅用isEmpty判断结束,而没有加上isEnd),因为本地复现并测试是很困难的,一般都会以一个正常结束的假象迷惑你,让你以为自己没有问题;下策是在wait()前后安排一对sout,然后以海量数据尝试复现(或者浪费一次提交机会使用评测机进行测评),针对性解决出现的不能结束线程的症结。
当然,还有一个解决方法,就是梳理好思路,然后remake(。)本人由于第七次作业迟迟找不到原因,果断remake,也只花了很少的时间。许多我知道的同学也通过remake驱逐了这个幽灵。
-
-
鬃积雨云:CTLE
-
轮询
建议每一次本地运行都使用Run中的
Profile 'MainClass' with 'Windows Async Profiler'
选项,运行结束后在Profiler导航栏的Timeline处查看有没有轮询现象,如果出现了轮询,会有这样一堆密集的红条出现:这是我现场“复现”出的一个轮询(情况可以更糟,比如连成一块红色矩形)——从它如此轻易能被复现,就能看出它能轻易被解决。这里“复现”它只需要加一个notifyAll()。如果本地出现了轮询,一方面可以看看有没有在不清楚的情况下滥用notifyAll(),我的策略是只对共享对象中的修改方法中执行线程唤醒(比如那种带有set、remove之类关键词的方法),其他的不加;另一方面也可以看看有没有一直在修改并访问一个公共对象。
解决的办法是使用异步处理,可以避免程序因为等待某个资源而一直阻塞,从而提升程序的并发处理能力。比如,把轮询替换为事件通知(类似setEnd那样的就是一个事件通知,有则通知,而不需要我们一直去查询),就可以避免轮询耗费CPU 的问题。异步处理的具体实现方式就因人而异了。
-
-
蔽光高层云:WA
-
题意理解问题
-
调度策略问题
请通过仔细阅读指导书解决这两个问题。
-
线程错误提前结束
线程错误结束也可能导致WA,这里建议每个线程结束时设置一个sout检查结束时的情况(比如等待队列是否为空等),视情况修改线程结束逻辑。
-