前言
本博客记录了笔者在面向对象编程第二单元的学习过程与相关思考。
- 复杂度分析使用IDEA的插件MetricsReloaded
- 代码量分析使用IDEA的插件Statistic
- UML图绘制使用draw.io绘制
- 时序图绘制使用mermaid编写生成
题目说明
面向对象编程第二单元的主题为:多线程模拟实时电梯系统。
- 第一次迭代:建立多线程电梯系统,设计电梯调度算法
- 第二次迭代:新增自定义调度策略、重置指令
- 第三次迭代:新增双轿厢电梯、第二类重置指令
三次迭代
第五次作业
设计与架构
听学长说未来两次迭代后的代码复杂度会很高,应该只在必要的情况下增加,以避免逻辑混乱。本次迭代种笔者共设计了两类线程,一类是输入线程 InputThread
,一类是电梯线程 Elevator
。这两个类共同对任务列表类 QuestTable
进行写操作:InputThread
通过任务分配策略类 QuestScheduler
(目前只有指派策略,即将任务直接指派给某个id的电梯)对 QuestTable
进行操作,Elevator
直接对 QuestTable
进行操作。
synchronized方法
本次迭代中笔者只在共享类 QuestTable
内使用了设置了锁,使用的也是最基本的synchronized方法:
public synchronized void add(Quest quest) {...}
public synchronized Quest get(boolean dir, int floor) {...}
public synchronized boolean isEmpty() {...}
将共享类的所有方法加上synchronized关键词,保证了其方法的原子性,使同一时间只有一个线程能调用共享类的方法。由于java目前的synchronized效率很高,此做法简单高效,没有什么显著缺点。
电梯调度
笔者使用了LOOK策略作为电梯调度策略。策略逻辑如下:
public Advice getAdvice(boolean dir, int curFloor,int curNum, int maxNum, ArrayList<Quest> curQuest) {
if (/*有乘客进出*/) {
return Advice.OPEN;
}
if (/*电梯非空*/) {
return Advice.CONTINUE;
}
if (/*等待队列为空*/) {
if (/*输入结束*/) {
return Advice.END;
} else {
return Advice.WAIT;
}
}
if (/*前进方向没有乘客*/) {
return Advice.REVERSE;
} else {
return Advice.CONTINUE;
}
}
顺便一提,LOOK调度算法是现实生活使用最广泛的电梯调度策略。
量子电梯
本次迭代正确性说明中有这样的约束:
移动一层花费的时间:0.4s
“花费的时间”体现在何处呢?大部分同学的直观理解是在电梯开始移动到电梯到达新的楼层的时间间隔为0.4秒,这样也是最符合客观现实的一种理解。但按评测标准,只要电梯输出ARRIVE与上一次动作之间相隔0.4秒就满足正确性要求,如:
输入:
[1.0]1-FROM-2-TO-9-BY-1
输出:
[1.0100]ARRIVE-2-1
满足正确性要求(电梯上一次动作时间可视为0.0s)。
为什么称之为量子电梯呢?因为在电梯移动的时候既在当前楼层又在目标楼层。
wait-notify问题
因为第一次接触到多线程,写代码时不是很清楚wait-notify的机制,闹出了不少笑话。
- 为什么 wait 与 notify 必须在同步块内?
当调用 obj.wait 时,当前的线程释放obj对象的锁并进入obj的等待队列中;调用 obj.notify 时,线程将在执行完同步块(释放锁)后从obj的等待队列中移出一个线程并将其唤醒。如果线程没有得到obj的锁,以上的操作将没有任何意义(也是不被允许的)。 - 为什么不能直接调用 wait 方法,阻塞当前进程?
线程由就绪态转换成阻塞态的同时,一定会进入某种资源的等待队列,否则无法被唤醒。
架构图
时序图
程序结构分析
代码量分析
本次作业 Source Code 共381行,主要集中在 Elevator
类,因为其中包含了电梯的多种行为。
复杂度分析
Elevator
与 ElevatorScheduler
两类复杂度较高。前者的 run 方法的while循环中有较多的if语句,后者则有遍历 QuestTable
的 noFurtherQuest 等方法。
第六次作业
设计与架构
本次迭代新增:
- 接收到重置指令的电梯需在一定时间内完成重置动作,再投入电梯系统运行。
- 调度器会根据电梯运行情况(方向、楼层)将乘客请求合理分配给某部电梯。
本次迭代架构上的变化较大,加入了新线程 QuestScheduler
将乘客分派到不同电梯,并为该线程设计了一个总等待队列类 UndoneTask
。后者的实现与第一次迭代的电梯等待队列类 QuestTable
大体相同,但其内部的容器更为简单。
分配算法
笔者自行编写了一种基于路程的打分分配算法,主要逻辑如下:
//返回的分数为电梯从当前楼层出发到接到乘客共运行的楼层数
//在QuestScheduler里取分数最低的电梯分配该乘客
public int getScore(Quest quest) {
if (/*乘客需求方向与电梯运行方向相同*/) {
if (/*电梯内人数+与电梯运行方向相同的剩余任务数<电梯容量*/) {
if (/*电梯无需掉头即可接到乘客*/) {
return /*电梯当前楼层与乘客出发楼层之差的绝对值*/;
} else { //需要掉头
return /*电梯运行一圈的楼层数-电梯当前楼层与乘客出发楼层之差的绝对值*/;
}
} else { //电梯这趟接不到乘客
return /*电梯接到乘客需要遍历的圈数x电梯运行一圈的楼层数-电梯当前楼层与乘客出发楼层之差的绝对值*/;
}
}
} else { //乘客需求方向与电梯运行方向相反
//...
}
}
笔者对”电梯接乘客“这一问题进行了大致的建模,在一些细节上进行了简化,并添加了一些对未来的简单预测,最终得到了这样的算法。对未来的预测仅限于一些预设情况,并非实时的判断。性能上略优于影子电梯。
if的原子性
考虑如下代码:
if (questTable.isEnd()) { //only for demonstration
break;
} else {
if(questTable.isEmpty()) {
questTable.waitForTask();
}
//...
}
虽然该段代码调用的 QuestTable
方法都是原子操作,但是由于if-else结构并没有被同步,所以在执行过程中还是可能会有不可预测的bug,如:
if (questTable.isEnd()) { //only for demonstration
break;
} else {
//此时QuestScheduler线程调用了QuestTable线程的setEnd()方法
if (questTable.isEmpty()) { //isEmpty仍然成立
questTable.waitForTask();
//该Elevator线程进入等待状态
//而QuestScheduler不会再唤醒它
//最终导致Elevator线程不会结束
}
//...
}
笔者想到了两个解决方法:
- 调用有等待时间上限的 wait :
questTable.waitForTask(100);
- 在if-else外添加synchronized:
synchronized (questTable) { if (questTable.isEnd()) //... }
缓存分配问题
当五部电梯全都处于Reset状态时,同时涌入大量乘客,如何保证这些乘客不全部分配到一个电梯中?
- 限制电梯等待队列人数
本单元对最大任务量进行了限制,即最大任务量不会超过100。可据此设置某一电梯的最大等待人数。如:
此方法易实现,大部分情况性能与缓存分配持平。public static int MAX_WAIT_NUM = 20;
- 缓存分配
缓存分配指将分配给Reset状态电梯的乘客缓存在等待队列中,待电梯重置完成后再输出Receive信息。如:
此方法对性能没有显著提升,但不失为一种有趣的解决方法。if (!allocated.isResetting()) { TimableOutput.println("RECEIVE-" + quest.getPersonId() + "-" + allocated.getId()); } allocated.getQuestTable().add(quest);
架构图
时序图
程序结构分析
代码量分析
本次作业 Source Code 共757行,主要集中在 Elevator
与 QuestTable
类。前者包含电梯的多种行为,后者则包括了分配策略所需要的一些方法。
复杂度分析
Elevator
与 ElevatorScheduler
两类复杂度较高。主要原因是加入了基于路程的打分分配算法,其中包含多层循环、if-lese以及函数调用。
第七次作业
设计与架构
本次迭代新增:
- 在同一电梯井道内同时拥有两个独立的电梯轿厢的双轿厢电梯。
- 第二类重置请求将电梯修改为双轿厢电梯。
轿厢不碰撞
在经历了前两次迭代的试炼 (折磨) 后,这个问题还是蛮简单的。笔者新建了一个换乘层类 TransferFloor
,并令到达换乘层的电梯获取换乘层对象的锁,在离开该层时释放换乘层对象的锁。需要注意的是,到达与离开均以输出为标准:
if (nextFloor == transFloor) {
synchronized (transferFloor) {
while (transferFloor.isUsing()) {
transferFloor.waitSignal();
}
transferFloor.use(); //get lock
}
}
TimableOutput.println("ARRIVE-" + nextFloor + "-" + id + getStr());
if (curFloor == transFloor) {
transferFloor.finish(); //release lock
}
curFloor = nextFloor;
总的思路就是在输出ARRIVE前先获得进入换乘层的许可,离开换乘层时归还许可。
量子电梯问题
在第三次迭代的强测中出现了一个神奇的bug:
报错信息为:Elevator
的 open 方法中使用的 sleep 传入的时间参数为负数。相关函数如下:
private void open() {
lastAction = TimableOutput.println("OPEN-" + curFloor + "-" + id + getStr());
out();//只访问了一个非共享的ArrayList
if (elevatorScheduler.getAdvice(direction, curFloor, maxFloor, minFloor,curNumber.get(), maxNumber, curQuest, transferFloor) == Advice.REVERSE) {
reverse();//改变方向
}
try {
Thread.sleep(Assistant.OPEN_CLOSE_TIME + lastAction - System.currentTimeMillis());
//因为实现了量子电梯,所以没有直接使用 Assistant.OPEN_CLOSE_TIME 作为sleep的参数。
} catch (InterruptedException e) {
e.printStackTrace();
}
//...
}
可以看出,只有在 c u r r e n t T i m e > l a s t A c t i o n + O P E N _ C L O S E _ T I M E currentTime > lastAction + OPEN\_CLOSE\_TIME currentTime>lastAction+OPEN_CLOSE_TIME的情况下会出现传参为负的bug。但当前执行的sleep函数与上次动作之间仅仅执行了两个没有sleep的函数,为什么会导致超过400ms呢?
操作系统调度线程时为线程分配一定量的时间片,并为线程分配一个处理器,使之从就绪态变成运行态;线程使用完时间片后将处理器还给操作系统,并从运行态变成就绪态。在本地执行时笔者的程序最多有14个线程(12个电梯线程、1个调度线程和1个输入线程),运行时每个线程都能分到时间片;但由于强测的评测机是高并发的,不能保证一个线程能按时分配时间片(0.4秒不给分配时间片无论如何都有点离谱)。笔者的bug可能就是由于评测机的并发问题导致的,因此在编写程序时要尽量保证程序的鲁棒性,不要默认程序一定在完全理想的环境下运行。
架构图
时序图
本次迭代基本没有更改时序关系,时序图与第六次作业相同。
程序结构分析
代码量分析
本次作业 Source Code 共933行,主要集中在 Elevator
与 QuestTable
类。前者包含电梯的多种行为,后者则包括了分配策略所需要的一些方法
复杂度分析
Elevator
类复杂度较高。主要原因是笔者没有将AB轿厢拆开成为两类,在 Elevator
类中实现了普通电梯、双轿厢电梯,所以方法比较冗杂,复杂度较高。
同步块&锁
本单元笔者主要使用了两种锁:synchronized与读写锁。因为要使用wait-notify体系,所以前者使用的更加广泛一些,后者只在 QuestTable
中使用。此外,笔者使用了 AtomicInteger
与 AtomicBoolean
两个原子类,降低了锁的使用频率。
- 第五次作业
- 第六次作业
- 第七次作业
程序bug
本单元笔者的代码只在第三次迭代强测中出现了一个bug,其余互测、强测均未出现bug。下面整理本地和强测的bug:
- 第五次作业
- 第六次作业
- 第七次作业
debug方法
- 输出判断
输出判断指通过在代码中添加输出语句,或在代码原有的输出语句中添加额外信息,以达到观察多线程运行情况的目的。笔者主要通过此方法寻找程序出现异常的原因。 - 代码走查
代码走查指在代码书写完毕后重新通读代码以检潜在的bug。笔者主要通过代码走查检查逻辑谬误、死锁、线程不安全等问题,因为此类问题往往难以通过人工构造数据点解决。
心得体会
线程安全
在经历了了面向对象编程与操作系统两们课的双重折磨学习后,笔者总算是对多线程初窥门径。线程安全与死锁的预防是多线程编程的最核心的两块内容。
笔者并没有出现任何有关线程安全的bug,无论是自测还是互测。这可能归功于笔者在书写代码前对线程安全进行了系统性的学习才开始书写代码,并且在书写代码的过程中多次进行代码走查。尽管如此,笔者自认为对线程安的学习并不完全系统,因为本单元的并发程度不高,涉及到的共享资源也并不复杂(其实只是一个数组而已)。
希望未来对操作系统与数据库等内容的学习,以及未来工作的具体实践,能够帮助本人真正掌握线程安全相关内容。
层次化设计
层次化设计要求编程者自顶向下对整个设计任务进行分层分块,并对每层每块分别建类实现。高层的类只关注抽象后的功能调用,而底层的类则负责每种功能的具体实现。这样的做法在降低每层代码的复杂度发同时也会增加程序的代码总量,所以应该合理平衡代码复杂度与代码量。
第七次作业笔者共设计了十三个类、一个枚举类与一个接口,个人感觉层次化设计方面做的不错,类间逻辑清晰,类内职能单一(且总代码量控制在900行,没有突破1000行的大关)。
经历了面向对象编程课程一二单元的洗礼,笔者初步领悟了层次化设计的目的:简化大型项目的代码复杂度,让编写出来的代码更具有可读性与可扩展性。可以说层次化设计是合作开发大型应用程序的基本前提,没有这种设计思路,也谈不到合作开发了。