面向对象 | 第二单元 | 总结

第二单元的内容为Java多线程设计,主要包括了Java多线程的实现方式、多线程同步控制、死锁的产生与消除等问题,以复杂度迭代式递增的电梯调度问题为载体,锻炼多线程程序的设计、实现、调试的能力。

一、设计策略

我在第二单元相较于第一单元的进步之处在于,三次作业成功实现了同一设计框架下的迭代开发。

在第一次作业中,我就引入了三个线程:输入请求线程(RequestInput)、调度线程(Dispatch)和电梯运行线程(ElevatorRun),三者之间构成了三种生产者-消费者结构,其中特别要指出的是,Dispatch生成电梯运行指令序列送给ElevatorRun是一个生产者-消费者结构,ElevatorRun更新电梯信息并送给Dispatch也是一个生产者-消费者结构,这种调度与电梯运行完全分离的设计结构是出于多部电梯的考虑,当存在不止一部电梯时,调度线程会依据某一算法宏观地进行调度和指令分配,各个电梯只根据各自接收到的指令运行。这里所说的“电梯运行指令序列”有三大类情况:空序列、上或下一层、上或下一层并且有用户进出,设计电梯运行指令序列这一结构的目的在于使得Dispatch能够在最小时间粒度上对ElevatorRun进行安全且高效的闭环控制。

第二次作业轻松地继承了第一次作业的结构,只是ElevatorRun线程不止一个,相应的要更改Dispatch中的调度算法,使之适应多部电梯。在第二次作业中,引入了Algorithm接口,使调度算法本身完全独立起来,这样在调度算法需要修改或优化时,只需要更改实现了Algorithm的类即可,这里的多部电梯是完全相同的电梯,Dispatch对于所有电梯的调度是完全同步的,当所有电梯执行完各自的指令序列时,Dispatch才会进行下一次计算、发送指令序列。

第三次作业在原先的结构上进行了比较大的扩展。因为引入了不同种类的电梯,且每种电梯不止一个,设计了接口Elevator和抽象类ElevatorAbstract来管理不同种类的电梯。对于同类电梯,采用第二次作业中的设计结构,即一个调度线程(考虑到命名的语义问题,在第三次作业中更名为Schedule)和多个电梯运行线程,对于三类电梯而言,设计一个总的请求分配线程(Dispatch),其作用是初步分析所有的请求,按照一定算法将其分配给某一类电梯的调度线程去,当然这中间是经过了缓冲区的(奉生产者-消费者结构为圭臬,所有的线程共享访问都用生产者-消费者结构来实现)。要指出的是,Schedule还承担了在每一次电梯执行完指令序列后,提取换乘请求并将其送回Dispatch的任务,这是程序中处理换乘的方式。

上面已经提到,所有的共享访问都是采用生产者-消费者结构来处理的,缓冲区中设计特定的boolean型变量来进行同步控制,比如在Elevator中有readyForCmd,表示ElevatorRun已执行完当前的指令序列,Schedule停止等待并进行下一次调度计算和指令发送,在CommandBuffer中有commandReady,表示Schedule已经完成指令发送,ElevatorRun停止等待并执行指令序列。值得一提的是,关于线程结束的设计,在前两次作业中,采用boolean型变量inputEnd来表示RequestInput输入结束,当Dispatch获取到该inputEnd为真时,Dispatch需要完成对当前所有请求(以及电梯正在服务的请求)的调度,然后将lastCommand设置为真,ElevatorRun在获取到lastCommand为真时,结束线程。在第三次作业中,显然不可以直接采用前两次的方式,因为存在换乘的问题,RequestInput结束并不代表所有请求都得到了分配,所以设计了一个新的类Total,这个类的对象用来记录当前未完成请求的个数,Dispatch根据inputEnd以及Total对象中未完成请求的个数来决定是否要终止进程。

二、扩展性分析

总体上讲,我认为第三次作业的程序构架是不错的。SRP原则要求每一个类应该尽量承担单一的功能,符合得比较好,有改进的地方在于Elevator接口和它的实现类,Elevator接口里其实有两大类方法,一类方法是含有与电梯运行密切相关的(up, down, in, out, open, close),还有一类是与多线程(Elevator为ElevatorRun和Schedule所共享)共享变量密切相关,这其实可以再细分为两个类。OCP原则要求软件应该尽量通过增加软件实体的方式进行功能扩展,而不是通过修改软件实体的方式,程序在这方面则与具体的功能扩展有关,下面从不同情形的功能扩展分析可以看出程序在针对一些情况下很符合OCP原则,而在其它一些情况下却无法符合OCP原则,总体上讲,在程序构架设计之初,并没有考虑到扩展性的问题,所有OCP原则符合情况并不好。LSP原则要求使用基类对象的方法必须能在不了解衍生类的条件下使用衍生类,这一原则符合得较好,程序为电梯和可到达楼层序列分别设计了两个基类ElevatorAbstract和FloorAbstract,这两个基类实现接口Elevator和Floor,程序的其它部分基本只使用Elevator和Floor对象。ISP要求一个接口对应一组高度内聚的操作,除了前面提到的两个接口外,程序还有一个Occupied接口,用于处理电梯执行特定动作是花费时间的问题,Floor和Occupied对于ISP的符合程度较好,而上文分析过的Elevator则对ISP原则符合程度较差。DIP原则要求高层逻辑模块应该不依赖于底层模块的细节,这一原则符合得比较好。

1、更多类型的电梯

对于这种情况的处理几乎是完美的,程序设计了Elevator和Floor两大接口,分别用于管理不同类型的电梯和不同的可停靠楼层序列,当出现新类型的电梯时,只需要增加新的类实现Elevator和Floor接口,程序其它部分只会出现极少数的改动,当然这些极少数的改动也是可以避免的,比如设计工厂来管理管理Elevator的创建,在Floor里添加静态方法来替换算法中对于实现了Floor接口的所有类分别调用某一方法的操作。

2、特殊请求

主要有两种特殊的请求,某一电梯在完成当前的服务后进入一定时长的维修状态,某一电梯立即放下所有的请求并进入维修状态。对于后一种情况的处理很容易的,程序对Request类进行了二次封装,构成RequestInfo类,这个类能够记录用户在出电梯时的楼层,如果该楼层与用户目标楼层不同,则需要换乘,这一设计直接解决了立即进入维修状态的问题。对于前一种情况处理起来则比较困难,Schedule完全控制了同一种类所有电梯的运行方式,而Schedule中Algorithm的对象完成了每一部电梯上行、下行、上人、下人的计划,所以为了解决这一问题,这两个类需要较大的改动。

3、优先级请求

存在不同优先级的用户请求,这本质上是针对算法而言的,只需要在Algorithm的实现类中对算法进行修改即可,或者干脆重新写一个Algorithm的实现类替换掉原来的即可。

三、程序结构分析

重要类信息统计表格
  行数 属性个数 方法个数 最大分支个数 最大循环层数
ElevatorAbstract 134 7 16 4 1
FloorAbstract 43 1 6 3 0
CommandBuffer 46 3 5 1 1
CommandCreator 64 7 6 2 1
Dispatch 101 5 7 4 2
DispatchBuffer 42 2 5 1 1
ElevatorRun 47 2 2 7 2
MyAlgorithm 233 2 15 7 2
RequestBuffer 49 3 6 1 1
RequestInfo 52 3 8 3 0
RequestInput 36 2 2 3 1
Schedule 94 7 5 4 1

可以看到,ElevatorAbstact,Dispatch,MyAlgorithm这三个类的复杂程度是比较高的。对于ElevatorAbstact类,上文也提到过,其实可以拆分成两大类以降低复杂度;Dispatch完成了对所有请求的预分配功能,如果将分配算法本身提取出来成为单独的一类,就能够降低其复杂度;MyAlgorithm集成了整个复杂的电梯调度算法,如果按照调度步骤进行划分,也可以分为结构较为简单的几个类,然后由一个上层的类进行封装并实现Algorithm接口。

线程协作图这是第三次作业的线程协作图,清晰地描绘线程构架和依赖关系,前文已经有过简单的描述。主线程启动Request,Dispatch,Schedule这三类线程,ElevatorRun由管理该类型电梯的Schedule启动,程序启动之初,每类Schedule各自启动一个Elevator,之后根据电梯请求启动新增的Elevator。
homework7 UML这是第三次作业的UML类图,为了将结构和重要的依赖关系清晰地展现出来,省去了部分属性、方法和类。可以看到,线程与线程之间一律构成生产者-消费者结构,这是在程序设计之初决定的——统一用生产者-消费者结构处理线程共享访问的问题。另外值得注意的是,对于Elevator接口,有一个ElevatorAbstract抽象类来实现,ElevatorAbstract又被ElevatorA,ElevatorB,ElevatorC所继承,管理可停靠楼层序列的Floor也运用了这样一个接口、一个抽象类、三个类的设计结构。

四、漏洞分析

综合三次作业从程序调试的初期到强测漏洞修复中间出现的重要漏洞,大致可分为两类:多线程漏洞和算法漏洞。

在程序初步完成之后的调试初期,多线程漏洞比较明显,某些线程无法启动、某些线程无法结束、动态新增的线程无法正常运行、调度线程无法正常运行,这类漏洞一般表现为程序没有输出或只有极少输出,也就是死锁。我采用的主要调试手段是在程序中一些重要位置增加输出信息,比如在每个线程的启动和结束时输出信息,在线程进入wait()和离开wait()时输出信息,这些信息基本能够直接确定死锁发生的位置,再进一步分析代码逻辑即可以确定死锁的原因。

System.out.println(getName() + ": start");//线程启动时输出
System.out.println(getName() + ": end");//线程结束时实处
System.out.printlen(threadName + ": start waiting for xxx");//线程进入wait()时输出
System.out.printlen(threadName + ": end waiting for xxx");//线程离开wait()时输出

 

值得一提的是,第三次作业在强测时出现了一个与线程同步相关的多线程问题,这是三次作业中唯一一个与死锁无关的多线程漏洞,也是我在调试初期和中测中没能发现的漏洞。漏洞表现为运行时错误,或者个别动态新增电梯的Schedule和ElevatorRun“错位”了一个指令序列,比如一个从3楼到5楼的用户请求,电梯在4楼接入用户、6楼放出用户。经过对代码逻辑的静态分析,发现这一漏洞出现的根源在于Schedule向各个电梯获取并复原readyForCmd信息的时机不对,原先这一操作放在动态新增电梯前,导致同一个计算循环内,动态新增电梯的readyForCmd字段无法复原,在极少数情况下程序会出现前述的表现,将这一操作移至计算循环的末尾就解决了问题。

关于算法漏洞,主要是调度算法和请求分配算法的逻辑不完备或者效率低下的问题,与多线程本身没有关系。在程序设计之初,我针对管理可停靠楼层序列的Floor进行了安全保护,当电梯运行到不合法的楼层时,程序立即输出相关错误信息然后终止运行,这一设计在程序调试的初期起到了不小的作用,帮助找到了算法中的一些漏洞。还有就是针对一些特殊的请求(部分换乘请求),最初的算法是存在漏洞的,针对特别的换乘请求进行调试,很容易发现并修复算法的漏洞。

在hach别人的代码的过程中,我会针对自己代码出现过的漏洞构造测试样例,比如一些特殊的换乘请求,也会针对RTLE进行测试,构造一些对于调度算法效率要求较高的测试样例。

 

五、心得体会

第二单元多线程编程在代码逻辑复杂度、代码量、调试难度上都要比第一单元提升很多,我花在第二单元上的时间相应也增加了很多,就程序架构的可扩展性和可迭代性而言,第二单元相较于之前是有进步的。多线程方面的漏洞是比较大的麻烦,尤其是上述提到的关于线程同步的漏洞,花费了很大时间才发现漏洞的原因。总体上说,通过第二单元的训练,基本上熟悉了Java多线程程序设计、实现和调试。

 

猜你喜欢

转载自www.cnblogs.com/buaa18373088/p/12723515.html