2024-BUAA-OO Unit 2
前两次作业的UML类图没有多大差别,不过是类内部一些方法的改变,大致框架如下:
第一次作业
架构分析
第一次作业是接触多线程的开始,在实验代码的基础上修改完成了电梯的大体框架:
除了主线程之外,还有InputThread
、Schedule
、Elevator
(6个)这几个线程。
WaitList
:总表
InputThread
:从输入中获取乘客请求加入总表
Schedule
:将总表的请求分发给各个分表(指定分发)
Elevator
:
RequestList
:特定电梯的分表Strategy
:电梯的运行策略–LOOK
在分表中对人员的记录采取的方式过于冗杂(如下),出发点是想要更方便快捷地查找到分表的信息不想总遍历,后来发现遍历也不会耗费很长时间反而维护这么多的记录信息会很麻烦,简简单单一个ArrayList就挺好的。
但是LOOK策略在hw5写的时候严重依赖于这些冗杂的属性,所以尽管看着这堆很难受,在hw6hw7并没有做出修改。(敲打
//RequestList的属性 private boolean isEnd; private int[] requestFloor = new int[12]; //requestFloor[i]表示第i层所有的目标层 private int[][] detailFloor = new int[12][12]; //detailFloor[i][j]表示分表中请求为从第i层到第j层的人数 private ArrayList<Person>[] up = new ArrayList[12]; //up[i]表示第i层向上的请求集合 private ArrayList<Person>[] down = new ArrayList[12]; //down[i]表示第i层向下的请求集合 private int upCall = 0; //第i位为1表示第i层有上行请求,否则没有 private int downCall = 0; //第i位为1表示第i层有下行请求,否则没有
bug分析
第一次作业的要求较为简单,实验代码给了基本的实现框架,出现的bug大多是在本地实现LOOK策略时产生的逻辑性错误。
第一次体会到多线程的神奇大概是同一类型的数据只能特别特别具有随机性地hack到(x
第二次作业
架构分析
task1:电梯调度策略
影子电梯真的碰都不敢碰,所以hw6调度选择的是一种听起来很自然而然的策略:
①优先分配空梯
②分配给可以捎带的电梯
③分配给最短时间能接到人的电梯
一方面,这种分配在很多情况下的性能确实不好;另一方面,我在实现时有读写冲突的线程安全问题,缺少buffer设计问题,以及实现上述策略途中的逻辑问题。嗯。。真的不如random(x)
task2:电梯重置
为RequestList
类增添resetRequest属性,在InputThread
中直接为指定电梯分派reset指令。完善Strategy
,reset操作的优先级最高。完善Elevator
中reset时电梯的操作,移除电梯内部人员->输出reset->移除等待队列人员。
bug分析
在此次作业上由评测结果直观地显示出来的层出不穷的问题,才让我意识到我关于多线程还是想的太少太少,,自以为的太多太多。
- 电梯相关的读写冲突
电梯线程一直在运行,它的floor、personNum、moveTime、maxNum等属性一直在发生改变;调度器线程不定时地需要获取电梯目前的状态来做出选择。所以对电梯属性相关的修改or读取都应该上锁。
- 电梯reset时清空电梯的操作
原先removeRequestList
的方法在电梯类中且没有加锁,存在很大的线程不安全的问题。把removeRequestList
的方法更改到RequestList
中并上锁。
- 为
RequestList
添加buffer
原先的“不为reset时的电梯分配”在短时间内重置多部电梯并输入大量请求的情况下运行时间会超时。需要添加一个buffer,在电梯在重置时分配请求,并将收到的请求先放入buffer,重置结束后再进一步处理。
- 把Strategy的getAdvice方法加锁
锁为电梯的等待队列,因为电梯可能在执行完一个if判断后状态发生了改变。虽然看起来不影响正确性,但能让我们对程序运行的确定性更有把握。
第三次作业
UML类图
第三次作业为了能够方便一些地创建/销毁电梯,在架构上做出了一些调整,大致框架如下:
注:MainList和ElevatorQueue实际上会被InputThread、Schedule、Elevator 、RequestList都调用,但都连上线实在太丑了。
架构分析
task:双轿厢重置请求
在电梯进行reset操作时判断是普通重置请求还是双轿厢重置请求。针对双轿厢重置请求,我的实现是创建两个新的电梯线程并销毁被重置的电梯线程。
为了能够方便一些地创建/销毁电梯,提取出来两个单例模式:
1. 实现所有请求的总表单例模式(原waitlist)——MainList类
2. 实现电梯相关队列(电梯队列和电梯等待队列)单例模式——ElevatorQueue类
就可以在电梯类的doublereset方法中方便的调用ElevatorQueue进行电梯线程的销毁和重置啦。
但是需要注意单例模式的实现,在多线程的背景下更建议采取饿汉式
- 双轿厢之间两个轿厢不碰撞
采取了讨论区中的同学提到的方法,为双轿厢电梯增添共享对象,让双轿厢电梯在到达换成楼层完成任务后及时离开。
class Flag {
- State state
+ void setOccupied()
+ void setRelease()
}
enum State {
+ OCCUPIED
+ UNOCCUPIED
}
bug分析
-
双轿厢在重置时会创建/销毁电梯。所以
Schedule
类挑选电梯时以及Elevator
类doublereset方法创建/销毁电梯的过程应该对ElevatorQueue加锁,否则会出现访问空指针等问题。 -
本地时有cpu轮询的问题发生在
InputThread
。由于第一次没有为总表设置输入端输入的结束标志,导致InputThread
会在输入端无输入但分表还有可能输入时轮询。
//错误
while (true) {
Request request = elevatorInput.nextRequest();
if (request == null && mainList.getResetNum() == 0 && mainList.getPersonNum() == 0) {
mainList.setEnd();
//System.err.println("InputThread end");
break;
} else {
//balabala
}
//正确
while (true) {
Request request = elevatorInput.nextRequest();
if (request == null) {
mainList.setInputEnd();
//System.err.println("InputThread end");
break;
} else {
//balabala
}
- 对性能的要求大让步,改为了最简单的random
变与不变
线程结束条件的变化(说到底是线程之间关系的变化):
- hw5:
总表的结束条件:输入线程中无输入
电梯线程的结束条件:总表为空 且 总表结束
- hw6:
总表的结束条件:输入线程中无输入 且 没有未处理的reset请求
电梯线程的结束条件:总表为空 且 总表结束
- hw7:
总表的两个输入来源–输入端和分表–的结束标志区分开来
电梯线程的结束条件:总表为空 且 总表结束(输入结束&&没有未处理的reset请求&&请求人数归零)
稳定不变的是类内部封装好的方法与处理逻辑
UML协作图
MainClass线程中启动各个线程。
InputThread线程向总表中加入申请,并负责设计总表输入端的结束标志。
Schedeule线程向电梯分表中分派请求,设置电梯线程的结束条件。
Elevator根据策略运行电梯。
同步块的设置和锁的选择
同步块通常使用synchronized关键字来实现,可以锁定对象或类,确保在同一时间只有一个线程可以访问同步块中的代码。
锁的作用是保证同一时间只有一个线程可以执行同步块中的代码,防止多个线程同时访问共享资源导致数据不一致或出现竞态条件。因此,在同步块中的处理语句应该是对共享资源的操作,确保在同一时间只有一个线程在操作共享资源,从而避免数据冲突和不一致性。
在设计同步块时,需要注意锁的粒度,尽量将同步块的范围缩小到最小,避免锁定过多的代码,影响程序的性能。
心得体会
- 线程安全
- 一定要避免读写冲突
在多线程的学习中,总会有类似这样的困惑:写写冲突自然而然应该避免,但是读写冲突呢?读写冲突会对正确性有影响吗?大不了不就是多跑几趟性能差点吗?
rwg老师在课上举过一个例子,大意是我们在一个线程创建一个变量时,可能只是先给了这个变量一个地址但还没有为它开好空间,而如果此时另一个线程尝试去访问这个变量,就会发生空指针错误。其实说到底,就是我们不能确定在底层究竟是怎么实现的,面对多线程我们不能主观臆断不会产生正确性上的错误,需要人为地加锁保证众多不确定中一点点的确定性。
- notifyall()的使用
在研讨课和同学们的讨论中学习到:不要滥用notifyall(可能导致轮询),也不避免使用notify(可能死锁)。
滥用notifyall:如果有大量的线程在等待对象的通知,而且这些线程在唤醒后并没有实际的工作可做,那么频繁调用 notifyAll() 会导致性能下降,因为大量的线程被唤醒但却无事可做。应该针对程序中使用wait()方法的对象,分析相关变化是否会影响下一步的操作,来判断是否需要notifyall()。
- 层次化设计
-
贯穿作业始终的生产者-消费者模式
-
合理划分各个层次的责任和接口,避免层次之间的紧耦合
比如在执行reset操作时完成“清空电梯等待队列”这一方法应该放在RequeList
方法中,而非在Elevator
里把等待队列取出来进行操作。前者不仅实现了较好的封装,而且对这一操作的加锁也自然而然。(RequestList
本就被多个线程共享,里面的方法大多数都加锁了)