面向对象之多线程(可捎带电梯调度)
面向对象之多线程(可捎带电梯调度)
1. 题目重述
本题完成的任务为多部多线程可捎带调度电梯的模拟,电梯系统具有的功能为:上下行、开关门、新增一部可以使用的电梯,电梯系统在某一层开关门时间内可以上下乘客。电梯系统可以采用任一的调度策略,只要保证在一定时间内将所有乘客送至目的地即可。
本题采用的是目的选层电梯,在电梯的每层入口,都有一个输入装置,让每个乘客输入自己的目的楼层,所以一个电梯请求除了人员id,还有这个人的出发楼层和目的楼层。
电梯类型分为A型
、B型
、C型
,在新增电梯时指定类型。初始有3部电梯,编号分别为A
、B
、C
,新增电梯编号为X1
、X2
、……、Xn
。三类电梯的开关门时间都分别为0.2s
。
- 三类电梯的可停靠楼层、上升或下降一层时间、最大载客量
- A型:
-3, -2, -1, 1, 15~20
0.4s
6人
- B型:
-2, -1, 1, 2, 4~15
0.5s
7人
- C型:
1, 3, 5, 7, 9, 11, 13, 15
0.6s
8人
- B型:
- A型:
样例输入
[1.0]X1-ADD-ELEVATOR-A
[2.0]1-FROM--3-TO-2
样例输出
[2.4060]ARRIVE--1-X1
[2.8070]ARRIVE--2-X1
[3.2070]ARRIVE--3-X1
[3.2080]OPEN--3-X1
[3.2080]IN-1--3-X1
[3.6080]CLOSE--3-X1
[4.0090]ARRIVE--2-X1
[4.4100]ARRIVE--1-X1
[4.8100]ARRIVE-1-X1
[4.8100]OPEN-1-X1
[4.8100]OPEN-1-B
[4.8110]OUT-1-1-X1
[4.8110]IN-1-1-B
[5.2120]CLOSE-1-B
[5.2120]CLOSE-1-X1
[5.7120]ARRIVE-2-B
[5.7120]OPEN-2-B
[5.7130]OUT-1-2-B
[6.1130]CLOSE-2-B
2. 电梯调度策略
2.1 ALS调度策略
-
ALS电梯的请求分为主请求和被捎带请求
-
主请求选择规则:
- 如果电梯中没有乘客,将请求队列中到达时间最早的请求作为主请求
- 如果电梯中有乘客,将其中到达时间最早的乘客请求作为主请求
-
被捎带请求选择规则:
-
电梯的主请求存在,即主请求到该请求进入电梯时尚未完成
-
该请求到达请求队列的时间小于等于电梯到达该请求出发楼层关门的截止时间
-
电梯的运行方向和该请求的目标方向一致。即电梯主请求的目标楼层和被捎带请求的目标楼
层,两者在当前楼层的同一侧。
-
-
其他:
- 标准ALS电梯不会连续开关门。即开门关门一次之后,如果请求队列中还有请求,不能立即
再执行开关门操作,会先执行请求。
- 标准ALS电梯不会连续开关门。即开门关门一次之后,如果请求队列中还有请求,不能立即
2.2 LOOK调度策略
2.2.1 SCAN调度策略的改进
SCAN调度策略,使电梯沿一个方向进行扫描,每到达一个楼层,捎带当前楼层本电梯可以捎带(该请求的到达楼层是本电梯可停靠的楼层且电梯此时尚未满载)的请求,直至电梯到达运动方向上最远的可停靠层,再转变运动方向进行扫描。简单来说,理解为电梯在最低和最高可停靠层来回扫描,同时捎带和完成请求。
很明显,这样的调度策略很有可能会造成性能的损失,如果运动方向上没有需要捎带的请求或者电梯已无法捎带请求并且目前已在电梯内的请求需要停靠的楼层都在相反方向,则可以立刻调转方向扫描,减少来回的无效路程,这就是LOOK调度策略,具体的电梯线程实现可见下方的流程图。(有人可能会说,万一调转方向后,原先的运动方向上产生了需要捎带的请求呢?本人的观点是,这种情况的确有可能出现,但是电梯无法预知未来,所以只能选择当前情况下的最优选择,在大部分情况中,采用LOOK调度的性能要比SCAN调度好)
2.2.2 电梯线程流程图
2.2.3 换乘策略
由于本次题目的请求并不一定可以由一个电梯单独完成,所以需要让乘客进行换乘。
具体的实现方式是:在PersonRequest
类的原有属性fromFloor
和toFloor
上增加finalFloor
属性,如果该请求不需要换乘,将finalFloor
赋值为0,否则将finalFloor
赋值为toFloor
,将toFloor
赋值为中转楼层。当该请求到达中转楼层toFloor
时,向调度器的请求队列中增加一个新请求,新请求的fromFloor
为中转楼层、toFloor
为原先的finalFloor
(即该请求所要到达的最终楼层)、finalFloor
为0。具体的换乘策略如下表所示:
fromFloor | toFloor | 中转楼层 |
---|---|---|
-3 | 2~14 | 1 |
-2、-1、2 | 3 | 1 |
2~14 | -3 | 1 |
3 | -2、-1、2 | 1 |
3 | 4、6、8、10、12、14 | 5 |
4、6~14 | 3 | 5 |
16~20 | 2~14 | 15 |
2~14 | 16~20 | 15 |
3. 多线程交互模式
3.1 生产者-消费者模式
3.1.1 模式定义
生产者(Producer)是负责产生数据的线程,消费者(Consumer)是处理数据的线程,当生产者消费者以不同的线程运行时,两者之间的速度差异会引起问题,比如消费者想要获取数据,数据还未生成;生产者想要交付数据,消费者还无法接收数据。为了解决这种生产者和消费者的强耦合问题,可以让生产者消费者通过缓冲区(Channel)进行通讯,进而消除不同线程间处理速度的差异。
通俗来说,生产者将生产的数据直接移交缓冲区,消费者直接从缓冲区中提取需要的数据,两者不直接交互。
3.1.2 模式结构
3.1.3 本题UML时序图
3.2 观察者模式
观察者模式定义了对象间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新,最典型的例子就是微信公众号订阅与通知系统。
微信公众号是被观察者,每一个关注公众号的用户是观察者。一旦公众号有更新(被观察者状态改变),就会向每一个关注该公众号的用户推送信息(通知观察者),然后关注此公众号的用户在微信中看到新信息(观察者状态更新)。
- 在观察者模式中通常会先设计观察者与被观察者的接口
- 观察者接口中包含
update
方法,用于被观察者状态更新后对观察者进行通知。 - 被观察者接口中包含增加观察者、移除观察者以及通知其所有观察者的方法。
- 观察者接口中包含
- 设计观察者类和被观察者类实现对应的接口
- 在被观察者类中,需要维护一个列表用来存放其所有观察者。
- 通知方法通过对其所有观察者调用
update
方法实现通知与观察者的更新。
4. 第一次作业的血泪史
4.1 使用wait()避免暴力轮询
- 问题原因:消费者线程使用暴力轮询来观察是否有新的请求产生,导致CPU运行超时
- 惨况:在强测AC的情况下,互测中因为CPU运行超时被hack了19刀
- 解决方法:当请求队列中没有可以接收的请求时,使用
wait()
使消费者线程进入等待状态,等待notifyAll()
指令唤醒线程。其实,多线程协调运行的原则就是,当条件不满足时,线程进入等待状态;当条件满足时,线程被唤醒,继续执行任务。
4.2 不是所有的同步方法都需要notifyAll()
- 问题原因:对于多线程同步方法的机制不清楚,在每个加了
synchronized
的方法中都notifyAll()
- 解决方法:了解多线程中线程同步的实现机制,以对象锁的同步方法为例
public synchronized void method() { // 锁住this
……
} // 释放锁
用synchronized
修饰的方法是同步方法,锁住的对象是this
,也就是调用该方法的实例,在生产者-消费者
模式中,锁住的通常就是共享对象。
- 锁池(lock pool):假设线程A已经拥有了对象object的锁(正在调用object的某个同步方法),而线程B也试图获取object的锁(试图调用object的同步方法),但是object的锁已经被A占有,线程B就会被阻塞,进入object的锁池中等待。当线程A(执行完同步方法或执行object的
wait()
)释放锁时,位于object锁池中的线程去竞争这把锁,竞争成功的线程则获得这把锁,竞争失败的线程则继续被阻塞在锁池中。 - 等待池(waiting pool):线程A调用了对象object的
wait()
时,线程A会释放object的锁同时A就进入object的等待池等待,当锁被释放时,等待池中的线程不会去竞争该对象的锁。当object的notifyAll()
被调用后,会唤醒所有等待池中的线程,被唤醒的线程进入object的锁池中,这些线程就可以去竞争object的锁了。
在对锁池和等待池有一个清晰的了解下,可知绝大多数情况下,可以理解为该同步方法可能改变其他等待池中线程的状态(使其不再等待)时,才需要在保证线程安全的地方加上notifyAll()
唤醒等待池中的线程。
5. 面向对象程序设计原则
以下内容引自课件“面向对象程序的需求分析与设计原则-1-分析与设计原则”
5.1 SOLID(经典的5个设计原则)
5.1.1 SRP(Single Responsibility Principle)——单一职责原则
SRP原则,顾名思义,就是每个类或方法都只有一个明确的职责,类职责就是使用多个方法从多个方面来综合维护对象所管理的数据,方法职责就是从某个特定方面来维护对象的状态(更新、查询等)。
如果类或方法的职责过多,会导致类或方法的逻辑难以封闭,容易受到外部的影响,因外部因素的变化而变化。
5.1.2 OCP(Open Close Principle)——开闭原则
OCP原则的含义是对扩展开放,对修改关闭。简单来说,当程序进行扩展增加新功能(open)时,不需要修改原有的代码(close)。OCP原则能够使程序的扩展性更好,易于维护和升级。
在使用继承实现OCP原则的过程中,需要保持好子类和父类之间的交互关系,并使用好接口和抽象类。
5.1.3 LSP(Liskov Substituion Principle)——里氏代换原则
LSP原则的含义是任何父类出现的地方都可以用子类来代替,并且不会导致相应类的程序出现错误。实现OCP原则的关键步骤就是抽象化,而父类与子类的继承关系就是抽象化的具体实现,所以LSP原则是对实现抽象化的具体步骤的规范。
- 反例:子类虽然继承了父类的属性和方法,但子类也增加了一些新的属性和方法,可能会破坏父类存在的某些约束。比如Queue类和SortedQueue类,Queue类提供了一个返回最近一次入队列的元素,其实现方式是返回队列尾部的元素,但是SortedQueue类,会对队列中的元素进行一个排序,返回队列尾部的元素无法实现返回最近一次入队列的元素。
5.1.4 ISP(Interface Segregation Principle)——接口隔离原则
ISP原则的含义是一个接口只封装一组高度内聚的操作,使用多个隔离的接口,避免封装多种可选的方案,降低类之间的耦合度。
5.1.5 DIP(Dependency Inversion Principle)——依赖倒置原则
DIP原则的含义是高层次模块不应该依赖于低层次模块,应该依赖于抽象,针对接口编程。
5.2 其他重要设计原则
- 层次化抽象原则:程序中具有抽象层次关系(继承或实现接口等)的类在问题域中也具有同样的抽象层次关系。
- 均衡原则:避免出现God类和Idiot类。God类就是”无所不能“的类,各种事情都在这个类中完成,会使这个类十分臃肿;Idiot类就是”什么也干不了“,只有一两个属性和get、set方法的类。
- 局部化原则:类之间不要冗余存储相同的数据,方法之间不能够出现控制耦合。当类C获得了数据D后,其他类如果想获得数据D,只能向类C要。控制耦合就是直接根据传递进来的参数来决定是否要执行某个分支。
- 完整性原则:一个类需要提供针对相应数据进行处理的完整方法集(完整是相对于问题域需求的)。
- 共性抽取原则:把不同类之间具有的共性数据或处理抽象成层次关系,避免冗余。
- 显示表达原则:显示表达所有想要表达的数据或逻辑,不适用数组存储位置或者常量来隐含表示某个特定状态或数据。因为维护程序的人有可能会get不到这里的隐含逻辑。
- 信任原则:调用方法时,调用者需要检查和确保方法的基本要求能够被满足,获得调用结果后需要按照约定的多种情况分别进行处理。
- 懂我原则:所有类、对象、变量、方法等的命名做到“顾名思义”。不要嫌变量或方法名长(当然也不能太长),维护程序的时候一旦理解错了变量或方法的含义,得不偿失。
- Tips:在许多项目中,都会使用一些约定俗成的缩写方式,比如"4"--"for","2"--"to"等,尽量避免使用只有自己看得懂的缩写。
- 具体的命名规范可以参考《阿里巴巴Java开发手册》。