面向对象课程第二单元作业总结
前言
电梯系列作业分为三个阶段,逐步深入实现越来越复杂的电梯运行状态模拟。
第一阶段实现单部多线程傻瓜调度(FAFS)电梯的模拟;
第二阶段实现单部多线程可捎带调度(ALS)电梯的模拟,在第一次作业的基础上添加了捎带需求;
第三阶段实现多部多线程智能(SS)调度电梯的模拟,在第二次作业的基础上增加了换乘的需求。
目录
一、设计策略
电梯系列作业的两个重点在于代码架构和调度算法设计。下面就代码架构和三次调度算法的设计分别阐述。
整体架构
在第一次作业的架构设计过程中,受到上一次作业的启发,考虑了架构对于后续扩展的适应性,所以本单元的三次作业的架构基本上没有太大改变。
电梯调度的情境中,很显然存在三个对象——模拟用户输入、调度器和电梯。考虑到后续可能存在的多电梯的扩展,笔者将用户输入,调度器和电梯分别设计成三个线程,用户输入和调度器、调度器和电梯之间分别设置一个共享对象由于数据的交互。
本着“功能分离”的原则,笔者将整个模拟运行的过程分为几个小模块分给不同的对象执行。用户输入只管接收请求,并将请求放入与调度器的共享队列中;调度器只管从用户请求队列取指令,根据此时电梯的运行状态(剩余容量、运行速度等)信息对请求进行一定的分解,将分解出的指令转发给特定的电梯执行;电梯只管运行调度器分配的指令。关于详细的线程间数据共享问题在会详细论述。
结构图如下:
电梯调度算法
对于不同的电梯调度需求需要应用不同的电梯调度算法。
第一阶段电梯需求非常简单。因为是第一次编写多线程程序,所以笔者没有在调度算法上花费太多时间,使用了先来先服务的策略,更多地关注了Java的多线程机制。
第二阶段电梯需要实现捎带请求。参照现实中电梯的运行规律,确定了“一条道走到黑”的调度算法。电梯初始运行时先根据各楼层上下人数的多少确定一个运行方向,向这个方向运行直到该方向的楼层没有乘客并且电梯内没有乘客为止,然后再重新开始这个过程。在电梯向某个方向运行的过程中,电梯会检查并捎带当前停靠楼层同方向的乘客。这样虽然很多情况下不是最优的算法,但是保证了电梯在两趟(一上一下)内能够将现有的所有乘客送达目的地。
第三阶段电梯电梯调度算法的重点在于实现换乘需求。说起换乘,就想到了寻路;说起寻路,就想起了图算法。那么怎样建立这个图呢?再次参考现实情况,往往人在乘坐公共交通工具的时候会选择少换乘的路线,一来这样比较方便,二来可以减少影响到达时间的因素,进而更快到达目的地。
根据少换乘的原则确定调度算法。将楼层抽象成结点,根据某一部电梯可到达的楼层在这些楼层之间建立一个无向完全图,以边的类型来标记根据不同可达楼层构造的无向完全图。
搜索使用类似广度优先搜索算法,找出所有换乘最少的线路,然后根据当前电梯中的人数、电梯运行速度等参数确定一条Cost最小的路线。
二、实现评估
经典OO度量和类图
电梯系列作业的代码架构在第一次作业时就已经确定,但是在实际完成过程中应实际代码编写的需求和性能方面的考虑,实际上还是有少许改动。
第一二次作业评估
第一、二次作业的类图和代码复杂度度量类似,在这里仅列出第二次作业的分析。
第二次作业类调用关系图:
类图大体上清晰明了,三类线程,三个共享对象,其中因为模拟用户输入和调度器之间、调度器和电梯之间的共享对象涉及到线程终止通信机制,所以继承了SharedObject
类。关于线程结束的机制在论述。
第二次作业类代码长度:
第二次作业代码复杂度:
Method | ev(G) | iv(G) | v(G) |
---|---|---|---|
homework.Main.main(String[]) | 1 | 1 | 1 |
homework.common.SharedObject.SharedObject() | 1 | 1 | 1 |
homework.common.SharedObject.SharedObject(boolean) | 1 | 1 | 1 |
homework.common.SharedObject.ioStreamOpen() | 1 | 1 | 1 |
homework.common.SharedObject.setIoStreamState(boolean) | 1 | 1 | 1 |
homework.common.TwoTuple.TwoTuple(A,B) | 1 | 1 | 1 |
homework.common.TwoTuple.getFirst() | 1 | 1 | 1 |
homework.common.TwoTuple.getSecond() | 1 | 1 | 1 |
homework.elevator.Elevator.Elevator(ElevatorController,ElevatorState) | 1 | 1 | 1 |
homework.elevator.Elevator.atomRun() | 1 | 3 | 3 |
homework.elevator.Elevator.automaticRun() | 1 | 3 | 3 |
homework.elevator.Elevator.bootElevator(int) | 1 | 2 | 2 |
homework.elevator.Elevator.closeDoor() | 1 | 1 | 1 |
homework.elevator.Elevator.inOne(int,int) | 1 | 1 | 1 |
homework.elevator.Elevator.inPack(List<TwoTuple<Integer, Integer>>) | 1 | 2 | 2 |
homework.elevator.Elevator.move() | 1 | 4 | 4 |
homework.elevator.Elevator.openDoor() | 1 | 1 | 1 |
homework.elevator.Elevator.outOne(int) | 1 | 1 | 1 |
homework.elevator.Elevator.outPack(Set) | 1 | 2 | 2 |
homework.elevator.Elevator.run() | 3 | 5 | 5 |
homework.elevator.Elevator.targetRun(int) | 1 | 2 | 2 |
homework.elevator.ElevatorController.ElevatorController(ElevatorState) | 1 | 2 | 2 |
homework.elevator.ElevatorController.acceptRequest(PersonRequest) | 1 | 1 | 1 |
homework.elevator.ElevatorController.popInPack() | 1 | 1 | 1 |
homework.elevator.ElevatorController.popInPack(int) | 1 | 3 | 3 |
homework.elevator.ElevatorController.setDestFloor() | 8 | 7 | 9 |
homework.elevator.ElevatorController.setRunDirectionByFloor(int) | 1 | 3 | 3 |
homework.elevator.ElevatorController.setRunDirectionByPeopleNumber(int) | 1 | 2 | 2 |
homework.elevator.ElevatorController.wake() | 1 | 1 | 1 |
homework.elevator.ElevatorState.ElevatorState() | 1 | 2 | 2 |
homework.elevator.ElevatorState.getCurrentFloor() | 1 | 1 | 1 |
homework.elevator.ElevatorState.getDirection() | 1 | 1 | 1 |
homework.elevator.ElevatorState.getOutPack() | 1 | 1 | 1 |
homework.elevator.ElevatorState.getOutPack(int) | 1 | 1 | 1 |
homework.elevator.ElevatorState.isEmpty() | 1 | 1 | 1 |
homework.elevator.ElevatorState.personIn(int,int) | 1 | 1 | 1 |
homework.elevator.ElevatorState.personOut(int) | 1 | 1 | 1 |
homework.elevator.ElevatorState.setCurrentFloor(int) | 1 | 1 | 1 |
homework.elevator.ElevatorState.setDirection(Direction) | 1 | 1 | 1 |
homework.elevator.Floor.Floor(int) | 1 | 1 | 1 |
homework.elevator.Floor.PassengerCome(int,int) | 1 | 2 | 4 |
homework.elevator.Floor.downGroupSize() | 1 | 1 | 1 |
homework.elevator.Floor.getBuildingFloors() | 1 | 1 | 1 |
homework.elevator.Floor.getPeopleGoDown() | 1 | 1 | 1 |
homework.elevator.Floor.getPeopleGoUp() | 1 | 1 | 1 |
homework.elevator.Floor.isEmpty() | 1 | 2 | 2 |
homework.elevator.Floor.nearFloor(int,int) | 1 | 1 | 4 |
homework.elevator.Floor.upGroupSize() | 1 | 1 | 1 |
homework.request.RequestList.RequestList() | 1 | 1 | 1 |
homework.request.RequestList.add(PersonRequest) | 1 | 1 | 1 |
homework.request.RequestList.fetchAll() | 3 | 4 | 4 |
homework.request.RequestList.othersNotify() | 1 | 1 | 1 |
homework.request.UserInput.bindBuffer(RequestList) | 1 | 1 | 2 |
homework.request.UserInput.run() | 3 | 3 | 4 |
homework.schedule.Scheduler.Scheduler(RequestList) | 1 | 1 | 1 |
homework.schedule.Scheduler.assignRequestTo(PersonRequest,ElevatorController) | 1 | 1 | 1 |
homework.schedule.Scheduler.assignRequests(List) | 1 | 2 | 2 |
homework.schedule.Scheduler.register(ElevatorController) | 1 | 1 | 1 |
homework.schedule.Scheduler.run() | 3 | 4 | 5 |
Total | 74 | 98 | 108 |
Average | 1.25 | 1.67 | 1.83 |
从上面的类代码长度和经典OO度量来看,每个类的代码长度大致均衡,复杂度、耦合度较低,功能划分相对明确;空行、注释比列合理,代码风格良好。
第三次作业评估
第三次作业类图:
由于第三次作业IDEA自动生成的类图实在让人不忍细看,所以笔者手绘了一幅简化版,简化了很多类间调用的细节。
第三次作业类代码长度:
从类代码长度来看,最大的类是Elevator
电梯运行模拟类,纯代码189行;空行比例10%,注释比例21%;代码风格良好。
第三次作业经典OO度量:
Class | OCavg | WMC |
---|---|---|
homework.Main | 1 | 1 |
homework.common.SharedObject | 1 | 4 |
homework.common.TwoTuple | 1 | 3 |
homework.elevator.Elevator | 2 | 38 |
homework.elevator.ElevatorController | 2.08 | 25 |
homework.elevator.ElevatorState | 1.09 | 12 |
homework.elevator.ElevatorState.Direction | 0 | |
homework.elevator.Floor | 1.62 | 13 |
homework.request.RequestList | 1.5 | 6 |
homework.request.UserInput | 2.5 | 5 |
homework.schedule.Instructor | 2 | 20 |
homework.schedule.Scheduler | 1.86 | 13 |
homework.schedule.graph.Edge | 1 | 5 |
homework.schedule.graph.Graph | 3.29 | 23 |
由于方法数量过多,所以在这里仅展示类的平均圈复杂度和总圈复杂度。
从上面的数据可以看出,Graph
类方法复杂度较高,而Elevator
类体量过大。改进方案可以对Graph
类中的方法进行优化,并简化Elevator
类的运行逻辑。
除此之外还有两个方法复杂度超标。
其中一个是elevator.ElevatorController.setDestFloor()
,用于设置初始电梯运行楼层。实际上这个方法的并不复杂,这个方法中需要使用循环来读取各楼层中乘客的数量,其中还有分支语句,因而出现了了复杂度过高的问题。
另外一个是schedule.graph.Graph.route(int,int,int[])
,顾名思义,这个方法是用来搜索完成请求的最少换乘路径的。这个方法基于广度优先搜索算法来实现最少换乘路径的搜索,需要维护很多的状态,各个逻辑之间关联度较高,不易分割。而且图存储的数据结构使用的是两层HashMap
嵌套,这就造成了在读取数据时逻辑复杂度上升,编程和调试的体验都非常差。改进方案可以进一步封装图存储的数据结构,并提供各种各样的数据访问接口,虽然在程序的性能上可能会有所损失(实话这小破程序还不需要抠那点儿性能),但是带来的结构上的优化是不可小觑的。
线程间协作
线程间的协作可以分成两个部分,一个是业务逻辑之间的协作,一个是线程终止逻辑的协作。在下面漏洞分析部分详细阐述,这里只关注业务逻辑的协作。
下面是最具代表性的第三次作业的时时序图:
上面是业务逻辑线程协作图,为了时序图的简洁,笔者将两个相邻线程之间的共享对象省略,并以异步消息的方式表示使用共享对象通信。
SOLID分析
[S] Single Responsibility Principle (单一功能原则)
电梯系列代码基本上符合单一功能原则,每个类完成单一的功能。线程类仅仅涉及线程运行的逻辑,对于电梯类,笔者还将电梯的控制与运行分离;共享对象类有两个功能,一是数据共享,二是线程间状态通信,但是笔者封装了SharedObject
类进行状态通信,使用继承机制将两个功能的实现分开。
[o] Open Close Principle (开闭原则)
很遗憾本系列作业的设计基本上不符合开闭原则。笔者认为设计出符合开闭原则的代码需要有一个全局视野,即能够预见以后会出现什么样的需求。在此基础上,设计者便能够对系统的某些部分进行合理适当地抽象,对某些结构进行优化。
在第一次作业的时候,已经为后续算法的加入预留了接口,在添加调度算法的时候是直接在源代码中修改,所以不算应用了开闭原则。如果要符合开闭原则,可以实现一个调度算法接口,让调度算法实现此接口,这样便能够保证开闭原则。
[L] Liskov Substitution Principle(里氏替换原则)
里氏替换原则认为“程序中的对象应该是可以在不改变程序正确性的前提下被它的子类所替换的”。由于本次作业中没有大量使用继承机制,所以无所谓符合里氏替换原则。想来如果事先考虑了开闭原则,那么便能够用里氏替换原则来衡量开闭原则的实现质量了。
[I] Interface Segregation Principle(接口隔离原则)
接口隔离原则认为“多个特定客户端接口要好于一个宽泛用途的接口”。由于本次作业中没有大量使用继承机制,所以也无所谓符合接口隔离原则。
[D] Dependency Inversion Principle(依赖反转原则)
依赖反转原则认为一个方法应该遵从“依赖于抽象而不是一个实例” 。本次作业完全不符合依赖反转原则。可以看到,代码中类间调用关系比较复杂,而且都是直接调用,中间并没有接口之类的抽象层次。这是因为在最初设计的时候便没有考虑到提升抽象层次。
想来本次作业中可抽象的部分还有很多,例如共享对象的模型、线程终止时状态通信的机制等,这些都是可以再优化的点。
总结
由于在设计过程中没有考虑到设计原则,所以本次作业在SOLID原则方面还有很大的欠缺,定痛改前非!
三、漏洞分析
三次作业的强测均通过,没有发现Bug(但是不代表没有)。下面是笔者在编写代码过程中遇到的一些小问题。
线程间状态通信
在编写多线程程序的过程中很重要的一个问题便是怎样结束程序的执行。在电梯模拟运行的情境中,共有三个线程(实际上在第三次作业中存在四个线程,将调度器分离成 两个线程,一个用于队请求进行换乘寻路,一个用于向电梯分发指令;三个电梯算作一类线程),用户输入线程在检测到输入结束之后终止,调度器线程在检测到用户输入线程结束之后随即终止,电梯线程需要执行完所有调度器分配的指令并且调度器线程已经结束才可以终止。
从上面的分析可以看出,此情景下普遍存在一个线程依赖另另一个线程结束而结束的需求,所以笔者封装了SharedObject
类,只需要让所有的共享对象继承这个类,便能够完成线程间的状态通信。其代码如下:
public class SharedObject { private boolean ioStreamState; public SharedObject() { this(true); } /** * The initial state of {@code ioStreamState} is true. * @param ioStreamState true means the thread is still alive. */ private SharedObject(boolean state) { ioStreamState = state; } /** * Only one of two connecting thread should set {@code ioStreamState} to * update its statement under such scenario. * @param ioStreamState open mark of the IO stream. */ public synchronized void setIoStreamState(boolean ioStreamState) { this.ioStreamState = ioStreamState; notifyAll(); } public synchronized boolean ioStreamOpen() { return ioStreamState; }}
以模拟用户输入线程和调度器线程之间的通信为例,在线程结束之前,ioStreamState
为true
,表示模拟用户输入线程尚未结束。在模拟用户输入线程结束之前,将ioStreamState
设置为false
,表示线程已经结束,并通知调度器。调度器发现模拟用户输入线程结束后随即终止。
在最终确定下这个方案之前笔者走了些弯路。
最开始笔者使用Thread.getState()
方法来判断线程是否结束,于是在线程结束之前便只需要通知监听线程即可,而不需要反转标志位。考虑下面的情况,\(A\)通知\(B\)自己即将终止,然后\(A\)线程放弃CPU;\(B\)线程开始运行,\(B\)调用A.getState()
方法来判断线程\(A\)是否结束;显然此时线程\(A\)并没有结束,那么线程\(B\)重新进入等待,之后再也没有线程唤醒线程\(B\),于是造成程序无法终止。
后来笔者查看Thread.getState()
方法源码,源码中也很清楚的注释了本方法用于监控系统状态而不是线程同步:
/** * ... * This method is designed for use in monitoring of the system state, * not for synchronization control. * ... */public State getState() { ...}
线程间数据共享
生产者消费者
本次作业中共享数据大量采用了生产者消费者模型。在踩了很多坑之后发现,将线程同步的操作放在共享对象中是最省心的办法,也就是把synchronized写在共享对象中。
提高并发度
抱着提高并发度的想法,笔者考虑将共享对象拆分开。以电梯和调度器之间的共享对象为例,将每个楼层作为一个小共享对象。调度器添加请求和电梯获取请求时只需要取得特定楼层的锁即可,而大多数情况下二者不会同时需要获取一个楼层的锁,于是并发度便有所提高。
这样的想法对于第二次作业仍然是可行的。但是到了第三次作业,只获取局部的数据已经无法满足调度器寻路功能的需求了,调度器需要一个全聚的视野,而访问全局数据需要添加一个共享对象全局的锁,于是笔者之前设计的拆分共享对象来提高并发度便无用武之地了。如果不添加这个锁便有可能造成线程不终止。
有趣的是刚开始笔者没有注意到这个问题,直接提交评测结果没有发现错误。这也警示了多线程的Bug很多时候很难检测,因为Bug复现往往要求在特定的时间节点触发一定的事件。所以在编写多线程程序时工程化和纪律性非常重要。
其实仔细思考一下会发现,调度器和电梯大多数时间都是处在Wait状态,争夺锁的机会本来就不多,所以这种提高并发度的优化也是无关紧要的。
四、心得体会
经过上面的思考,笔者总结了下面的几条体会:
- 涉及到多线程程序共享对象的处理时,最好将线程同步的操作写入共享对象中。于是在外部看来,对共享对象的读取是一个阻塞式读取,能够大大简化读者和写者的逻辑。
- 测试往往不能检测出多线程程序的所有错误,在编写代码时熟悉各种多线程设计模式很重要,而且在优化代码的时候需要很小心,防止因小失大。
- 将SOLID原则应用到设计阶段中去,会大大提升代码质量。细致划分代码的功能,让一个类专注于某一个功能;整体分析需求,提炼代码功能和结构上的共同点,提升代码的抽象层次,进而提高代码扩展性。