前言
OO第二单元在电梯运行的场景下,展现了多线程、同步锁的知识。写代码的坎坷是暂时的,但是获取的知识是受益许久的。
同步块的设置和锁的选择
第一次作业(总第五次作业)
在第一次作业中,我全部使用 synchronized
实现的同步块和锁,为了解决主请求列表的读写互斥、写写互斥和各个电梯分配后的请求列表的读写互斥。
synchronized
的好处在于使用起来简单,只需在 synchronized
后指定Object
即可获取Object
的锁,并在 synchronized
块结束后由程序自动释放锁。但相较于 ReentrantReadWriteLock
或其他 Lock
,synchronized
的灵活性、扩展性较差,只能等待其他线程释放锁之后才能有获取锁的机会,而不是像 Lock
有tryLock()
等其他方法。
第二次作业(总第六次作业)
重构前
第二次作业重构前,我是在第一次作业的基础上进行的增量开发,所以仍然使用 synchronized
实现的同步块和锁,虽然使用起来简单,但是由于第二次作业增加了新增电梯、维护电梯等请求,使得电梯调度、运行的代码量大幅增加,使得 synchronized
差灵活性的影响更为严重,所以由于混乱导致的死锁、少锁的现象也更为严重,导致此次作业分数极低。
重构后
第二次作业重构后,我使用 ReentrantReadWriteLock
实现的同步块和锁,对每个被共享的类中会出现读写、写写互斥的参数设计了 ReentrantReadWriteLock
。它具有灵活性好、支持公平非公平锁从而使得多线程调度的多样性更好。但相较于 synchronized
,ReentrantReadWriteLock
的代码实现相对繁琐,且要时刻注意在同步块结束后释放锁(即调用unlock()
方法)。
第三次作业(总第七次作业)
第三次作业是我在第二次作业重构后代码的增量开发,所以同步块和锁的实现与第二次作业重构后相同,此处不赘述。
调度策略与架构分析
本次作业综合考量了等待时间和耗电量,而这两者是难以平衡的。所以我选择了将输入的请求平均分配给所有电梯,从而试图得到更短的等待时间。(由于第三次作业中可能出现电梯无法到达所有楼层的问题,所以在分配时还判断了电梯是否能满足当前换乘路线中的最先路径,若不满足,则按顺序判断下一个电梯是否能满足)
三次作业的UML类图
第一次作业:
第二次作业(重构后):
第三次作业:
稳定和易变的内容
Input
,即输入线程几乎不变。
在第二次作业中,为了实现加入电梯的功能,从而我将调度器内原本6个电梯请求队列的容器box1
、box2
、box3
、box4
、box5
、box6
更改为了一个 ArrayList<Box>
存有所有电梯的请求队列容器。
为了实现维护电梯的功能,我在 Scheduler
中增加了一个 ArrayList<Integer>
,存有已维护的电梯的编号,从而可以通过判断编号是否在此数组内,而在分配请求时避免分配给已被维护的电梯。同时,我在电梯中存储有需要共享的属性的容器 ParaBox
增加了toMaintain
和hasMaintain
两个属性,分别代表是否接受到维护请求和电梯是否处理完维护请求,从而实现电梯的维护和电梯维护后的停止。
不难发现 Direction
类中相较于第一次作业新增了...ForNew
,这里主要是由于原先第一次作业的代码未兼容维护电梯的指令,当电梯停止后接受到新的指令时,会在run()
方法的while
循环中新增一个while
循环一直义无反顾地运行直至到达新指令的起点,导致在接受到维护指令时不能第一时间寻找就近楼层下客,而是到了起点才能进入run()
方法的while
循环下一轮对维护指令进行判断,与题目要求不符。但是,这种操作增加了耦合度,直至撰写这份总结时我才发现这一点,其实可以把ForNew
作为参数放在 Elevator
中降低耦合度,无需修改 Direction
。
在第三次作业中,为了实现路径规划的功能,我新增了 ReachTable
类,用于存储目前的电梯的可达性;我也新增了 Path
类,用于规划此次作业中由于某些电梯不能到达全部楼层而需要换乘的问题。在 Path
类中,通过Dijkstra算法计算最短路径从而得到换乘最少的路径,从而节省由于换乘等待电梯造成的等待时间增加。
协作图(总)
注:
Main
是主线程,用于新建需要用到的 Input
输入线程、Scheduler
调度器线程、Elevator
默认的六个电梯线程以及诸多参数。
ReachTable
类用于存储楼层之间的可达性。
Limiter
类内有22个信号量类 Semaphore
的对象,用于实现同一时间同一楼层最多4部电梯服务,最多2部电梯只接人的限制。
mainBox
是 Box
类的一个对象,用于存储未被分配的请求。
调度器交互方法
调度器存有mainBox
(前文已叙述作用),ArrayList<Box> boxes
用于存储若干电梯的请求列表。
输入线程会判断输入请求的类型,并调用调度器线程对应的方法。若是新增了乘坐电梯的请求,调度器就会向mainBox
中插入请求;若是维护电梯的请求,调度器会选取相应电梯的Box
,将相应Box
的ParaBox
成员的toMaintain
参数置为真,同时更新 ReachTable
;若是新增电梯的请求,调度器会在boxes
中插入一个新建的Box
,同时新建一个 Elevator
线程并执行。
调度策略在调度器线程的run()
方法中,结合ExtendedPersonRequestFormat.correspond(Boolean[])
方法判断某个电梯是否能到达某个请求的起始楼层,实现~~(类似地)~~平均分配。
Bug and Debug
第一次作业(总第五次作业)
第一次作业中bug主要是运行时间过长。bug出现的原因是对锁不够了解,导致我将上楼、关门时线程sleep
的0.4s也放入同步块内,导致调度器几乎只能每次给每个电梯分配一个请求,大大增加了电梯的运行时间。
发现bug后,我加强了对锁、对同步块的理解和学习,将sleep
方法放出同步块内,此时请求调度正常,未超时。
第二次作业(总第六次作业)
第二次作业的bug主要是程序不停止、请求丢失等问题。我没有使用课上教学的读写锁,仍然使用synchronized
,但是由于要实现维护电梯、新增电梯,导致代码的规模显著增大,synchronized
灵活性差的影响愈加显著,死锁、忘了锁等问题频繁出现,且代码太过复杂难以分析锁的位置。
发现bug后,我弃用了synchronized
,转而使用读写锁,使得代码灵活性更强,锁的位置、获取锁释放锁的逻辑更为明晰。
第三次作业(总第七次作业)
第三次作业的bug主要是程序不停止、请求丢失两个问题。程序不停止的原因在于我的换乘路径是在请求输入时即规划好,导致后续若有电梯被维护,而路径中有一段仅能由此电梯运行时,出现请求无法分配的问题,从而无法停止。请求丢失问题主要在于删除已处理好的请求数组中的请求时,是先遍历数组再进行lock()
的,导致可能会出现需要被删除的请求被重新取出,但由于无法响应而被丢弃。
发现bug后,我在每次请求分配或重新分配时都会对路径进行重新规划,同时优化了删除已处理好的请求数组中的请求的方法。
我的debug方法
1. 对关键的函数、位置使用print
方法打印当前状态,根据输出发现问题。
2. 使用idea设置断点,使用附加调试器查看线程状态。
3. 简化输入,直至可以使用最少的输入复现bug,然后再使用1、2的方法,从而降低需要查看的状态的复杂度。
心得体会
此次第二单元的三次作业,在电梯运行的场景下,让我明白了多线程的若干知识。我明白了多线程写作的重要性。在若干次因为锁的问题而出Bug的场景中我逐渐明白了构建一个清晰明了架构的重要性,这不仅赏心悦目,更让发现bug、解决bug变得更为简单。在此次作业中,我仍存在方法复杂度过高、类之间耦合度过高、共享了过多不必共享的参数等问题。这几次强测的低分正是对我这种不科学行为的提醒,在今后的作业中,我要多加注意这些问题,构建清晰明了的架构、践行SOLID原则,不断增强自己的面向对象和java编程能力。