OO_Unit2_blog
1.1 锁的选择
第二单元第一次课讲了synchronized
上锁的方法,而之后的课程中又讲了ReentrantLock
高级锁。尽管ReentrantLock
可以实现更多的线程控制功能,但是考虑到相对来说使用synchronized
代码实现比较容易并且不容易出错,因此三次迭代开发中均只使用了synchronized
方法给共享对象中的方法加锁。
1.2 同步块的设置
在三次作业中,我均设置了请求队列类PersonQueue
。而在第五次作业中,这个类是所有类中唯一共享的类,因此在第五次作业初期对synchronized
理解不到位的情况下,一股脑对PersonQueue
类中所有的方法前都加上了synchronized
,之后两次作业也沿用了这个传统。
而在后两次的作业中,我将PersonQueue
的封装成了VerticalTrack
和HorizonTrack
两个类,类中存放PersonQueue
数组用于电梯轨道的管理。因此在新增的两个轨道类中我也无脑地对其中的所有方法进行了synchronized
处理。
除此之外,有时电梯会在同一个条件判断语句中调用多个共享共享的方法,为保持语句块前后共享对象的状态一致,还需对电梯线程run()
方法内的语句进行同步处理。
如下面这一段在电梯线程run()
方法开头的代码,如果没有进行synchronized
同步,则在一个if()
括号内部调用verticalTrack.isEmpty()
和verticalTrack.isWaitEmpty()
时,共享对象verticalTrack
的状态可能发生变化,将会导致逻辑表达式的结果出现意想不到的状况。此外,为了保证两个if()
语句块内的verticalTrack.isEmpty()
返回值相同,我将两个条件判断语句放置到了同一个同步语句块中。
synchronized (verticalTrack) {
//process end
if (verticalTrack.isEmpty() && verticalTrack.isWaitEmpty() &&
inElevator.isEmpty() && NewMainBuilding.getInstance().isEnd()) {
return;
}
//wait for next request
if (verticalTrack.isEmpty() && inElevator.isEmpty()) {
try {
verticalTrack.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
continue;
}
}
2 调度器与线程交互方式
在三次作业中,我其实没有实现真正意义上的调度器。最多只是实现了按照出发的楼层、楼座,将电梯请求分配到了具体的队列中,而各种电梯的接送行为并没有一个统一的类去组织。
在第五次作业中,由于不涉及电梯线程之间的交互和影响,因此我没有采用调度器的方式。而在第六、七次作业中,尽管引入了多部电梯共享楼座和横向电梯的概念,但是了解往年博客后发现使用调度器和电梯自由竞争相比性能差异并不大,并且实现电梯自由竞争从难度上明显小于实现调度器,因此我在后续两次作业中仍然采用电梯自由竞争的策略。
而对于线程之间的交互,我在三次作业中均采用了生产者-消费者模式,电梯间的交互通过读写共享对象(此处为请求队列)实现。输入线程作为生产者将请求放入请求队列(托盘)中,而电梯线程作为唯一消费者每次从托盘中取走相应的请求,进行开关门上下客的处理。而在第七次作业中,由于引入了换乘,一部分的电梯请求也作为生产者,将换乘后的电梯请求添加到相应请求队列中。
3 架构模式分析
3.1 第五次作业
类的调用关系图如下。第五次作业中设置了三个线程类InputThread
,Dispatcher
,Elevator
实现电梯请求的分配和处理,它们均在主函数类中创建并运行。而共享队列PersonQueue
也在主函数中被创建,并且提供线程之间交互的渠道。
此外我还专门设置了线程安全的输出类Wrapper
负责输出,防止输出序列不递增的现象发生。
而在电梯接送策略方面,我使用了生活中绝大多数电梯使用的look
策略。但是由于我在这次作业中将每一座的所有请求放入同一个队列中,因此每次寻找最高楼层和最低楼层的请求时需要同步遍历整个队列,而且有时还要从队列中取出特定方向特定出发楼层的请求,需要传入很多参数,导致代码块设置十分混乱。因此在第六次作业中我对请求队列设置的逻辑进行了重构,对每个请求进行了精细化的分配,解决代码风格杂乱、容易出错的问题(详见下一次作业的分析)。
UML协作图如下。其实这次作业中的协作图是三次作业中最冗杂的,我设置了三级流水的线程InputThread
,Dispatcher
,Elevator
分别负责电梯请求的输入、分派、处理。但是后来发现其实电梯的输入和分派完全可以放置到同一个类中进行处理,而标志输入是否结束的isEnd
信号其实可以全局共用同一个。因此第六次作业我只设置了InputThread
和Elevator
两个线程类,更清晰简洁地处理了电梯分配的过程。
第五次我的代码架构真的很shi,出了不少bug,因此我在第六次作业时下定决心对我写的shi山进行大换shi。
3.2 第六次作业
第六次作业我将线程类简化成了InputThread
和Elevator
,并且使用了单例模式创建了NewMainBuilding
类,集中管理整个新主楼的电梯等待队列。
初始电梯线程在NewMainBuilding
类初始化时创建,而新加入的电梯线程则在输入线程InputThread
创建。此外将输出线程安全类改名成了Printer
。
吸取上一次作业的教训,我为特定楼座、特定楼层、特定方向的请求单独设置队列,将其精细化处理。具体来说,我在单例类中设置了轨道类VerticalTrack/HorizonTrack
数组,大小分别为5/10
,表示特定楼座/楼层的轨道;而每个轨道类内又设置了请求队列PersonQueue
的数组,大小分别为10/5
,表示给定楼座/楼层下从某一楼层/楼座 出发的请求队列;而每个PersonQueue
又由两个ArrayList<PersonRequest>
的列表组成,分别储存从同一地点出发,但是方向不同的两种乘梯请求。
这样一来,只需在NewMainBuilding -> Track -> PersonQueue
逐级实现getRequest
和addRequest
方法。则可以从InputThread
调用NewMainBuilding
中addRequest
方法实现请求的精细化分配,而且在获得请求时,这次作业将从队列中查找请求的过程转化为了通过出发信息检索队列的过程 ,除去了许多代码冗长的方法,提升了面向对象编程的风格特性。
关于横向电梯,我采用了类似环形的look
方法,定义了换向逻辑如下:
电梯换向条件(大前提,且关系):1.电梯是空的(注意是下客后为空)。2.上电梯的乘客中没有捎带者。3.当前轨道有请求。
此外再满足接下来两者中的一者(或关系),则电梯更换换方向:1.当前楼层有逆向的请求。2.原方向上没有请求。
至于时序图,由于删除了Dispatcher
类,因此线程的时间关系较上一次作业更为清晰。
这次作业中在只在单例类NewMainBuilidng
中设置了输入结束标记isEnd
,当输入完毕后在将其设置为true
,并且唤醒所有的电梯线程。电梯线程检测到当且的轨道为空、电梯内乘客为空并且输入结束标记isEnd
为真后,结束run()
方法。
3.3 第七次作业
第七次作业主要有三个任务。
第一个是自定义电梯参数,这个只需在电梯类内增加相应字段,简单替换方法中的参数即可。
第二个是实现电梯的换乘,本次作业中我采用了链表的方法,将官方包的类PersonRequest
封装至自定义类MyRequest
中,并且在其中设置字段nextRequest
链接到下一乘梯请求。在输入乘梯请求时,在InputThread
中根据标准换乘策略,将请求拆分成1~3个同座或同楼层的可达请求,并且将其通过字段nextRequest
链接起来。
而在PersonQueue
中,新增了一个名为waitRequests
的ArrayList
用于存放前序请求尚未执行完的电梯请求。在前序请求完成后,只需将后序请求从其对应PersonQueue
中的waitRequests
移动到同一个类中待处理的请求列表即可实现换乘。
第三个是开门权限位switchInfo
的设置与使用,其实这里只需要对水平轨道类中getRequest
方法进行修改。在获取乘梯请求时将switchInfo
信息传入,在遍历数组时将无法开门的楼座通过continue
语句跳过即可。
此外这里还有一个问题,就是对于不同的switchInfo
,相同起点相同方向的乘梯请求可能会有不一样的上梯行为。如对于在AB座又开门权限的电梯,请求A->B
能上电梯但是请求A->C
不能上,但是按照之前的分配逻辑这两个请求会放在同一个ArrayList
中,因此直接在队列中使用getOneRequest
的模式会出锅。
为解决这个问题,我首先想到了在队列中写遍历方法,但感觉这样代码会很杂乱,可能还会涉及到垂直电梯的更改,pass!最后我再次采用精细化分配的思想解决了这个问题:我在横向轨道类HorizonTrack
中将PersonQueue[5]
变为二维数组PersonQueue[5][5]
,前一个下标代表出发楼座,后一个下标代表抵达楼座,将相同起点、相同方向、但不同终点的请求放入不同的队列中,这样以来只需对HorizonTrack
中的方法调用数组的地方进行一位至二维的转化即可。
时序图和上次比较,电梯线程自身也成为了生产者。当一个乘梯请求结束并且下一个乘梯请求存在时,需要激活后续请求并且唤醒轨道上的所有电梯。
4 程序bug分析与测试策略
4.1 自己的bug及解决方法
bug可真多啊~
4.1.1 第五次作业
这次作业没做课下测试,中测过了之后就想着清明节玩去了。回来一看强测WA声一片,再仔细看看自己代(shi)码(shan),一度怀疑前几天自己的眼睛是不是瞎了。“是可AC,熟不可AC”,找到bug之后自己都给气笑了🙄。课上bug如下:
-
判断电梯容量时
<
和<=
用混了,电梯能进7个人😄。 -
输出线程安全问题。
-
在端点处电梯为空时,需要某一个方向的所有乘客上电梯。而在循环条件中我调用队列的
isEmpty()
方法进行判断电梯是否为空,由于它的值会动态变化,在第一个乘客上电梯后它将变为false
,将会跳出循环上乘客的循环。因此我的电梯每次在端点接人时一次性只能上一个人,导致了部分点TLE
。
4.1.2 第六次作业
第六次作业我痛定思痛,下决心好好课下测试,于是自己先手捏了一些基础样例,后来又找同学要了评测机和强的测试数据跑。课下把每一个bug都看得清清楚楚,如下:
-
环形
look
策略的bug,一开始空电梯上客条件比较苛刻,有时会产生电梯在新主楼ABCDE座转圈圈,但就是不接人的问题。后来只要空电梯所停楼座有请求,我就让它开门上客~就解决了这个问题。 -
CPU时间爆了。后来发现这是多电梯访问同一轨道对象才有的问题,因为我一开始无脑
notifyAll()
,导致在run()
方法的每次while循环中,电梯在调用wait()
方法之前都会在其它函数中调用notifyAll()
方法。这样一来,就会使得两个电梯线程在任何时刻无法同时处于等待状态,一直在相互唤醒,占用大量的CPU资源。实际上,只要在添加新请求和设置输入结束标志时的notifyAll()
是必要的,将多余的notifyAll()
去除即可解决这个问题。
4.1.3 第七次作业
第七次作业引入了换乘,我课下借助测试也找到了一些bug:
-
新电梯忘记加switchInfo到轨道里。
-
电梯线程终止条件!!!当输入线程终止,但尚未换乘到最后一步的电梯时。最后一步对应的电梯可能提前终止。因此在
PersonQueue
类中设置待激活的请求列表waitQueue
是有必要的,在判断电梯线程是否结束时,它提供了将来是否会有人换乘到这座电梯上的信息。 -
设置上乘客条件时,不仅要求出发楼座为当前楼座,还需要目的楼座在权限之内。解决方法:开二维数组,判断时传入
switchInfo
的信息(前面详细讲了)。 -
电梯开门时间可能在0.2~0.6s之间变化,电梯开门时间可能出现真包含的关系,而第六次作业电梯的换向逻辑无法处理这一点。所以电梯1和电梯11同时下到A1开门接人后,电梯1抢先接完了所有上行乘客,则电梯11会因为没有接到乘客而没有换向,最终进入阴曹地府。解决方法:懒癌犯了,用了条
if
语句在1楼和10楼打了换向补丁就没管了🤣。
4.2 测试策略
4.2.1 本地测试策略
-
打开任务管理器来检测轮询问题,如果正常,CPU占用率一般不会超过1%。
-
将上一次强测数据喂进来跑,看看自己改造时有没有产生新的bug~
-
刚写完的代码可以手捏数据跑跑看看有没有问题,因为感觉中测真的挺弱的TAT。
-
本地用投喂包跑时可以直接写个bash脚本run.sh(代码如下),每次测试时只要在git bash中输入
bash run.sh
就可以开启评测。而且在脚本中还能指定生成jar包位置,每次更新jar包代码时也不需要手动移动jar包。
4.2.2 互测hack策略
-
第五次作业看2006水群,发现大家在讨论输出线程不安全的bug。回到互测屋子一看,大家好像还真有这个bug,而且我好像也有这个bug🤣。但是看着互测屋子风平浪静安静如鸡,我最后也没提刀子。
-
第六第七次作业由于做了很多课下测试,所以课上直接用课下的测试数据刀人。两次互测交了三次,还真刀中了一次~
4.2.3 与第一单元测试策略的差异
-
感觉测试的效率降低了,对于稍微大一点的数据,每次得花个几十秒才能跑出结果。
-
debug难度大大增加。
-
没法使用IDEA的调试功能查看代码的问题,只能老老实实地
println
,而且每次改完还要重新跑个几十秒。 -
定位一个bug真的要很久很久。比如第七次作业的数组越界bug,错误数据平均需要跑十几次才能复现,每次缩小bug范围后还需要添加
println
打包后接着跑。
-
-
第二单元互测中,感觉手动构造数据难度增大了,大家更多的是通过随机生成的强数据去直接hack别人,而不是针对代码中的bug构造数据去hack别人。
5 心得体会
5.1 精细化管理/层次化管理
如果跟别人说我最后代码里有5*10*3 + 10*(5*5)*3 = 900
个ArrayList
来对乘梯请求进行分类,别人大概率会嫌这个方法麻烦。但是精细化管理真的救了我的x命,不仅帮助我避免了在ArrayList
中遍历的麻烦,而且在请求队列类PersonQueue
中只需要getOneRequest()
一个方法,不需要任何额外参数,就从第五次作业获取请求一直到第七次作业。 此外还由于NewMainBuilding -> Track -> PersonQueue -> ArrayList
的层次化管理和层级检索机制的建立,使得我在这几个类定义的方法中,几乎没有超过10行的,使代码保持了很好的面向对象特性。
5.2 我是懒汉
我单例模式学得还不是很深入,但是在第七次作业里对换乘请求链接时,发现单例模式下getInstance()
真的好用!有了单例模式,再也不用在构造方法中传入对象了!突然想起我在第一单元表达式化简的作业中,存放自定义函数的类也可以使用单例模式~fabulous~ 懒汉模式果然适合我这个懒汉。
5.3 一定一定一定要做好课下测试
第五次作业没好好做课下测试,交上去之后就在清明假期自信开浪了,最后结果出来发现强测WA得姹紫嫣红www。
后来第六第七次作业课下我都预留好提前量,每次预留半天的时间用于课下测试,最后两次作业都在同学的帮助下找到了不少的bug,最终也都平安度过了强测和互测,也拿到了不错的性能分。在此感谢htr同学和cjj同学为我提供架构上的启发和课下测试的帮助,要没有他们我现在说不定已经在补给站的边缘徘徊了~
5.4 结语
前几天回去看上学期的java课些的小游戏代码,才渐渐明白当时写的几个线程是怎么运作的,才知道线程每个run()
方法的while循环里面加的Thread.sleep()
用意何在。虽然第五次作业强测的当头一棒让我自闭了好几天,但是这一个月电梯抡下来感受还是可以用"故余虽愚,卒获有所闻"来形容的。
希望五月再接再厉,别再翻车了TAT。