Object-oriented_第二单元总结
本博客为面向对象课程第二单元的三次作业总结。
引言
第二单元的OO课程,致力于培养多线程的思想,达到线程安全、熟练掌握进程同步与贡享。课程的三次作业形成迭代关系,以电梯调度为主题,具体地,第一次作业为单电梯调度,来实现一定时间限制下(可捎带电梯为基准)在不同模式下的目的选层电梯的接送请求;第二次作业增加初始电梯数量至三部,需求与第一次作业基本相同;第三次作业限制了电梯的停靠需求,涵盖全楼层停靠电梯、奇数楼层停靠电梯、高层低层停靠电梯(1-3, 18-20)。总体来说,第二单元作业使我处理多线程问题的能力得到有效提升。
本篇博客行文思路总结如下:
-
以涵盖整体设计、策略分析、可视化等三次作业的基本分析为基础,开展涵盖同步块设置、锁的选择、调度器设计与交互等的针对性分析;
-
开展程序BUG分析,涵盖线程安全问题分析,发现BUG策略分享等;
-
心得体会。
本文篇幅较长,展现了三次作业的设计思路、实现过程与心得体会,涵盖了笔者对面向对象课程第二单元作业思考的结晶,希望能为大家带来启发。
汲取前一次博客的助教反馈建议,本次博客撰写过程中会注重以类图为代表的可视化展现,以达到易于抓住重点,良好展示关键思路的目的。
第一次作业
基础分析
作业描述:单电梯调度,限载6人,ALS作为调度性能基准
架构分析
整体思路概述:
-
本次作业以生产者消费者模式为指导:
-
生产者:输入处理线程(
PersonInput
) -
消费者:电梯运行线程(
Elevator
) -
传送带:电梯调度器(
Elevator Scheduler
)
-
-
采用三线程模式:
-
输入处理线程:
PersonInput
-
电梯调度线程:
Elevator Scheduler
-
电梯运行线程:
Elevator
-
-
共享对象:
-
等待队列:
WaitQueue
-
处理队列:
ProcessingQueue
-
电梯内部正在处理的请求队列:
Elevator.personlist()
-
本次作业采用架构如下:
本次我将UML类图以以上可视化形式展现,因此以上流程图中的名称为重要的类名。具体说明图示类的功能如下:
-
构成主要流程的类:
-
PersonInput:
处理输入进入等待队列。 -
Scheduler:
将等待队列加入处理队列。 -
Elevator Scheduler:
进行电梯调度。 -
Elevator:
电梯运行。
-
-
参与组成的类或对象:
-
WaitQueue:
等待队列。 -
Processing Queue:
处理队列。 -
Person:
人请求的对象。
-
-
其他类与辅助类:
Main
,Output
调度策略
本次作业,采用的基于两级调度器Scheduler
与Elevator Scheduler
的调度策略。
-
Scheduler
具体调度策略:作为生产者与消费者的重要组成部分,第一级调度器将等待队列中的请求送入处理队列,由上述类的描述可知,等待队列接收来自输入的请求,直接将第一级调度器将请求送入处理队列。该过程类似于本单元第一次上机测试的调度策略流程,易于理解。
-
Elevator Scheduler
具体调度策略:第二级调度主要功能是作为处理队列中的请求与电梯实际运行过程的交互,在本次作业中,为保证一定性能我在该二级调度器中使用
doit()
函数预先模拟了电梯的运行过程,选出运行时间最短的相应请求送入电梯的personlist
。
调度算法
调度算法对运行时间的影响巨大。本次只有一个电梯,涉及电梯及时处理人的请求调度,传统地,电梯的高效调度算法如下。
常见的电梯调度运行策略主要有以下几种:
-
Scan:循环算法
-
FCFS:优先送入先入的请求到目的地
-
Look:类似循环算法,在当前方向上没有请求且电梯为空时掉头
经测试,传统LOOK算法在一定情况下优于sstf算法,但也并不能达到理想情况下极好的运行。因此,在本单元的作业中,我一种基于sstf算法,与其他策略相结合的方式,通俗来讲,即电梯不论请求的楼层或请求的进出,永远响应最近的请求。具体而言,调度器会将电梯内的最近的出请求楼层计算出来,与电梯外的最近的进入请求的楼层相比,选择距离最小的请求,进行相应。通过该算法,解决了特殊情况下使用LOOK算法的缺陷。事实证明,该种调度算法也获得较高的性能分数。
对于不同到达模式下的策略:
-
Random模式:
-
正常使用以上策略即可。
-
-
Night模式:
-
遍历请求队列,找到最高请求进入的楼层,而后下楼。
-
在此过程中只要电梯未满且有人需要上电梯则携带。
-
-
Morning模式:
-
电梯初始在一楼等待,直至当电梯乘满或输入结束再开始运行,直至为空。
-
输入结束且电梯为空时结束线程。反之回到一楼,按照Random的策略正常执行。
-
针对性分析
同步块设置和锁的选择
本次作业,我对共享对象中例如获取队列的共享方法使用了同步块。对于共享对象,由于我的第一级调度器与输入处理线程共用了一个等待队列,因此将WaitQueue
设置为线程安全类,必要方法使用synchronized
,因此在PersonInput
和Scheduler
读写该等待队列时,均需要先获得WaitQueue
的同步锁才能进行操作,由此实现了线程安全。除此之外,由Elevator Scheduler
和Scheduler
共享的ProcessingQueue
,以及由Elevator Scheduler
和Elevator
共享的ProcessingQueue
也进行了与上述类似的同步设置与上锁处理,保证了线程安全。
在每次锁住后,我会寻找对应合适的时机在同步块中使用notifyAll、wait()等处理语句进行解锁或交锁,举例如下。
synchronized(WaitQueue.class) { //锁住共享对象
if (WaitQueue.isEmpty() && WaitQueue.getclose()) {
synchronized (ProcessingQueue.class) {
ProcessingQueue.setclose();
ProcessingQueue.class.notifyAll(); //唤醒所有锁住的线程
}
return;
} else if (WaitQueue.isEmpty()) {
try {
synchronized (ProcessingQueue.class) {
ProcessingQueue.class.notifyAll();
}
WaitQueue.class.wait(); //睡眠,交出当前线程的锁
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
本次作业,我对于锁的选择采用了基本的synchronized
方法,对于初期来说使用较为快捷与方便。
调度器设计
调度器的设计上,本次我采用了两级调度器Scheduler
与Elevator Scheduler
-
Scheduler
设计:在第一级调度器的设计上,由于等待队列接收来自输入的请求,当输入完毕时,会使用
WaitQueue.close
进行标记;等待队列为空时,使用WaitQueue.isEmpty
进行标记。如果等待队列为空,且输入结束,则设置处理队列的关闭信号ProcessingQueue.close
;当输入未结束,则进行等待。如果等待队列不为空,则第一级调度器将请求送入处理队列进行处理。对于调度器中的共享对象, 结合了上述同步块与锁的加入,实现了线程安全的调度。 -
Elevator Scheduler
设计:第二级调度器主要功能是作为处理队列中的请求与电梯实际运行过程的交互,我在处理队列上,设计了处理队列为空的信号
ProcessingQueue.isEmpty()
和处理队列关闭的信号ProcessingQueue.getclose()
。当处理队列为空,且已经关闭时,则Schedule end,notifyall
后返回,反之处理队列为空而未关闭时,则交出锁等待。处理队列不为空时,由前文调度器策略,对当前处理队列的所有请求进行模拟,选出最优的一系列请求送入电梯,并在每次进行模拟结束后对于共享对象进行notify,保证了共享对象下的线程安全。
调度器与线程交互方式
对于本次作业的调度器,主要有两次交互,首先是调度器自身与自身线程的交互,以及第二级调度器与电梯线程进行交互。例如,二级调度器依据处理队列的不同情况为电梯线程中的共享对象增加请求,是本次调度器与线程的主要交互。
synchronized (ProcessingQueue.class) {
if (ProcessingQueue.isEmpty() && ProcessingQueue.getclose()) {
notifyend = true;
for (Elevator elevatora : elevators) {
synchronized (elevatora.getPersonArrayList()) {
elevatora.getPersonArrayList().notifyAll();
}
}
return;
} else if (ProcessingQueue.isEmpty() || notifysame) {
notifysame = false;
for (Elevator elevatora : elevators) {
synchronized (elevatora.getPersonArrayList()) {
elevatora.getPersonArrayList().notifyAll();
}
}
try {
ProcessingQueue.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
int prevail = ProcessingQueue.getsize();
doit(elevator);
if (ProcessingQueue.getsize() == prevail) {
notifysame = true;
}
}
}
优缺点分析
优点:可扩展性良好,架构较为清晰,即使只有一部电梯也设计了具有模拟功能的调度器,能够良好处理请求。
缺点:由于初次作业理解深入程度有限,线程安全性仍有待增强,只使用了synchronize
锁的模式。
第二次作业
基础分析
作业描述:多电梯调度,增加电梯指令,限载6人,ALS作为调度性能基准,其余同上次作业。
架构分析
整体思路概述:由于第一次作业可扩展性较好,第二次作业的架构在第一次作业的基础上进行开发,整体架构相似。
-
本次作业仍然以生产者消费者模式为指导:
-
生产者:输入处理线程(
RequestHandler
) -
消费者:电梯运行线程(
Elevator
) -
消费者:电梯运行线程(
Elevator
) -
传送带:电梯调度器(
Elevator Scheduler
)
-
-
采用
2+n
线程模式:-
输入处理线程:
RequestHandler
-
电梯调度线程:
Elevator Scheduler
-
多电梯运行线程:
Elevator
-
-
共享对象:
-
等待队列:
WaitQueue
-
处理队列:
ProcessingQueue
-
电梯内部正在处理的请求队列:
Elevator.personlist()
-
电梯表:
ELEVATORS
-
本次作业采用架构如下:
具体说明图示类的功能如下:
-
构成主要流程的类:
-
RequestHandler:
处理输入进入等待队列。 -
Scheduler:
将等待队列加入处理队列。 -
Elevator Scheduler:
进行电梯调度。 -
Elevator:
电梯运行。
-
-
参与组成的类或对象:
-
WaitQueue:
等待队列。 -
Personlist:
作为生产者消费者模式下的处理队列。 -
Person:
人请求的对象,本次作业基于初始请求类进行了扩展。
-
-
其他类与辅助类:
Main
,Output
调度策略
在第二次作业中,我仍然采用了基于两级调度器Scheduler
与Elevator Scheduler
的调度策略,但与第一次作业稍有不同。
-
Scheduler
具体调度策略:该部分与第一次作业基本等同,即作为生产者与消费者的重要组成部分,第一级调度器将等待队列中的请求送入处理队列。但一些不同之处在于,这里增加了对于输入中增加电梯请求的处理,根据提供的输入包对电梯的ID等进行解析,来及时向存储电梯的队列中补充电梯。
-
Elevator Scheduler
具体调度策略:仍作为处理队列中的请求与电梯实际运行过程的交互,但在本次作业中的预先模拟过程中,采取的策略电梯随机抢占,即对抢到了第二级调度器线程的电梯进行请求的模拟以及输送。
在这里我并未根据多部电梯的实时情况进行调度,这是由于这会增加共享对象的引入,导致线程不安全的几率会大大增加,而经过部分简单样例的测试,这种调度方式与随机抢占的差别并不大。因此我选择了更为安全的方式,即不能因为追求相差无几的性能分而失去了至关重要的线程安全的保障。
调度算法
本次作业涉及了三部到五部电梯,经多种测试用例发现,在第一次作业使用的策略仍能发挥较好结果,因此本次作业的调度算法与第一次作业选择一致。
针对性分析
同步块设置和锁的选择
本次作业的同步块和锁设置在了线程的方法内,即包括第一级调度器Scheduler
与输入处理线程RequestHandler
共用的等待队列WaitQueue
,由Elevator Scheduler
和Scheduler
共用的处理请求队列Personlist
,由Elevator Scheduler
和Elevator
共享的PersonArraylist
,以及由Scheduler
和Elevator Scheduler
等共用的电梯队列。
在本次作业中,我对于锁的选择仍采用了基本的synchronized
方法,但在第一次作业同步块设置和锁的选择的基础上,本次作业缩小了锁的范围,即将多余的读-读锁去除,将必要的读-写锁等进行上锁。这也在不损失安全的情况下,获得了更好的性能。
调度器设计
调度器的设计上,本次仍采用了两级调度器Scheduler
与Elevator Scheduler
-
Scheduler
设计:该第一级调度器功能同第一次作业。
-
Elevator Scheduler
设计:第二级调度器的主要功能基本不变,但增加了不同电梯的调度操作,即使用
synchronized (ELEVATORS)
锁住电梯队列,再针对某一个电梯进行同第一次作业的操作。
调度器与线程交互方式
第二次作业的调度器,仍包括调度器自身与自身线程的交互,区别是二级调度器开始与多个电梯线程进行交互。
for (int k = 0; k < elevators.size(); k++) {
Elevator elevator = elevators.get(k);
synchronized (Personlist.class) {
if (Personlist.isEmpty() && Personlist.getclose()) {
notifyend = true;
for (Elevator elevatora : elevators) {
synchronized (elevatora.getPersonArrayList()) {
elevatora.getPersonArrayList().notifyAll();
}
}
return;
} else if (Personlist.isEmpty() || notifysame) {
notifysame = false;
for (Elevator elevatora : elevators) {
synchronized (elevatora.getPersonArrayList()) {
elevatora.getPersonArrayList().notifyAll();
}
}
try {
Personlist.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
int prevail = Personlist.getsize();
doit(elevator);
if (Personlist.getsize() == prevail) {
notifysame = true;
}
}
}
}
优缺点分析
优点:第二次作业主要在第一次作业基础上迭代而来,保持了良好的可扩展性与清晰的架构。同时,缩小了锁的锁住的范围,在一定程度上提升了性能。又由于对于线程的理解加深,线程安全得到进一步增强。
缺点:追求性能的预先模拟算法在进行设计多梯调度时比较复杂与冗余,简洁性有待提升。
第三次作业
基础分析
作业描述:多电梯调度,三种电梯,停靠楼层、运载人数、运行速度各有不同
架构分析
整体思路概述:
-
本次作业仍以生产者消费者模式为指导:
-
生产者:输入处理线程(
RequestHandler
) -
消费者:电梯运行线程(
ElevatorA, ElevatorB, ElevatorC
) -
传送带:电梯调度器(
Scheduler
)
-
-
采用三线程模式:
-
输入处理线程:
PersonInput
-
电梯调度线程:
Scheduler
-
电梯运行线程:
ElevatorA, ElevatorB, ElevatorC
-
-
共享对象:
-
等待队列:
WaitQueue
-
处理队列:
Personlist
-
电梯内部正在处理的请求队列:
ElevatorN.personlist()
-
电梯表:
ELEVATORS
-
本次作业采用架构如下:
具体说明图示类的功能如下:
-
构成主要流程的类:
-
RequestHandler:
处理输入进入等待队列。 -
Scheduler:
电梯调度。 -
ElevatorN:
电梯运行。
-
-
参与组成的类或对象:
-
WaitQueue:
等待队列。 -
Personlist:
处理队列。 -
Person:
人请求的对象。
-
-
其他类与辅助类:
Main
,Output
调度策略
本次作业,与以往两次作业不同,简化了调度器,去除了冗余部分,采用一级调度器Scheduler
,很好地精简了代码,在一定程度上增加了线程安全。
-
Scheduler
具体调度策略:
Scheduler
汇总了以往两级调度器的功能,作为生产者与消费者的重要组成部分,既将等待队列中的请求送入处理队列,又作为处理队列中的请求与电梯实际运行过程的交互对请求进行调度。同时,经过个人测试发现,在多电梯过程中doit()
函数在该情况下预先模拟了电梯的运行过程对性能几乎并无提升,因此省略了这一部分。
调度算法
-
接送算法
-
接送算法仍然沿用前两次作业中的策略,经测试具有较好性能。
-
-
换乘策略
-
经用例分析,我设定3层与18层为统一换乘层,同时奇数层作为B类电梯运行的换乘层。具体来说,B类电梯在涉及需要换乘的请求时会根据目的地就近选择奇数楼层,C类电梯涉及需要换乘的请求时会根据目的地就近选择3或18楼层,而A类电梯设定为直达即可。
-
-
-
用例分析发现,本次作业不针对模式设定并不会影响总体平均性能,因此本次统一对待。
-
UML顺序图
针对性分析
同步块设置和锁的选择
本次作业,我在以下共享对象对象的涉及中进行了同步块和锁的设置。主要包括,调度器Scheduler
与输入处理线程RequestHandler
共用的等待队列WaitQueue
,由Scheduler
和ElevatorN
共享的ElevatorN.PersonArraylist
,以及由Scheduler
和Elevator Scheduler
等共用的电梯队列ElevatorN
。
在本次作业中,在之前缩小了synchronize
锁的范围基础上,我尝试选择使用lock
上锁,可能由于作业设定的因素,性能并未过多改变, 但逻辑性得到了提升,应当作为进阶使用。
调度器设计
调度器的设计上,本次采用Scheduler
的一级调度器。
-
Scheduler
设计:在第二次作业的基础上,
Elevator Scheduler
本调度器汇总了以往两级调度器的设计,即涵盖了等待队列为空的信号,处理队列为空的信号ProcessingQueue.isEmpty()
和处理队列关闭的信号ProcessingQueue.getclose()
等情况的处理,大体上没有差异。本次作业更加注重对于共享对象的监视与notify,进一步提升了共享对象下的线程安全。
调度器与线程交互方式
第三次作业的调度器,包括调度器自身与自身线程的交互,调度器与电梯线程的交互,调度器与等待队列的交互。例如,调度器与等待队列的交互如下:
synchronized (WaitQueue.class) {
if (WaitQueue.isEmpty() && WaitQueue.getclose()) {
synchronized (Personlist.class) {
Personlist.setclose();
Personlist.class.notifyAll();
}
return;
} else if (WaitQueue.isEmpty()) {
try {
synchronized (Personlist.class) {
Personlist.class.notifyAll();
}
WaitQueue.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
for (int i = 0; i < WaitQueue.getlist().size(); i++) {
Request request = WaitQueue.getlist().get(i);
requests.add(request);
WaitQueue.getlist().remove(request);
i--;
}
}
}
可扩展性分析
-
本次作业中,主要的功能设计有:
-
三种不同的到达模式
-
三种不同的电梯
-
乘客的调度方式
-
-
性能的评判标准:
-
乘客总等待时间
-
电梯运行总时间
-
-
在功能与性能的平衡上看待架构设计的扩展性:
-
本次作业基本属于第一次作业起始下来的迭代,延续了处理输入、调度分配、电梯执行的基本流程。
-
对于在前两次作业上的可扩展性上的提升,即正如前文所述,在保持了调度方式的性能的前提下缩略了调度策略,将两级调度器转换为一级调度器,减小了代码体量。同时,本单元作业设计时,我利用多种用例,预先演练了策略不同性能,取了平均程度上相对较好的算法开展功能的迭代设计,例如停靠楼层的选择(即换乘功能的性能),这也保障了可扩展性。
-
对于代码长度方面,例如我使用了数组存放可停靠楼层, 使用如下算法(B电梯在奇数层进行换乘),也保障了可扩展性。
for (Person person : Personlist.getPeople()) {
if (!personsame(person) || Personlist.getsize() == 1) {
if (Main.CANSTOP[1][person.getFromFloor() - 1] == 1) {
if (Math.abs(person.getToFloor() - person.getFromFloor()) == 1) {
continue;
}
int lenth = Math.abs(person.getFromFloor() - current);
if (lenth < min) {
min = lenth;
bestperson = person;
}
}
}
} -
在迭代过程中,一些扩展换乘方法直接加入了类中,而不需其他改动,新功能大多通过增补来改动,而非删减。
总的来说,保障程序模块化、做到分工明确,保证增补为主要的方式来进行代码的迭代,是保证代码良好扩展性的重点。
-
BUG分析
自身程序BUG分析
本次作业出现的bug不多,但都出现于线程安全问题,大多数通过多次定时输入测试而发现。
-
读写问题
本次作业出现了以下有关读写锁的bug,出现于电梯类中,功能为执行电梯运行(elevator_run
方法)。出现原因是由于为保障锁的效率,减去了过多锁住的内容,而导致出现了读写冲突。主要特征表现于,在读-写时因忘记对同步对象使用synchronized
,导致读写内容不一致,造成输出错误的特征。
synchronized (this.personArrayList) {// where causes bug
if (getsum() == 6) {
bestperson = seekout();
} else {
bestperson = seekin();
}
}
gotofloor(bestperson);
open();
synchronized (this.personArrayList) {
out();
in();
}
close();
synchronized (Personlist.class) {
Personlist.class.notifyAll();
}
-
死锁问题
本次作业出现了以下有关死锁的bug,出现于第一次调度器类中,功能为执行将等待队列的请求输入到处理队列中(run
方法)。该bug出现的原因是忘记在结束后进行唤醒操作,造成了死锁。这类死锁问题主要特征表现于,忘记及时唤醒进程,或出现了相互锁住的情况,导致造成了超时错误的特征。
if (WaitQueue.isEmpty() && WaitQueue.getclose()) {
synchronized (ProcessingQueue.class) {
ProcessingQueue.setclose();
ProcessingQueue.class.notifyAll();// where causes bug
}
return;
} else if (WaitQueue.isEmpty()) {
try {
synchronized (ProcessingQueue.class) {
ProcessingQueue.class.notifyAll();
}
WaitQueue.class.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
因此,对于死锁问题,应该及时对锁进行跟踪,判断wait()相应的notify,以及主要当涉及多个锁嵌套的情况时,出现的虚假唤醒而造成的相互死锁的问题,这类死锁问题正是第二单元应当注意的重点所在。
HACK策略
测试策略
本次我采取了阅读代码与自动测评机的模式,由于多线程的不确定性,我结合测评机进行评测,同时,在阅读代码时,易于找出线程不安全的样例,这是可以构造针对性的数据进行测试。
有效性
-
测试时间较传统自动评测得到减少,针对性更强
-
成功率得到提高
-
线程安全问题易于暴露
策略差异
-
本单元的评测机的策略相对复杂,在构造时不像第一单元简单搭建即可。
-
本单元评测机的数据构造时,无需类似第一单元过多的暴力数据,由于线程的特殊性,对于代码问题的暴露程度得到提升。
-
本单元在测试时,阅读代码增多,这相对第一单元而言更容易发现锁的问题。
-
本单元在某种顺序执行才会出现bug,因此也需要用大量数据来寻找漏洞。
心得体会
线程安全
-
作为交互的方式,多线程的重点之一就是共享变量,对于共享变量要注意读-写的情况,及时加锁,同时只对必要的加锁。
-
注意并线程终止条件,可以通过预先自行模拟,保证无论在任何情况下都会正常终止。
-
可以通过手动枚举解决死锁问题,并注意及时跟踪,及时notify进行唤醒。
-
注意锁的嵌套情况,线程安全主要关注于锁的一系列问题,避免死锁而导致线程安全问题的发生。
层次化设计
-
我在三次作业均选取了生产者-消费者模式,三次作业层层迭代,线程数目逐渐增多,这些使我更深刻得掌握了生产者-消费者的模型,同时,良好的模型也有助于多线程项目的开发。
-
进行层次化设计时,要理清不同线程之间的交互关系和共享变量,尽可能进行简化,这不但利于代码可扩展性的提升,也能够有效地避免线程安全问题的产生。
收获经验
-
线程安全的问题比较复杂,但经过深刻理解,可以得到合理使用,总之,保证线程安全,我认为是解决多线程问题的重中之重,在这一方面我从本单元的学习中也收获颇丰。