面向对象第二单元总结
第五次作业
一、基本需求分析
本次作业的需求简而言之是构建一个电梯模拟系统,通过此系统对电梯的调度进行模拟,根据给定时间顺序的输入行为,给出对应的电梯行为输出。
二、架构设计
本次作业的行为主体有:电梯、调度器、乘客以及输入行为的输入器和输出行为的输出器
2.1 电梯行为解析
电梯具有两个状态:活动状态、暂停状态
在活动状态时,电梯具有:上升、下降、开门、关门四个行为以及结束运动控制线程的毒丸行为
在暂停状态时,电梯具有:乘客进入电梯、乘客下电梯、更改目的地、更改状态四个行为
将上述两个状态区分为两个线程对电梯进行掌控,活动状态为运动控制器掌控电梯,暂停状态为任务控制器掌控电梯,两者互相使用wait和notify方法协同。
2.2 输入器行为解析
输入器与电梯的任务控制器借助共享对象任务队列
构成生产者-消费者模型,输入器作为生产者,向任务队列加入乘客需求信息,
但是与一般的生产者、消费者模型不同的是,由于生产者和消费者之间并不存在由sleep
造成的时间差,而且托盘容量并没有限制,所以生产和消费之间并不采用wait
和notify
来进行协调,而是当输入线程读取到新的输入时,就开始尝试获取对应楼栋的任务容器的锁,当加入完成后释放锁并继续等待输入。而当调度器获取到电梯的锁时,尝试获取任务列表的锁更新自身待处理任务队列,并对电梯行为进行规划,为电梯设定下一步运动目标,进入wait
并提醒运动线程接管电梯
2.3 行为设计所依赖的数据信息
本次作业设计中,将程序结构区分为电梯部分与输入部分,两部分之间由调度器来进行桥接。其中调度器和输入部分组成生产者-消费者模型,调度器作为消费者,输入部分作为生产者。其中输入部分不进行wait()
,而是有输入等待来进行阻塞,调度器在分发托盘上wati()
,当输入线程收到新数据时对其进行notifyAll()
操作唤醒。
而调度器与电梯运动控制器也组成了一组生产者-消费者模型,调度器作为生产者,电梯运动控制器作为消费者,电梯本身作为两者进行协同的托盘。当调度器从输入线程收到新的请求后,加入电梯托盘中并对电梯运动控制器进行notifyAll()
操作。
2.3.1 输入部分
输入部分的功能为,将输入的数据进行分类投放到各个楼栋的托盘中,其功能类有:输入器、任务“托盘”;功能类有需求类
而任务托盘中需要使用一套以起始楼栋为key
、任务容器为value
的散列表,分别对应每一个楼层的任务容器,这一个散列表应该成为一个不可修改的对象,因为在初始化之后则不允许再次改变,而对于其每一个value
来说,需要加锁进行保护,由楼栋的调度器线程与输入线程来竞争对应楼栋的任务容器
2.3.2 电梯部分
电梯部分使用状态模式、策略模式实现调度器对电梯的调度,使用运动线程来保护电梯的冻结时间(即移动、开门、关门的过程内部)不被控制器操作
- 状态模式 :即电梯是进行上升、下降、开门、关门、停止等操作由状态来决定,而运动控制器设置这些状态,并根据当前状态决定下一个状态
- 策略模式 :对于电梯的运动,将电梯的运动计划算法封装为一个类,继承策略的接口并放置在调度器中,通过得到电梯接受的任务、电梯当前目的地、当前调度器待处理任务返回电梯的新目的地,这些数据并不需要储存在策略类中,只需要在调用方法时传入即可。
- 运动线程 :运动线程负责设置状态并进行状态中的行为,并获取返回值休眠时间进行休眠,运动线程的只需要获得电梯的引用即可
- 电梯:电梯是一个数据的重要载体,电梯需要保存电梯自己接受的任务,并且记录被设置的状态,同时要记录自己的目的地以及当前的楼层。
- 电梯的运动控制器和调度器需要对电梯本身对任务容器进行竞争,本次作业采用分层的结构,输入类作为根节点,通过任务容器进行各楼栋的调度器的任务分发,而楼栋的调度器通过电梯自己的任务容器对各电梯进行任务的分发。电梯的运动策略在运动控制模块中,电梯的调度策略放置于电梯调度器中。
2.4 需要进行互斥保护的部分
首先考虑输入部分,输入部分中的各栋楼的任务容器可能会被输入和调度器两个线程同时操作,所以需要在任务容器加锁,以保证数据的安全
在电梯部分,需要对电梯进行互斥保护,同一时间任务调度器和运动控制器只能有一个掌握了电梯的控制权,而对任务调度器的通知使用notifyAll()
实现
核心部分的UML图如图所示
三、线程安全设计
在本次作业中,存在两个部分的共享对象:楼层分配器、电梯。
对于共享对象的保护,本次作业采用了synchronized
同步块来进行处理,对于共享的容器,其元素PersonRequest
是不可变对象,而托盘本身并不会返回容器的应用,所有的对容器的操作都通过托盘内部的操作来实现。synchronized
同步块就放置在这些方法上。使得生产者与消费者的行为组(一个或多个行为)交替进行。
第六次作业
一、增量需求分析
1.1 多部纵向电梯
在第二次作业中,每一栋楼层可以动态增加电梯,所以与之前的第一次作业中的每一栋楼只有一部电梯不同,需要在原来的基础上增加多部电梯调度的策略以及动态电梯增加的功能,由于在上一次作业中,由楼栋调度器来管理多部电梯,所以本次作业中,同样将新增电梯的功能加入楼层调度器之中,同时将第一部电梯的建立放入调度器的构造之中
1.2 横向电梯
同时,第二次作业中增加了横向电梯的功能,此时电梯不再只有楼栋之间的纵向电梯是相互独立的,楼层的横向电梯同样是独立的,所以在原有的楼栋调度器的基础上,还需要新增楼层横向电梯调度器,每一个楼层调度器分管该楼层横向电梯的调度
二、修改部分设计
2.1 第一次作业中的增量适配设计部分
在第二次作业中,需要进行以下的修改,按照输入数据流顺序排序如下
- 在输入类中增加横向电梯任务的处理方法(HoriTaskArranger,VertTaskArranger)
- 新建横向电梯调度类,其结构与楼栋电梯调度类类似
- 完善电梯调度器中多部电梯调度策略的方法
第六次作业中的架构如图所示
2.2 增量设计部分实现细节
2.2.1 横向电梯处理方法
由于本次作业增加了横向电梯且横向电梯与纵向电梯具有很多相似的性质,独立的性质较少,所以决定将电梯作为一个抽象类,由横向电梯和纵向电梯来对其进行继承。
2.2.2 横向电梯调度策略
对于横向电梯,我使用了指导书中给出的类ALS调度策略,定义了主请求以及请求的方向来实现捎带的决策。其余并没有太大更改
2.2.3 多电梯任务分配策略
本次作业中一个调度器可以对应多个电梯,所以调度器在获得请求的时候需要决定由哪一部电梯来执行,本次作业中借鉴指导书中的均匀分配的方法。在电梯分配策略中建立了历史任务表。每次向某一部电梯分配请求时,增加其对应历史任务计数,而增加电梯时,在历史任务表中添加其对应的条目。
每次选择电梯时,选择历史任务最少的电梯。
三、线程安全设计
由于修改部分过少,并没有涉及新的部分,所以本次的线程安全设计没有进行改动
第七次作业
一、增量需求分析
1.1 换乘功能
在本次作业中,不再保证乘客的请求在同一栋楼或同一楼层,所以就回存在需要换乘的存在,我们只选择最多两次换乘的情况,所以对于换乘我们可以将其简化为三个步骤,(把冰箱门打开,把大象放进冰箱里,关上冰箱门),
- 乘坐一种电梯
- (可选)乘坐另一种电梯
- (可选)乘坐第一种电梯的相同电梯
1.2 横向电梯可达性
横向电梯的可达性由定制的可达信息\(M\)来决定,设定电梯的可达性并不复杂,只是由\(M\)的\([4:0]\)位来决定,每一位是1则代表某一楼栋是可达的。
1.3 电梯信息定制
需要对于电梯的各种信息进行定制,上一次作业已经实现这一个功能
二、修改部分设计
在第三次作业中,按照工作量降序可以分为三个部分:
- 换乘功能的实现
- 可达性的定制
- 电梯参数的定制
2.1 换乘功能的实现
换乘功能的实现主要是将一段不可达路径拆分为可达的若干路径,而分配器对于向路径中的所有调度器分发任务,调度器收到任务后再规划下属的电梯,向适宜的电梯分发任务。
为了适配请求的分段,新建Person
类来作为在程序中流动的请求。
而换乘的方式可以有先横后竖或先竖后横,即有以下几种情况
- 横直达
- 竖直达
- 先横后竖
- 先竖后横
- 先竖后横后竖
- 先横后竖后横
我们在分配时,应该权衡距离与等待运输压力来对换乘电梯进行选择。
首先,我们在楼栋/楼层分配器中对路径进行规划。先找到所有可达路径中,移动路程最小的,如果移动路程最小的路径有多个,我们就从中选出横向楼层压力最小的。
为此我们需要给出一个运输压力评估方法,取整个电梯等待任务队列中所有的乘客数为电梯的压力数。每当电梯中加入一个人时,其压力数自增,而一个人离开电梯时,该电梯压力数自减。楼层或楼栋的压力数即为其下属所有电梯的压力数之和。
在规划路径时采用静态规划的方法,先计算出移动路程最短的路径,得到移动路程最短的路径后,如果只有一个则直接选择,如果有多个则选择压力最小的一条路径。
在电梯分配时也同理,选择当前调度器下的压力最小的电梯来执行这一次任务。
之后直接将划分好的任务序列投放至对应的楼栋/楼层,又楼层来对各自负责的部分进行电梯分配,分配完成后将任务向电梯投放。与前两次作业不同的是,这次投放的位置不再是等待队列。而是电梯类中新增的一个notAvailable
队列,代表还未生效的请求。每次任务完成一个阶段时,会对下一阶段的电梯进行notifyAll()
操作,唤醒电梯将其加入等待队列。
2.2 电梯参数的定制
在上一次作业中,横向电梯和纵向电梯继承自电梯类,电梯类中可以自定义开关门时间与运动时间,将这一参数的自定义下放给横向电梯和纵向电梯,使横向电梯和纵向电梯的构造函数中可以对时间参数进行定制。在电梯的调度器中进行构造的电梯传入默认的定制时间,而动态增加的电梯则传入请求中给出的定制时间。
对于默认存在的一层横向电梯,可以在横向电梯调度器的构造函数中判断电梯的层数,若在一层则生产一个默认的横向电梯。
而对于横向电梯的可达性属性,将其设置在横向电梯及横向调度器类中,可达性的掩码形式并不可见,而是暴露出方法来判断对于该层来说,某一请求是否是可以实现的。
三、线程安全设计
在本次作业中,引入的新的线程安全问题出现在任务序列上。由于任务队列自身的信息可能同时由多个电梯来获取,以及一个电梯线程来更新。此时有可能出现线程安全问题。
所以需要对任务序列中的内容加锁,由于本次的任务序列满足多读单写的情况,未完成该序列的电梯会查询当前任务序列的情况,而只有当前正在执行这一任务序列的电梯会修改这一任务序列的信息。所以本次使用了ReentrantReadWriteLock
来对任务序列信息进行保护。
本次的思路和之前相同,将对任务序列的信息的所有字段都进行了封装,并不向外暴露引用,所有对其进行修改或读取的操作都又类内部的方法来实现。在这些方法中使用lock()
和unlock()
获取和释放锁,实现对共享资源的保护。
电梯协作流程
本次作业在线程的协作上保持了一致,三次作业并没有对协作的流程进行修改。
三次作业之间线程的协作都是基于生产者-消费者模式,由输入线程作为第一级生产者,调度器作为第一级的消费者和第二级生产者,通过第一级的BuildingDispatcher
托盘类进行协作。电梯运动控制器作为第二级消费者,通过第二级的Elevator
托盘类来进行协作。
在结束线程时,由于电梯线程的结束需要在其所有的任务完成后才可以结束,所以采用了毒丸+结束标志结合的方式来对线程进行终止。
当输入线程接收到输入完成的信号后,会向所有调度器投送一个null
对象并自行结束,调度器在接收到这些null
对象后会将所有电梯的结束标志位设为true
后自行结束,但是电梯的结束标志位被置为真后并不会立刻结束,而是在每次完成所有任务后进入wait()
状态之后或从wati()
方法被唤醒后对于标志位进行检查,如果标志位为真则自行结束。
调试方法以及Bug分析
由于在之前编写多线程程序demo时已经发现了多线程调试的困难性与不易性,所以本次作业秉承以架构设计来规避bug的方针来进行设计,在第一次作业中确定了整个程序的架构,之后避免了大部分线程安全带来的bug。
公测、互测bug分析
本次作业中被测出的bug主要有两类
- 算法太烂超时
- 输出线程未加锁导致输出时间戳不递增
在第5次作业中,对于输出类,使用了synchronized
块修饰调用输出类语句的方法而非使用synchronized
关键字修饰输出类方法来对输出类进行保护,导致忘记了在电梯输出arrive
信息的时候上锁,第六次作业遂直接使用synchronized
关键字对输出方法进行了修饰。
由于第5次作业的架构设计和修改耗费了太多时间,最后对电梯的运动算法随便写了一个,就导致强测中一个点超时,之后修改为look算法后第6次作业没有再出现超时的情况,第7次作业中look算法与电梯调度算法配合不佳又有一个点超时,属于是十分让人头大。
心得体会
第二单元着重于多线程下的面向对象程序设计,多线程是程序设计难以避免的一个关键点。我之前对于多线程编程可以说是一张白纸,完全没有接触过,在第二单元作业的完成过程中,让我对多线程编程具有了一定的认识。
第五次作业想着“没有多线程编程的经验的话,就去看看一些靠谱的模式吧”,于是去翻阅了《图解Java多线程设计模式》,在设计模式的帮助下,算是设计出了一个比较靠谱的电梯系统。
同时完成这一单元的同时,OS讲到了进程调度的部分,结合OO中的实践和OS中的PV操作,能够将多线程设计的一次实践泛化到其它的语言中,可能在别的语言中并没有synchronized
块,而是仅靠lock来实现线程同步或互斥,但从PV的角度来理解Java中的synchronized
块、lock()/unlock()
等同步方法后,OO中所学到的方法就可以运用到其它的语言之中。
OO啊OO,顶是真的顶,学到东西也确实是能学到东西。