北航面向对象课程第二单元总结

OO 面向对象第二单元总结

单元作业介绍

本单元通过编写一个程序模拟电梯的运行,一共五栋大楼,每栋大楼高度为10层,乘客的请求中会直接包含目的地,需要通过对电梯的控制,使所有乘客请求都被完成,且电梯运行时间要尽可能少。

第一次作业只有纵向电梯,且每栋楼只有1个电梯。

第二次作业新增横向电梯,并且电梯数量可以随时间变化而增加,模拟新的需求到来。

第三次作业允许自定义电梯的容量及速度等参数,横向电梯到达楼座有限制,且会有乘客必须通过换乘才能到达目的地。

同步块与锁

锁的选择

在三次作业中,我都使用了synchronize这个同步块,来代替锁的功能。且一般都是对方法进行同步,这样子会比较无脑,也会导致程序架构运行较慢,相比于使用lock,我的思路也不是特别清晰,并且在过程中有很多不必要的notifyAll(),在程序运行时容易使CPU空转。

但最后还是一直沿用了同步块的设定,一方面是因为逐步厘清了逻辑,另一方面也是因为迭代开发,不想改变原有的东西。

同步块之间关系

同步块主要应用于对该类对象的一个关键属性进行修改或查看的方法。最主要的是对requestQueue这个类的方法进行同步,这个类的对象用来表示乘客请求等待队列,另有一个标志布尔型变量isEnd表示是否已经不会有新的请求添加进来。

由于是用ArrayList<PersonRequest>存放等待队列中的乘客请求,而这个数据结构本身是线程不安全的,所以必须要在使用过程中对其加锁,在这里就是对操作这个属性的方法变成同步块。包括有以下函数:

 public synchronized void addPersonRequest(PersonRequest personRequest) {}
 public synchronized void setPersonRequests(ArrayList<PersonRequest> personRequests) {}
 public synchronized PersonRequest getOneRequest() {}
 public synchronized PersonRequest findOneRequest() {}
 ​
 //对isEnd的修改也进行同步
 public synchronized void setEnd(boolean isEnd) {}
 public synchronized boolean isEnd() {}
 public synchronized boolean isEmpty() {}

这样就使得请求队列类本身是一个线程安全的类。而这个类在之后的调度及策略中会多次使用,故首先对它封装好是一个必然的选择。

调度器设计

第一次作业

第一次作业只用了一层调度器,因为五栋大楼,很自然的五个共享队列。根据大楼编号的不同,分到不同的共享队列中,所以结构简单。

 public class Schedule implements Runnable {
     private final RequestQueue waitQueue;
     private final ArrayList<RequestQueue> processingQueues;
     //waitQueue与输入线程相连,表示未调度但已检测到输入的等待队列
     //processingQueues是五个共享队列
     //Schedule的run方法既是将waitQueue中取到的乘客请求归类到这五栋大楼中
     
 }
 ​
     if (waitQueue.isEmpty() && waitQueue.isEnd()) {
                 for (RequestQueue processingQueue : processingQueues) {
                     processingQueue.setEnd(true);
                 }
                 //OutputThread.println("Schedule End!!!");
                 return;
             }
     //通过对输入线程的截止和为空来判断调度器线程可以结束,以此对前后的线程分别进行响应与通知

第二次作业

第二次作业也只用了一层调度器,延续第一次作业的设计。五栋大楼,十层楼层,总共15个共享队列。但共享队列分为了横向和纵向队列。

 public class Schedule implements Runnable {
     private final RequestQueue waitQueue;
     private final ArrayList<RequestQueue> verticalQueues;
     private final ArrayList<RequestQueue> horizontalQueues;
     //属性意义同第一次作业
 }
 ​
     if (waitQueue.isEmpty() && waitQueue.isEnd()) {
                 for (RequestQueue processingQueue : verticalQueues) {
                     processingQueue.setEnd(true);
                 }
                 for (RequestQueue processingQueue : horizontalQueues) {
                     processingQueue.setEnd(true);
                 }
                 //OutputThread.println("Schedule End!!!");
                 return;
             }
     //输入结束后的调度器进程响应与第一次作业相同

第三次作业

这次作业因为增加了换乘,所以不能通过直接调度(指一次性)完成该乘客请求。但由于十五个队列已经在第二次作业中设定好,所以增加这个换乘功能的工作就给到了调度器,让调度器直接将需要换乘的乘客分为多个乘客请求,在第一个乘客请求完成时,才会向相应的队列中加入第二个请求,以此类推。此外,为了使得换乘中的请求完成后能够及时通知调度器开启下一个请求,调度器类使用了单例模式

 public class Schedule implements Runnable {
     private final RequestQueue waitQueue;
     private final ArrayList<RequestQueue> verticalQueues;
     private final ArrayList<RequestQueue> horizontalQueues;
     private static Schedule instance;
     public static final HashMap<Integer,ArrayList<Integer>> CONDITION = new HashMap<>();
     public static final HashMap<Integer, ArrayList<PersonRequest>> EXCHANGE_LIST = new HashMap<>();
     
     //使用单例模式,可以让乘客请求完成后,不需要声明Schedule对象,便可直接唤醒下一个乘客请求。
     //用CONDITION存放各楼层横向电梯的信息,用于判断需要换乘的乘客请求能否通过该层横向电梯。
     //EXCHANGE_LIST表示需要换乘的乘客拆分出来的多个请求,当前一个请求完成后,就把下一个请求分配出去。
     
     public synchronized void preReturn() {
         //等待换乘请求队列完成(为空)
         while (!EXCHANGE_LIST.isEmpty()) {
             try {
                 wait();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }
 }
 ​
      if (waitQueue.isEmpty() && waitQueue.isEnd()) {
                 //需要等待流水线完成
                 preReturn();
                 //需要等待所有换乘队列完成
                 for (RequestQueue processingQueue : verticalQueues) {
                     processingQueue.setEnd(true);
                 }
                 for (RequestQueue processingQueue : horizontalQueues) {
                     processingQueue.setEnd(true);
                 }
                 //OutputThread.println("Schedule End!!!");
                 break;
             }

线程协同的架构模式

第一次作业

第一次作业算是花的时间最久的吧。线程有输入线程调度器线程电梯线程共三类。乘客请求的调度非常清晰,具体是哪栋楼出发就分配到哪个共享队列。具体需要较多思考的其实是策略的安排。在开始时,我使用了ALS策略,但我实现的这个策略和基准策略有所差异,做了些自己想要优化的步骤,但在一些强测点中却并跑不过基准策略,导致RTLE。然后发现Look策略能够拥有十分优秀的性能,在debug阶段重写了策略,相比于自己实现的ALS,Look策略基本上每个点都快了十多秒,这也更加坚定了我后续策略实现是基于Look的想法。

第二次作业

 

第二次作业中,我将横向电梯与纵向电梯归为一类,都有Process对象,这个对象实际上是共享队列和策略的杂糅。因为横向电梯大致采用的是碰到有乘客请求便直接就近往这个方向一直(顺时针/逆时针)转,纵向电梯则是Look策略。当存在多部电梯时,无论是横向还是纵向都采用自由竞争策略。选择这个策略主要是考虑到让所有电梯都有活干,而不是给定让某部电梯去接某位乘客。我们的目的也不是省电什么的,只要能让电梯一次开关门的吞吐量大就会减少电梯运行时间。

第三次作业

第三次作业最主要的优化地方是加入了换乘。对电梯个性化的处理也只是增加了电梯类中的几个属性罢了。对于我所构造的电梯的策略而言,所有电梯都是平等的,所以这些属性的不同对我而言没有任何值得额外去思考的点。换乘可分为静态换乘动态换乘,所谓“静态”是指在调度器接收到这个乘客请求时,就会为这个请求规划出之后的路线且不随时间更改;所谓“动态”是指在这个乘客请求被处理过程中路线可能会发生变化,譬如随着横向电梯的增加而更新所有换乘步骤。“动态”换乘较为复杂,且当乘客在电梯中时再改变路线不一定能够用时最短,所以我采用“静态”换乘。

这次作业还有一个新增的限制是引入了横向电梯不能在某些楼座开门。这使得支持换乘的部分调度貌似又大有可为了。例如一部横向电梯可以在5层从A座到B座,另一部横向电梯可以在5层从B座到C座,那么就可以通过至少两次换乘直接在5楼从A座到C座而不需要坐纵向电梯。无疑这是一种优秀的策略,但在我看来类似这种策略和“动态”换乘的想法基本一致,通过寻找出发地与目的地间最短路线来为乘客请求进行规划。按道理来讲是会实现理论上的最短时间的,因为路线是最短的,但实际上考虑到换乘的等待时间,这种策略的时间期望实在是难以计算。也因此我实现的换乘调度中,乘客最多乘坐3次电梯(先纵向、再横向、最后纵向)通过减少换乘次数来达到一个容易期望的时间成本或许是一个更方便的调度策略

使用“静态”换乘来调度乘客请求,用HashMap<Integer, ArrayList<PersonRequest>>这个容器来存放乘客的换乘路线,Key中的Integer存储乘客id,可以更方便的进行检索。当乘客从一个电梯中出来时,我们需要及时告诉调度器这个乘客的一个乘客请求完成了,但很显然再向电梯传入一个调度器对象或实现一个调度器属性是很臃肿的,于是我想到了上机实现的单例模式。其实本来是想要在调度器中实现类方法,构造类属性的,但由于之前设计的等待队列属性是非静态的,而类方法不能操作非静态属性,我也就懒得改了哈哈,单例模式也足够了。当这个乘客请求并不需要换乘或已经完成了换乘的最后一步时,就什么也不做。但当这个乘客在下电梯时还没到达目的地时,就需要将调度器提前规划好的路线的下一步放到对应的共享队列中,进行换乘的下一步。这个实现在后续判断调度器线程结束时产生了一个bug。也就是当所有的换乘请求都被处理完时,才能向电梯线程发出结束的信号,而普通请求处理完成但换乘请求没有被处理完时,需要用wait()使线程挂起,防止轮询

时序图

主要流程如下:

  1. 主线程主要用于创建输入线程,调度器线程和电梯线程。

  2. 一共15个共享队列分别对应5楼座和10楼层,调度器将一个完整的乘客请求分解为至多三个分步乘客请求,并将第一个步骤的请求立马调度给对应的共享队列

  3. 每个乘客请求执行完后,再次调用调度器类的方法,并将可能的下一步乘客请求继续调度到对应的共享队列。

  4. 输入完毕后,输入线程结束。等待所有请求拆分完成,再等待所有拆分完成的请求都被完全调度,即可通知共享队列不再有新增乘客请求,调度线程结束

  5. 不再有新增乘客请求后,当电梯运载完剩下的乘客并让乘客OUT后,电梯线程结束

公测及互测相关bug

问题特征

第一次作业的bug体现在RTLE(电梯超时),原因在于我的策略是ALS,在具体的实现上跟基准策略不太一样,这就导致了不仅性能分低,而且还超时。由于策略的选择不当,导致强测有两个点被卡超时,再加上通过了的点性能也并不好,所以强测只得了82.7分。

第二次作业的bug出现在线程安全上,强测有幸没有被检测到,但互测时还是被眼尖的同学发现了,具体原因是对ArrayList<PersonRequest>进行的修改未同步,以及在if条件语句中使用wait(),这导致了轮询。强侧中自由竞争加Look策略能够很明显的提高性能,这次强测得了98.7分。

第三次作业在中测提交过程时又发生了轮询现象,基本每个测试点的CPU时间都很大。经过反复调试发现当所有输入处理完时,还有EXCHANGE_LIST这个表示换乘乘客请求集合的请求队列没有处理完,导致线程一直循环检测这个请求队列是否为空。在强测和互测中没有被发现bug。可能是删掉了不必要的notifyAll(),强测的性能较高,达到了99.4分。

修复方法

第一次作业的问题大部分应该归结于策略的问题,由于是一部电梯共享一个队列,所以对共享队列的线程安全是无法测出的,这也导致了后续实验也还是要面对这个bug。我重新编写了策略,使用Look算法代替ALS算法,这也使得我的电梯运行时间大大缩短。

第二次作业的bug主要是体现在对同步块与锁的理解有误,有些函数不需要synchronized(),有些同步块也不需要notifyAll()。在bug修复中,我直接对personRequests这个属性进行同步,这样子使得逻辑更加清晰,同时也把修改这个属性的代码进行封装。在使用isEnd()isEmpty()方法时,其实是不需要notifyAll()的,所以也把这些冗余的成分删掉。

第三次作业的轮询需要在换乘乘客请求队列未处理完时继续等待,因此设置了preReturn()这个函数,表示在这个线程结束之前,还应该让换乘乘客请求队列处理完。

 public synchronized void preReturn() {
         while (!EXCHANGE_LIST.isEmpty()) {
             try {
                 wait();
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }
         }
     }

hack策略

本单元的作业是多线程,具有随机性,所以在hack时也是秉持着随机的原则(^.^)

具体想法是将所有请求队列都放在同一层,用大数据量检测线程是否安全。同时,也可以在输入时间截止前1s内投入大量的乘客请求,以期能卡出RTLE的错误出来。当然,这跟具体的策略有关,而事实上没有什么最佳策略,不同策略会适用于不同投放场景。

当然这些都是我脑子里的一些想法,互测的时候我并没有hack其他人,希望为互测房的和平事业做出自己的贡献。(^@.^)

心得体会

线程安全

线程安全的保证是在设计多线程程序中特别需要考虑的一点。线程不安全主要体现在读写相关的操作不是原子操作,保证线程安全的方法可以是加锁同步块。在本单元作业中,我主要使用了同步块来保护共享对象。java本身提供了一些线程安全的数据结构,但像我们经常使用到的ArrayList就是线程不安全的,经常会有情况是在用for循环遍历时报错,这也变相的提醒了本身对这个对象的保护并没有做到位,该线程在遍历结束前,切换到了另一个进程,而另一个进程修改了这个对象,再回到原进程时就被检测出遍历时修改的错误。

另一个与线程安全有关的问题是cpu轮询,在编写程序的过程中,主要发现有两种错误易造成轮询。第一种错误是泛滥notifyAll(),这会唤醒很多还是不该唤醒的进程,尤其是当if与wait()连用时,极有可能在循环中一直跑下去,notifyAll()的设定一定是要能够改变到被阻塞的某个值时才使用,若少了notifyAll()则可能会造成死锁,也会引起线程安全问题。另一种错误是没有弄清进程结束的标志,当其中一个标志达到时,需要继续通过wait()等待另一个标志达到,否则就是cpu在该线程中无限循环,直至该标志位满足。

层次化设计

本单元作业的层次结构的设置相较于第一单元不是特别清晰。就大的方面而言,输入线程分解出乘客请求和增加电梯请求(也算是一个调度器了),调度器线程将乘客请求进行处理,放在不同的共享队列中。最终每部电梯都是一个线程。电梯分为横向电梯和纵向电梯,但我都把它们看做电梯类,因为电梯只管操作。而纵向队列和横向队列我是分开各设了一个类,主要是因为横向和纵向的策略不同。在层次化设计中,我将共享队列策略放在一个类中,这样更容易在一个共享队列(同一栋楼或同一楼层)中施行策略,将策略作为电梯的一个属性,这让策略的实现变得方便,但却让共享队列和策略混在一起,耦合性较高。在调度器调度时,也是感觉到如果要在每个队列中额外再引入调度器的话,会显得很臃肿,且调度器类不再需要额外的对象,需要处理的数据都可以算作static类型,所以使用了单例模式,事实证明这的确很方便。

其实电梯作业除了让我们学会使用多线程外,还有一个方面就是让我们想方设法用一些策略加快电梯的调度。在研讨课和与同学们的私下交流中,发现了很多策略,在运行过程中随时更改主请求,损失函数计算,优先调度速度快的电梯,考虑迪杰斯特拉算法等。但就我的评测经历来看,自由竞争无疑是更能够体现出日常生活中的电梯调度状况的。对于横向电梯,哪怕让它一直顺时针跑,性能分都可以很高。在我看来,只要能让电梯的一次开关门能够实现更多的乘客进出,就能够使得电梯运行时间大大减小,依照这种思想,已经有电梯在该楼层开门时,其他电梯则不会在该楼层有开门的动作(除非有乘客要下车),这样一来,自由竞争一类的算法具有较高的性能也是说得通的。

posted @ 2022-04-28 21:32  南风北辰  阅读(149)  评论(1编辑  收藏  举报