OO第5-7次作业总结
OO第5-7次作业总结
这三次作业全部是关于电梯的。主要锻炼了多线程编程的能力,以及了解一些调度算法的使用。
1、设计分析
1.1、单电梯FCFS调度方案
第五次作业,不是一般的简单,几乎人人满分的那种。dalao们甚至在30行内写出单线程调度就解决了。摸鱼划水的一次。本着练习多线程的目的,我还是老老实实写了调度。只有一个请求队列,由于不考虑捎带,所以直接FIFO队列即可。由于当时对同步和锁的掌握很浅,为了求稳,对于同步访问(队列为空时等待)采用了轮询的办法。这样虽避免了死锁,但浪费CPU时间。
架构:首先定义两个线程(输入、电梯),它们在主线程中启动,共享一个请求队列。这队列用线程安全容器LinkedBlockingQueue
。一方面是为了阻塞,另一方面是为了保证队列长度(即size()
方法)的访问是一个原子操作。另外定义一个状态类WhenToStop
,用来指示输入是否结束。stopInputting()
方法用来通知两个线程输入已结束。
电梯的移动,是直接到目标楼层的,不需要考虑某一层掉头等的问题。
UML类图:
复杂度分析:
Methods:
Method | ev(G) | iv(G) | v(G) |
---|---|---|---|
Elevator::Constructor | 1 | 1 | 1 |
Elevator.absSub(int,int) | 2 | 1 | 2 |
Elevator.closeDoor() | 1 | 3 | 3 |
Elevator.getOff() | 1 | 1 | 1 |
Elevator.getOn() | 1 | 1 | 1 |
Elevator.openDoor() | 1 | 3 | 3 |
Elevator.run() | 2 | 3 | 4 |
Elevator.toFloor(int) | 1 | 3 | 3 |
InputThread::Constructor | 1 | 1 | 1 |
InputThread.run() | 1 | 4 | 4 |
Main.main(String[]) | 1 | 1 | 1 |
WhenToStop.isInputting() | 1 | 1 | 1 |
WhenToStop.stopInputting() | 1 | 1 | 1 |
Total | 15.0 | 24.0 | 26.0 |
Average | 1.15 | 1.85 | 2.0 |
Classes:
Class | OCavg | WMC |
---|---|---|
Elevator | 1.625 | 13.0 |
InputThread | 1.5 | 3.0 |
Main | 1.0 | 1.0 |
WhenToStop | 1.0 | 2.0 |
Total | 19.0 | |
Average | 1.46 | 4.75 |
耦合度分析:
Class | Cyclic | Dcy | Dcy* | Dpt | Dpt* |
---|---|---|---|---|---|
Elevator | 0 | 1 | 1 | 1 | 1 |
InputThread | 0 | 1 | 1 | 1 | 1 |
Main | 0 | 3 | 3 | 0 | 0 |
WhenToStop | 0 | 0 | 0 | 3 | 3 |
Average | 0.0 | 1.25 | 1.25 | 1.25 | 1.25 |
复杂度控制还是比较好的,没有一个方法的复杂度超过5。耦合度也不高。线程间通信只靠两个共享对象。
时序图(线程间通信机制):
1.2、单电梯可捎带
这次需要可捎带,所以第五次作业那种只设置一个共享队列的方法不适用了。因为捎带请求必须同时考虑电梯外请求和电梯内乘客,并在合适时机掉头。
我查了一下可行的调度算法,包括:scan算法,look算法,ALS,还有FCFS,最近距离等。
scan算法是上下循环调度,类似于摆渡车、轮渡,缺点是没有乘客的楼层也要停。
look是scan的改进,没有乘客和请求的楼层不停,直接掉头。
ALS和FCFS太熟悉了,不用说。
还有一种是最近距离,也就是当电梯内有乘客时,乘客优先。无乘客时,距离当前楼层最近的请求优先。但这会出现某些请求“饿死”的情况。
综合以上几种,还是look比较好。
基本思想就是,请求要分为电梯外请求和电梯内乘客两部分。电梯外请求按照出发楼层分类,电梯内请求按照目标楼层分类。每当电梯到达某一层时,检查是否需要上客或下客。下客的标准是到达目标楼层,上客的标准是他的请求方向和电梯当前运行方向(不是主请求的请求方向)相同。有则开门,无则甩过直接走。每当准备移动到下一层时,检查是否需要掉头(检查是否掉头是一个扫描过程,如果电梯内有乘客则不能掉头,否则扫描电梯外请求,如果当前方向上没有请求则掉头,如果两边都没有请求,则停下来等待)。
这次由于是单部电梯,所以直接复用上次的架构,不需要增加调度盘。线程安全上,考虑了用lock
和codition
来进行同步、互斥。这样灵活性比较高。
所用容器如下(直接粘贴Main类源码,省略import):
public class Main {
public static void main(String[] args) {
TimableOutput.initStartTimestamp();
final ReentrantLock reentrantLock = new ReentrantLock(); // 锁和条件
final Condition condition = reentrantLock.newCondition();
final HashMap<Integer, LinkedBlockingQueue<PersonRequest>> outerRequests
= new HashMap<>(); // 电梯外请求
for (int i = -2; i <= 16; i++) { // 这里的楼层做了特殊处理,以便电梯能连续运行,输出时再转换
outerRequests.put(i, new LinkedBlockingQueue<>());
} // 每层的请求都要初始化一个空队列
final InputtingState state = new InputtingState(); // 指示输入是否完成
InputThread inputThread = new InputThread(outerRequests,
reentrantLock, condition, state); // 输入线程
Elevator elevator = new Elevator(outerRequests,
reentrantLock, condition, state); // 电梯线程
elevator.start();
inputThread.start();
}
}
电梯内乘客的容器只在Elevator类中定义和使用,不需要共享。容器类型和电梯外请求相同。
UML类图:
复杂度分析:
Methods
Method | ev(G) | iv(G) | v(G) |
---|---|---|---|
Elevator::Constructor | 1 | 2 | 2 |
Elevator.checkDirection() | 10 | 6 | 10 |
Elevator.closeDoor() | 1 | 4 | 4 |
Elevator.downOneFloor() | 1 | 3 | 3 |
Elevator.getFloor() | 2 | 1 | 2 |
Elevator.getOff() | 1 | 2 | 2 |
Elevator.getOn() | 3 | 5 | 5 |
Elevator.isEmpty(HashMap<Integer, LinkedBlockingQueue<PersonRequest>>) | 3 | 2 | 3 |
Elevator.moveOneFloor() | 1 | 2 | 2 |
Elevator.openDoor() | 1 | 3 | 3 |
Elevator.realFloor(int) | 2 | 1 | 2 |
Elevator.run() | 3 | 9 | 10 |
Elevator.upOneFloor() | 1 | 3 | 3 |
InputThread::Constructor | 1 | 1 | 1 |
InputThread.run() | 1 | 4 | 4 |
InputtingState.isInputting() | 1 | 1 | 1 |
InputtingState.stopInputting() | 1 | 1 | 1 |
Main.main(String[]) | 1 | 2 | 2 |
Total | 35.0 | 52.0 | 60.0 |
Average | 1.94 | 2.89 | 3.33 |
Classes
Class | OCavg | WMC |
---|---|---|
Elevator | 3.08 | 40 |
InputThread | 1.5 | 3 |
InputtingState | 1 | 2 |
Main | 2 | 2 |
Total | 47.0 | |
Average | 2.61 | 11.75 |
可见检查掉头的方法checkDirection()
的复杂度还是比较高的,远远超过平均复杂度。这个方法要综合考虑乘客和外请求,代码量也比较大,达到了30行。另外,Elevator
类是实现核心功能的,但是包含了调度方法,所以复杂度也比较高。当时是为了方便,调度器和请求队列合一。
耦合度分析:
Class | Cyclic | Dcy | Dcy* | Dpt | Dpt* |
---|---|---|---|---|---|
Elevator | 0 | 1 | 1 | 2 | 2 |
InputThread | 0 | 2 | 2 | 1 | 1 |
InputtingState | 0 | 0 | 0 | 3 | 3 |
Main | 0 | 3 | 3 | 0 | 0 |
Average | 0.0 | 1.5 | 1.5 | 1.5 | 1.5 |
耦合度依然不高,因为两个线程通信仍然只需要两个共享对象、一把锁、一个条件(监听器)。
时序图(线程间通信机制):
1.3、三部电梯协同调度
这次又不一样了,不仅增加了两部电梯,而且增加了乘客数量限制、停靠层限制。所以,这决定我们需要一个调度盘来把乘客请求分配到合适的电梯上去。并且,考虑换乘问题。
电梯类统一建模,但是增加一些属性,包括停靠层限制、核载、运行速度。利用(半)工厂模式,构造函数只传入电梯类型,根据电梯类型来决定这些属性的值。由于仍然采用look算法,所以可以复用上次的电梯,只需要对电梯运动情况和掉头的条件做一些调整。
架构就是三个电梯、一个调度盘,请求队列分三块,一块是原始请求,也就是刚输入的时候,没有经过调度器分配,可以理解为站在大厅门外。一块是分配后请求,可以理解为站在某个电梯门口。最后是电梯内乘客。
换乘不难解决,为了一劳永逸的防止出错,我们把未完成的请求扔回原始请求中,也就是回滚。这样保证各个电梯互相独立。电梯只负责运送乘客,不管任何调度问题。这样保证耦合度低,不易出错。
另外的坑点,注意停止条件。这一次,使用共享对象unfinishNum
(自定义一个类SyncInteger,模拟原子整数),表示未完成的请求个数(从一个请求产生到它从电梯中出去并上到目标楼层为止,这段时间称作未完成)。当输入结束,并且未完成请求个数为0时,所有线程全部终止。再有就是注意输出互斥问题,因为输出函数对stdout是竞争关系,所以同一时刻只能有一个线程在输出,否则会导致输出穿插(这个穿插是指行内穿插)混乱。
三部电梯的调度,应该让它们尽量并行,这一步由调度器Dispatcher
来完成。调度器也是一个线程,和输入线程、电梯线程并发。具体的调度优化,详见:https://www.cnblogs.com/wancong3/p/10739633.html
UML类图:
复杂度分析:
Methods
Method | ev(G) | iv(G) | v(G) |
---|---|---|---|
Dispatcher::Constructor | 1 | 1 | 1 |
Dispatcher.dispatch(PersonRequest,int) | 3 | 4 | 4 |
Dispatcher.fixedFloor(int,int,int) | 13 | 4 | 15 |
Dispatcher.run() | 3 | 11 | 12 |
Elevator::Constructor | 2 | 7 | 10 |
Elevator.checkDirection() | 10 | 9 | 13 |
Elevator.closeDoor() | 1 | 4 | 4 |
Elevator.downOneFloor() | 1 | 3 | 3 |
Elevator.fixedFloor(PersonRequest) | 12 | 4 | 14 |
Elevator.getFloor() | 2 | 1 | 2 |
Elevator.getOff() | 1 | 4 | 4 |
Elevator.getOn() | 5 | 6 | 10 |
Elevator.isEmpty(HashMap<Integer, LinkedBlockingQueue<PersonRequest>>) | 3 | 3 | 4 |
Elevator.openDoor() | 1 | 3 | 3 |
Elevator.realFloor(int) | 2 | 1 | 2 |
Elevator.run() | 3 | 11 | 12 |
Elevator.syncOutput(String) | 1 | 1 | 1 |
Elevator.toNextFloor() | 1 | 3 | 3 |
Elevator.upOneFloor() | 1 | 3 | 3 |
InputThread::Constructor | 1 | 1 | 1 |
InputThread.run() | 1 | 4 | 4 |
Main.main(String[]) | 1 | 7 | 7 |
SyncInteger.SyncInteger(int) | 1 | 1 | 1 |
SyncInteger.getValue(int) | 1 | 1 | 3 |
ThreadRunning.isRunning() | 1 | 1 | 1 |
ThreadRunning.stopRunning() | 1 | 1 | 1 |
Total | 73.0 | 99.0 | 138.0 |
Average | 2.81 | 3.81 | 5.31 |
Classes
Class | OCavg | WMC |
---|---|---|
Dispatcher | 5.75 | 23 |
Elevator | 4.33 | 65 |
InputThread | 1.5 | 3 |
Main | 7 | 7 |
SyncInteger | 2 | 4 |
ThreadRunning | 1 | 2 |
Total | 104.0 | |
Average | 4.0 | 17.33 |
复杂度较高的方法就是fixedFloor()
,用于寻找合适的换乘点。它采取了随机顺序的方法,并且循环搜索。
另外值得注意的就是,调度器类和电梯类的复杂度都比较高,可能是因为使用了过多的共享对象。还有就是Main类的OCavg竟然达到了7,也和初始化过多的共享对象有关。但是没有办法啊,本来电梯换乘是乘客考虑的,强行扔给调度器也是真的懒(哈哈哈~)。
耦合度分析
Class | Cyclic | Dcy | Dcy* | Dpt | Dpt* |
---|---|---|---|---|---|
Dispatcher | 0 | 3 | 3 | 1 | 1 |
Elevator | 0 | 2 | 2 | 3 | 3 |
InputThread | 0 | 3 | 3 | 1 | 1 |
Main | 0 | 5 | 5 | 0 | 0 |
SyncInteger | 0 | 0 | 0 | 4 | 4 |
ThreadRunning | 0 | 0 | 0 | 4 | 4 |
Average | 0.0 | 2.167 | 2.167 | 2.167 | 2.167 |
遵循SOLID原则,调度器和电梯功能分离,降低耦合度还是有效果的,我觉得不要管那些极限性能,这样设计才是最佳方案。系统稳定性比单个数据的性能重要得多。
SOLID原则:
1、单一责任:每个类只管自己该管的事情。我觉得这个是重中之重,电梯就是运输乘客,调度器才负责具体哪个乘客上哪个电梯。这样增加了可维护性,即使要改电梯也不会牵一发动全身。
2、开闭控制:哪些类支持扩展,哪些类是final的,哪些函数设置为对外接口,哪些函数是封装的。另外,同样的功能可以定义成抽象方法,支持不同的实现。电梯其实可以这样做,把开关门、上下客和移动到下一层的方法作为抽象方法。只不过,一次作业中没有多种不同实现的电梯,所以目前还没必要这么做。
3、里氏替换:和自定义类的继承(is-a继承)有关。extends Thread
不是啥设计层面的继承,不用管。
4、依赖倒置:强调依赖关系中,高层模块的抽象。电梯当然不会依赖自己。(但这个原则在第三次作业,就是多项式、三角函数复合求导中至关重要,因为求导方法依赖于具体的表达式形式,只有把表达式类抽象出来才能得到语法树)
5、接口分离:说的是,不要用单一接口实现多功能。电梯的设计,远没有这么复杂。
时序图(线程间通信机制):
2、bug杀虫和测试方法
如果按照测试的标准,这三次的强测没有错误。互测呢,根本没有针对性,很难发现bug。
来说说中测发现的bug。第五次就不说了,没有bug。
第六次,没提交的时候,就有严重的问题,停不下来。后来发现使用await()
和signalAll()
用错了位置,在lock()
和unlock()
之外使用,不仅无法结束,还报一大堆异常。
第七次更可笑。我知道三楼肯定会出一些小差错,就故意输入三楼的数据,结果上下往返停不下来。发现是换乘点有问题。再后来自己手动输入没问题,连文件输入都没测试,过于自信的提交,结果等了半分钟不出结果,已经有点慌了。第四分钟,不出所料,统统统统RTLE。
好吧,我的数据果然不行。借了它的两组数据发现问题很严重,一部电梯停下时另两部没停。干脆这样吧,当一个电梯将要结束时,通知其它电梯也停下来(因为此时电梯停止的条件已经满足,只要一接到signal立刻停止)。
有点怕了不敢乱提交,就自己用C写了一个数据生成器,又把官方的输入接口反编译了一下,自己加了定时输入。测了二三十组吧,没问题才敢提交(我知道复用上次的电梯,逻辑肯定不会有问题,就怕出在同步问题上)。
互测呢,感觉就是象征性地跑一跑交空刀刷活跃度。后来第六次身份公开,发现我和六系四大神仙一个组(具体是谁,我屋的人肯定知道),我一打擦边球进A组的菜鸡也能享有这样的待遇,难怪谁都发现不了问题。专门拜读了他们的代码,感觉一些强优化还是非常牛B的,我等算法小白甘拜下风。另外,每个人的调度思路都不同,不太可能通过读代码来针对性出数据,只能是测一测比较容易出错的地方,然后随机生成几个大量数据走压力测试。和第一单元不一样啊,第一单元完全是语法分析,这是多线程。没有了WF写起来真轻松,互测就八脸懵逼了。
在这里特别感谢老柴削面、东方削面HDL、DYJ、ZYY三位奆佬提供的定时输入方法、Special Judge和对拍器,虽然最终没用它们发现bug,但是至少解了燃眉之急。
多线程调试,尤其是当死锁的时候,最好的方法就是printf,你可以在printf的字符串中加一个debug,记得在提交的时候逐一删除(可以用idea的正则表达式替换,超级方便,所以我说你写个debug这样的标志)。线程安全相关问题,printf大法好!
这次测试和第一单元不同的地方在于,不是简单工具可以解决的,必须靠自己分析题目逻辑来判断是否正确。
3、收获
三次作业强迫我学习了多线程。其实最重要的收获就这一点。具体点就是线程安全考虑。只要是设计并发程序,时刻都要考虑线程安全。掌握了线程安全的几种方法:同步、条件锁、原子类、线程安全容器。
还有就是随机算法的认识,在数据参差不齐或完全随机的情况下,随机算法能显著提高平均性能。因为在实际中,最坏情况由于出现概率极小,往往不重要,这也是平摊分析的基本思想。
这三次作业,我感到OO经过改革比往届好得多了,除了前两次作业以外,已经是真正的面向对象。类设计、多线程、并行编程在工程开发中还是非常重要的。另外就是强测做得越来越好,互测已经不怎么是得分手段了,从而能真正体现出程序的质量(当然,有时候也会栽,强测炸的情况不是没有过,如果bug修复能捡回强测部分分就更好了)。
4、工具推荐
时序图工具:PlantUML Integration
安装方法:(这个是IDEA插件,喜欢用Eclipse的小伙伴自行去找,反正我是没找到)
1、IDEA-文件-设置
2、选择Plugins选项卡,单击上方Plugins Market
3、搜索PlantUML Integration
4、install
5、重启IDEA
使用方法详见:https://blog.csdn.net/River_Continent/article/details/79426064
另外给大家推荐一个PlantUML详解:https://www.cnblogs.com/Jeson2016/p/6837186.html