OO课Unit2电梯仿真项目技术回顾
BUAA.1823.邓新宇
2020/4/17
从多线程的协同和同步控制方面,分析和总结自己三次作业的设计策略
关于共享资源,主要是单例模式的Building,其内部为每一个楼层建立一个Floor实例,分上行和下行保存正在该层等待电梯的乘客。
整体的逻辑分为三个部分:
由于猜测到后续可能需要增加电梯数目,在设计控制器的时候,就考虑了多部电梯时的调度。总体分为2个部分:
这两个部分都是一个生产者-消费者模式。在第一部分中,电梯是生产者,控制器是消费者,电梯产生调度请求,控制器对这个请求进行响应。第二部分中,输入器是生产者,控制器和电梯是消费者,输入器产生乘客请求,控制器和电梯来处理这个请求(我不认为这是work thread模式,因为是电梯来决定具体处理哪些请求,而不是由控制器来决定的)。
关于程序的终止,输入器结束输入后,向控制器发送中断信号,控制器收到中断信号后,进入准备退出模式,此时如果等待队列中的电梯数量和电梯总数相等时,就会结束全部电梯线程,然后结束控制器线程。
电梯的控制器权在电梯线程和控制器线程间交替转移,在第一次作业中其实是完全可以不进行这种交替转移的,但是这样的交替转移可以应付电梯数量增加时的情形。
第二次作业中,主要是为电梯增加了对自己本身的控制。多数情况下,电梯的运行方向、是否开关门仅通过电梯本身的状态和Floor的状态就可以确定,这个时候就没必要请求外部进行调度了。也就是说,仅仅是电梯为空/电梯即将为空且当前楼层没有乘客的时候,才需要请求外部调用。此时,原先的控制器不再精细地控制每个电梯的每一步动作,所以将其更名为调度器。其余部分基本不变。
此时,主体逻辑变成如下两个部分:
仅仅是多了一步电梯的自调度。只需要获取电梯本身状态和某一Floor的状态时,进行电梯的自调度,当需要整个Building状态时,调度器进行调度。调度器每次调度都需要获取Building和所有Floor的锁,并且一次只能调度一个电梯的在某一层的行为,若电梯的所有行动前都需要调度器调度,将导致很大的开销,所以需要不需要Building信息的调度转移到电梯本身。
第三次作业依旧没有改动Building的结构,也没有改动输入器-乘客请求-调度器&电梯
这个生产-消费者模式的内容。对于新增电梯这种请求,由于所有电梯线程都是调度器的子线程,所以这种请求的消费者应当是调度器,所以为电梯-调度请求-调度器
这个生产者-消费者模式,添加了新的生产者——输入器。
此时调度器的运行方式变为:
还有一个较小的改动是,当输入器通知调度器有新的乘客请求时,调度器只将可以到达该楼层的电梯从等待队列添加到请求队列中。
关于换乘的调度,通过为Passenger包装一个PassengerRequest(本段简称Request),Request有一个换乘策略Map,创建Request实例的时候,会利用当前楼层和乘客目标楼层信息查询换乘策略,进而确定当前的目标。
从功能设计和性能设计的平衡方面,更新和总结自己第三次作业架构设计的可扩展性
电梯部分我选择将电梯拆分为若干部分,分别控制电梯的不同功能:
类名
作用
控制器Controller
电梯自己进行运行方向决定、上下乘客控制
容器Container
管理乘客
门Door
开关门
电机Motor
管理楼层状态
而Elevator类基本上只是把这些部件组合在一起,Elevator的所有方法其实都是对不同部件的调用。
我没有选择通过继承的方式来区分ABC三种电梯,因为它们数据的结构和行为方式几乎是一样的,只是有些参数不相同。我将这些不相同的参数打包保存至ElevatorType中,这样,如果要增加仍旧是参数不同的新的电梯类型,只需要为ElevatorType增加新的成员即可。
把电梯的各个组件拆分,可以配合工厂模式,选配不同的控制器/容器/门/电机,进而可以增加更多类别更加不同的电梯,大大提高了扩展性。
调度器、电梯的控制器都可能需要获得Building、Floor、Container的信息,而如果直接对这些实例进行查询,但是根据我观察,调度器和控制器所需要的信息大概是“去X层的请求有多少”、“都有去那些层的请求”、“X层是否有乘客请求”、“以X层为目的地的请求有多少”、“在X层下客之后还有多少空间”之类的,这些信息在楼层->人数
或者类似的Map中查询和计算更为方便,但是Floor和Container都是通过一维的列表进行存储的(否则可能需要多加不少锁)。
于时如果要直接对Floor/Container/Building进行查询,每次不同的查询都需要重新进行一次遍历,而一次调度可能需要查询各类信息,所以会带来不小的损耗。
于是我通过辅助的信息载体类,即若干内部Info类进行传递信息,它在创建的时候,遍历外部类的容器,存储为更利于查询计算的形式,同时抛弃一些不必要的信息。调度器/控制器通过调用Floor/Building/Container的getInfo()方法获取Info对象,然后对Info对象调用getSpaceAfterPull()、hasTargetCompareTo()、isEmpty()、getNearestRequest()等方法就可以快速查询相关信息,并且查询的过程是在Info类内实现,一方面简化了调度器和控制器的计算,另一方面也让这一部分查询计算的过程得以封装。
换乘策略主要是在PassengerRequest类中实现。为了提高复用性,换乘策略通过(起始地, 总目的地)-> 小目的地
的键-值对保存在一个Map实现的策略矩阵中,当创建PassengerReqest时,会查询该矩阵,并保存小目的地信息。
电梯和调度器进行上下客选择时,只关注小目的地即可。
当调用getOff()方法时,会判断是否到达总目的地,如果没到达,就会向Building中添加新的PassengerRequest。
当更改电梯的可到达楼层时,只需更改策略矩阵即可,扩展性比较高。
观察三个电梯的可停靠楼层,我们可以发现两个现象:
从第一点,我们可以知道,A类型电梯从低楼层到高楼层之前往复移动的代价是十分高昂的,而中间一部分又可以通过BC两类电梯进行换乘,所以可以通过如下策略提高性能:
要实现这样的策略,需要在A型电梯数量从1变到2时进行两方面调整:
先讲第二个方面,更改策略矩阵即可让新创建的PassengerReques获得新的换乘策略,但是旧的PassengerRequest的更改就有问题了,由于为了加快访问速度,创建PassengerRequest对象时,就查询换乘策略矩阵,保存目的信息,而非调用PassengerRequest对象的getTarget()方法时才查询,这就需要更新旧的PassengerRequest的数据。
最开始打算主动让每一个PassengerRequest刷新,但是这就需要获得每一个PassengerRequest的引用,可是旧的PassengerRequest分布在Floor、Elevator中,十分杂乱,如果要保证线程安全就需要挂起几乎全部线程;如果让每一个新建的PassengerRequest都添加到一个static的列表中,又带来删除的问题;更重要的是,会导致线程安全问题,又需要大量加锁。
所以采用了被动刷新,即更改策略矩阵后,调用getTarget()方法时进行刷新。需要增加两个标志位,一个static标志位needFresh,标志着策略矩阵已更改,一个实例对象内部的标志位freshed标志着本实例是否刷新过。当调用getTarget()方法时,当needFresh && !freshed
时就查询策略矩阵更新目的地。
而关于第一方面,由于更改类型的时机只有电梯本身才可以掌握,所以我选择让调度器通知电梯改变类型,电梯来决定合适更改类型、更改为A_LO还是A_HI型。我采用了如下策略:调度器收到添加第二个A型电梯的时候,为已经在运行的A型电梯发送请求,这个请求是一个单独的对象,然后更改PassengerRequest换乘策略矩阵并设置needFresh标志位,同时启动两个线程:
这两个线程启动后调度器就可以继续处理下一个请求了,而不必等待A型电梯完成类型改变和新电梯添加完毕。当A电梯数量不低于2时,再添加新的A型电梯时,只需要启动1号线程即可。
这一部分更新的确增加了相关部分的耦合度,但是增加耦合度的地方也大多是直接涉及业务逻辑的,也不是不可以接受。关于电梯更改类型的请求,由于这个请求是一个抽象类,要求一个抽象方法来判断更改成何种类型的电梯,所以这一部分结构调整其实可以修改为任何电梯类型的更改策略,扩展性也不低。
基于度量分析自己的程序结构
文件名
总行数
代码行数
代码占比
注释行数
注释占比
空行数
空行占比
ElevatorDispatcher.java
316
264
0.835
28
0.089
24
0.076
ElevatorType.java
240
212
0.883
0
0.0
28
0.117
Elevator.java
222
183
0.824
5
0.0225
34
0.153
Container.java
198
163
0.823
10
0.051
25
0.126
Floor.java
181
154
0.851
8
0.044
19
0.105
PassengerRequest.java
158
138
0.873
1
0.006
19
0.120
Controller.java
182
134
0.736
30
0.164
18
0.099
Motor.java
109
98
0.899
0
0.0
11
0.101
Building.java
87
70
0.805
2
0.023
15
0.172
FloorNumber.java
81
70
0.864
0
0.0
11
0.136
FloorNumberMap.java
81
68
0.840
0
0.0
13
0.160
Tester.java
75
67
0.893
3
0.04
5
0.067
TargetInfo.java
77
66
0.857
0
0.0
11
0.143
Passenger.java
69
56
0.812
0
0.0
13
0.188
ElevatorID.java
63
53
0.841
0
0.0
10
0.159
TrafficInput.java
58
51
0.879
2
0.034
5
0.086
MainClass.java
49
46
0.939
0
0.0
3
0.061
DispacherRequest.java
51
42
0.824
0
0.0
9
0.176
UnorderedPair.java
47
39
0.830
0
0.0
8
0.170
Door.java
55
38
0.691
8
0.145
9
0.164
Logger.java
43
33
0.767
3
0.070
7
0.164
ElevatorFactory.java
31
27
0.871
0
0.0
4
0.129
TypeChangeRequest.java
26
20
0.769
0
0.0
6
0.231
ElevatorIsFullException.java
19
15
0.789
0
0.0
4
0.211
Total:
2518
2107
0.837
100
0.040
311
0.124
写的有点太多了,这导致两个问题:
看来以后确实要注意控制代码量。设计架构的时候,要设计简单实用一点。
而且注释写的不是很多,虽然通过很长的变量名和函数名使得看代码也很快能明白啥意思,但是还是注释更直观一些吧。学习一下javadoc注解也应该提上日程了。
matrics分析所有方法,基本复杂度≥4的方法如下:
方法
基本复杂度
设计复杂度
圈复杂度
traffic.building.ElevatorDispatcher.setStatus(Elevator)
8.0
10.0
10.0
traffic.building.elevator.Controller.checkNextStep(FloorNumber,Floor,TargetInfo)
5.0
10.0
10.0
traffic.building.Floor.passengersGetOn(Elevator,boolean)
5.0
5.0
8.0
traffic.building.Floor.hasNoRequest(List
4.0
2.0
4.0
traffic.building.elevator.Motor.nextFloor(boolean)
4.0
4.0
4.0
null.preferToWaitAt(FloorNumber)
4.0
1.0
4.0
traffic.TrafficInput.run()
4.0
6.0
6.0
traffic.building.elevator.Motor.go(boolean)
4.0
7.0
8.0
traffic.building.FloorNumber.valueOf(int)
4.0
3.0
4.0
traffic.building.Floor.freshInfo(List
4.0
3.0
4.0
Total
297.0
351.0
393.0
Average
1.332
1.574
1.762
前两个是调度器和控制器的核心方法,都是分析Floor/Building/Container的信息来为Elevator设定新的状态的方法,和Floor/Building/Container都有较高的耦合,三个复杂度都很高。
第三个passengersGetOn确实是比较纠结的点,一方面,每一层的乘客信息应当都是Floor进行管理的,passengersGetOn应当是Floor的方法,但另一方面,Floor应当只是一个容器,不应该承担这些逻辑控制工作。想了好久也没有更好的解决方案了。
类名
平均圈复杂度
总圈复杂度
traffic.TrafficInput
3.0
6.0
traffic.building.ElevatorDispatcher
2.727
30.0
traffic.building.ElevatorDispatcher.AwakeUntilGet
2.5
5.0
traffic.building.Floor
2.375
19.0
traffic.building.elevator.Motor
2.375
19.0
traffic.building.PassengerRequest
1.923
25.0
traffic.building.elevator.Controller
1.909
21.0
traffic.tools.FloorNumberMap
1.9
19.0
traffic.building.Floor.Info
1.857
13.0
traffic.building.elevator.ElevatorType
1.818
40.0
traffic.building.elevator.Container
1.769
23.0
traffic.building.Building
1.6
8.0
traffic.building.elevator.ElevatorID
1.5
9.0
traffic.building.ElevatorDispatcher.ChangeToDoubleAMode
1.5
3.0
traffic.building.Building.Info
1.5
9.0
traffic.building.elevator.TargetInfo
1.375
11.0
traffic.building.FloorNumber
1.333
12.0
traffic.tools.UnorderedPair
1.333
8.0
traffic.building.DispacherRequest
1.333
8.0
traffic.tools.Tester
1.333
4.0
traffic.building.ElevatorDispatcher.AddNewA
1.25
5.0
traffic.building.Passenger
1.2
12.0
traffic.building.elevator.Elevator
1.129
35.0
traffic.building.elevator.Controller.Info
1.0
3.0
traffic.building.elevator.TypeChangeRequest
1.0
3.0
traffic.tools.Logger
1.0
4.0
traffic.building.elevator.ElevatorIsFullException
1.0
4.0
traffic.building.elevator.Container.Counter
1.0
8.0
traffic.building.elevator.Door
1.0
6.0
MainClass
1.0
1.0
traffic.building.elevator.ElevatorFactory
1.0
3.0
Total
376.0
Average
1.6
12.129
总体的复杂度控制的还不错。
总圈复杂度前三为:ElevatorType(40)、Elevator(35)、ElevtorDispatcher(30)
ElevatorType的总圈复杂度较高,但平均圈复杂度并不高,该类集合了A、B、C、A_HI、A_LO五种电梯类型的参数信息,还涉及A型电梯的优化,总圈复杂度高不出意料。
Elevator是一个组装类,自然和部件之间耦合较高。
调度器需要获取Building、Floor、Elevator信息进行综合判断,还承载着电梯线程的管理工作,这个圈复杂度确实不算高,甚至有点低出我的意料之外了。
总调度器和电梯的控制器的圈复杂度都不高,而它们其实都需要大量Floor/Building/Container类的信息,我觉得这应该归功于各个Info内部类(上一章节提到过)。
主要都是因为代码规模过于庞大导致的细节地方被忽略了。比如PassengerRequest的needFresh忘记置位之类的。
还出现过一个本地无法复现,提交后随机出现的bug,虽然很快就被我修复了。这个出现在第三次作业,增加了前文提到的A型电梯的优化后出现的。由于本身过程比较复杂,我将出现问题的一部分抽象出来:
class A {
public void f() {
/* */
}
}
public class B {
private A a;
private ExecutorService executor = Executors.newCachedThreadPool();
private void go() {
executor.submit(() => a.f());
}
public void g(A newA) {
go();
a = newA;
}
}
关注class B,在该类的g()方法中,我本希望提交旧的a的f()作为新线程,然后更改a。在本地没有问题,但是提交后就会出现实际提交的是新的a的f()。这是因为go()中的lambda表达式,其实现方式其实是匿名内部类,意味着它是一个普通的内部类,它实现了Runnable接口,但是它是通过B.this.a获取的a对象,并没有a的副本,也就是说它获取的是运行时B中的字段a指向的对象,而不是go()函数调用时B中的a对象,那么B中a更改后,这个匿名内部类的a也会更改。
但是这有什么问题呢?问题就在于,确实是这个线程先被提交,后更改a,但是却无法保证这个线程先开始运行,后更改a。在本地测试的时候,由于我的PC资源比较充足,所以新的线程刚被提交就开始执行了,没有问题;但是评测机分配给我的程序的资源就没有那么充足,刚刚提交的线程可以没开始运行,a就先发生了改变。
怎么解决呢?我的方法是改写了go()方法:
private void go() {
executor.submit(new Runnable() {
private A a = B.this.a;
@Override
public void run() {
a.f();
}
});
}
这样虽然还是匿名内部类,但是为它增加了a的副本这样的字段,这样,调用go()的时候会实例化这个匿名内部类,此时就会保存一份a的副本,这样在它被执行的时候,就会准确调用旧的a了。更好的方法应当是创建静态的内部类,这样可以节省一个B.this的字段的空间,但是这样的话代码不够简洁。
另外,我认为这样也许也是可行的,但是我没有进行过评测机那样的测试,不过在本地运行没有问题。
private void go() {
A copy = a
executor.submit(() => copy.f());
}
因为这样会强制Java为lambda表达式生成闭包时创建一份a的副本。
分析自己发现别人程序bug所采用的策略
由于没空写评测机,就只好挑一些边界条件。
第三次作业,与3层相关很容易出现bug,尤其是3层到2层的换乘。因为3层只有C电梯可以到达,而C只停留奇数层,所以3→2就不得不3→1→2或者3→5→2,3和2相邻却不得不换乘一次。
我到现在都不知道为啥不知不觉就写了那么多,也写了好多文件,停下来看一眼行数统计的时候就发现写的有点太多太复杂了。不过的确很多类都是几乎不需要再更改的,所以洋洋洒洒拆分了很多文件确实在寻找实现的时候很快。
这次作业写的时候思路确实有点乱,没有想清楚哪些是共享对象哪些不是,就开始写了。以后涉及多线程的时候,一定要先考虑清楚哪些是共享对象,哪些不是,有了清楚的规划,才能写出高内聚低耦合、简结高效的代码。
手机扫一扫
移动阅读更方便
你可能感兴趣的文章