2019面向对象程序设计第二单元总结
写在前面
OO第二单元是对多线程协调调度的考验,经过三次多线程作业后,我深深明白,这个第二单元的优化重点在于架构设计与保证线程安全。作为一个优化党,在这个单元,我在单部电梯和多部电梯的调度优化上做足了工作,但优化重心的偏差也让我吃了亏,这个单元的踩坑反而让我感到庆幸,因为我也明白了想拿足优化分的前提在于架构设计的合理性、线程的安全性和全方面的自主测试。
一.三次作业的设计思路
Ⅰ.单部多线程傻瓜调度(FAFS)电梯
思路:先来先服务,电梯不捎带
线程设计重点:1.如何协调读入与电梯线程。2.如何终止程序。
经过OO第二单元的练习,我发现第一次电梯作业虽然简单,但其在架构设计上却是最关键的一次作业,为何这么说?因为第一次作业的架构如果设计的好,后面的作业只需要对电梯内部以及调度器的调度策略进行修改,而无需大改架构,而不合适的架构也许并不一定造成强测的重大损失,但会造成代码无法复用,次次重写。
1.如何协调读入与电梯线程:
1°采用多线程交互模式:生产者消费者模式
2°协调线程运作方式:wait()与notifyAll()
托盘对象(调度器)
读入线程:InputThread
电梯线程:Elevator
读入线程向托盘对象中存入请求,电梯从托盘对象中读出请求,必须要保证线程安全,做到读写互斥。
电梯线程为空时,应该wait() ,输入线程是阻塞的,不能wait(),但每次拿到请求时应该唤醒电梯。
以下是调度器存取请求的方法
//输入线程存请求
synchronized void put(PersonRequest request) {
requestList.putRequest(request);
//调度器每拿到一个请求就唤醒电梯
notifyAll();
}
//电梯取请求,并把请求的参数转化为int数组便于电梯直接处理
synchronized int[] get() {
while (requestList.isEmpty()) {
try {
//请求队列为空就wait
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
PersonRequest request = requestList.getRequest(0);
int[] req = new int[3];
if (request != null) {
req[0] = request.getFromFloor();
req[1] = request.getToFloor();
req[2] = request.getPersonId();
} else {
req = null;
}
//唤醒电梯
notifyAll();
return req;
}
2.如何终止程序:
第一次作业我用了比较原始的方法实现程序终止,第二、三次作业我用了更好的方法:互斥锁。这里先介绍第一次作业的方法。
由于第一次作业是先来先服务,并用队列的数据结构存储请求,当输入线程读入null时,直接将null其存入调度器请求队列,并实现自身终止即可;电梯线程读入到null也自动终止即可。
//输入线程run方法
public void run() {
while (true) {
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
dispatch.put(null);
break;
} else {
dispatch.put(request);
}
}
}
//电梯线程run方法
public void run() {
while (true) {
int[] request = dispatch.get();
if (request == null) {
break;
} else {
analyze(request);
}
}
}
Ⅱ.单部多线程可捎带调度电梯(ALS&LOOK)
在第二次作业中,我为了测试不同调度策略电梯的性能,写了一部纯贪心电梯,一部基于LOOK算法的优化电梯。
最后得出的结论是:对于随机30组数据而言,LOOK算法的平均运行时间要明显优于纯贪心电梯。下面是两部电梯的整体设计思路
1.纯贪心电梯(ALS贪心升级版):
· 如果电梯中有乘客,则把电梯内部请求队列中目的楼层距离当前楼层最近的请求作为主请求,每层更新主请求。
· 如果电梯中没有乘客,则把电梯外部距离当前楼层最近的乘客请求作为主请求。
· 被捎带请求:电梯运行方向与该请求目标方向一致,即主请求的目标楼层和被捎带请求的目标楼层一致,与ALS电梯不同的是,纯贪心每次捎带一个请求后,都会立即刷新主请求,可能会把刚刚捎带的请求设为主请求。
2.基于LOOK算法的优化电梯:
· 电梯层层刷新,刷新时的情况有两种,一种是处于STILL状态时,即电梯内无任务且静止在某个楼层时;二是电梯处在上下行状态时,这时电梯内可能有任务,也可能没有任务,正在去接人。
· 电梯在运行方向上的最底层和最顶层之间运行,即每次完成一个方向上的运行,尽可能把这个方向上的全部任务完成。
· 我的优化:①不是一定不折返,而是条件性折返,折返之前计算损失,若折返的损失小于不折返该方向重新运行一遍的时间,则折返。②电梯在做完一个方向上的请求时,不一定改变运行方向,会根据门外请求重新决策向哪个方向运行。
设计框架:
3.多线程如何终止程序的改进:
· 读入线程和电梯线程共用一把锁,输入在读入null后把死亡标记位置1,然后自身终止,电梯线程没有要做的任务并且发现死亡标记位为1时,实现自身终止,否则执行wait(),等待被唤醒。
public class ThreadDeadFlag {
private boolean deadFlag;
ThreadDeadFlag() {
this.deadFlag = false;
}
//电梯线程get锁
synchronized boolean getDeadFlag() {
return this.deadFlag;
}
//主线程set锁
synchronized void setDeadFlag() {
this.deadFlag = true;
}
}
III.多部多线程智能电梯(SS)
经过讨论,我这次作业的设计是调度器将请求按照不同拆分方式尽可能多地分给电梯,让所有电梯抢人。经过实践,这样的电梯性能分还是不错的。
调度器拆分请求的策略:能直达的尽量不换乘(这也与日常生活中的电梯类似)。不能直达的,将请求在统一方向上尽可能拆分,比如:
1-FROM-3-TO-16
尽可能同向分成
1-FROM-3-TO-15(B,C电梯)
1-FROM-15-TO-16(A电梯)
如果所有楼层均无法满足同向拆分的条件,再反向拆分(这也是一大hack点,许多人没有发现这种情况)。
如
1-FROM-2-TO-3
则只能换乘到1楼了。
1-FROM-2-TO-1(B电梯)
1-FROM-1-TO-3(A,C电梯)
设计亮点:电梯共用请求队列,电梯之间通讯协作完成共享请求
以下是多线程整体设计思路
这样共享请求队列的设计,线程安全是关键之处,每当一个电梯取走一个请求的时,需要拿到同步锁并将公共队列中其他电梯中的同id第一步请求删除,并且在完成第一步请求时,notify需要完成第二步请求的电梯,并将电梯队列中其他的同id第二步请求删除,保证删除完全。
//这个函数是我处理请求的框架函数
void parseRequest(PersonRequest request) {
//当读到null时,设置锁,并且唤醒三部电梯
if (request == null) {
flag.setDeadFlag();
synchronized (lockA) {
lockA.notify();
}
synchronized (lockB) {
lockB.notify();
}
synchronized (lockC) {
lockC.notify();
}
return;
}
int id = request.getPersonId();
int fromFloor = request.getFromFloor();
int toFloor = request.getToFloor();
boolean flag = false;
//dircEle是处理能直达请求的函数
if (dircEle(id, fromFloor, toFloor)) {
flag = true;
}
//如果不能直达则换乘,transEleUp和transEleDown分别是处理向上和向下请求的函数。
if (fromFloor < toFloor && !flag) {
flag = transEleUp(id, fromFloor, toFloor);
} else if (fromFloor > toFloor && !flag) {
flag = transEleDown(id, fromFloor, toFloor);
}
//如果请求不能同向分割,则用detach函数分割至1楼
if (!flag) {
detach(id, fromFloor, toFloor);
}
synchronized (lockA) {
lockA.notify();
}
synchronized (lockB) {
lockB.notify();
}
synchronized (lockC) {
lockC.notify();
}
}
二.类图、基于度量的分析
Ⅰ.第一次作业
类图:
度量分析:
注:
ev(G)为Essentail Complexity,表示一个方法的结构化程度
iv(G)为Design Complexity,表示一个方法和他所调用的其他方法的紧密程度
v(G)为循环复杂度
OCavg为平均循环复杂度
WMC为总循环复杂度
UML协作图:
Ⅱ.第二次作业
类图:
度量分析:
注:
ev(G)为Essentail Complexity,表示一个方法的结构化程度
iv(G)为Design Complexity,表示一个方法和他所调用的其他方法的紧密程度
v(G)为循环复杂度
OCavg为平均循环复杂度
WMC为总循环复杂度
UML协作图:
Ⅰ.第三次作业
类图:
度量分析:
注:
ev(G)为Essentail Complexity,表示一个方法的结构化程度
iv(G)为Design Complexity,表示一个方法和他所调用的其他方法的紧密程度
v(G)为循环复杂度
OCavg为平均循环复杂度
WMC为总循环复杂度
UML协作图:
三.基于Solid原则的评价
1.SRP(Single Responsibility Principle):
我的程序设计每个类各司其职,与单一功能原则相符。输入线程仅负责将输入分发给调度器,调度器仅负责给电梯分配请求,电梯只负责如何更高效地将人送至目的地。
2.OCP(Open Close Principle):
开闭原则:类,模块,函数等应该对于扩展是开放的,但是对于修改是封闭的。
本单元我没有重构,而是每次作业在上次作业的基础上进行拓展,当然电梯功能的改动让我对电梯类进行了微调,其他的便仅仅是调度功能的改变。本单元我设计的电梯可拓展性是比较好的。
3.LSP(Liskov Substitution Principle), ISP(Interface Segregation Principle)and DIP(Dependency Inversion Principle):
本单元我用的继承与接口不多,这也是我需要改进的地方,我的电梯类较为庞大,应该设置一个抽象类,将一部分基础功能放在抽象类下,然后再继承拓展。但对于电梯A,电梯B,电梯C从一个父类继承而来的做法是不太妥当的,因为这样不符合代码尽可能复用的原则。
总结:
本单元我的设计较好的符合SRP和OCP原则,但使用的继承与接口不多,在之后的作业中,这是我在设计层面上需要进一步思考的部分。
四.BUG分析
在第二单元中,我虽然在优化方面做的还可以,拿了不少优化分,但强测仍然暴露了我设计层面上的bug。
1°概率性漏输入:
这个问题在我第二次作业中暴露了出来,也与我第二次作业自主测试不足有关。这次强测的炸点对做足了优化的我造成了深深的打击。
这个bug的缘由在于:线程不安全,最后我通过测试发现,缘由在于输入线程在把输入给了调度器之后,调度器可能自己留了请求,有一定几率没有把请求全部给电梯,输入一多之后,就会出现电梯少人现象。
bug解决:
进一步用wait(), notifyAll(),协调输入线程、调度器、电梯之间的关系
主线程与调度器协调,拿到输入后执行:
synchronized (inputDeadFlag) {
//主线程与调度器协调
inputDeadFlag.notifyAll();
}
调度器同时与主线程与电梯协调:
if (flag) {
//与电梯协调
synchronized (dispatchDeadFlag) {
dispatchDeadFlag.notifyAll();
}
}
synchronized (inputDeadFlag) {
//与主线程协调
try {
inputDeadFlag.wait();
inputDeadFlag.notifyAll();
} catch (InterruptedException e) {
e.printStackTrace();
}
inputDeadFlag.notifyAll();
}
电梯与调度器协调:
void tryWait(int i) throws Exception {
synchronized (dispatchDeadFlag) {
if (i == 0) {
dispatchDeadFlag.wait();
dispatchDeadFlag.notifyAll();
} else {
dispatchDeadFlag.wait(i);
dispatchDeadFlag.notifyAll();
}
}
}
2°CPU超时问题
这个问题是由于同一时间内争夺CPU资源的线程过多所致,众多同学的惨痛经历已经证明了,第三次作业中用wait()和notifyAll()并不足以解决这一问题。
最好的解决方法:
在wait()+notifyAll()的基础上,再让电梯线程不时sleep(1),“先缓缓,等下跑”。
五.互测中的hack策略
1.自动评测
· 数据自动生成以及定时输入
//对每个请求,根据其输入时间构造指令Instr
Public Instr(String req,double time) {
this.req = req;
this.time = time;
}
·windows命令行操作:
javac -cp elevator-input-hw3-1.4-jar-with-dependencies.jar;timable-output-1.1-raw-jar-with-dependencies.jar src/*.java -d src/class
javac -cp elevator-input-hw3-1.4-jar-with-dependencies.jar;timable-output-1.1-raw-jar-with-dependencies.jar TestClass/src/*.java -d TestClass/class
·结果检查
2.精心构造测试数据
下面提供一组测试数据,把所有屋中全部人的bug全部测出。
(该数据点基本测全了全部类型的换乘)
[0.1]2830-FROM-2-TO-3 //只测Archer,Saber的B电梯停靠问题
[0.9]9-FROM--3-TO-14
[0.9]219-FROM--2-TO--3
[1.3]299-FROM--2-TO-3
[1.4]2791-FROM-2-TO-18
[1.5]1023-FROM-8-TO--3
[1.5]11-FROM-20-TO-4
[1.5]19-FROM-10-TO--3
[1.5]12-FROM-12-TO-19
[1.5]13-FROM-3-TO-2 //Assassin换乘bug
[1.5]14-FROM-3-TO-20
[1.5]20-FROM-2-TO-4
[1.5]21-FROM-6-TO-3
[1.5]5-FROM-3-TO-6
[1.5]6-FROM-8-TO-3
[1.5]2891-FROM-4-TO-3
[1.5]7-FROM-10-TO-3
[1.5]8-FROM-3-TO--3
[1.5]3-FROM-8-TO-3 //Berserker的B电梯有可能会爆满
[1.5]17-FROM-2-TO-17
[1.5]233-FROM-17-TO-19
[1.5]234-FROM-12-TO-18
[1.5]222-FROM-2-TO--3
[1.5]24-FROM-14-TO-3
[1.5]26-FROM--3-TO-14
[1.5]128-FROM-9-TO-16
[1.5]138-FROM-8-TO-18
[1.5]553-FROM--3-TO-2
[1.5]997-FROM-3-TO-4
[1.5]998-FROM-3-TO-6
[1.5]999-FROM-4-TO-20
[1.5]333-FROM-2-TO-3
[1.5]444-FROM-3-TO-6
[1.5]555-FROM-3-TO-4
[1.5]666-FROM-2-TO-20
[1.5]169-FROM-3-TO-4
[1.5]181-FROM--3-TO-4
[1.5]200-FROM--3-TO-2
[1.5]201-FROM-9-TO--3
[1.5]209-FROM--3-TO-2
[1.5]290-FROM-16-TO-3 //Rider因为这条数据超时
[1.5]208-FROM--3-TO-12
[1.5]891-FROM-2-TO-19
[1.5]777-FROM--3-TO-18
[1.6]280-FROM-14-TO-16
[1.6]95-FROM-10-TO-5
[1.7]976-FROM-12-TO--3
[2.5]10-FROM-14-TO-19
[3.5]9913-FROM-3-TO--2
[3.5]1017-FROM-19-TO-2 //Lancer40条指令大概率超时
六.心得体会
经过这个单元的实战,我深刻明白了指导书中的一句话:“真正靠谱的架构,一定是可以做到兼顾正确性和性能优化的。好好优化架构才是拿高分唯一正确的思路。”
我深切感受到,这一单元与多项式单元的最大不同在于:想依靠性能分胜出的前提,是有一个漂亮的架构。
本单元最大的一个特点就是:程序的运行带有不确定性,同一个测试样例多电梯可以运行出不同的运行结果(可以都是正确的),优化难度以及debug难度骤增,不认真进行自动化自主测试的同学会吃大亏。debug的难度在于线程安全可能导致一些难以复现的bug。
本单元中我熟练掌握了生产者模式和Worker-Thread模式,进行了自主构建评测机,但仍有许多我需要进一步提升的地方,比如尝试利用阻塞队列这种更高效的方式。
文末再次感谢老师和助教们的辛勤付出,祝北航OO越来越好。