OO第二单元总结之线程大冒险第一关
第二个单元的三次作业均为多线程电梯的设计,旨在让我们能够理解多线程在面向对象设计时的重要意义,并熟练掌握在保证线程安全和性能高效情况下的多线程协同的设计模式——在本次作业中主要体现在生产者-消费者模式。三次作业从开始简单的傻瓜调度电梯,到加入捎带的ALS捎带算法以及最后多部电梯的组合运行模式,逐步加强了我们对线程设计的深入理解,在要求陆续增多的情况下合理设计,保证线程的高效和安全。
一. 总体设计思路
第一次作业
第一次作业是一个简单的傻瓜调度的电梯,是经典的生产者-消费者模式。生产者每读到一个请求就向托盘放入请求,同时消费者只要托盘中有请求就读取出来进行处理,由于放/取请求遵循先入先出的原则,故托盘可设计为一个队列。生产者和消费者各自只与托盘进行交互,互不干涉,保证多线程。托盘的队列为他们的共享对象,放/取操作不能同时进行,故要对其进行加锁,当放/取操作结束后再释放。
多线程的协同与同步控制方面:生产者线程将读到的请求放入托盘中,当读到null时则中止,其中使用到托盘中的put函数;消费者线程每次读取托盘中的一个请求,将请求执行完以后再继续读取下一个请求,当读到null时则中止,其中使用到托盘中的get函数。其中put函数只要有请求就放入,不存在需要阻塞的情况,而get函数当托盘中无请求时需先进入阻塞态(使用wait()),直到再有程序时就进行唤醒(notifyAll()),两者不能同时进行,故用锁synchronized进行锁住。
第二次作业
第二次作业相比于第一次作业,变成了多线程ALS(可捎带)调度算法的电梯。即当电梯运行过程中,如果托盘中请求队列中有请求到来的时间早于它到达请求出发楼层的时间,且请求的目标方向与电梯的运行方向一致,则可将该请求记为可捎带请求,在运行过程中执行该请求。
主体设计思路是,电梯读入一个请求,开始执行请求。首先建立一个ArrayList存已经进入电梯里的请求,在每一层停下来,建立两个ArrayList分别装从托盘中取出的所有可捎带的请求和目标楼层是这一楼层的请求,分别执行其对应的操作。当最开始的请求执行完并且电梯里无请求时,操作完毕。电梯继续向托盘读入请求。
托盘中除了get()和put()函数外,再新建了一个getorder()的函数来取出其中可捎带的请求,判断是否可捎带的标准与指导书一致。
多线程的协同与同步控制方面:相比于第一次作业增加了托盘中的一个同样取出请求的函数,当托盘中无可取出请求时无需等待,因此不用进行线程阻塞/运行态的转换,但这个函数同样涉及到共享对象请求队列的改变操作,因此也需要通过synchronized进行锁住来防止出现线程安全问题。
第三次作业
第三次作业中增加了电梯的个数,每个电梯都有特定的到达层数,对每个请求可能会使用到不止一台电梯。因此在第二次作业的基础上,可以采用先将每一个请求进行划分,划分的每个部分请求都一定能在一台电梯上执行完,当第一个部分请求执行完以后电梯才可读取第二个部分请求。基于这个思路,只需对调度器请求队列进行处理,并当电梯执行完第一个部分后传递信号给调度器可以读取下一个部分请求,即可沿用第二次作业的设计。
多线程的协同与同步控制方面:相比于第二次作业增加了对请求队列的划分处理和信号传递,都无需等待,信号传递后队列中该请求会将第一个部分请求remove出去,对共享队列进行了修改,故也需要synchronized进行锁起来。
二. 自我测试发现的bug
第一次作业
(1)时间错误
第一次写完以后发现从高层到低层时间会特别快,经检查代码后发现在处理从出发楼层到目标楼层时,忘记考虑结果为负数,加了绝对值后即可。
第二次作业
(1)NULLPOINT错误
在对托盘读到的请求进行判断处理时,一定要将是否为空放在判断的最前面,当多个判断条件用&连接时,也会按照顺序从第一个开始判断。
(2)ArrayList超出长度的错误
在电梯从托盘的请求队列中取出可捎带请求时,同样要将该请求从请求队列中remove掉,此时没有处理好for循环中i的值也要相应的减少,使得最后爆出超出ArrayList长度的错误。
(3)不能停止的错误
在电梯运输逻辑中,判断是否出来时,开始忘记加上了主请求已完成的判断条件,导致电梯会一直停不下来。后来加上条件后即恢复正常。
第三次作业
在处理对请求的划分时没有出现很大的逻辑错误,但最后却因为线程安全问题,出现了很多RUNTIME ERROR和TLE的错误。
(1)处理请求划分时没把握好顺序
开始的时候,我忘记了在请求划分后的第一个部分请求执行完以后再remove掉引入第二个部分请求,导致会出现请求部分顺序混乱的情况。
因此,我将Person Request类增加了一个参数state,当为true时可被电梯读入,为False则不能被读入。划分后第一个部分请求的State为True,其余均为False,当第一个请求被读取后State变为False,当第一个请求执行完之后将其remove掉,再将接下来的请求的State变为True。通过这样达到顺序正确的要求。
(2)线程安全错误
在处理wait()和notifyAll()时没有梳理清关系,导致有些情况线程不会被唤醒,有些情况线程不会进入等待状态而是直接结束,因此出现了很多运行时间的报错。
三、代码分析
五个总结程序结构的度量参数具体含义如下:
(1)ev(G):基本复杂度,用来衡量程序非结构化程度的,范围在[1,v(G)]之间,值越大则程序的结构越“病态”。非结构成分降低了程序的质量,增加了代码的维护难度。
(2)Iv(G):模块设计复杂度,用来衡量模块判定结构,即模块和其他模块的调用关系。软件模块设计复杂度高意味模块耦合度高,这将导致模块难于隔离、维护和复用。
(3)v(G): 循环复杂度,用来衡量一个模块判定结构的复杂程度,数量上表现为独立路径的条数,即合理的预防错误所需测试的最少路径条数。
(4)OCavg:类方法的平均循环复杂度。
(5)WMC:类方法的总循环复杂度。
部分设计原则如下:
(1)单一功能原则:一个类只承担一种类型的责任,即只有单一功能,以避免同一个类中职责间的影响。
(2)里氏替换原则:子类可在任何父类能够出现的地方替换父类,且经替换后代码还能正常工作。
(3)依赖反转原则:代码应当取决于抽象概念,而不是具体实现,即依赖于抽象而非实例。
(4)接口隔离原则:多个专门的接口优于单一的总接口。
(5)开闭原则:对扩展开放,对修改封闭。
参考博客链接:https://www.cnblogs.com/qianmianyu/p/8698557.html
https://www.cnblogs.com/panxuchen/p/8689287.html
https://www.cnblogs.com/huangenai/p/6219475.html
第一次作业
(1)类图
(2)方法度量
(3)分析
1.复杂度分析
可以看得出来本次作业中各个类及各方法的复杂度都控制得较好,结构也比较清晰直观,方便在后面的作业中进行扩展。
2.设计原则分析
根据查找资料,从线程所需的依赖反转原则和开闭原则的角度来看,本次电梯类只是单纯的根据输出信息进行操作,而没有真实抽象的模拟上下楼层及开关门的情况,显得比较实例化。
第二次作业
(1)类图
(2)方法度量
(3)分析
1.复杂度分析
相比于上一次作业来说,结构基本保持,比较清晰。可以看出ElevatorOperate类的Operate方法复杂度非常高,其中由于对电梯运行请求的处理过于繁琐,含有多个if/else嵌套以及复杂的判断条件,使得其复杂度大大提升。
2.设计原则分析
从单一功能原则角度分析Operate方法,可以发现,在这个方法里放的功能过于繁多,例如完成从当前楼层到出发楼层以及出发楼层到目标楼层两个步骤,分别每层的运行,捎带人进来,结束的判断……功能过于复杂,因此导致了设计结构很冗长。
(1)类图
(2)方法度量
(3)分析
1. 复杂度分析
本次作业沿用了第二次作业的Operate方法,因此其复杂度原因一如上面描述比较高。同时在其他方法中同样存在大量if/else的判断条件,嵌套复杂,因此复杂度也很高。
2. 复杂度分析
依旧从单一功能原则来看,依旧存在在一个类里放多个功能的情况,尤其体现调度器Tray类,作为生产者和消费者之间的桥梁,取放请求、拆分请求、获取可捎带请求等功能都在这里实现,因此调度器的效率极为低,且很容易炸CPU。
四、to hack or not to hack
三次作业的互测环节,均是拿一些自己构造的样例对某一个或某两个人的代码进行检测,遇到错误则提交。尽量保持hack的次数不要太多。
第一次作业
这一次作业比较简单,大多数人都不会有错误,因此没有发现bug。
第二次作业
考虑采用了对一些当电梯刚离开当前楼层例如一层,就传来出发楼层为一层的请求,如此往复,若完全按照指导书的ALS调度,则会出现类似傻瓜电梯一样的送完一个人再送下一个人的情景,可能出现tle错误。同时根据代码寻找可能出现的特殊样例bug。
第三次作业
这次作业较为复杂,可多次选用一些需要不停调换的请求进行hack。
五、感想
一定要掌握线程安全的思想!线程安全思想还比较薄弱的我,在第三次作业几乎遭到了毁灭的打击,差点用光所有的评测机会才堪堪交上,而且还是极度不安全的可能过可能不过的状态。线程不安全一时爽,提交火葬场。在研讨课的时候,很多大佬们也分享了关于线程安全的思考和如何判断线程安全问题症结的方法,在之后的学习里也要合理利用这些方法,设计一个安全又高效的线程!
同时印象很深刻的是,如果掌握了线程安全的思想,在设计时就会轻松很多,合理安排每个线程与彼此的交互,这样对每个线程来说只要专注于眼前的任务即可,设计也会更加的轻便简洁。