OO第二单元总结博客
前言
相较于第一单元作业,由于对面向对象语言和层次化设计有了比较充分的认识,第二单元相对轻松(但还是很痛苦)。第二单元作业相较于第一单元,输入输出接口课程组已经提供,没有第一单元非常琐碎的化简等细节问题,困难点分布比较集中,攻克起来更加容易,主要是多线程编程的程序安全问题。第二单元作业第一次作业花了很多时间入门多线程与熟悉电梯调度算法,消耗了很多脑细胞,面向对象已经抛到一边了;有了第一次作业的基础,第二三次作业相对容易入手,通过重构让自己的代码更加面向对象,提高可扩展性,并进行了比较充足的调度优化。多线程编程的确很酷,线程之间的交互与现实世界非常贴切,每个线程做好自己的事情,并且由我们确定好线程交互规则,线程就开始运作,仿佛活了起来,非常奇妙!总之,本单元还是有非常大的收获。
总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句直接的关系
三次作业都是使用的物理锁(synchronized+代码块)进行的同步控制,三次作业主要理解了三个问题:如何确定用多少个锁?锁住的代码块的范围应当如何确定?如何尽可能减少锁的嵌套?
第一次作业
第一次作业比较简单,输入线程与电梯线程之间只有一个共享对象share
,包括共享队列与输入结束信号两部分,所以在两个线程中对share
有读写操作的代码块我都使用了synchronized(share)
进行同步控制。
第二次作业
第二次作业也第一次作业类型,只不过数据共享是分层次的,但都是相似的。我抽象出Queue
的类,存储共享队列与共享信号,输入线程与总调度器共享一个Queue
的对象,总调度器与各个电梯各共享一个Queue
的对象。两两之间存在共享,不存在三者的共享。对Queue
类对象操作的代码使用synchronized(xxx.queue){}
进行同步。
第三次作业
第三次作业在第二次作业的基础上增加了count
对象,用于统计当前时刻剩余未完成的请求数,由输入、总调度器、电梯共享,输入线程与电梯线程会对count
进行写操作,总调度器和电梯线程会对count
进行写操作,相应的代码块使用synchronized(count){}
进行同步控制。对Queue
进行了扩展,存储两个共享信号:输入结束信号inputEnd
、程序结束信号finalEnd
,对两者的操作代码也使用synchronized(xxx.queue){}
进行同步。
锁与数据共享是紧密相关的,如何确定数据共享的数量与结构很大程度上决定了锁的使用。对于初学者,锁的使用中要尽量减少嵌套关系,不然很容易出现死锁的bug,这个将会在下文的bug分析中仔细阐述,我在本单元作业中,除了count
对象为三者共享,其余的所有共享都是保证在两者的,所以对count
的加锁也是让我思考了许多时间,确保不会产生死锁。在保证正确的情况下,应当进一步减小锁的颗粒度,与monitor对象无关的代码应当移除锁外。在第三次作业中,Dispatcher
类中判断输入是否结束,如结束则notify
各个电梯线程的代码被放在了count
锁住的代码块中,其实是没有必要的。
本次作业由于对锁不是很熟练,于是都使用了比较严谨但是也用起来比较繁琐的物理锁,即synchronized(monitor){}结构。对于进一步的学习,可以尝试着使用更加灵活的逻辑锁Lock来使代码更加清晰。
总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互
第一次作业
第一次作业并未抽象出调度器这个模块,但是定义了共享对象Share
这个类,主要完成电梯线程与输入线程的数据共享,电梯的运行策略通过书写状态机状态转移逻辑,完成了ALS算法,针对不同的模式完成了不同的方法。本次作业中电梯线程与输入线程通过Share
类交互,完成乘客请求和输入线程结束信号的传递。Share
类包含共享队列与输入结束信号,输入线程将乘客请求放入共享队列,电梯从共享队列中获得请求并执行,输入线程结束时将输入结束信号置True,电梯读取这个信号并在请求全部完成时结束线程。
第二次作业
第二次作业重构了架构,使用分级调度管理。由总调度器线程dispatcher
将输入得到请求分配给各电梯,每台电梯内置一个“调度器”完成单电梯运行的调度(电梯内置的“调度器”并不是一个线程,我更愿意称之为策略类,而不是调度器)。
总调度器dispatcher
与输入线程共享待调度队列与输入结束信号,输入线程将乘客请求加入到待调度队列,总调度器dispatcher
内置了多种调度算法,根据需要选择合适的算法将请求分配给各个电梯。每个电梯内置一个策略类strategy
,根据输入模式选择合适的策略完成单部电梯运行,总调度器和每个电梯之间共享一个电梯等待队列和输入结束信号,总调度器需要将请求从待调度队列拿出并放入目标电梯的等待队列,相应电梯从自己的等待队列中获取请求并执行。输入线程结束将结束信号传递给总调度器,再由总调度器传递给各个电梯,各个线程完成自己的工作并输入结束后会结束线程。
第三次作业
第三次作业基本沿用了第二次作业的调度架构,这次作业调度工作不仅需要完成请求的分配,还需要完成请求的分割(将请求分割成单部电梯一次运输可以完成的原子请求),同时本次作业的进程结束判断比较复杂。
对于请求的分割,目前的调度器负担已经比较重,我将这一部分功能转移到其他部分。于是我建立了一个换乘表类transferTable
,通过动态规划获得一个换乘表,通过逐步查表法将请求分割成一个个原子请求。调度器dispatcher
层面只需要调度一个个原子请求即可。
本次进程结束不能通过输入结束进行判断,而是需要判断全部请求是否完成。但为了morning策略能够正确执行,我保留了输入结束信号的传递。在输入线程、总调度器线程dispatcher
、电梯线程之间添加了共享计数器count
用来统计剩余未完成请求数,输入线程得到新的输入时count++
,电梯将请求运送到最终目的地时count--
,总调度器线程与电梯线程结束的判断逻辑中,需要读取count
的值并判断是否为0。
从功能设计与性能设计的平衡方面,分析和总结自己第三次作业架构设计的可扩展性
第一次作业
- UML类图
- UML协作图
第二次作业
- UML类图
- UML协作图
第三次作业
- UML类图
- UML协作图
morning模式的设计难度更高,在此展示morning模式下的UML协作图。
- 扩展性分析
针对电梯的扩展性较好,针对调度算法的扩展性较好,但是针对策略的扩展性差。
电梯使用工厂模式,不同类型的电梯只需要传递不同的参数即可创建不同的电梯。模拟电梯继承电梯类,模拟电梯可以获取当前电梯状态并进行模拟,所以增加新的电梯种类时完全不需要修改模拟电梯的内容,就可以支持新的功能。乘客换乘只需要查transferTable
,电梯是否可以经停只需要查mapTable
,增加新的种类电梯只需要修改这两个类就可以,mapTable
使用独热码标记相应楼层可以停留的电梯,相应种类的电梯通过掩码查询是否可以经停,增加新的电梯种类只需要修改独热码并添加新的掩码即可,transferTable
中只需要再增加一个可达性矩阵即可。
调度器中将调度算法抽象为一个个方法,更换新的调度算法只需要再调度器中更改方法调用即可完成,更进一步来看,调度器还可以支持根据外部状态随时调整调度算法的功能。
针对策略的扩展性较差,不同的策略(morning、random、night)在状态转移中有诸多不同,策略不同使得策略类高度相似而在代码其余部分出现不同,扩展性非常差。分析原因,应当是我最初没有回答好一个问题:“策略类需要完成什么工作?”,从我的架构设计来进行反思,我的不同策略类分别表示了不同的状态转移方式,于是应当这样改进:将状态转移的公共操作如查询电梯状态等放入strategy抽象类,在不同模式状态转移路径出现差异的部分定义不同策略的方法,使得状态类的状态转移代码都是进行的比较抽象层面的操作。这样可以使得代码再策略方面有更好的扩展性。
分析自己程序的bug
课下进行了周密的形式验证与数据测试,三次作业的强测与互测均未出现bug。下面主要谈一谈自己的公测与自测中遇到的bug。
第一次作业
第一次作业主要主要是电梯的状态转移不够周全,出现bug。主要原因是把所有的状态方法写到Elevator
中,状态转移逻辑代码switch
语句块和状态方法中均有,非常零散。为了实现捎带功能,对电梯使用了两个方向不同的读入缓冲区buffUp buffDown
,没有必要,直接从等待队列中获取并判断好方向就可以,只要注意好不要超载。
第二次作业
第二次作业比较顺利,使用状态模式解决电梯状态转移问题,用LOOK算法取代了ALS算法,代码更加容易理解,并且性能分得到很大提高,没有遇到bug。
第三次作业
第三次作业由于粘贴了一部分代码出现了两处代码修改不同步导致出现了bug,可见自己的抽象能力仍然有待提高,在初步的设计架构中,三种策略random、morning、night只需要重写不同的部分即可,结果在写代码的过程中,需要修改的地方竟然不止在三个策略类,在别的地方仍然需要修改,例如我的Rest
状态类,于是我的策略类代码只好复制粘贴,导致bug。本质上看,这个bug的出现是因为我对于策略类的抽象不够深入,仍然需要将策略类进一步内聚,使得其余部分代码与策略彻底无关。
第三次作业的自测中,我出现了死锁,原因是我另外创建了一个共享对象标记输入线程结束,inputEnd
起初并没有放在Queue
类中,结果发生了死锁。以下通过代码展示一下这个bug,均与morning模式有关:
/* InputThread 输入线程 */
if (request == null) {
synchronized (dispatchingQueue) {
synchronized(inputEnd) {
inputEnd.setInputEnd(true);
dispatchingQueue.notifyAll();
}
dispatchingQueue.setFinalEnd(true);
dispatchingQueue.notifyAll();
}
break;
}
/* Dispatcher 总调度器线程 */
synchronized(inputEnd) {
if (inputEnd.isInputEnd()) {
for (Elevator elevator : ele2waitingQueue.keySet()) {
synchronized (ele2waitingQueue.get(elevator)) {
ele2waitingQueue.get(elevator).notifyAll();
}
}
}
}
/* Rest 电梯的Rest状态类 */
synchronized(elevator.getWaitingQueue()){
..............;
elevator.getWaitingQueue().wait();
while (true) {
synchronized(elevator.getInputEnd()) {
if (!((elevator.getWaitingQueue().getQueue().size()< elevator.getPersonNumMax())
&& (!inputEnd.isInputEnd()))) {
break;
}
elevator.getWaitingQueue().wait();
}
}
}
在这段代码中出现在morning模式中,电梯在输入结束前,需要等人满之后才会运送乘客,在输入结束后,不再等待人满。空电梯在waitingQueue
对象上等待,当调度器给电梯分配一个请求时会唤醒电梯,电梯进入while循环等待人满,人不满并且输入未结束继续在waitingQueue
对象上等待。输入线程如果结束会将inputEnd
置True,调度器线程检测inputEnd
如果为True,唤醒电梯。
看似比较合理,但是在A锁套B锁的结构下,线程释放A锁等待而不是释放B锁等待本身就让人感到十分奇怪。while循环中,电梯释放了waitingQueue
的锁等待,此时还拿着inputEnd
的锁,输入线程结束时,无法拿到inputEnd
的锁所以被阻塞无法唤醒调度器,调度器更无法唤醒电梯,然而电梯只有被调度器唤醒才能够继续执行从而释放出inputEnd
的锁,这就出现了矛盾,从而死锁。
解决办法是将沿用老办法inputEnd
也放到Queue
类中,将所有的信号和队列放到一个类来共享,然后只锁这一个对象,可以解决死锁的问题。
分析自己发现别人程序bug所采用的策略
- 列出自己所采取的测试策略及有效性
分析自己程序bug主要通过形式验证与数据测试,互测中主要通过数据测试。
前两次作业中,通过梳理状态转移图,将电梯各个运行状态之间的状态转移逻辑进行了周密的检查,所以我的电梯没有成为过“∞电梯”、“震荡电梯”、“吃人电梯”、“造人电梯”、“量子电梯”。另外对电梯的锁的结构进行了梳理,绘制了一张锁的并列嵌套关系表,通过改变数据共享方式,例如将联系紧密的数据打包成一个共享对象减少锁的嵌套,并针对这张表尽可能构造极端的各进程取得锁的序列,检查是否会出现死锁。最后,对实验中的难点——如何让进程正确的结束进行了检查,重点检查了每个进程的结束判断逻辑是否周密,后期我将判断部分改成冗余法判断使得进程在进入waiting
状态前后都进行是否结束的判断,从而使得程序一定能够正确结束,并在不同的时刻输入^D检查是否能够正确结束。
数据测试主要通过构造极端数据的方式进行,针对不同的输入模式、加不同的电梯等情况构造测试用例。例如在请求的楼层跨度上我考虑了大跨度楼层分布,小范围楼层分布,相邻楼层分布,针对第三次作业,我分别对于ABC电梯的特点,设置了分别与不同类型电梯适应的楼层分布,对于时间,我考虑同时大量请求进入,时间稀疏的请求进入,针对换乘,设置了许多不顺路的情形,第三次作业中单部电梯就能够完成但是比较慢的情形。特别地,对morning模式这个易错的模式,进行了许多与输入结束有关的测试,主要检测是否能够完成所有请求,不会有遗漏。
我的测试策略帮助我安全地度过了强测与互测,在互测中也能够取得一定的战绩。多线程的线程安全类bug存在并不一定能够复现,在第一次作业互测中,我成功hack了两次,均为“电梯吃人”的bug。第二次作业互测中,发现有一名同学只用一步电梯完成所有请求,于是我针对这个情况构造了许多边界数据进行压力测试,可惜互测210s的限制过于宽松,无法使TA超时,第二次作业互测无功而返。第三次作业中,在本地的自动测评中发现某个同学在遍历ArrayList
中出现内容项的修改触发了异常,可能是将线程对象放入ArrayList
,但是线程运行具有不确定性,所以有时候会触发异常有时候不会;另外我还发现一个同学morning模式下会出现电梯的震荡不止的情况,在本地测试也会发生一次复现bug,多次没有复现的情况,可能是状态转移不周密导致的,我连续提交了多次测试用例,在平台测评机中却一次也没有成功hack,比较可惜。
- 分析自己采用了什么策略来发现线程安全相关的问题
(1)通过数据测试发现自己出现了死锁,使用IDEA的快照功能能够很清楚的看出来哪个线程得到了锁,哪个线程在waiting,并且可以查看锁的ID,结合自己的代码逻辑便可以分析出是那两个线程之间发生了死锁。
(2)另外如上所述,通过检查锁的层次结构,枚举边界数据的访问序列,进行人脑模拟多线程测试。
- 分析本单元的测试策略与第一单元测试策略的差异之处
第一单元是静态的测试,主要考虑测试数据本身的极端性,而且写对拍机简单,使用Python的sympy
直接写就可以,如果发现了bug则刀一次中一次。本单元的测试数据不光要考虑数据本身的极端性,还要考虑序列的时间特征,本次写测评机要满足的数据约束比较多,正确性检查比较麻烦,由于多线程的不确定性,即使发现了bug也难以一次就复现,需要同样的数据多次测试。
心得体会
- 线程安全层面
在我看来,线程安全是本单元最难的部分,如果不关注电梯的性能,可以说线程安全是本次作业唯一的难点了。课堂上讲解的几种经典情形在本次作业中得到了充足的展现。第一次作业中一遍遍阅读代码才发现一个潜在的线程安全问题,但是对于这类问题见得多了也就更敏感了,在第三次作业中,如果写到可能出现线程不安全的地方,便会下意识地提醒自己这里要多做些工作。
线程安全与生活中许多场景是非常相似的,以生活场景作类比再来回过头来理解这个问题,其实里面也不过是非常朴素的道理,更多地是开发者要有线程安全的意识,然后再谈用什么样的技术手段维护线程的安全。java中有许多线程安全的类,其目的就是服务于多线程编程,既然说“不要重复造轮子”,那么接下来的学习中还应该学习使用这些东西,让自己变得更加专业。
- 层次化设计
层次化设计方面老生常谈了,从第一单元就开始了,本单元提高明显,但是仍旧在这方面有许多不足之处。第一次作业基本采用面向过程,主要用来熟悉多线程与电梯调取算法了。第二次作业才着手重构,“输入-调度器-电梯”的层次架构基本明确,这次作业采取状态模式建模,在run方法中只需依次执行状态的操作,电梯进行了参数化,创建不同类型的电梯只需要传递不同的参数。第二次作业使用模拟电梯来进行优化,只需要继承电梯类并重写状态的操作即可,修改起来非常容易,这些都实现了较好的可扩展性。但是电梯的状态转移与电梯策略仍然高度耦合,层次化做的不够好,使得实现morning策略的过程中有较大工作量。
虽然在设计架构的时候已经进行了充足的设计,但是在实际写代码的过程仍然与最初设计在实现上发生抵触,这是不可避免的,初学者难以一遍成功,还需要实际开发的过程中不断迭代调整架构,还需要更多的训练才能够让自己对设计有更高的敏感度,对实现过程的可能遇到的问题要有一定的预见性。