面向对象设计与构造 第二单元总结
面向对象设计与构造 第二单元总结
写在前面
在第二单元作业发布前,我已经从多方了解到多线程编程的难度,也亲眼看到了一些学长们遇到的bug。于是乎,在作业发布之前几天,我就恶补了Java多线程相关的知识,了解了可能会用到的一些设计模式,为程序架构的可扩展性做了很多工作。
与此同时,经过了第一单元的"毒打",我已经对OO程序设计有了一些初步的想法,第二单元的三次电梯作业正好帮助我验证了我的程序设计步骤。事实证明,想要程序可以迭代开发,想要减少多线程的bug,一定要提前规划出一个清晰的架构,同时也要注意程序的每一处细节。
下面我将详细的分析这个单元的作业,阐述我的设计思路与使用到的一些方法。
同步块与锁
为保证线程安全,可以使用 synchornized 块,synchornized 方法以及 lock() 方法,对共享数据进行上锁。如果某线程想要读取共享数据,在当前线程占用锁的情况下将被阻塞;只有在当前占用锁的线程读取/写入完成数据,释放同步锁,另一个线程才能够访问共享数据。在我的电梯程序中,我选择对共享数据采取synchornized关键字进行同步锁,具体实现方式为 synchornized 方法。在建立线程安全类后,将涉及共享数据的容器装入此类,并新建相关状态成员,对此容器的内容,状态以及是否 wait 或 notifyAll 建立 synchornized 方法。当其他对象调用这些方法时,程序会判断容器是否被占用,如果被占用则会等待上一个线程释放同步锁后再进行操作,操作的时候则会对此容器上锁,防止其他线程同时对容器操作。

调度器设计
纵览三次作业,个人认为设计调度器的重要性完全不亚于电梯设计与线程安全表设计。调度器可以是显性的(独立的一个Java class文档),也可以是隐性的(比如将判断是否获取请求的自由竞争算法,调度可以写在电梯类中)。无论采取哪种方法,调度器设计的首要策略一定是高效,均衡,线程数据安全。
架构设计
第一次作业,由于只有一部电梯,调度器的存在看似并不重要。我们只需要将RequestProducer读取到的请求通过一个托盘传递给电梯就可以。于是,会有部分同学直接让Elevator与RequestProducer共享一个waitList候乘表,比如这样:

这样看起来没有什么问题,但是这种设计显然没有充分考虑到后续的增量开发。当多电梯加入之后,这种设计架构无形之中限制了可行的调度策略,即只能在电梯中调度,实现自由竞争方法(后面会详细介绍调度算法)。个人认为合理的架构应该是这样:

通过新建Scheduler类,将waitList中的请求分发给processList,processList作为每个电梯的候乘表,在电梯内部进行请求的实现。这样看似复杂,实际上将每个模块很好的独立、封装起来,能够在合理分发请求的同时保障线程以及共享数据的安全。
第二、三次作业的调度器架构大致相似,区别主要集中在调度算法。由于多电梯的加入,调度器要兼顾到每个同规格/不同规格的电梯,同时要对不同的到达模式作出不同的响应,这要求我们对调度器的成员、方法做出合理的规划:

我最终实现的架构如上。Scheduler拥有waitList与processListAll(装所有电梯的候乘表)两个主要成员,在Scheduler中将waitList的请求按照不同模式的不同算法,分发给每个电梯的候乘表,每部电梯获取候乘表后独立运行,互不干扰。
算法设计
第一次作业中,调度器只是把请求从waitList直接搬到单电梯的processList,只需要使用一个循环来传递数据即可。第二、三次作业对调度的方式有了较高的要求,但是我们如果秉持负载均衡的原则,也不难获得简单而又高效的方法。
第二次作业中,我们要对3~5部相同的电梯进行统一管理,由于测试使用到的数据随机性较强,没有任何一种算法是最快的。我在完成这次作业的时候,了解到有两类方法:自由竞争(每个电梯线程根据自己的所在楼层、运行方向、processList中的候乘人数以及eleList中待运送的乘客进行判断,选择是否对waitList中的一个请求进行争夺,实现时需要将waitList共享给Elevator),统一调度(由调度器根据不同的方法将waitList的请求分发给每个电梯的processLis)。从强测角度来看,自由竞争的用时普遍比统一调度要少一点点。(我比较菜,不会写自由竞争)
自由竞争方法
自由竞争由于我没有尝试去实现,只做了一点点的了解,感兴趣可以参考这篇博客:https://www.cnblogs.com/wangyikun/p/12725747.html
统一调度方法
统一调度的时候也有各种不同的方法,比如惩罚函数等等。(惩罚函数就是读取电梯的各个状态参量,为每个参量设置一个权值,将最后的带权得分相互比较,判断应该分配给哪个电梯)
我在设计的时候,尝试去实现过惩罚函数,但是无奈参数太难调,加之OS压力比较大也没去搞自动测试,最终不了了之。但是,这些时间也不是白白付出的。有时候,我们不能局限于某些具体的调度算法,要站在一个全局的高度看待这些算法设计。无论是自由竞争,还是惩罚函数,最终的目的都是选择最合适的电梯放入请求,同时也要保证多个电梯都能用上。总而言之,就是要实现电梯的负载均衡。在强测数据随机性较强且所有电梯规格相同的情况下,我们只要实现负载均衡,加之高效的单电梯调度,就可以得到比较好的分数。
因此,我在第二次作业中尝试了一个大胆的方法——平均分配。将waitLis中的每个Reques均匀分配给所有的电梯,保证所有电梯都不会空闲。在Morning、Night模式下,均分前先将waitList分别按照目的楼层、起始楼层进行排序,这样保证分给每个电梯的请求都能覆盖所有楼层,减少某些电梯全跑低层而某些楼层全跑高层的耗时不均衡情况。
@Override
public void run() {
while (true) {
if (waitList.isEnd() && waitList.isEmpty()) {
processListAllEnd();//Scheduler结束条件,如果waitList结束且为空,则通知所有电梯的候乘表,结束Scheduler线程
return;
}
if (waitList.isEmpty()) {
waitList.threadWait();//Scheduler等待条件,如果waitList没有结束但是为空,则等待
} else {
switch (type) {
case "Night":
assignNight();
break;
case "Morning":
assignMorning();
break;
default:
evenAssign();
break;
}
}
}
}
public void evenAssign() {
for (int i = 0; i < waitList.getSize(); i++) {
if (evenFlag == processListAll.size()) {
evenFlag = 0;
}
PersonRequest request = waitList.getRequest(i);
processListAll.get(evenFlag).addRequest(request);
processListAll.get(evenFlag).threadNotifyAll();
waitList.removeRequest(i);
i--;
evenFlag++;//通过evenFlag循环给每个电梯打入请求
}
}
第三次作业由于电梯出现了不同的规格,平均分配无法保证所有电梯的运行时间均匀,因此我更换了电梯的调度策略——通过电梯的速度、等待-载荷比(候乘表与电梯运行表的总人数与电梯最大载客量的比值-> TDL)以及一个简单的学习参数(learn),实现对所有请求的大致划分。

首先找到能到达请求起始楼层的TDL最小的电梯Emin,再找到能到达请求目的楼层的TDL最大的电梯Emax;如果电梯TDL差值大于学习参数learn,先判断Emin能不能到达目的楼层,如果可以则请求给Emin,否则将请求拆分成两个分别给Emin与Emax;如果电梯TDL差值小于参数learn,则从所有电梯中寻找能够独自完成此请求的TDL最小的电梯,将请求给此电梯。
第三次作业架构设计分析
程序UML类图

我在设计架构时使用的思维导图:

MainClass构造并启动Elevator、Scheduler、RequestProducer线程。RequestList是线程安全容器,用于存放总请求表waitList与电梯候乘表processList。Person是我自建的乘客请求类,方便调度时使用RequestProducer读入请求,判断请求类型,如果是增加电梯则直接创建并启动新的电梯线程,否则将乘客请求放入waitList。调度器共享waitList,通过调度算法将waitList中的请求分发给各个电梯的processList,由电梯独自执行请求。由于多电梯线程共享输出,为了保证输出安全,新建Output类并将输出方法上锁,在每个线程中调用Output的加锁方法完成结果输出。
程序UML协作图

功能设计
线程的启动与结束
如果仔细阅读上面的UML图,不难发现我的`RequesrList线程安全容器自带了一个成员end。各个线程通过对不同候乘表的end进行读写,可以合理的实现等待与结束。
//RequestProducer
if (request == null) {
waitList.end();
break;
} else {...}
//Scheduler
if (waitList.isEnd() && waitList.isEmpty()) {
processListAllEnd();
return;
}
if (waitList.isEmpty()) {
waitList.threadWait();
} else {...}
//Elevator
if (processList.isEmpty() && processList.isEnd() && eleList.isEmpty()) {
return;
}
if (processList.isEmpty() && !processList.isEnd() && eleList.isEmpty()) {
processList.threadWait();
} else {...}
//MainClass
在所有线程都结束后,main线程也会自动结束
换成请求的拆分
关于乘客请求,我自建了一个Person类,将读入的乘客请求转化为Person,其中包含有效位valid,换乘标志transfer以及换成对应的二级请求transferRequest。将请求拆分后的换乘请求赋值给原请求的transferRequest成员,将原请求transfer置为true,新请求valid置为false。如果电梯在乘客走出时发现是换乘乘客,则将其换乘标志transfer置为false,并将其换成请求的valid置为true。每个电梯只会执行valid为true的请求。通过这样的设计,能够保证一个换乘请求不会出现换乘时序错乱。
性能设计
多电梯调度算法
具体内容请见 调度器设计 -> 算法设计
单电梯调度算法
LOOK算法
我的电梯程序使用LOOK算法调度单电梯。LOOK 算法是扫描算法(SCAN)的一种改进。对LOOK算法而言,电梯在最底层和最顶层之间运行,当 LOOK 算法发现电梯所移动的方向上不再有请求时立即改变运行方向。
//use LOOK algorithm to deal with Random requests
public void dealRandomRequest() {
if (needStop()) {//如果在这一层需要停靠(有人要下电梯或有人要上电梯且请求方向与电梯方向相同),则停靠并进出乘客
doorOpen();
eleListOut();//乘客出电梯,eleList送出Request
eleListIn();//乘客进电梯,eleList从processList读入Request
doorClose();
}
while (needMove()) {//需要移动的条件是电梯移动的方向上,即将到达的楼层有乘客要进/出电梯
move();
if (needStop()) {
doorOpen();
eleListOut();
eleListIn();
if (!needMove()) {
getPassenger();//如果电梯在某一层eleList为空,则先判断这一层是否有新的同方向乘客,有则带上继续运行,否则关门并改变方向
}
doorClose();
}
}
changeDirection();//完成一遍楼层扫描,电梯变换方向
}
第一次作业因为遇到了 一些奇怪的bug,我只使用了ALS算法,没有再进行算法的优化,第二、三次作业我的单电梯都实现了LOOK算法,单电梯运行耗时减少了1/2~1/3。
这里列举一些其他的部分调度算法,感兴趣的可自行学习。
先来先服务算法(FAFS)
先来先服务(FCFS-First Come First Serve)算法,是一种随即服务算法,它不仅仅没有对寻找楼层进行优化,也没有实时性的特征,它是一种最简单的电梯调度算法。貌似去年OO第一次电梯就是要求这样的算法。今年这样写必定会超时。
捎带算法(ALS)
可捎带电梯调度器将会新增主请求和被捎带请求两个概念
主请求选择规则:
- 如果电梯中没有乘客,将请求队列中到达时间最早的请求作为主请求
- 如果电梯中有乘客,将其中到达时间最早的乘客请求作为主请求
被捎带请求选择规则:
- 电梯的主请求存在,即主请求到该请求进入电梯时尚未完成
- 该请求到达请求队列的时间小于等于电梯到达该请求出发楼层关门的截止时间
- 电梯的运行方向和该请求的目标方向一致。即电梯主请求的目标楼层和被捎带请求的目标楼层,两者在当前楼层的同一侧。
评测机使用这种算法作为基准,也就是说,如果不想强测超时,至少也得实现ALS算法。
扫描算法(SCAN)
扫描算法是一种按照楼层顺序依次服务请求,它让电梯在最底层和最顶层之间连续往返运行,在运行过程中响应处在于电梯运行方向相同的各楼层上的请求。它进行寻找楼层的优化,效率比较高,较好地解决了电梯移动的问题。
但是如果有人从10楼到1楼,电梯会先从1楼到10楼接上乘客,然后再跑到20楼,最后再向下到一楼,这样会在一些手动构造的数据面前吃亏。
更多内容可参考:https://www.jianshu.com/p/637031821228
程序bug分析
公测、互测bug分析
很幸运,由于架构设计合理且算法性能较好,在公测、互测以及自我测试中均未发现bug
线程安全问题
共享数据安全 && 线程安全容器
为什么要实现线程安全容器?线程安全容器可以将共享的容器作为成员,将容器的操作方法上锁,这样不仅优化了代码风格,还能很好的封装并实现许多容器相关的方法。
我实现的RequestList中包含了当前处理的模式,请求表以及end标记。end标记用于记录此前的请求表是否结束,当RequestProducer输入结束时,waitList中的end将会被置为true,如果waitList已经end而且为空表,则会给电梯的候乘表processList置end,此时RequestList线程便可以自然结束了。这里的end标记涉及线程的结束,一定要重视!
P.S. 第一次作业在不实现RequestList的情况下,也可以完成。这里要用到Java自带的线程安全容器。主要可用的有阻塞队列BlockingQueue、并发容器CopyOnWriteArrayList等,这些容器可以在处理一些简单的数据共享中使用。更多相关内容可以参考这里:https://www.cnblogs.com/chengxiao/p/6881974.html
由于要保障多线程的数据安全,我们在不同线程共享一个容器的时候,要对容器上锁,避免单个数据的重复读写。常见的方法有synchornized块以及synchornized方法。在我个人看来,在程序中大量使用synchornized块不仅复杂化了代码,同时也会有产生bug的风险。本着Less is more的原则,我们可以为容器专门开一个线程安全类,在其中用synchornized方法对容器的操作上锁。下面给出一些代码示例:
import com.oocourse.elevator1.PersonRequest;
import java.util.LinkedList;
public class RequestList {
private static String arrivePattern;
private final LinkedList<PersonRequest> list;
private boolean isEnd;
public RequestList() {
this.list = new LinkedList<>();
isEnd = false;
}
public synchronized void addRequest(PersonRequest i) {
list.offer(i);
}
public synchronized PersonRequest getRequest(int i) {
return list.get(i);
}
public synchronized void removeRequest(int i) {
list.remove(i);
}
public synchronized void threadWait() {
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public synchronized void threadNotifyAll() {
this.notifyAll();
}
//......
}
轮询超时风险 && wait-notify的正确使用
所谓轮询,是由CPU定时依序询问每一个周边设备是否需要其服务,有即给予服务,服务结束后再问下一个周边,接着不断周而复始。从定义不难看出,无脑轮询造成的直接结果就是CPU超时。想要规避轮询超时的风险,需要科学使用wait-notify方法。举个例子,第一次作业中,最简单的想法是电梯与生产者共享一个waitList,电梯通过不断遍历waitList查询是否有请求,有则服务,无则继续遍历询问。这种bug可以用这样的方法解决:电梯判断waitList是否为空,如果为空则调用wait()方法等待;生产者在读入并将新请求加入waitList的后,通过调用notify()方法唤醒等待的电梯线程。
寻找CPU超时除了自主分析,还可以使用JProfiler插件或者JavaVisualVM程序,两者殊途同归。下面介绍JavaVisualVM的使用方法。
JavaVisualVM不用自己下载,它已经包含在jdk中,是Java虚拟机相关信息的官方可视化程序。程序的位置一般在jdk安装文件夹下的bin文件夹中,名称为jvisualvm.exe

开启JavaVisualVM后,在IDEA中运行自己的程序,之后JavaVisualVM左侧应用程序栏会出现刚才开启的main线程:

双击进入MainClass(pid 13568) (不同线程的pid会不同,名字取决于你自己的主类的名字),选择抽样器,抽样CPU,可以查看每个方法、线程的CPU自用时间。由于现在没有输入数据,页面会显示所有方法时间为空:

随便输入一组数据,可以发现表格产生了变化:

com.oocourse.*是OO课程组提供的官方包,由于一直在读取输入的请求,CPU时间必然会很长,这对我们的程序没有影响,不会造成我们的程序超时。寻找CPU超时的bug只需要看CPU自用时间,其他的耗时可以不用关注。但是,一些其他自用时间异常也揭示了可能存在一些问题。
线程死锁的分析
线程死锁是多个并发进程因争夺系统资源而产生相互等待的现象。从我个人经验而言,容易产生死锁的程序往往没有实现线程安全容器而是到处使用synchornized块。但是,问题不在synchornized块,而是在于加锁的范围太大,或者是加锁的对象错误,或者是在使用共享资源前没有进行合理判断。举个例子,第三次作业中,有同学是在电梯中判断是否需要换乘,需要则新建请求,通过电梯共享的waitList将请求添加回去。这样理论上可行,但是假设需要换乘的是waitList的最后一个请求,Scheduler将此请求分发给电梯后,由于waitList已为空表,则调用了wait方法,而电梯在把请求送回后也会进入wait状态,此时Scheduler与Elevator互相等待,导致程序卡住无法运行。
个人认为解决死锁的比较好的方法,就是尽量减少synchornized块的使用,将需要共享的数据放入线程安全容器,对所与可能的操作实现synchornized方法。这样,在程序中直接调用相关方法即可避免死锁产生,同时也美化了代码观感。
死锁相关的内容可以看这里:https://blog.csdn.net/guaiguaihenguai/article/details/80303835
互测bug分析策略
测试策略
通过python生成大量随机数据,将其保存成带有时间戳的文本,再利用C语言以及Shell脚本,实现了一个半自动的评测机。构造的数据包括楼层边界数据,用以判断设计准确性以及考量调度性能;楼层密集型数据,判断请求分派是否合理;大规模均匀数据(覆盖乘客在每两个楼层之间的易动,方向包括上行与下行),用于比较算法的综合性能。
hack策略
由于OS压力比较大,这几次作业就没有主动刀过别人......
hack的策略其实和测试策略几乎差不多,我有尝试用自己的评测机去跑别人的代码,但是都通过了,可能是我的数据不够强。如果针对性地找bug,个人感觉大部分互测程序都不会存在正确性错误,主要易错点应该是超时问题。可以寻找其多电梯调度算法存在的漏洞,用强边界数据去卡TLE。
与第一单元测试的对比
由于加入了多个线程,且请求的输入时间不统一,这次的测试不能只着眼于正确性,而要兼顾程序的运行时间(CPU_TIME)以及电梯完成所有请求的实际时间(REAL_TIME)。包括数据的构造,不再是像第一单元的以"强"为主,而是要兼顾对运行速度的考量。此外,要注意模拟数据的时间戳,实现对数据的定时投放,需要对shell管道有基本的了解。
心得体会
都说第二单元电梯很难,但从结果上来看,电梯涉及的知识点其实并不是太多。虽然完成的过程中遇到了一些挺讨厌的bug,但是最终都一一找了出来,在强测中也拿到了比较满意的分数。
经过三次电梯作业的设计与实现,我对Java多线程的设计方法有了明确的认知,同时也对多线程的安全问题有了初步的了解。每次作业的增量,都促使我们对架构设计不断完善,加强对各个模块的封装。
实现电梯的过程中,比较困难的部分主要在架构设计、调度算法以及多线程的实现。这些知识完全可以通过预习工作以及课上内容,实现熟练掌握与运用。其中涉及的生产者-消费者模型,更是设计模式中比较重要的部分,对这次的电梯作业有很大的帮助。
总而言之,完成这一单元的同时,我也同时自主学到了很多东西。付出大量时间的同时,也学习了许多设计方法、debug策略以及得到了漂亮的分数,可谓一分耕耘一分收获吧。
一些可能用到的知识点
多线程的实现方式
继承Thread类的方式
- 创建一个继承于
Thread类的子类 - 重写
Thread类中的run():将此线程要执行的操作声明在run() - 创建
Thread的子类的对象 - 调用此对象的
start():①启动线程 ②调用当前线程的run()方法
实现Runnable接口的方式
- 创建一个实现
Runnable接口的类 - 实现
Runnable接口中的抽象方法run():将创建的线程要执行的操作声明在此方法中 - 创建
Runnable接口实现类的对象 - 将此对象作为参数传递到
Thread类的构造器中,创建Thread类的对象 - 调用
Thread类中的start():① 启动线程 ② 调用线程的run()->调用Runnable接口实现类的run()
实现Callable接口
- 与使用
Runnable相比,Callable功能更强大些 - 实现的
call()方法相比run()方法,可以返回值 - 方法可以抛出异常
- 支持泛型的返回值
- 需要借助
FutureTask类,比如获取返回结果
使用线程池
提前创建好多个线程,放入线程池中,使用时直接获取,使用完放回池中。可以避免频繁创建销毁、实现重复利用。类似生活中的公共交通工具。
方法优劣及进一步解释详见:https://www.html.cn/qa/other/21938.html
主流方法是继承Thread类或者实现Runnable接口,我使用的是实现Runnable接口,以防后期某些类可能会用到继承关系。
生产者-消费者模式(Producer-Consumer Mode)
生产者-消费者模式是一个经典的多线程设计模式,它为多线程间的协作提供了良好的解决方案。这个模式中,通常有两类线程,即若干个生产者线程和若干个消费者线程。生产者线程负责提交用户请求,消费者线程则负责具体处理生产者提交的任务。生产者和消费者之间通过共享内存缓存区进行通信,这样就避免了生产者和消费者直接通信,从而将生产者和消费者解耦。不管是生产高于消费,还是消费高于生产,缓存区的存在可以确保系统的正常运行。
Producer-Consumer Mode中的角色
- Product:即生产者线程锁需要提供的产品。
- Producer:生产者,负责产生对应的产品,其把产生的产品放入到队列Channel中
- Consumer:从队列Channel中获取对应的产品,获取之后对其进行业务处理。
- Channel:Channel是两者共享的区域,在设计模式中Channel的作用是解耦生产者与消费者。
不难看出,我们要实现的电梯完全可以套用生产者-消费者模式,我们将每一个电梯请求(PersonRequest是官方包已经实现的一个内部class,但是这里还是建议自己创建一个Request类,方便后面作业的扩展)看作Product,将读入请求的部分看作Producer。那么,电梯与请求生产者之前的Channel是什么呢?我们要建立起生产者和消费者的联系,需要让他们共享数据,这个存放数据的容器,正是我们需要实现的Channel。此外,电梯的运行,也需要实现一个调度器Scheduler,进行统一的管理。在第一次作业中,我们可以不实现这个Scheduler,因为我们只有一个电梯,暂时不需要进行调度,可以直接将共享数据从生产者传递给电梯。
更多的内容可以看这里:https://www.cnblogs.com/luego/p/12048857.html
另外,我也准备了一份生产者-消费者模式的简单代码,以供参考:https://bhpan.buaa.edu.cn:443/link/9279679E7E78DEA2A8F9F9145FAB96D4 (有效期限:2025-05-19 23:59)

浙公网安备 33010602011771号