2022面向对象设计与构造第二单元总结
一、第一次作业
1.1 同步块与锁
第一次作业的锁主要设在RequestQueue类,这个类的核心属性是一个Arraylist<Request> 容器,而RequestQueue类也重写了Arraylist的size(),get(),add()等方法,并为这些方法加上了类锁。在第一次作业中,RequestQueue类被设为Elevator类的一个属性,用于保存电梯的请求队列。因为这个队列可能会同时被主线程和电梯线程读写,所以要对它的方法加锁。本次作业我的同步方法均是使用synchronized关键字。
1.2 调度器的设计
因为第一次作业的电梯数量较少,电梯之间的分工也十分明确,所以我在本次作业中没有使用调度器。取而代之地,我将调度器的工作全部交给了主线程,代码如下:
点击查看代码
while (true) {
PersonRequest request = elevatorInput.nextPersonRequest();
if (request == null) {
for (Elevator elevator : elevators) {
elevator.setEnd(true);
if (elevator.getState() == Thread.State.WAITING) {
synchronized (elevator) {
elevator.notifyAll();
}
}
}
break;
}
Request r = new Request(request);
int index = r.getBuilding() - 'A';
elevators.get(index).addOutOfElevator(r);
if (elevators.get(index).getState() == Thread.State.WAITING) {
synchronized (elevators.get(index)) {
elevators.get(index).setCallUp(true);
elevators.get(index).notifyAll();
}
}
}
不过,虽然没有专门建立跨电梯的调度器,我认为我的电梯内部调度算法还是值得一提的。不同于大多数人使用的look算法,我使用了组合优化领域的一种经典算法:模拟退火算法(SAA)。以下是模拟退火算法的基本原理:
“模拟退火算法来源于固体退火原理,是一种基于概率的算法,将固体加温至充分高,再让其徐徐冷却,加温时,固体内部粒子随温升变为无序状,内能增大,而徐徐冷却时粒子渐趋有序,在每个温度都达到平衡态,最后在常温时达到基态,内能减为最小。”
它的基本流程是:
具体到这次作业,我将电梯的所有内外请求拆分成两倍的“停留点”作为初始解空间;使用电梯运行总时间作为适应度函数;通过随机交换停留点但不影响原有的单个请求FT(from-to)先后次序来产生随机扰动。理论上来说,不考虑之后请求的影响,在参数合适的条件下,这种算法能够得到单个电梯运行的全局最优解。
1.3 Bug分析
1.3.1 自己的Bug
1.3.1.1 中测阶段Bug
和第一单元一样,这个单元的第一次作业在中测就遇到了不小的麻烦。中测让我发现了自己代码的两个Bug:
- 没有对共享对象加锁。因为刚接触多线程编程,所以我对线程安全还不是很敏感,导致我一开始在电梯类中直接用Arraylist实现请求队列。
- 线程结束逻辑错误。我在RequestQueue类中定义了一个end属性,用以表示请求队列是否已完成所有的读入。当输入线程读入NULL后,各个电梯类的请求队列end属性被置true。然后当电梯线程在循环中识别到end为true后直接break跳出循环结束线程。但这样做的错误是,当输入结束时,电梯请求队列中完全有可能还有很多请求等待响应,所以这时是不能直接结束电梯线程的。正确的做法是,判断跳出的条件还应该加上请求队列的size为0。
1.3.1.2强测和互测阶段Bug
第一次作业的强测也是错的一塌糊涂。20个强测点中挂了4个点,互测中被hack了7个点。经过排查,这些错误来源于以下Bug:
- 输出线程不安全。在程序运行是,可能会有多个电梯线程同时调用输出类,但官方提供的输出类并不是线程安全的,这样就可能导致输出错误。实际上,在截止提交之前,我就在群里看到过有助教警告说官方输出包是线程不安全的,但我因为过了中测,就没有放在心上。事实证明,永远不要抱有侥幸心理。
- 对共享对象的线程保护不够完善。虽然在之前我已经对RequestQueue类中的所有方法加上了对象锁,但对于这样的语句:
Iterator<Request> out = outOfElevator.iterator();
while (out.hasNext()) {
do something
}
虽然iterator()方法是加锁了的,但在循环过程中,其他线程可能会修改outOfElevator,导致迭代器报错。
1.3.2 别人的Bug
值得自我批评的是,因为本单元作业比较不便使用暴力测试,出于自己的懒惰,所以我在这几次互测中都没有对别人进行hack。
二、第二次作业
2.1 同步块与锁
在第一次作业的基础上, 这次作业添加的同步块主要是针对这次新加的调度器里面的电梯队列属性。因为我在这次作业设计的架构是主调度器-->分层/楼调度器(次级调度器)-->纵向/横向电梯 的三层架构,主调度器线程和次级调度器线程都会对次级调度器中的电梯队列进行读写,所以必须对读写电梯队列的语句块上锁。
2.2 调度器设计
如上所述,本次作业我建立了两级调度器机制,输入线程读入请求之后交给主调度器,主调度器根据请求的类别选择向合适的次级调度器添加电梯/添加请求。次级调度器再根据一定的规则*分配给合适的电梯,然后就是电梯的工作了。
一定的规则:我在本次作业中使用的规则是:计算本调度器所管理的全部电梯的期望响应时间(指如果电梯按当前路径运行,到达待分配请求的起始点所需要的时间),选择期望响应时间最小的电梯,将请求分配给它。
关于调度器与其他线程之间的交互:
- 主调度器:主调度器只与次级调度器进行交互。具体来说,次级调度器含有请求队列和电梯列表两个属性,主调度器通过同步地向其中添加元素实现请求分配和电梯添加。特别地,当输入包读入null之后,主调度器会将各个次级调度器的请求队列属性的end属性置true,从而向次级调度器传递结束信息。
- 次级调度器:次级调度器除接受主调度器上述交互外,只与电梯线程进行交互。具体来说,在次级调度器请求队列不为空的情况下,次级调度器线程通过不断循环将请求投入合适的电梯。特别的,当次级调度器检测到请求队列的end属性为true时,次级调度器会将自己管理的各个电梯的请求队列的end属性置true,从而向电梯传递结束信息。
2.3 Bug分析
1.3.1 中测阶段Bug
中测阶段未发现Bug
1.3.2 强测和互测阶段Bug
我的本次作业强测表现优于上次作业,只挂了一个点,互测也没被hack。在Bug修复时,虽然我在本地运行了不下二十次错误数据点,但仍然没有复现原bug。于是我选择直接通过运行逻辑分析来寻找bug。经过排查,我找到的可能出错的地方是:
由于自己比较特殊的设计,我的电梯类在run()方法的循环中,进入wait状态的条件判断与实际进入wait状态有一定的距离。在这段距离中,JVM完全可能调度次级调度器线程向电梯里面添加请求。如果这个请求恰好是此电梯的最后一个请求,那么这个电梯将始终不能被唤醒,从而导致超时错误。
三、第三次作业
3.1 同步块与锁
对于第三次作业的换乘问题,我的大体解决方案是:请求输入后,先在主调度器中判断是否需要换乘,若需要换乘,则将请求拆分为2或3个子请求,将第一个子请求投入次级调度器,其余子请求加入主调度器的换乘队列。当第一个子请求出电梯时,会向主调度器传递乘客ID,主调度器再根据ID将后续子请求投入次级调度器。
因此,对于新加入的换乘队列属性,既会被主调度器线程写,又会被电梯线程读写,所以必须对换乘队列加锁。
3.2 调度器设计
本次作业的调度器设计基本延续了上一次作业的架构,主要做了以下几点扩展:
- 将主调度器设计为单例模式。这样就可以在子请求出电梯的时候,由电梯线程调用主调度器的相关方法,访问换乘队列,进而向次级调度器投喂后续子请求。
- 提取调度器公共代码块,进一步提高内聚度。
- 修改结束逻辑。在本次作业的完成过程中,我发现了一个之前没有注意到的小bug:当主调度器调用次级调度器的setEnd()方法时,次级调度器也会紧接着调用电梯的setEnd()方法,而如果此时次级调度器还没有讲自己的请求队列完全清空,就有可能导致电梯线程错误地以为之后没有请求从而结束线程。为了解决这个问题,我将次级调度器和电梯的setEnd()方法都加上了对象锁。这样的话,当调用setEnd()方法时,线程将一直阻塞直到次级调度器/电梯进入wait状态释放锁。而一旦它们进入wait状态,就表明它们的请求队列已经清空,自然就可以放心地结束线程了。
关于调度器与线程的交互,本次作业唯一新增的交互是电梯线程与主调度器线程的交互。前文已经进行分析,不再赘述。
3.3 架构分析
UML类图
UML协作图
类名 | 功能 |
---|---|
Task3 | 主类,初始化时间和主调度器 |
MainScheduler | 主调度器类,管理次级调度器 |
PortraitScheduler | 纵向电梯调度器类,管理纵向电梯 |
TransverseScheduler | 横向电梯调度器类,管理横向电梯 |
PortraitElevator | 纵向电梯类,运送纵向请求乘客 |
TransverseElevator | 横向电梯类,运送横向请求乘客 |
MyRequest | 乘客请求类 |
RequestQueue | 请求队列类,是主要的共享对象 |
Strategy | 策略类,用于寻找单部电梯最佳运行策略 |
Point | 辅助类,在模拟退火算法中作为基本交换单元 |
MyOutput | 输出类,将官方输出包加锁实现 |
3.3.1 可扩展性分析
可能遇到的扩展需求及其解决思路:
- 延时换乘:考虑换乘过程的时间花费
- 解决思路:把换乘队列的数据结构换为BlockingQueue,设定一定的延时出队时间。
- 性能:仅需改动少量代码,基本不影响性能。
- 横竖电梯:既能跨楼又能跨层的电梯
- 解决思路:新增横竖电梯类,内设状态属性标志当前在横向移动还是纵向移动,其他基本可移植原电梯类。
- 性能:横竖电梯的增加会让路径规划的复杂度大大上升,对性能有一定的不利影响。
- 含优先级请求:请求附带优先级
- 解决思路:修改策略类,在寻找最优调度路径时检查是否符合优先级先后顺序。
- 性能:符合优先级的调度顺序不一定是最快调度顺序,因此会损失一部分性能。
3.4 Bug分析
3.4.1 中测阶段Bug
中测阶段发现了一个bug:
- 在我最开始的设计中,电梯类关于出电梯的逻辑如下:
public void out() {
Iterator<MyRequest> in = inElevator.iterator();
while (in.hasNext()) {
MyRequest r = in.next();
if (r.isNeedTransfer()) {
MainScheduler.getInstance().transfer(r.getId());
}
if (r.getTo() == floor) {
in.remove();
String s = String.format("OUT-%d-%c-%d-%d", r.getId(), building, floor, id);
MyOutput.print(s);
}
}
}
其中的transfer()方法负责控制主调度器将后续子请求投喂给次级调度器。
这样写不仔细思考似乎没有问题。但实际上,因为我的结束逻辑设计(上文已述),当transfer()的是最后一个请求的倒数第二个子请求时,电梯线程会阻塞直至所有电梯处理完成自己的请求。这样导致的后果是,该子请求的OUT信息不能及时输出,而最后一个子请求又会很快输入IN信息,使得评测机认为乘客重复进入电梯从而报错。当然解决方案也很简单,将transfer()函数的调用放在信息输出之后就行了。
3.4.2 强测和互测阶段Bug
在本次强测中,我一共挂了三个点,互测被hack了一次。经检查,bug是由于线程交互逻辑漏洞引起的。
在电梯线程中,我判断电梯wait()的条件是请求队列为空,计算下一个目标点的条件是开门且得到原目标点。这就导致在计算请求队列到进入wait状态的这段时间内,如果有新请求加入,使得请求队列由空变为非空,电梯就既不能进入wait状态也不能计算下一个目标点,从而不断循环导致轮询。
四、心得体会
4.1 线程安全
线程安全之于多线程编程就如水之于生命。在单元的一开始,我是几乎没有线程安全意识的。最开始的代码中,唯一使用同步块的地方是wait-notify语句。后来随着理论课和实验课的进行,我逐渐了解了共享对象、读写冲突,明白了为什么要加锁,怎么加锁。到现在,我可以说已经很熟练地掌握锁的使用了。不过,不是所有的线程安全问题都是能通过加锁解决的,很多问题实质上是多线程导致的代码逻辑问题。对于这方面问题的认识和解决,我还有很多功课要做。
4.2 层次化设计
优秀的层次化设计对于一个高效高可维护性的程序来说至关重要。在第一次作业中,因为需要管理的电梯较少,所以我只是使用了主类和电梯类的两层架构。但这样的架构在第二单元遇到了明显的困难。如果将所有的调度与管理仍然交给主类的话,会导致主类的代码十分臃肿。于是,听取助教的建议,我为电梯管理建立了两层的调度器类,这样加上原来的两层,最终是一个四层的架构。在这个架构下,无论是可阅读性还是可维护性都得到了很大的提高。当然,层次化设计也不是层次越多越好,根据需求和工具,找到最合适的设计,才是最重要的。