Java多线程程序设计总结——电梯

第一章 基本架构

第一次作业架构

二话不说,先上架构。

第一次作业架构

总体设计

总的来看,我的作业架构主要包括输入类(InputHandler),总调度器(Simulator),电梯类(Elevator),乘客类(Passenger),输出类(OutputHandler)。输入类不断将请求打包为乘客类后放入总调度器的等待队列中,总调度器从自己的等待队列中取出请求分配到合适的电梯等待队列中。电梯在合适的楼层从自己的等待队列中取出请求,放入自己的运行队列中。当电梯发出一定的行为时,电梯调用输出类输出。在我的架构中,输入类,调度器,电梯类均为线程,输出类不是线程。

在完成第一次作业我就本着第一次就把事情做对的态度,充分考虑了后续作业的扩展性。这主要体现在以下方面。

  • (1)我考虑了横向电梯,甚至是斜向电梯。我把电梯的运动抽象为二位运动,定义了Point类表示电梯当前位置,包括楼座域楼层。

  • (2)我考虑了电梯存在不可到达的楼层。为此,我定义了ElevatorPoint类,该类在Point类的基础上增加了isReachable字段,表示电梯是否可以到达该位置。

  • (3)我考虑了乘客的换乘情况,在乘客类中定义了路径数组,存放换乘路径;在调度器中定义了PassengerComeFromElevator()方法,专门处理到达中转站的乘客请求。

  • (4)我考虑了动态增加电梯的情况,在调度器中定义了ElevatorComeFromInput()方法,并且开了电梯线程池ElevatorPool维护各电梯。

输入设计

输入类是线程,实时接受外部输入,并调用乘客工厂的方法将请求包装为乘客对象,放到调度器的总等待队列中。

调度器设计

调度器采用单例模式,负责将总等待队列中的乘客分配到合适电梯的等待队列中,本身是一个线程。之所为调度器单开线程,是因为输入线程需要实时接受输入,如果在输入线程中进行乘客的分配难以满足输入的实时性需求。

调度器中包含两个队列,一是乘客的总等待队列,二是电梯队列。电梯队列单独开设一类ElevatorPool,采用单例模式,便于动态增加电梯。

第一次作业中调度器线程结束的条件是输入线程停止并且调度器等待队列为空。

电梯类设计

电梯类是这三次作业中代码量最大的类。电梯类除get,set方法外,主要包含了以下方法:

  • void run():线程运行方法,每当电梯从一个状态转移到另一个状态时,它会调用电梯状态status的act方法。这里我采用了状态模式与策略模式,电梯具有状态属性Status,它是一个接口,包含ArrivedPoint,Opening,ReadyToGo三种状态类,每一种状态都有一个act方法,这个方法根据所在状态类调用电梯策略类Strategy中不同的方法,包括arrivedAtPointAct(),openingAct(),readyToGoAct()。电梯的策列属性Strategy是接口其中定义了上述的三个方法。Strategy接口目前只包含一个类Look,代表电梯调度的Look策略。Look类中具体实现了上述三个方法。这样的设计模式有以下优点:

    • 利用Java的多态特性,电梯在不同状态下"自动"调用不同act()方法,避免大量if-else。
    • 电梯可以采用不同策略,例如当电梯需要采用ALS策略时,只需要定义Als类,令其继承Strategy接口,并且让电梯的Strategy属性为Als对象即可。
  • boolean whetherToOpen():该方法用于判定电梯在达到某一层后是否开门。具体判定逻辑为:

    • 若电梯内部有人到站,开门。
    • 若电梯内部无人到站,但是电梯外部有人想乘坐该部电梯,开门。
    • 否则不开门。
  • int findExtremePointInOneQueue(int queueType, int highestOrLowest):该方法根据queueType在指定的队列中(queueType = 0 - 等待队列,queueType = 1 - 内部队列)寻找乘客目的地或者出发地(等待队列中的乘客看出发地,内部队列中的乘客看目的地)的最高点或最低点(highestOrLowest = 0 - 最低点,highestOrLowest = 1,最高点)。

  • int findExtremePoint(int highestOrLowest):该方法调用两次findExtremePointInOneQueue()方法,分别在等待队列与内部队列中寻找最高点或最低点(highestOrLowest = 0 - 最低点,highestOrLowest = 1,最高点)。取二者的最大值为最高点或者最小值为最低点。

  • void stepForward():该方法让电梯前往电梯路径(route)中下一个可以到达的地点(Point),同时负责等待与输出相应到达信息。

  • void stepBack():该方法与stepForward()类似,区别是让电梯前往电梯路径(route)中上一个可以到达的地点(Point)。

  • void doorOpen():顾名思义,该方法负责电梯开门,包括输出开门信息,等待0.2s,电梯内部队列乘客出门。

  • void doorClose():电梯关门,包括输出开门信息,等待0.2s。我的设计中有个亮点,就是可以在电梯开门与关门的0.2s中实现实时进人。这要依靠下面的void halt(double time)方法。

  • void halt(double time):该方法负责等待time的事件,在此期间,电梯可以进人。为实现这一点,电梯配备了Timer对象,每当调用void halt(double time)方法时,Timer对象单开一个线程,负责计时,此时电梯则处于等待(wait)状态,当调度器将请求分配给了电梯或者调度器发出了最终结束的指令时,电梯线程被唤醒,如果有乘客则进行进人操作。此时若计时器时间未截止则电梯继续等待。当计时器时间截止后,计时器用synchronized获得电梯的等待队列,用notifyAll()方法唤醒电梯线程,此时电梯结束等待。

  • void halt():该方法重载了halt方法,负责在乘客并未到达,电梯暂时没有乘客可以接送时让电梯休眠,避免轮询。该方法让电梯在等待队列上执行wait操作,这样一旦调度器将乘客加入该电梯的等待队列或者调度器下达了最终停止命令,电梯就会被唤醒。

  • updateDirection():该方法根据当前电梯位置与电梯的目的地更新电梯运行方向。

  • pointOneTheWay(Point point):该方法判定某一位置是否在电梯的运行方向上。

  • passengerOut():遍历电梯内部队列,将到达目的地的乘客赶出电梯。

  • passengerInQueue():该方法为调度器调用,负责将乘客加入电梯的等待队列。

  • end():该方法为策略类调用,负责将电梯线程状态置为停止,这样电梯的run()方法执行下一个状态的act()方法前会判定电梯线程状态为停止,从而终止run()方法,电梯线程结束。

第一次作业中电梯的终止条件为:输入线程结束 && 调度器的等待队列为空 && 自己的等待队列为空 && 自己的内部队列为空。策略类在电梯刚到站点和电梯准备出发时均会检查电梯是否可以停止,如果是则调用电梯的end()方法,将电梯线程状态置为停止。

以下是第一次作业的时序图:

第一次作业时序图

从中可以看到,中间的并行块par中有三个并行的Loop块,它们分别代表InputHandler不断将乘客放入Simulator的等待队列中,Simulator不断将等待队列中的乘客分配到相应Elevator的等待队列中,Elevator运行时不断将到达目的地的乘客从运行队列中赶走,并且把自身等待队列中的乘客取出放入运行队列中这三个并行的过程。

此外,Simulator的停止条件为等待队列comingPassenger为空且输入线程结束,电梯的停止条件为输入线程结束 && 调度器的等待队列为空 && 自己的等待队列为空 && 自己的内部队列为空,这一条件可以简化为Simulator结束 && 自己的等待队列为空 && 自己的内部队列为空。这样,输入线程一定最先结束,调度器随后结束,各电梯也陆续结束。

为突出重点,我省略了OutputHandler的相关交互,相信聪明的读者可以自行理解。

策略设计

我采用Look策略,该策略的原始版本为电梯从最底层移动到最顶层,然后从最顶层移动到最底层。这一策略优化后的行为是:最高移动到max(等待队列中乘客出发楼层最高点,内部队列乘客到达楼层最高点),最低移动到min(等待队列中乘客出发楼层最低点,内部队列乘客达到楼层最低点),这样就需要频繁地地遍历等待队列域内部队列来更新目的地,因此被称为"Look"。

在我的Look策略中,电梯的初始状态为ArrivedAtPOint,在arrivedAtPointAct()方法中,首先判定是否结束(终止条件上文已经提到),如果到达则将电梯的isEnd标记为true,arrivedAtPointAct()方法返回,否则更新目的地(destination)与方向(direction),接着arrivedAtPointAct()方法调用电梯的wetherToOpen()方法判定是否开门,如果需要开门则将状态设置为OpenningStatus,然后调用电梯的doorOpen()方法。

在openingAct()方法中,首先调用电梯doorOpen()方法,将到达目的地乘客送走,等待0.2s,同时进人,开门结束根据电梯当前的乘客信息更新电梯目的地与方向,紧接着调用doorClose()方法,等待0.2s,同时进人。最后将电梯的状态设置为ReadToGo。

在readyToGoAct()方法中,首先判定电梯是否可以结束。如果可以结束则设置电梯的isEnd为true,方法返回,如果没有结束则更新电梯的目的地与方向,然后根据电梯的方向调用电梯的stepForward()或stepBack()方法。

第一次作业很遗憾我没有为Look策略进行更高层次的优化。我一开始的做法是只要电梯等待队列中的乘客所在楼层与电梯当前楼层相同则让乘客进入电梯,作业结束后我才发现可以在乘客目的楼层在电梯运动方向上时才让乘客进入电梯可以大大提升性能(夸张的数据点可以提高10s)。为此只需要在等待队列乘客进入电梯的判定条件中加上elevator.pointOnTheWay(passenger.getToPoint)。

输出设计

输出单开一个类,但是输出不是线程。输出类采用单例模式,其中用synchronized封装in, out, open ,close, arrive五个方法,实现输出线程安全。

第二次作业架构

第二次作业增加了环形横向电梯,动态增加电梯。其中横向电梯域动态增加电梯我已经预料到了,但是环形电梯是我没有考虑到的。

环形电梯的难点在于Look策略变了(当然也可以不要性能直接当一般电梯处理)。由于环形电梯总共有5个楼座可以到达,我的做法是对于环形电梯,将电梯当前位置前两个位置 - 电梯当前位置 - 电梯当前位置后两个位置视为电梯运行的路径,这样环形电梯就可以转化为一般电梯一样用Look策略规划路径。

以下是第二次作业架构图。

第二次作业架构

由于第二次作业需要在规划路径,前进后退时区分环形电梯与非环形电梯,我在电梯类中引入属性isCircular作为电梯是普通电梯还是环形电梯的标志。在findExtremePointInOneQueue(),findExtremePoint(),pointOneTheWay(),stepForward(),stepBack()方法中用if-else为普通电梯与环形电梯分别进行了实现。这样的坏处是电梯类代码膨胀,达到了400多行。

第二次作业允许一栋楼有多个电梯,这便产生了两种分配策略,一是自由竞争,即不再为每个电梯单独设置等待队列等着调度器去分配,二是在调度器中设置一个总的等待队列,电梯去里面"抢"人;二是调度器分配策略,即我再第一次作业中采用的策略。

强测结果表明自由竞争策略比调度器分配策略效率高5分左右。

第二次作业由于不涉及换乘,因此线程之间的协作关系与第一次作业类似,时序图就不上了。

第三次作业架构

第三次作业增加了有不可达楼层的环形电梯与乘客换乘的要求。

在第二次作业基础上,我在电梯池ElevatorPool中增加了为乘客规划路径的方法,该方法接受乘客作为参数,利用电梯信息为乘客规划一条路径,并将它填入乘客的路径数组中。为简单不易出错,我采用静态规划,即只在乘客到达之初规划一次路径,乘客到达中转站点时不重新规划路径。

我填充了调度器中passengerComeFromElevator()方法,该方法位于调度器中,仅会由电梯调用。该方法会判定乘客是否到达目的地,如果没有到达则更新乘客当前位置与下一个目的地,并将其放入调度器的等待队列中。

考虑到第二次作业电梯类因为if-else而膨胀到的400行代码是在不优雅,我采用了继承解决问题。对于电梯的共性方法,如get,set,doorOpen(),doorClose(),halt()等,我在抽象电梯类Elevator中实现它们,对于环形电梯与普通电梯的特性方法,如stepForward(),stepBack(),我分别在环形电梯类CircularElevator与纵向电梯类VerticalElevator中实现它们。最终我的抽象电梯类代码为290行,环形电梯类代码为280行,纵向电梯类代码为220行,虽然总数增多了,但单个类与单个方法的代码减少了,并且减少了if-else分支。

以下是第三次作业的架构。

第三次作业架构

图中电梯类看上去仍然很大,但这只是因为它的方法定义多,实际上电梯类有很多抽象方法是分别在环形电梯类与纵向电梯类里面分别实现的。

第三次作业涉及换乘,乘客不仅可以来自输入还可以来自电梯,因此电梯线程的终止条件不再是:输入线程结束 && 调度器的等待队列为空 && 自己的等待队列为空 && 自己的内部队列为空。因为即使这个条件满足,其他电梯中仍然可能存在换乘乘客需要使用当前电梯。

为简化实现,我规定所有电梯必须统一停止(前两次作业中电梯工作独立,因此可以轻松地做到电梯自己停止;第三次作业中电梯间建立了协作关系,因此某一个电梯终止的判定条件较为复杂且开销大),并且终止命令由调度器统一发出。为实现这一点,我参考了第四次课上实验第二个练习的做法,在调度器中定义了counter属性,该属性初始值为0,在乘客初次到达时加1,在乘客最终到达目的地时减1,在乘客到达中转站时不变。这样,输入线程结束 && counter == 0 便可以作为电梯与调度器结束的必要充分条件。

由于需要具备两个条件,因此满足结束条件有两种情况:

  • 输入线程结束,过了一段时间counter才变为0,这种情况较为常见。
  • counter先变为0,过了一段时间输入线程才结束,这种情况较为罕见,只有在输入最后增加电梯时才可能出现。

为实现第一种情况,我再Simulator的counterDecrease()方法中判定counter == 0 && 输入结束,若为真则调用finalEnd()方法;为实现第二种情况,我在Simulator类中的inputEnd()方法中判定counter是否为0,如果为0则调用finalEnd()方法。

finalEnd()方法调用了ElevatorPool的endAllElevator()方法,同时将Simulator的end标记为true,并且在synchronized(comingPassengers)中用comingPassengers.notifyAll()来唤醒可能正在等待的Simulator线程,从而达到结束Simulator线程的目的。

以下是第三次作业的协作图:

第三次作业时序图

可以看到,第三次作业电梯送出乘客后调用了Simulator的passengerComeFromElevator()方法,该方法根据乘客是否到达从而决定减小counter还是将乘客加入调度器等待队列中。同时,Simulator与电梯的停止条件也变为了各自的end属性为true。此外,Simulator还会调用finalEnd()方法结束自己与电梯线程。这样,输入线程仍然最先结束,调度器与所有电梯几乎同时结束。

第二章 同步块的设置

Java张主要存在两种类型的锁,一是synchronized同步块,它是Jvm级别的锁机制,可以将任一对象指定为锁,具有隐式释放锁的方便性二是lock,它是Java语言级别的锁,需要显示释放锁,比synchronized更灵活。

三次作业中我均采用synchronized()同步块,没有采用Java提供的lock锁。以最为复杂的第三次作业为例,synchronized主要用在以下对象上:

  • Simulator的等待队列comingPassengers,它会被InputHandler线程(放入Passenger)与Simulator线程(取出Passenger)操作。

  • Simulator的ElevatorPool对象,它会被Simulator线程(为乘客安排路径,将乘客分配到合适电梯的等待队列)与InputHandler线程(加入电梯)操作。

  • Elevator的等待队列waitingPasengers对象,它会被Simulator线程(加入乘客)与电梯线程(取出乘客)操作。

  • OutputHandler对象,它会被所哟电梯线程操作。

凡是使用上述对象的地方都应该处于同步代码块中!

第三章 程序bug

第一次强测与公测我均没有被测出bug。

第二次公测我没有被测出bug,但强测寄了一个点,错误是RTLE。具体原因是纵向电梯面对同时到达的一上一下两个乘客会出现上下横跳无法停止的情况,考虑以下输入:

[1.0]1-FROM-A-2-TO-A-1
[1.0]2-FROM-A-8-TO-A-9

如果1,2两个乘客被同时分配到电梯等待队列中,并且电梯此时位于2-8楼之间,则会出现上述情况,以电梯内部为空,位于6楼,处于关门状态并且direction = 1(向上)为例,具体分析如下。

  • 电梯看到8楼有人等待,而自身direction为1,于是把目标设置为8楼。
  • 电梯到达8楼,判定电梯没有结束后更新方向direction为0(因为已经到达目的地),接着更新目的地destination,由于direction为0且2楼有乘客等待,因此destination被设置为距离当前位置最远点且有人等待的楼层2,然后电梯更新direction为-1(向下)。
  • 电梯判定是否开门,尽管外面有人等待,此时电梯direction为-1,与8楼乘客向上的需求不符,因此电梯不接受8楼乘客,转而向下。
  • 电梯到达2楼,出现同样的问题。
  • 电梯在2楼与8楼之间横跳,2个乘客都上不了电梯。
  • 寄!

出现这个问题的原因是电梯更新目的地发生在了乘客进入电梯前,原本的目的地设置在8楼就是为了让8楼乘客进电梯,现在更新了destination与方向后8楼的乘客却上不了电梯了。为解决这一问题,我规定电梯在到达某一楼层是只更新direction,将要出发前才重新找destination并且更新direction。

第三册作业我在公测中被测出了1个bug,在强测中大寄,主要问题是两个bug。

首先,我再pointOnTheWay()方法中遍历电梯的等待队列waitingPassengers时没有加synchronized,这导致了某一线程用iterator遍历waitingPassengers同时其他线程增删waitingPassengers的元素时产生了ConcurrentModification异常。

其次,我为环形电梯定义的pointOnTheWay()方法导致环形电梯出现了左右横跳乘客上不了电梯的情况。考虑以下输入:

[1.0]1-FROM-A-1-TO-A-1
[1.0]2-FROM-B-1-TO-E-1
[1.0]3-FROM-C-1-TO-A-1
[1.0]4-FROM-D-1-TO-B-1
[1.0]5-FROM-E-1-TO-C-1

假设5个乘客同时被分配到了6号电梯,此时6号电梯内部为空,位于A座1楼,方向direction为1(A-B-C-D-E-A循环),destination为C座1楼,正准备出发,则会发生以下事件。

  • 电梯到达A座1楼出发到达B座1楼,更新direction为1(因为目的地C在B后面),发现等待队列中有2号乘客,但是2号乘客的最短路径为B-A-E,与电梯运动方向相反,于是电梯不开门,电梯将要出发前,将目的地更新为D(因为电梯方向为1且D在B后面两个位置)。
  • 电梯到达C座1楼,发生了同样的事。
  • 电梯最终的运动形式为A-B-C-D-E-A-B-C-D-……,乘客无法上电梯。
  • 寄!

我的解决方案是简单粗暴地改写环形电梯的pointOnTheWay(Point point)方法,只要电梯的路径中有point,则返回true。

第四章 互测方法

我在互测中采用以下办法:

  • 将大量乘客请求在同一时间投入同一个电梯。例如在70.0s到达70个乘客,请求全部在A座。
  • 意想不到的数据,例如第二次第三次作业中在输入的最后放电梯。

总体上,电梯单元的互测主要依靠随机轰炸。

第五章 可扩展性分析

我预设了以下客户的恶心需求:

  • 打印乘客路径信息:我保留了乘客的路径数组,当乘客到达目的地需要打印路径信息时,只需要把乘客路径数组中的信息打印出来即可
  • "斜向"电梯,我的电梯用Point类表示位置,可以完全支持二维运动的电梯路径表示。我的架构仅仅需要修改乘客的路径规划策略,采用dijkstra算法找出二维图的最短路径作为乘客的换乘路径

第六章 总结感悟

总的来说,电梯单元我的收获有:

  • 学会了Java基本的wait,notifyAll,sleep,synchronized使用。
  • 了解了(没有实践)volatile关键字原理,它仅仅保证可见性,不保证原子性,可以代替只有一条涉及一个变量语句的同步块。在我的架构中,电梯的end变量可以用volatile修饰,从而修改或者读取end变量时可以不用synchronized。
  • 用C++编写了评测机,熟悉了C++语法。
  • 第一次使用了抽象类。
  • 学会了生产者消费者模式,状态模式,策略模式,单例模式,抽象工厂模式,主(调度器)从(电梯)架构。
  • 学会了利用IDEA将Java代码转化为jar包,这样运行起来比java MainClass更方便;同时我学会了利用批处理程序提高测试效率。
  • 初步学会了用starUML画时序图。
posted @ 2022-04-29 18:35  Combinatorics  阅读(127)  评论(0编辑  收藏  举报