2022-面向对象设计与构造-第二单元总结
第五次作业
架构分析
整体结构
本次作业我使用了生产者——消费者模式,ReqMaker
作为生产者读取输入并包装成请求放入请求队列中,Elevator
作为消费者从请求队列中获取请求并满足乘客的请求,UML类图如下
其中ReqMaker, Elevator
继承了Thread
类,是单独的线程;TowerReqQueue
是请求队列,采用FIFO(先进先出)的方式进行存取;Request
用于封装请求;SafeOutput
将官方输出包中的println
封装成一个同步方法使其线程安全,用于输出。
我没有使用调度器,因为我认为这次作业中调度器是冗余的,而且它会带来更多线程安全的问题。
同步块设置和锁的选择
同步块主要有以下几个:
1.在SafeOutput
中:
public static synchronized void println(String str) {
TimableOutput.println(str);
}
显然,这里的锁是SafeOutput.class
。这保证了输出的安全(即时间戳单调递增)。
2.在TowerReqQueue
中:
public synchronized void putReq(Request req) {
if (req.isUp()) {
upReqs.get(req.getFromFloor() - 1).add(req);
} else {
downReqs.get(req.getFromFloor() - 1).add(req);
}
size++;
notifyAll();
}
public synchronized Request takeReq(int floor, boolean up) {
while (isEmpty() && !end) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (end && size == 0) {
return null;
}
size--;
if (up) {
return upReqs.get(floor - 1).remove(0);
} else {
return downReqs.get(floor - 1).remove(0);
}
}
public synchronized void end() {
end = true;
notifyAll();
}
public synchronized boolean notHaveDirReq(int floor, boolean dirUp) {
if (dirUp) {
for (int i = floor + 1; i <= MAX_FLOOR; i++) {
if (haveReq(i)) {
return false;
}
}
} else {
for (int i = floor - 1; i >= 1; i--) {
if (haveReq(i)) {
return false;
}
}
}
return true;
}
这些同步块的锁都是调用方法的对象,保证了多个线程同时访问这一对象时的安全。
takeReq, putReq, end
都是会导致对象状态改变方法,因此要加锁;notHaveDirReq
要多次访问reqs
字段,需要加锁防止执行中reqs
字段被其它线程修改。
3.在Elevator
中:
public void run() {
while (true) {
synchronized (reqQueue) {
while (reqQueue.isEmpty() && isEmpty()) {
if (reqQueue.isEnd()) {
return;
}
try {
reqQueue.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
exchangePassenger();
checkDir();
move();
}
}
在这个同步块中,锁是电梯对应的请求队列。
在电梯判断是此时应该继续运行还是等待的时候,应该先获取对应的请求队列的锁,如果当前请求队列为空且电梯内无人,应该释放锁并等待通知(wait
)。
时序图
本次作业各个线程的协作时序图如下(Queue
代表TowerReqQueue
)
Bug分析
自己的bug
强测有一个点RTLE,主要原因是我在判断是否需要开门进人时只考虑了请求队列中这一层是否有人,而没有考虑到如果电梯满了且这一楼没有人下来时不需要开门进人的情况,导致出现了电梯空开门的问题(开了门,但是什么也不做又关上了)。解决办法是在判断是否需要进人时看一下是否有人下以及电梯是否是满的。顺带一提,感觉这个点时间卡的有点儿死,时间限为120s,强测中我的程序120s没跑完被杀死了。但是本次多次测量时间都是118s,改完bug提交上去测得时间为114s,也就是说只有6s的容错空间,但是其它很多点都达到了15s以上甚至更高,且在第七次作业中大部分点时间限都比我的运行时间多30s以上,因此我个人认为这个点时间限不合理,把本应该是性能的问题判定成了正确性的问题。
别人的bug
本次互测中我只hack
到了输出时间戳非递增的问题,原因是没有把不安全的输出方法封装成安全的。
hack的策略和第一单元类似,都是使用测评机暴力测试。对于随机生成的测试数据,我用参数控制了请求的疏密程度(时间上),重点测试了请求特别密集和请求特别稀疏的两种极端情况,以此来发现更多的线程安全问题。值得注意的是,当我发现别人输出顺序有问题且提交随机生成的数据hack不到人后,我手动构造了需要在同一时间输出大量内容的数据,以此来增大hack成功率(后来才知道当时hack不到是测评机的锅,用了这个方法还是hack不到)。
性能分析
调度策略
本次作业中我使用了look策略:电梯在1-10层之间来回扫描并捎带乘客,当电梯内没人且请求队列为空时电梯停下来,当电梯内所有乘客与当前电梯方向相相反且当前电梯的运行方向上没有乘客时立即转向(而不是到1或10层再转)。在细节上,请求队列在存取请求时是FIFO(先进先出)的,捎带时优先捎带与电梯运行方向相同的乘客但也捎带不同方向上的乘客(因为我认为这样能够减少开关门次数,或许能够获得更好的性能)。
强测性能
强测得分如下
可以看到强测有点拉胯,主要是因为上述的空开门问题,导致了一个RTLE和很多性能分为0的点。当我改完后,和性能分较高同学的程序对比发现捎带反方向的乘客是个糟糕的做法,虽然这能减少开门时间,但是请求多时会顶掉很多更合适的请求,导致性能下降。如果解决空开门的问题并不接受反方向的捎带,我的程序可以在强测中得到99分。
在设计时,我考虑到了捎带反方向的利弊,权衡之下选择了允许捎带反方向的请求,试试证明这是个糟糕的选择。感觉这里我陷入了局部的陷阱——只考虑了减少开关门带来的小优势,但是没有认识到电梯的性能是很宏观的,不接反向请求可以让电梯走更少的路程、捎带更合适的乘客带来的大优势。
第六次作业
架构分析
整体结构
本次作业仍然没有使用调度器,所以迭代起来相当简单,基本上把请求队列和电梯方向改一下就好了。对新增电梯的处理也很简然,只需要再开新的线程即可。多个电梯的请求调度上,采用自由竞争的方式,即哪个电梯先移动到请求所在的位置,哪个电梯处理这个请求。UML类图如下
其中FloorElevator
是纵向的电梯,TowerReqQueue
是某一个楼座的全部纵向请求队列;TowerElevator
是横向电梯,FloorReqQueue
是某一层的全部横向请求队列。FloorElevator, TowerElevator, ReqMaker
继承了Thread
类,是单独的线程。
线程之间的交互仍是生产者——消费者的模式:ReqMaker
作为生产者,Elevator
作为消费者,二者通过ReqQueue
进行交互。
同步块设置和锁的选择
本次作业同步块与上次作业的区别是在请求队列中,其他部分都与上次作业相同,下以TowerReqQueue
为例介绍(FloorReqQueue
同理):
public synchronized ArrayList<MyRequest> getReqs(int floor, int elevSize, boolean dirUp) {
ArrayList<MyRequest> temp = new ArrayList<>();
int count = elevSize;
if (!haveReq(floor)) {
return temp;
}
if (elevSize == 0) {
if (notHaveDirReq(floor, dirUp) && !haveReq(floor, dirUp)) {
if (dirUp) {
while (haveReq(floor, false) && count < 6) {
temp.add(takeReq(floor, false));
count++;
}
} else {
while (haveReq(floor, true) && count < 6) {
temp.add(takeReq(floor, true));
count++;
}
}
return temp;
}
}
if (dirUp) {
while (haveReq(floor, true) && count < 6) {
temp.add(takeReq(floor, true));
count++;
}
} else {
while (haveReq(floor, false) && count < 6) {
temp.add(takeReq(floor, false));
count++;
}
}
return temp;
}
增加了一个getReqs
方法,该方法根据参数取出特定的请求并装入容器返回。该方法是同步方法,锁是该TowerReqQueue
对象。加锁是因为该操作改变了当前对象中的内容,为保证线程安全,需要加锁。
解释一下这个方法:多个电梯自由竞争时,会出现两个电梯都开了门,但是只有一个电梯能够接到请求,这会降低性能。为解决这个问题,我给每个电梯加了一个缓冲区buffer
,在判断这一层要不要因为接人开门前,先尝试从请求队列中获取请求并将其放入buffer
中,然后根据buffer
是否为空判断。这样在判断前,电梯就已经拿到了请求并放入了缓冲区,这样电梯就会根据自己实际分到的请求决定要不要开门,就不会出现因为竞争导致的空开门。这个getReqs
方法就是在到达新楼层后、判断开门前用于获取请求来填充缓冲区的方法。
时序图
如果将两种电梯和两种请求队列配对,那么本次作业的时序图和上一次作业几乎完全一致,只是ReqMaker
增加了增加电梯的活动,如下图所示(Elevator
代表两种电梯,Queue
代表两种队列,FloorElevator
和TowerReqQueue
配对,TowerElevator
和FloorReqQueue
配对)
Bug分析
自己的bug
本次作业公测和互测均未发现bug。
他人的bug
本次作业发现了同房间内一位同学的CTLE的问题,原因是在特定情况下会进入一个分支进行轮询,导致CPU时间超时。
hack策略是使用测评机暴力测试。测试数据生成方面,给数据生成器设定了多个模式:有的模式之后横向请求,有的模式只有纵向请求,有的模式两种请求都有;有的模式横向、纵向请求都是同一个层、座的,有的则反之;有的模式请求时间上很密集,有的模式请求时间上和稀疏。测试时对不同的模式进行组合,每种组合下都进行大量测试,以此来对其它同学的程序进行较为全面地测试,让其问题充分地暴露。
性能分析
调度策略
本次电梯捎带策略为look,请求分配策略为自由竞争。与上次不同的是在纵向电梯的look策略中,不接受反方向的请求(实测这样会更快);在横向电梯的请求不分放方向,只要有就接。对于横向电梯,使用类似look的策略,转向条件是当前方向上下两个楼座没有请求且电梯内所有的人的请求目的地都是当前方向的反方向的下两个楼座内,可以证明这时转向一定是一个占优策略。
强测性能
强测得分如下
可以看到本次作业性能很好,很多测试点都得到了满分的性能分,其它测试点也都获得了很高的性能分,表明look的捎带策略和自由竞争的请求分配策略效果是很好的。
第七次作业
架构分析
整体结构
本次作业增加了换乘机制,我使用了流水线的设计模式,在母请求生成后根据当前电梯的存在情况,把整个母请求分成多个(最多三个)仅横向或仅纵向的子请求,每个电梯处理完一阶段请求后将下一阶段请求放入请求队列。由于每个电梯处理后下一阶段请求放入的请求队列是不确定的,如果继续使用上次作业的直接向对应队列增加请求的方案,会造成类之间过多的耦合,为了解决这个问题,本次作业中使用了一个Controller
(控制器)来完成所有的请求投放和电梯投放。同时因为本次作业中队列结束条件不再是输入结束,而是所有子请求全都加入队列中,因此我使用了Counter
来计数当前母请求的完成情况,这个Counter
是单例的。作业的UML类图如下
其中的Controller
提供静态方法,可以在增加电梯、投放请求,负责管理所有的电梯和请求队列;Counter
为当前的母请求处理情况计数,用于设置请求队列结束标志;BaseRequest
为子请求,只能是横向或者是纵向的,MyRequest
是母请求,包含1-3个子请求,对应输入中一个人的请求。其中的Controller
只负责新增电梯、投放请求、拆分母请求,不负责将子请求分配给指定的电梯,这与调度器不同,本次作业我仍没有使用调度器。
线程之间的交互与上次作业类似,但区别在于本次作业中Elevator
也可以作为生产者投放子请求,同时所有的请求投放都不是线程直接完成,而是通过Controller
来统一进行。
同步块设置和锁的选择
本次作业中的同步块和上次作业中的主要区别在Controller
中:
public static synchronized void addRequest(MyRequest req) {
if (!req.isReady()) {
req.setChangeFloor(getTheBestMidFloor(req));
}
if (req.isEnd()) {
Counter.getInstance().sub();
return;
}
BaseRequest bq = req.takeCurrentReq();
if (bq.isFloorReq()) {
towerReqQueues[bq.getFromTower()].putReq(bq);
} else {
floorReqQueues[bq.getFromFloor() - 1].putReq(bq);
}
}
public static synchronized void addFloorElevator(int id, char tower,
int maxSize, double velocity) {
FloorElevator elevator = new FloorElevator(id, tower,
towerReqQueues[tower - 'A'], maxSize, velocity);
elevator.start();
}
public static synchronized void addTowerElevator(int id, int floor,
int maxSize, double velocity, int arrive) {
TowerElevator elevator = new TowerElevator(id, floor,
floorReqQueues[floor - 1], maxSize, velocity, arrive);
towerElevators.get(floor - 1).add(elevator);
elevator.start();
}
public static synchronized void notifyEnd() {
for (TowerReqQueue towerReqQueue : towerReqQueues) {
towerReqQueue.end();
}
for (FloorReqQueue floorReqQueue : floorReqQueues) {
floorReqQueue.end();
}
}
可以看到把增加请求、增加电梯和通知请求队列结束放到了同步块中,这些方法的锁都是Controller.class
主要目的是保证同步性,防止多个线程同时调用该方法时导致的线程安全问题。
时序图
本次作业的时序图如下
相对于前两次作业,主要是增加了有关Controller
的部分,并将增加请求、增加电梯交给了它。
Bug分析
自己的bug
本次作业公测和互测均未发现bug。
他人的bug
本次作业互测时hack到了一位同学程序不能正常结束的问题,根据输出可以看到在特定情况下电梯在某些楼层/楼座之间反复横跳,陷入死循环导致的。
同时还发现了一位同学会抛出ConcurrentModificationException
异常的问题,原因是某些线程在遍历容器时其它线程在修改容器内容.但很遗憾的是由于这个bug复现率太低,交了很多次都刀不中(在单核的云服务器50路并发状态下测试复现率约为1/30,复现率确实太低了),而且我也想不到什么方法能够构造高bug复现率的数据,只能眼睁睁地看着bug逃走 :(
hack策略与上次作业基本相同,不再赘述。
性能分析
调度策略
本次作业在上次作业基础上,使用了静态寻找最佳换乘楼层的方法拆分母请求,具体做法是:当新的母请求产生时,根据请求参数判断是否需要换乘,如果需要换乘,则根据当前存在横向电梯的信息寻找一个能够一次横向换乘完成请求且使得纵向移动最少的换乘楼层进行请求拆分,并且此后不再修改。也就是说在请求到来时静态地选择换乘楼层,且只允许一次横向换乘。考虑到了不同电梯速度、容量不同可能带来的性能问题,可以使用计算最短带权路径的方式选择更优的请求分配方式,也可以把静态的方案改成动态的,但是这些都是局部的优化,对整体性能的提升有限(甚至可能减低整体的性能),而且会大大增加复杂度,可能会带来很多新的问题,因此我仍然使用自由竞争的请求分配策略。
强测性能
强测得分如下
由于本次性能分公式更加温柔(相对前两次),得分还不错,听说用动态方案+带权最短路径可以拿到99.9+
心得体会
本单元的主题是多线程,最多的问题就是线程安全问题,通过本单元的学习,我认为线程安全问题需要关注以下几点:
-
设计要简单、清晰、明了,要符合“高内聚,低耦合”的设计原则,这有助于梳理清楚各个类、线程之间的关系,避免不安全的操作
-
同步块的使用要谨慎,要考虑清楚是否真的有必要使用这个同步块,并尽可能将同步块转化为同步方法,让对象自己管理自己
-
要避免不必要的
notifyAll
,这会导致很多无意义的唤醒,使得CPU时间增多 -
写方法时要时刻考虑这个方法是否会被多个线程同时调用,如果会,是否可能出现不安全操作,如果会,那么应该加锁
-
锁的设置要谨慎,如在第七次作业中我认为电梯完成一阶段请求后投放下阶段请求时让获取对应请求队列的锁是不合适的,因为它可能将下阶段请求放到所有的请求队列中,那么就需要将所有的锁都暴露给这个电梯,使得他们之间的关系更为混乱,可能导致线程安全问题,交给一个单例的控制器是个好选择。
-
多做测试,通过大量、较为全面地测试可以发现很多线程安全的问题
由于本单元结构没第一单元那么复杂,我没有把重点放到层次化设计中去。对于第六次作业的横向电梯,我复制了一份纵向电梯和对应请求队列的代码并简单修改来实现其功能,整个作业的可扩展性较差,我认为可以从以下方面进行层次化设计,提高可扩展性:
-
提取横向电梯和纵向电梯的共同点,构建
Elevator
类;提取横向请求队列和纵向请求队列的共同点,构建ReqQueue
类 -
增加策略接口,实现不同的策略,来方便地进行策略切换,追求更好的性能
-
使用工厂模式,设计电梯工厂,以方便增加新型的电梯
这一单元感觉主要是第五次作业做得比较艰难,原因是多线程之前没接触过,第一次写踩了很多坑,尝试并推翻过很多方案,但是第一次作业做完干感觉迭代就简单多了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 地球OL攻略 —— 某应届生求职总结
· 提示词工程——AI应用必不可少的技术
· Open-Sora 2.0 重磅开源!
· 周边上新:园子的第一款马克杯温暖上架