第二单元电梯总结
三次作业的设计策略
第一次作业
第一次作业是单部可稍带电梯。我采用的主体架构是生产者-消费者模式,维护一个线程安全的类Controller
,Controller
类里面维护一个主请求队列,每次生产者调用Controller
类里面的put
方法往主请求队列里面塞入请求,电梯调用Controller
类的get
方法获得请求。我采取的调度策略主要是ALS调度策略,即可稍带策略。但是我最开始对于可稍带的理解有问题,我以为可稍带是指的是在电梯到达某一楼层之前或者电梯在这一楼层开门后关门之前请求到达请求队列时电梯可以进行捎带。我认为对于捎带来说,能接上就接上,因为你总要接这个人,总要花这个开关门时间。所以我就开了一个电梯的辅助线程,用来辅助电梯的输入。因为只有这样才可以实现在电梯已经开门后的0.4s内,当有新的可稍带请求到达时电梯能捎带。这样的设计在第一次作业看来还算可以应对,但在后几次作业中就非常复杂了。而且这样的优化其实对性能分并没有多大的影响,还徒徒增加了设计的复杂度。在每次要判断电梯下一步去哪一层时,挑离电梯最近的请求,无论是去接还是去送人,都挑最近的,即SSTF算法。
第二次作业
第二次作业是多部相同的可稍带电梯。主体架构同第一次,故也是在第一次作业基础上进行扩展。由于我第一次作业的辅助线程的设计,在这次作业里面就面临着各种线程安全的问题和线程之间的协调问题。比如我最开始遇到的问题就是电梯抢人,一个请求被分到了多部电梯,这样可以有多个解决办法。第一种是在多部电梯拿请求的时候就直接从主请求队列里面剔除;第二种是建立一个电梯内部的请求队列,放到电梯内部请求队列里面;第三种就是我采用的最愚蠢的做法:让多部电梯共同去抢请求,谁抢到就是谁的。这个愚蠢的做法主要是因为我偷懒,想要延续第一次作业的设计,即电梯拿主请求的时候并不把它从队列里面剔除,在辅助线程进行输入的时候再剔除,因此我就设计了每一层楼的锁,在电梯要在某一层开门之前,先要尝试获得这一层的锁。这样的设计现在看来真的是非常蠢,因为我完全应该建立电梯的内部队列,当时不这么做的原因就是我被“可稍带”洗脑了,我想要做到尽量完全可稍带,所以就没建立内部队列,让电梯每运行一层楼就判断一次,能捎带就捎带。这样的设计对性能并没有很大的优化,但是设计的复杂度却大幅增加。
第三次作业
第三次作业是多部不同型号的可稍带电梯,有可以增加电梯的请求,并且需要处理换乘的请求。主体架构同第二次,故也是在第二次基础上进行扩展。我增加了ElevatorFactory
类来处理新产生的电梯请求,感觉这样的封装非常方便去调用。由于此次作业的性能计算方法加入了乘客等待的时间,于是我就采取了如果电梯接到了第一个客户,然后接着就去送电梯内部所有的人,这样可以尽量减少乘客的等待时间。对于如何处理换乘的请求,我采取的方法是在生产者把请求放入请求队列的时候,进行一次判断,如果不需要换乘,则直接放入主请求队列,如果需要换乘,则把它分成两个请求,第一个加入主请求队列,第二个则加入到一个Map<PersonRequest,PersonRequest>
中。等到第一个请求完成的时候,如果此请求在这个Map中,则把其对应的值放到主请求队列中。这样的设计可以保证不会出现第一个请求还没完成就处理第二个请求的状况。
从功能设计和性能设计的平衡看第三次作业设计的可扩展性
对于第三次作业,我采用的架构仍是继续沿用前两次的架构。不同的是,电梯每次会从主请求队列里面拿到一个主请求,然后电梯先到主请求的出发楼层,期间能捎带的捎带,待接到主请求后,开始送电梯内部的所有人,等到送完所有人后,再从主请求队列中拿请求。这样的设计可以保证性能不会太差,即个人不会等很久,不会出现饿死现象,因为电梯会尽量先送完电梯内部的人。功能方面,扩展性也还算可以,新加入功能的话,无非是往类里面加方法,但是这会增加一个类的复杂度。下面用SOLID原则进行分析。
-
SRP(Single Responsibility Principle)
每个类或方法都只有一个明确的职责
从这个角度来看,我的
Elevator
类既可以实现运动到指定楼层,也可以扫描主请求队列来判断是否可稍带。因此我的电梯类职责过于多,没有很好的满足SRP原则。因此我应该再加入一个负责调度每个电梯的电梯调度器,这个电梯调度器负责从主请求队列里面拿请求,并判断是否可稍带,并处理一些策略的问题,而电梯只需要负责运行即可 -
OCP(Open Close Principle)
无需修改已有实现(close),而是通过扩展来增加新功能(open)
每次作业的迭代我必须要修改原来已经写好的一些方法和代码,这意味着我没有很好地满足OCP原则。经过反思,我认为可以通过写一个
Controller
的父类作为维护主请求队列的类,然后再写一个维护电梯子请求队列的子类继承自Controller
,这样可以很好的满足OCP原则。 -
LSP(Liskov Substitution Principle)
任何父类出现的地方都可以使用子类来代替,并不会导致相应类的程序出现错误
由于我的程序里面并没有继承关系,因此也不满足LSP原则。我认为想要很好的满足LSP原则需要做到让父类的方法和子类的方法实现的语义相同,这样在任何父类出现的地方都可以通过使用子类来代替。所以势必要使用方法重写来满足。
-
ISP(Interface Segregation Principle)
一个接口只封装一组高度内聚的操作
避免封装多种可能的方案
这个原则的意思是说让接口能干的事情尽量细分,让一个类尽量实现多个接口。在我的程序里没有设计接口,所以也不满足此ISP原则。这个原则的好处在于:如果我想新创建一个类,实现接口会很灵活,如果我想让这个类拥有一个接口所描述的方法,那么我就让其实现这个接口,否则就不让。
-
DIP(Dependency Inversion Principle)
依赖倒置原则
高层次的模块不应该依赖于低层次的模块,他们都应当依赖于抽象类
这个原则的意思我理解是多态的意思,既一个接口可以有多个实现类,不同类可以改写接口的方法,实现多态。由于在我的程序中没有接口和抽象类,因此也就没有满足这个设计原则。
综合以上设计原则,可以发现它们的共性特点都是与java的种种特性:封装、继承和多态紧密结合。合理地利用好这些特性,可以为我们的代码编写如虎添翼,事半功倍。就像是Java的容器一样,它们之间的关系算是一个非常满足SOLID原则的好例子。
基于度量来分析程序结构
由于三次作业都是迭代开发的,我没有重构,所以第三次的作业包含了前几次的程序结构,因此这里着重分析自己第三次作业的程序结构
代码行数
类图
方法度量
类的度量
-
由类的度量图可见
Elevator
类的类总圈复杂度很高,我认为原因在于我Elevator
类设计得过于复杂,前面也提到过,我的Elevator
类干的工作太多了,不仅要移动,还要判断能否捎带人,并且判断下一个要前往得最佳楼层是什么,还有一个原因就是这个类的方法太多了,太复杂。由方法度量图可以看见elevator
的run
方法基本复杂度偏高,我认为是由于run
方法中的控制流结构设计得确实不是很好。还有就是Elevator
类得gotoFloor
方法的基本复杂度和圈复杂度都过高,我分析可能是由于此方法中控制流太多,用了很多的if语句。但是经分析发现设计复杂度都不高,说明我的方法划分的还算不错,使得方法之间的耦合度不高。 -
分析类图可以发现我的类图最大的缺点在于没有接口和继承,全部都是单一的类,这样的坏处在于不能很好的利用面向对象的封装继承和多态特性。而且像
Elevator
类的方法数量过多,复杂度太高,应该单独划分出一个电梯调度器老控制每个单独的电梯。我这样设计的优点可能在于设计难度低,易于想到,但是如果对于更复杂的电梯系统来说,这样的设计绝对是万万不可的。
时序图
分析自己程序的bug
第一次作业
- 第一次作业强测没有出现bug,但是互测被hack出了线程安全的问题。在访问主请求队列
LinkedList
的时候,由于没有加锁,导致出现了线程安全的问题。有两个解决方案,第一个是把访问LinkedList
的方法写在Controller
这个线程安全的类里面,然后对此方法加锁(这样可以避免对象发布。第二种方法是在Controller
类里面先写一个clone
主请求队列的synchronized
方法。我这里认为第二种更好,因为第二种占用锁的时间会短一些,我采用的修复策略也是第二种方法
第二次作业
-
第二次作业强测和互测出现的bug都是
CPUTLE
。本质原因是我电梯调度算法的问题。在电梯的run
方法中,在每次电梯寻找下个要去的楼层的时候,总是选择最佳楼层。最佳楼层的定义是:请求队列里面的请求的起始楼层和已经在电梯内部的人们的目标楼层中所有楼层的最小值。然而我忽略了电梯有容量这个问题,所以就导致了电梯有可能在满人的情况下,不断地停在某一层,想要接人,但是又接不了,不停地循环,导致了CPUTLE
。其实在公测的时候这个问题就暴露了出来,但是由于多线程的不稳定性,这样的问题在跑几次后CPU时间会随着多线程的不稳定性而变化。在公测提交的时候CPU时间有些时候是8s
,有些时候是1.8s
,然后我也没有想明白这到底是为什么,这个时候其实我应该好好地找问题,但是我却放弃了找问题,就直接提交了,这是我最大的问题。通过这次作业,我意识到只要CPU时间大于2s
几乎一定是轮询了,对这种错误绝对不能松懈! -
我还意识到了一点就是轮询几乎一定是发生在
while
循环中。因为只有这样才会导致线程不断地占用CPU,因为程序的执行速度很快,如果没有在while
块中,那么这个线程要么就在waiting状态,要么就很快就执行完结束了
第三次作业
- 第三次作业强测和互测的bug是WA。出现这个bug的原因在于我在第二次作业的基础上进行迭代修改的时候,没有修改电梯的结束条件,导致电梯过早的结束。因为第三次作业中需要换乘的请求我放在了换乘队列里面,而我仍采用了第二次作业中主请求队列空就结束这个条件,所以就出现了这个严重的bug。
- 出现这个bug的原因在于,第一,我考虑得不周全,在进行迭代的时候考虑的太少。第二,也是最重要的一点是,我没有做好充足的测试。其实这个很容易就能测出来,多方几个换乘的请求就可以测得出来,但是由于我没有设计相应得检验结果是否正确的程序,也导致了我没有做最基础的测试。但是我竟然通过了公测,可见公测的测试是有多么的弱。吃一堑,长一智,我以后再也不会这样没做好测试就上阵了。其实就算不写验证结果是否正确的程序,也可以通过在程序中检查队列是否为空,在一些人请求完成的时候进行
printf
也是一种很好的解决方法,但不知道为啥,我第一次尝试提交,就过了公测,然后我就有了迷之自信,认为我程序没有bug了。结果就导致了我强测直接原地螺旋爆炸。这件事情告诉我们,一定不要有迷之自信,否则就会有事后的密制痛苦 - 第三次作业我从别人那里学到了评测机并不会滤掉标准异常,也就是说,标准输出可能不会告诉你,也可能会加密,但是标准异常他会告诉你,所以就可以用System.err.println来打印到标准异常里,通过评测机帮助自己debug
分析自己发现别人程序bug所采用的策略
- 这一单元我又继续延续了上一单元的手动测试,我发现手动测试真的测不出来啥。除了构造几个极其特殊的测试样例,例如定点在某一秒输入超多请求,又或者是在第三次作业中多构造需要换乘的请求,我确实也hack到了一些人
(但都是因为我在很垃圾的房)。 - 第一单元的测试更多的可能在于结果对就对了,第二单元的测试需要看过程实现的对不对,需要有一个时序的概念。
- 我也在这一单元发现了评测机的好处。第一,覆盖面广,可以疯狂构造测试用例,覆盖所有的可能去找bug。第二,自动化。手动测试是真的累,要一遍遍输入,输出,检查,自动加直接全部自动化处理,只需一键搞定。第三,也就是我认为最好的地方,在于不仅可以互测的时候找bug,也可以提前帮自己的程序找bug!!!第三次作业真的有点打击我的自信心了,最大的问题也在于我自己偷懒,不会搭评测机就不搭了,也不想测试,在这里我深刻检讨,以后一定要改掉这个偷懒的习惯,不会可以学,但是不能放弃。
- 所以,还等什么,下一单元一定要整个评测机出来!
心得体会
线程安全
线程安全方面我的心得体会是用syncronized
,wait
,notify
就足以解决多线程编程所遇到的所有同步,互斥,协作的问题了。所以我这里想说的是,能把这些东西玩会玩熟练,我觉得就非常可以了,终于那些高级的锁,和Concurrent
包里面的一些高级东西,可以以后在多线程方面深入研究的时候再去领悟。这三次作业的测试中我没有遇到死锁问题,是因为我在写作业的时候遇到了然后解决了。我感觉死锁问题其实挺好解决的,破坏死锁形成的必要条件之一,闭环,就可以很好的解决。在一个线程集合里面,任意的线程组合所需要的锁连接起来都没有闭环,即可保证绝对不会发生死锁。如果这样描述稍显复杂的话,只要心里头默默记住线程获取锁的顺序要一致即可。
设计原则
设计原则方面,我算是对生产者和消费者模式有了一个充足的理解。同时,我认为其实观察者模式与生产者消费者模式很像,观察者模式又被成为订阅发布模式,它们的区别在于:
-
消息是否被多个对象处理。生产者消费者模式是所有消费者抢占消息,订阅发布模式是所有订阅者共享消息。
-
主动权不同。生产消费者主动权在消费者,订阅发布模式主动权在发布者。也就说订阅者是把主动权交给了发布者,从代码层面更好的实现解耦。而且订阅发布模式没有所谓的中间队列的概念。
一些感想
- 感谢这一单元让我从一个对多线程一无所知的菜鸟,到对多线程有了基本初步的了解。我真的学到了很多关于多线程方面的知识,感谢辛勤付出的老师们和助教团队。
- 评测机是真的香,下一单元一定要整一个!
- 强测之前一定要做好充足的测试,一定要严肃对待任何的错误
- 写程序的时候要牢记SOLID原则,做到尽量满足SOLID原则
- 良好的架构比那点性能分重要得多!这点上我在这单元是真的深有体会
- 还是那一点,没有思路的时候,多请教别人,多与同学沟通,多上讨论区
- 是真的不能偷懒,偷懒必死!