一、写在前面
这一单元的关键词为“多线程”,旨在培养我们多线程程序设计的思想、理解对象的并发与协同、掌握线程安全设计的方法、分析并发场景的需求并进行设计。在训练和测试中进一步加深面向对象的思维方式,提升面向对象设计与构造的能力。
本单元的三次作业令我获益匪浅,可以说是从无到有地搭建起多线程编程的初步体系结构,尤其是第五次和第七次作业,令我找回了不久前第一单元通宵改代码的“快感”。(泪流满面┭┮﹏┭┮)
三次作业由浅入深、循序渐进,以电梯调度为背景,通过实现电梯的增删、维护、换乘等具体功能来实现线程运行和协作,其中的典型问题诸如互斥、协调、死锁都值得细细品味。
二、第五次作业
这次作业是整个迭代过程的起点和根本,因此难度不算很高,主要是熟悉多线程的基本操作和常见问题,为后续两次迭代打下基础。
作业需求不多说了,要注意的是6部电梯是在一个楼座里面的,题目中的描述没有说给电梯编号的事情,但并不是像去年那样每个楼座一部电梯,因此策略需求也有所不同,不要思维定势!(别问我咋知道的,就不告诉你!)
1. 整体思路
第一次作业的架构设计至关重要,可以说直接决定了本单元的日子会不会好过qwq,起初我没有思路,参考了很多学长学姐的博客,看了讨论区的文章,也没有建立起整体的概念,因此前两天十分折磨。直到星期五上机才从实验代码中学习到了整体设计架构,并以此为框架,完成了本次作业的书写。所以当陷入死局时,看看官方的代码,或是去讨论区逛逛,往往就会有意想不到的收获。
首先给出本次作业的整体调用关系和逻辑流动图:
在接下来的“层次化设计”小节中,我们还会和他见面的~
①线程类设计
本部分的设计极大地参考了周五上机的实验代码,从主类到输入类再到需求类都借鉴了很多。最终主要的类有六个,相应功能如下:
- 输入类:线程对象,生产者。将输入的需求放入等待队列中,暂无输入时令等待队列
wait
,输入为null
结束。 - 调度类:线程对象,消费者。从等待队列中拿出需求,循环放入6台电梯的需求队列中。
- 电梯类:线程对象,执行者。实现运行、移动、开关门、进出乘客功能。
②非线程类设计
-
主类:初始化相关对象,等待队列,需求队列
Map
,六台电梯,决策对象decision
,需求输入对象inputRequest
,并启动线程。 -
需求类:定义了一条需求包含的属性,如
id
,fromFloor
(起始楼层),toFloor
(目标楼层)。 -
需求队列类:实现放入、取出、判定为空、判定结束、设定结束、等待、告知等各种队列操作。
③层次化设计
代码可以分成明显的几层关系,层层递进,这主要得益于上机代码的良好框架。
首先由主类完成启动工作,随后输入类获取输入并放入等待队列中,决策类对等待队列中的需求进行调度,由单台电梯执行。
每个类只负责自己分内的业务逻辑,想办法将不同类之间的耦合控制在较低水平(毕竟多线程交互就挺让人头大的了,其他的能少想就少想,集中火力),这里用到了第一单元中的“层次化设计”思想,各个类所做的事情如下:
④均匀分配策略
在决策类中,我采取了最容易想到的均分策略,当然他肯定不是性能最优的,但从结果上看,调度的效率可以满足。大致做法为:优先为电梯编号(通过HashMap
的键值对来实现,键对应电梯号,值对应该电梯的需求队列),在调度类中循环从等待队列中取出需求并放入电梯中,随后电梯号自增,当达到7时,下一次回到1。
while (true) {
//为空且输入已经结束,通知所有线程
if (waitQueue.isEmpty() && waitQueue.isOver()) {
for (RequestTray value : requestTrayMap.values()) {
value.setOver(true);
}
//System.out.println("Decision End!");
return;
}
Request request = waitQueue.takeRequest();//拿出一个需求
if (request == null) {
waitQueue.requestTrayWait();
continue;
}
if (i == 7) { //如果已经超出6个就回到1
i = 1;
}
//将一个需求放入某一个电梯的需求队列中
RequestTray r = requestTrayMap.get(i);
r.putRequest(request);
requestTrayMap.put(i, r);//应该会覆盖的
i++;
}
⑤LOOK策略
本次作业中不同电梯之间没有交互,所以可以专注于单台电梯运行过程中的捎带策略。我采用了平日里最常见的LOOK策略,相信大家对这个策略都比较熟悉,就不详细展开了,可以参考往届学长的博客。
-
首先为电梯规定一个初始方向(向上),然后电梯开始沿着该方向运动。
-
到达某楼层时,首先判断是否需要开门:
-
如果发现电梯里有人可以出电梯(到达目的地),则开门让乘客出去;
-
如果发现该楼层中有人想上电梯(需求队列中存在出发地为该层的请求),并且目的地方向和电梯方向相同,则开门让这个乘客进入。
(通过两个方法实现)
-
-
下一步,进一步判断电梯里是否有人。如果电梯里还有人,则沿着当前方向移动到下一层。否则,检查请求队列中是否还有请求(目前其他楼层是否有乘客想要进电梯):
- 如果请求队列不为空,且某请求的发出地是电梯"前方"的某楼层,则电梯继续沿着原来的方向运动。
- 如果请求队列不为空,且所有请求的发出地都在电梯"后方"的楼层上,或者是在该楼层有请求但是这个请求的目的地在电梯后方(因为电梯不会开门接反方向的请求),则电梯掉头并进入"判断是否需要开门"的步骤(循环实现)。
- 如果请求队列为空,且输入线程没有结束(即没有输入文件结束符),则电梯停在该楼层等待请求输入(wait)。
- 如果请求队列为空,且输入线程已经结束,则电梯线程结束。
相关代码如下:
while (true) {
//核心逻辑
Suggestion suggestion = Decision.getSuggestion(
floor, num, dir, requestTray, innerQueue);
if (suggestion == Suggestion.OVER) { //如果已经结束
//System.out.println("Elevator" + id + "over");
if (isClose) {
return;
}
}
else if (suggestion == Suggestion.WAIT) { //应该是requestTray执行wait方法
requestTray.requestTrayWait();
}
else if (suggestion == Suggestion.MOVE) {
try {
move();//沿着原来的方向移动一层
} catch (InterruptedException e) {
e.printStackTrace();
}
}
else if (suggestion == Suggestion.REVERSE) {
dir = !dir;
}
else if (suggestion == Suggestion.OPEN) {
//要中断以模拟开关门时间
try {
open();
isClose = false;
close();
isClose = true;
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
2. 设计模式
多线程有很多经典的设计模式,本次作业中我采用了最基本的生产者-消费者模式和观察者模式。
①生产者-消费者模式
原理不多叙述,相信几乎所有人都会用到此模式。本次作业中的生产者为输入线程,消费者为调度线程,两者中间的“托盘”为等待队列。该模式非常好的反映了多线程编程的两个核心问题:互斥和协调。
互斥:
不同线程的共享对象(模型中的托盘,作业中的需求队列)在任何一个时刻只能被一个线程访问,这便是线程互斥。体现在代码中就是为所有可能引发数据竞争的方法都加上synchronized
关键字。对于该关键字的作用,我先前存在误解,在阅读讨论区同学的文章后逐渐明朗,对于synchronized
方法:
- 一次只能由一个线程运行,也称该线程是持有锁的。这里的“锁”是针对整个共享对象的,而不是针对某一个方法,假如共享对象类中有5个
synchronized
方法,其中有一个正在被线程执行,那么在执行完毕之前,该共享对象的其他所有的synchronized
方法都被同步锁定,不能被调用。下面的图片可以直观理解:
一锁锁所有!
- 锁被释放后,处于等待状态的某一个线程会获取该锁。
- 每一个共享对象实例都拥有一个自己的锁。
协调:
相较于互斥,这部分更难以理解些(对我而言)。协调的本质是“进行更加精确的控制”,这句话来自《图解》,要想进行协调,就要用到Object
类的wait
,notify
,notifyAll
方法。详细解释可以参考《图解》,这里列出一些我觉得比较重要且容易误解的点:
- 所有的实例都是拥有等待队列的,它是实例的一个“虚拟属性”。在实例的wait方法执行后,将调用该实例的
wait
方法的线程放入该实例的等待队列中(有些绕,但的确是这么回事,我在看书前理解的一直都是错的),这时候线程已经停止了操作。书中的图非常精辟:
notify
和notifyAll
,都是从等待队列中取出线程,理解了wait
,这两个函数也迎刃而解,书中图片如下:
-
这两者的区别:前者只唤醒一个线程,执行速度较快,但可能出现意外停止的情况(可以自行查阅),后者健壮性更好。
-
共享对象中哪些方法要加
synchronized
?(还是都加上吧,起码安全)- 考虑一下**“应该保护的东西”**!
-
要运行这三个方法,线程必须持有调用的实例的锁!为了保证操作的原子性,详见(84条消息) 为何wait和notify方法必须加锁?_wait() 方法为什么要放在 锁lim_打酱油的葫芦娃的博客-CSDN博客
②观察者模式
起初我看到训练中的观察者模式,因为知识储备不足而感到困惑,于是翻看《大话设计模式》中的相应章节自学,学懂之后回来再看,还是没想出如何在作业中应用。等到这次作业写完,才在讨论区中看到朱宇同学的分享,大彻大悟的同时又后知后觉,因为我无意间已经应用了这种思想。
对于我的架构而言,观察者模式其实已经被多次应用了(虽然代码都写完了)。输入类(也就是生产者类)操作等待队列,控制向其中加入元素,在输入结束时设定等待队列结束并通知所有的等待线程:
public void run() {
ElevatorInput elevatorInput = new ElevatorInput(System.in);
while (true) {
PersonRequest personRequest = elevatorInput.nextPersonRequest();
if (personRequest == null) {
waitQueue.setOver(true);
waitQueue.requestTrayNotify();
//System.out.println("Input End");//用作测试
try {
elevatorInput.close();//不知道这样可不可以
} catch (IOException e) {
e.printStackTrace();
}
return;
}
else { //等待队列添加需求
Request request = new Request(personRequest.getPersonId(),
personRequest.getFromFloor(), personRequest.getToFloor());
waitQueue.putRequest(request);
}
}
}
这其实就是观察者模式,输入类作为被观察者,负责接收输入端信息并分配给等待队列,等待队列由调度类分配给各个电梯线程,达到通知调度类的效果。
调度类又可以被电梯线程观察,当等待队列的情况发生变化时通知各个电梯,所以这是一条观察者-被观察者链条。
public void run() { //调度策略
int i = 1;
while (true) {
//为空且输入已经结束,通知所有线程
if (waitQueue.isEmpty() && waitQueue.isOver()) {
for (RequestTray value : requestTrayMap.values()) {
value.setOver(true);
}
//System.out.println("Decision End!");
return;
}
Request request = waitQueue.takeRequest();//拿出一个需求
if (request == null) {
waitQueue.requestTrayWait();
continue;
}
if (i == 7) { //如果已经超出6个就回到1
i = 1;
}
//将一个需求放入某一个电梯的需求队列中
RequestTray r = requestTrayMap.get(i);
r.putRequest(request);
requestTrayMap.put(i, r);//应该会覆盖的
i++;
}
}
这本质上都是相通的。
3. 复杂度分析
本次作业复杂度分析如下图:
图中可知Elevator
类的run()
方法和in()
方法、Decision
类的hasSameDir()
方法、getSuggestion()
方法、canGetIn()
方法、run()
方法的复杂度较高。原因在于这些方法中都含有较多的if-else
或for
语句块,有些方法中还会嵌套多层if-else
判断,逻辑关系比较复杂。
总体上看,本次代码的复杂度比较理想,主要因为在设计时采用了层次化思想,每个类的职责尽量单一,不同类间的耦合度较低,这和第一单元的训练是密不可分的。
4. UML类图
本次作业的UML
类图如下:
5. 时序图
本次作业的时序关系如下图:
6. bug分析
本次作业在强测中有一个测试点出现了RTLE
的情况,最终得分92.4985
,可见如果全部通过的话,分数应该在97
左右,虽不完美但已经可以满足。另外,本次作业遵循了鲁棒性和可扩展性的要求,为后续扩展带来了不小的便利。
对于该bug
,大概率是程序运行过程中难以控制的随机因素使然,我统一使用了HashMap
作为维护各种对象的容器,而HashMap
本身的随机性导致每一次从其中取出的对象未必一样,在某些情况下可能出现难以预料的问题。我也是直接把原来的代码再交一次就通过了。
在互测中没有被发现bug
,自己也没有发现别人的问题。值得一提的是,本次作业我学会了使用讨论区同学分享的评测机在本地进行自动化测试,效果十分不错,这也算是弥补了第一单元未能利用评测机的缺憾吧。先前一直认为评测机只是大佬们的开胃菜,我这个纯种小白只能望尘莫及,但真正上手使用过后才发现并没有想象中的那么高深,一番鼓捣后我也可以用得很熟练。这也挺有教育意义的:面对未知的领域,无论是谁,都多少会有畏惧和胆怯,这时候需要的只是勇敢地迈出第一步,亲身体验过后,方能感慨“也不过如此嘛”!
三、第六次作业
本次作业在上一次的基础上新增了动态添加和维护电梯的需求。
1. 迭代实现
本次作业大部分的架构都沿用了上一次作业,只做了简单的添加和一些修修补补的工作,下面主要分析一下新需求的实现。
①添加电梯
增加电梯的请求单独作为输入,在输入类中判定遇到该请求,需要在该处创建电梯对象并启动线程。新的电梯有单独的起始楼层、满载人数、移动一层所需时间,因此电梯类的构造函数需要翻新。电梯类中方法的参数不能再是常量,要改为类属性。
初始6部电梯的属性都是一样的(默认起始楼层为1
,满载人数为6
,移动时间0.4s
,开关门时间仍旧0.2s
),在主类中初始化。新增电梯单独传入参数,注意调度类的循环分配条件需要改!i
大于Map
的size
的条件不能用数字。
新加的电梯按照顺序放到requestTrayMap
里面,标号加一并保存。
②维护电梯
在收到维护请求后,根据请求中的电梯号从HashMap
中把该电梯的键值对删除。因为调度器随时可能把新的请求分配到要维修的电梯中,所以先把该电梯的需求队列和当前信息暂存,随后对他们进行处理,大致逻辑如下:
- 把需求队列转移到等待队列中(先放空,这样下一步也没法进人),清空需求队列。
- 电梯立刻停下来,放人出去(如果恰好有人要到这层就省事儿了,下一步不用算他们)。
- 把没有到站的人(电梯内没到终点的人)加到等待队列中.
转移乘客的对应代码如下:
private void transferTray(RequestTray tempTray, RequestTray waitQueue) {
HashMap<Integer, ArrayList<MyRequest>> tempMap = tempTray.getRequestMap();
for (Integer floor : tempMap.keySet()) {
for (MyRequest request : tempMap.get(floor)) { //遍历临时队列中该层的请求
waitQueue.putRequest(request);
}
}
tempTray.setMaintain(true);//设置为待维修,这句已经完成了notify
try {
sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
tempTray.getRequestMap().clear();//清空
tempTray.requestTrayNotify();//这句的notify不必要?
}
电梯维护的对应代码如下:
public void maintain() throws InterruptedException {
if (innerQueue.isEmpty()) { //已经为空,直接输出
TimableOutput.println("MAINTAIN_ABLE-" + id);
}
else {
TimableOutput.println("OPEN-" + floor + "-" + id);//开门
out();//到站的人都出去
if (!innerQueue.isEmpty()) { //如果此时电梯内非空
for (MyRequest request : innerQueue) {
request.setFromFloor(floor);//设定出发地为当前楼层
waitQueue.putRequest(request);//直接都加入等待队列中
}
outAll();//所有人都出去
}
sleep(200);//开关门的时间
sleep(200);
TimableOutput.println("CLOSE-" + floor + "-" + id);//关门
TimableOutput.println("MAINTAIN_ABLE-" + id);
}
}
2. 复杂度分析
本次作业复杂度如下图:
除了上次作业中复杂度较高的几个方法外,本次作业中线程类的run()
方法的复杂度有明显提升,主要原因是方法内部有角度if-else
判断语句和for
语句块。
3. UML类图
本次作业的UML类图如下:
4. 时序图
本次作业的时序关系图如下:
5. bug分析
本次作业在强测和互测中没有出现bug
,最终分数为91.9749
,可见性能分明显拉跨,有6
个测试点都是85
分,这可能是我未进行任何优化措施的结果,均匀分配策略在某种程度上可以保证相对不错的性能,但单台电梯的捎带过程中完全没有优化也导致某些数据跑得较慢。
本次作业同样遵循了鲁棒性和可扩展性的要求,面向接口编程的思想得到了充分体现,整个迭代过程也比较轻松。
本次同样没有发现其他同学的bug
。在这里分享一些前期测试过程中出现的问题吧:
- 使用
IDEA
进行断点调试时,一定要保证设下的断点是“线程”类型的!否则无法做到同步调试。 - 维护方法的逻辑要想清楚,先判断电梯内部有无乘客,若没有乘客直接打印后返回,若有乘客,先放出本应在这一层出去的人,再把其余未到站的乘客放回到等待队列中,并把他们从电梯内部队列中移除。最后模拟
400
毫秒开关门时间,打印关门信息和收到维护信息。 - 从维护的电梯中下来的乘客,他们的出发楼层要改成当前楼层。
- 输入的最后一条是维护时,不能让输入线程和调度线程太快结束,可能导致无法及时输出维护信息,不能结束电梯线程,可以在结束前
sleep
一小会。
四、第七次作业
1. 迭代实现
①单台电梯可停靠楼层
在输入时将掩码提取出来,利用公式算出所有可以到达的楼层,放到一个ArrayList
中,再给电梯增加一个可停靠楼层的属性,初始的六部电梯在任何楼层都可以停靠,故掩码为2047
,后来加入的电梯在输入类中提取出相应属性并放到构造方法中。
分析掩码的方法:
public ArrayList<Boolean> parseAccess(int access) { //将可达性整数解析成容器
ArrayList<Boolean> a = new ArrayList<>();
for (int i = 1; i <= 11; i++) {
if ((access & (1 << (i - 1))) != 0) { //表示对应位不为0
a.add(true);
}
else {
a.add(false);
}
}
return a;
}
向getSuggestion
方法额外传入一个参数arriveFloor
,表示当前电梯可达楼层,内部的判断逻辑需要做相应改动:
public static synchronized Suggestion getSuggestion(int curFloor, int curNum, boolean dir,RequestTray requestTray, ArrayList<MyRequest> innerQueue,
int maxNum, ArrayList<Boolean> arriveFloor) { //运行时逻辑
if (requestTray.isMaintain()) { //如果要维修
return Suggestion.MAINTAIN;
}
else if (canGetOut(curFloor, innerQueue) ||
canGetIn(requestTray, curFloor, curNum, dir, maxNum)) {
if (arriveFloor.get(curFloor - 1)) { //可以在该层停靠
return Suggestion.OPEN;
}
else {
return Suggestion.MOVE;//不能再该层停靠,继续移动
}
}
}
②同一楼层可停靠电梯
本次要求在任何楼层服务中和只接人的电梯数量分别不能超过4
台和2
台,起初我打算使用两个两个静态容器来模拟这种需求,但是在实现过程中发现根本无法处理同一时刻多个需求在同一楼层的情况,改了一天仍旧无果,最后只好进行重构,采用上机实验中介绍的信号量来实现。
具体做法为:在Decision
类中增加两个Semaphore
类型的ArrayList
,分别代表每一楼层服务中和只接人的信号量,任何时刻在该楼层服务中和只接人的电梯数目不得超过这两个数值。
//服务中的电梯数
private static final ArrayList<Semaphore> inService = new ArrayList<>();
//只接人的电梯数
private static final ArrayList<Semaphore> pickPerson = new ArrayList<>();
并为这两个容器设置初始化方法(一开始都设为4和2):
public void initialInService(ArrayList<Semaphore> a) { //初始化两个容器
for (int i = 0; i < 11; i++) {
Semaphore semaphore = new Semaphore(4);
a.add(semaphore);
}
}
public void initialPickPerson(ArrayList<Semaphore> a) { //初始化两个容器
for (int i = 0; i < 11; i++) {
Semaphore semaphore = new Semaphore(2);
a.add(semaphore);
}
}
信号量的相应操作通过getPickPerson()
和getService()
两个方法完成:
public static synchronized ArrayList<Semaphore> getInService() {
return inService;
}
public static synchronized ArrayList<Semaphore> getPickPerson() {
return pickPerson;
}
当电梯每次想要开门前,都需要尝试获取该楼层的信号量,只有成功获取才可以继续执行开门的操作,否则进入等待状态,如下面代码所示:
public void open() throws InterruptedException {
Decision.getInService().get(floor - 1).acquire();//尝试获取这一层的信号量
if (!hasOut()) { //如果没有人出去,获取只接人的信号量
pickPersonFlag = true;
Decision.getPickPerson().get(floor - 1).acquire();
}
TimableOutput.println("OPEN-" + floor + "-" + id);
sleep(200);//先输出开门信息,然后花0.2s开门
out();
in();
}
public void close() throws InterruptedException {
sleep(200);//花0.2s关门,然后输出信息
TimableOutput.println("CLOSE-" + floor + "-" + id);
Decision.getInService().get(floor - 1).release();//释放信号量
if (pickPersonFlag) { //如果是只接人电梯,数目减1
Decision.getPickPerson().get(floor - 1).release();
pickPersonFlag = false;
}
}
注意:maintain()
方法中在开门前同样需要获取信号量。
③换乘规划
采用floyd
算法计算当前可运行电梯所保证的任意楼层可达性、最短路径长度和下一步要到达的楼层:
public void floyd(int [][]x, int [][]b) { //最短路径
lock.writeLock().lock();
try {
for (int i = 1; i <= 11; i++) {
for (int j = 1; j <= 11; j++) {
for (Elevator elevator : elevators.values()) { //迭代所有的电梯
if (elevator.getArriveFloor().get(i - 1) &&
elevator.getArriveFloor().get(j - 1)) {
x[i][j] = 1;//可以直达的话,最少换乘次数为1
}
else if (x[i][j] != 1) {
x[i][j] = 1000;//不可以的话,设成一个很大的数
}
b[i][j] = j;//初始化时把所有的都赋值成终点楼层
}
}
}
for (int index = 1; index <= 11; ++index) { //index表示任意两个楼层的中间楼层
for (int i = 1; i <= 11; ++i) { //两重循环计算所有的最少换乘次数
for (int j = 1; j <= 11; ++j) {
if (x[i][j] > x[i][index] + x[index][j]) {
x[i][j] = x[i][index] + x[index][j];
b[i][j] = b[i][index];
}
}
}
}
} finally {
lock.writeLock().unlock();
}
}
在Decision
类中的allocate()
方法中进行调度:
public int allocate(MyRequest request, int elevatorID) {
int[][] x = new int[15][15];//最少换乘次数
int[][] b = new int[15][15];//下一步要去的楼层
elevators.floyd(x, b);
int i = elevatorID;
int fromFloor = request.getFromFloor();
int toFloor = request.getToFloor();
while (true) {
int maxID = getMaxID();//再获取一次最大ID,随时更新
while (elevators.get(i) == null) {
i++;//越过不存在或被维修的电梯
if (i > maxID) {
i = 1;//均匀分配返回到起点
}
}
ArrayList<Boolean> a = elevators.get(i).getArriveFloor();//获得该电梯的到达楼层
if (a.get(fromFloor - 1) && a.get(b[fromFloor][toFloor] - 1)) {
request.setToFloor(b[fromFloor][toFloor]);
return putRequestTrayMap(i, request);
}
i++;
}
}
这样处理的话,在Elevator
类的out()
方法中需要把需要在该层下电梯但尚未到达终点站的乘客重新放回到等待队列中,并更改他们的fromFloor
和toFloor
:
public void out() {
ArrayList<MyRequest> getOut = new ArrayList<>();
boolean flag = false;
for (MyRequest request : innerQueue) {
if (request.getToFloor() == floor) {
getOut.add(request);
}
}
for (MyRequest request : getOut) {
if (request.getDesFloor() != floor) {
flag = false;
break;
}
flag = true;
}
if (flag) { //如果下去的人都到终点了
InputRequest.setOver(true);
}
for (MyRequest request : getOut) {
innerQueue.remove(request);//直接删除对象即可
num--;
if (request.getDesFloor() != floor) {
InputRequest.setOver(false);//只要有人没到就没结束
request.setFromFloor(floor);
request.setToFloor(request.getDesFloor());//重置起始楼层和终止楼层
waitQueue.putRequest(request);//如果没有到终点,就再加到等待队列中
}
TimableOutput.println("OUT-" + request.getId() + "-" + floor + "-" + id);
}
}
注意到我为电梯类新加了desFloor
属性,作为终到楼层,区别于toFloor
(表示下一步要到的楼层)。
④公共容器的读写限制
在bug修复时,经常会出现ConcurrentModificationException
异常,其实就是在强化for
循环中修改了容器中的元素使然,单个线程显然无法完成这么鬼魅的操作,原因在于多线程并发的不确定性,比如说当前有一个线程正在迭代电梯容器中的对象,这时候InputRequest
类收到维护或增加电梯的需求,两者同时对elevators
容器操作,可能就会出现该问题。
笔者被这个漏洞掏干了心肺,最后无可奈何之下还是选择了重构,新加了两个类RequestTrayMap
和Elevators
,用以代替原来的两个HashMap
,在类中自定义操作对象的方法,并设置读写锁,保证线程安全。
package pack;
import java.util.Collection;
import java.util.HashMap;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Elevators<K,V extends Elevator> {
private final HashMap<K,V> elevators = new HashMap<>();
private final ReadWriteLock lock = new ReentrantReadWriteLock(true);
public void put(K key, V value) {
lock.writeLock().lock();
try {
elevators.put(key, value);
} finally {
lock.writeLock().unlock();
}
}
public void remove(K key) {
lock.writeLock().lock();
try {
elevators.remove(key);
} finally {
lock.writeLock().unlock();
}
}
public V get(K key) {
lock.readLock().lock();
try {
return elevators.get(key);
} finally {
lock.readLock().unlock();
}
}
public Collection<V> values() {
lock.readLock().lock();
try {
return elevators.values();
} finally {
lock.readLock().unlock();
}
}
}
2. 复杂度分析
本次作业的复杂度分析结果如下:
可见除了前两次作业中复杂度较高的方法外,新增的floyd()
方法复杂度登上顶峰。其实也很好理解,该算法需要经历三重循环遍历图的每一条边,复杂度难以避免。
3. UML类图
本次作业的UML类图如下:
4. 时序图
由于新增需求没有改动整体结构,故本次作业的时序图和上一次的几乎相同:
5. bug分析
本次作业在强测中出了很大问题,主要原因如下:
- 没能正确处理单一楼层服务中和只接人电梯数量的限制。应该利用信号量解决,但当时受时间所限(要准备蓝桥杯qwq)没能花很多时间测试,导致该问题没有被发现。
- 换乘算法过于简单,没有考虑到很多情况。起初我使用的是
DFS
(深度优先搜索)算法,居然也通过了中测(从侧面反映出不能仅仅依赖中测,这次就是惨痛的教训),就没有再过多关心,直到强测发现问题才幡然醒悟,最终选用floyd
算法。 - 没有维护
HashMap
读写过程中的安全性问题,导致很多地方出现ConcurrentModificationException
异常,进而导致CTLE
,最终选择重构,利用泛型构造类实现与HashMap
相同的功能,并且保证了对象访问的安全性。
具体处理办法和解决措施见上文“迭代实现”那块⬆
五、关于测试
1. 本地debug
多线程debug
的技巧也不算难,核心是把多线程化归成单线程,在每个类的run
方法的一开始都设下断点,随时手动选择切换线程测试,这样就能一次只调试一个线程,别的都在等待。本次作业中所有电梯都是平等的,因此只需要跟踪一台电梯的捎带过程即可,大大减轻了debug
的强度。
另外,可以在每一个线程的run()
方法中某些特定位置加上一些打印输出的语句,可以辅助定位,确定不同线程的执行情况,这也是发现轮询问题的一个好办法。
2. 测试
主要利用讨论区中分享的评测机进行自动化测试,辅以手动构造极端数据(我不太会这个)。
六、心得体会
1. 线程安全
这一点可以说贯穿三次作业,每次我都或多或少会遇到线程安全导致的奇怪问题,其实我觉得核心问题就在于多个线程共享对象的同时访问,最后我也是把JAVA
提供的处理同步问题的两大工具synchronized
和ReadWriteLock
全都搬出来才勉强解决了线程安全问题。
- 有关前者,可以参看“第五次作业→设计模式→①生产者-消费者模式→互斥和协调”。
- 有关后者,可以参看“第七次作业→1. 迭代实现→公共容器的读写限制”。
2. 层次化设计
这一点在本文的第二部分“第五次作业→整体思路→③层次化设计”处已经有了比较明确的解释,故省略。
3. 写在后面
第二单元以实时交互电梯问题为载体,通过短平快的三次迭代作业让曾是多线程小白的我(或许现在也是)快速上手多线程,完成从0到1的改变,在此再次感叹课程组设计的精妙并表达由衷的感谢。
本单元作业较往年主要新增了动态维护电梯和多电梯协作的需求,尽管乍一看来非常吓人,但只要多思考多讨论,便也不是不可跨越的难关。
尽管在第一单元的展望中我希望不要拖ddl,但是在第五次作业中由于多线程编程经验明显不足,debug方法完全不会,还是花了很多时间调试;第七次作业虽然没有像第一单元那样交不上中测,但强测给我狠狠地上了一课,把之前没投入的时间分秒不差地找了回来!(甚至还更多,毕竟整整两天!)。依旧非常惊心动魄,希望未来可以尽可能调度好个人时间和安排,尽量避免这种情况。
在本单元作业中,老师、助教和同学们给予了我非常大的帮助,可以说没有大家的帮助,我不可能完成这一单元的任务,再一次向大家表示由衷的感谢。另外,我也更深层地体会到了合作、讨论和有效沟通的重要性。讨论区和大群里同学们的分享交流给了我非常多的启发,这一单元的第一次研讨课上我也尝试了上台分享,这令我更深一步体会合作的重要性,希望在未来可以继续和大家一起讨论,一起进步。