BUAA-OO第二单元总结
1、总述
第二单元我们学习了 java 多线程,通过模拟多线程实时电梯系统,掌握了线程之间的交互、多线程中可能存在的线程安全问题以及生产者-消费者、单例模式、观察者模式、流水线模式等多线程协同的设计模式,在三次作业的迭代过程中不断强化线程之间的协同设计层次架构。
2、电梯调度设计
三次作业都采用了生产者-消费者模式,输出都采用单例模式。
2.1 第一次作业
对于单部纵向电梯,每部电梯有一个候乘表,并按上行/下行、出发楼层分成了20个等候队列(用两个 hashmap
分别表示上行、下行乘客,hashmap
的 key 是出发楼层,value 是存放请求的容器),电梯内部还用一个 hashmap
记录电梯内各个目的楼层的乘客。
电梯内部有 isOpen
、nowFloor
、desFloor
等状态,电梯运行逻辑为:每到一层先让目的楼层是 nowFloor
的乘客 OUT,之后再让出发楼层是 nowFloor
且移动方向与电梯运行方向相同的乘客 IN,其中每次进乘客都把 desFloor
设为新进乘客目的楼层与当前电梯 desFloor
中离电梯 nowFloor
最远的数值。当电梯内没有乘客时(nowFloor
== desFloor
),如果整个候乘表中没有请求,则电梯 wait,否则,先让出发楼层是当前楼层且上行的乘客进电梯,如果没有则让出发楼层是当前楼层且下行的乘客进电梯,如果也没有则设置离当前楼层最近的出发楼层为电梯的目的楼层 desFloor
。电梯结束的标志是输入结束且等候队列为空且电梯内为空。
public void run() {
while (true) {
//判断是否需要进乘客 电梯内没人且等候队列为空时会关门阻塞
//判断是否需要关门
if (输入结束 && 等候队列为空 && 电梯内为空) {
break;
}
if (nowFloor < desFloor) { // up
move(1);
} else if (nowFloor > desFloor) { // down
move(2);
}
//判断是否需要开门出乘客
}
}
电梯的开门关门函数都先判断 isOpen
状态再去操作,避免多余的开关门。电梯开门后先 sleep(400)
,再一次性出、进乘客,可以捎带更多的乘客。因为先出后进,所以乘客进电梯时先判断是否需要开门。
UML类图如下:
UML协作图如下:
2.2 第二次作业
第二次作业增加了横向电梯,横向电梯的运行策略基本同纵向电梯,不同之处是横向电梯可以循环移动,所以我取消了第一次作业中的 desFloor
,用一个整数 next
表示电梯当前的移动状态,正数表示顺时针移动,负数表示逆时针移动,绝对值为移动的距离,每次移动更新 next
。
每座/每层可以有多部电梯但没有换乘,我采用了均匀分配的原则,用两级生产者-消费者完成分配调度。输入线程根据座/层把请求分配到对应座/层的等候队列,每个座/层的 Distributor
将该座/层的请求均匀分配到该座/层的电梯中,各电梯之间按第一次作业的策略独立运行。
UML类图如下:
UML协作图如下:
2.3 第三次作业
第三次作业电梯参数可自定义且增加了换乘。我继承官方包的 PersonRequest
实现了一个新类 Passenger
,增加了修改出发/目的信息、保存最终目的信息、修改换乘状态等函数以实现换乘。换乘的处理采用实验课代码的流水线模式。对于每条请求,如果不需要换乘,则按第二次作业处理,否则,先根据请求的信息和电梯可停靠信息设置当前目的座/层,并加入对应楼/座的等候队列。需要换乘的乘客在前往初始目的楼层的过程中,每移动一层会查询是否能在该楼层提前换乘。换乘时 Controller
更新请求的出发、目的信息以及换乘状态,并根据当前信息把请求加入对应楼/座。
UML类图如下:
UML协作图如下
3、 线程安全处理
我在第一次作业中使用了 synchronized
,后续作业因为对 Lock
使用不熟悉且担心改动原有代码会出 bug,所以都是对方法无脑加 synchronized
。
以第三次作业为例,我用到了同步块的地方有(前两次作业做同样处理):
- 电梯调度器
Scheduler
的take()
、put()
、isEmpty()
、isEnd()
、setEnd()
方法。 - 总等待队列
WaitingQueue
的take()
、put()
、isEmpty()
、isEnd()
、setEnd()
方法。 - 管理电梯调度器
State
类的addElv()
、pickElv()
、setEnd()
、contain()
方法。 - 控制器
Controller
的needChange()
、addPassenger()
、setEndTag()
、getEndTag()
方法。
可以看出在生产者-消费者模式中,托盘的存取方法、状态修改查询方法都需要加锁确保一个时刻只能有一个线程调用。
所有同步块方法中只有取方法中可能会发生阻塞等待,存方法和状态修改方法中都需要有 notifyAll()
以唤醒等待的线程,状态查询方法都不需要加 notifyAll()
,否则可能造成轮询。
4、 bug分析
-
第一次作业:
- 自测时发现电梯在目的楼层让所有乘客出去后,如果这时等候队列为空,电梯会先阻塞,被唤醒后才会关门或接新乘客。但测评机似乎并没有把输出开关门时间间隔太长算作bug,只要间隔大于规定时间即可。应该在电梯阻塞前先判断是否需要关门。
-
第二次作业:
- 中测时遇到过无法处理最后一条请求的bug,原因是在 Distributor 中判断结束标志的位置不对,导致 take 了最后一条请求后还没处理就结束了。
- 强测中出现了轮询的问题,原因是在查询状态方法、以及 take 返回为 null 时加了多余的 notifyAll,导致当等候队列为空时,两个电梯线程会轮流wait、notifyAll,造成轮询。
-
第三次作业:
- 中测时遇到了 CTLE 的bug,原因是横向电梯在 A 座也有可能是不可开关门的,但我的写法是凡是在不可停靠的座都不去 take 乘客,这样电梯初始在 A 座且不可开关门时无法阻塞,导致轮询。
- 强测时出现了 RTLE 的bug,bug修复时抱着试试的态度,把需要换乘的请求在移动过程中的可能更改换乘楼层的判断取消(即完全改成静态拆分)后就不超时了。其实本来以为最初写法会在某些情况改善性能,结果不知道为什么反而会超时。
三次作业互测都没有 hack 成功,也没有被hack。
由于都采用了 synchronized 和均衡的调度策略,性能分并不高。
5、 心得体会
- 线程安全
- 线程安全是多线程中最重要的问题之一,对象共享是产生线程安全问题的根本原因,在实现时要理清楚类/对象会被哪些线程共享,线程的哪些操作会产生对象共享,并以此决定哪些方法/代码块需要加锁。
- 轮询非常消耗 cpu 资源,在多线程中由于不恰当的 notify 很容易造成轮询、死锁、线程无法结束等问题。一定要分析清楚发生阻塞和需要唤醒的逻辑,谨慎使用
wait
和notifyAll
。 synchronized
在执行完成或发生异常时会自动释放锁,在语义上很清晰,使用起来方便简单,但是性能低,使用场合有限。Lock 使用灵活,需要手动获取、释放锁。用条件锁、读写锁等可以实现更自由的线程交互,而且在竞争资源激烈时,Lock的性能要优于synchronized
。由于时间原因第二单元我没有使用 Lock,希望以后能通过实践体会到 Lock 的优点。
- 层次化设计
- 电梯单元的重点不再是设计结构,我曾试图把横向电梯和纵向电梯的共同点提取出来实现 Elevator 基类,但因为横向电梯和纵向电梯的实现逻辑稍有不同,继承 Elevator 类反而增加了复杂程度。
- 横向电梯和纵向电梯的调度器都实现了 Scheduler 接口,对于同一座/层统一管理,使得横纵电梯的 Distributor 可以共用。