BUAA-OO第二单元总结

1、总述

​ 第二单元我们学习了 java 多线程,通过模拟多线程实时电梯系统,掌握了线程之间的交互、多线程中可能存在的线程安全问题以及生产者-消费者、单例模式、观察者模式、流水线模式等多线程协同的设计模式,在三次作业的迭代过程中不断强化线程之间的协同设计层次架构。

2、电梯调度设计

​ 三次作业都采用了生产者-消费者模式,输出都采用单例模式。

2.1 第一次作业

​ 对于单部纵向电梯,每部电梯有一个候乘表,并按上行/下行、出发楼层分成了20个等候队列(用两个 hashmap 分别表示上行、下行乘客,hashmap 的 key 是出发楼层,value 是存放请求的容器),电梯内部还用一个 hashmap 记录电梯内各个目的楼层的乘客。

​ 电梯内部有 isOpennowFloordesFloor 等状态,电梯运行逻辑为:每到一层先让目的楼层是 nowFloor 的乘客 OUT,之后再让出发楼层是 nowFloor 且移动方向与电梯运行方向相同的乘客 IN,其中每次进乘客都把 desFloor 设为新进乘客目的楼层与当前电梯 desFloor 中离电梯 nowFloor 最远的数值。当电梯内没有乘客时(nowFloor == desFloor),如果整个候乘表中没有请求,则电梯 wait,否则,先让出发楼层是当前楼层且上行的乘客进电梯,如果没有则让出发楼层是当前楼层且下行的乘客进电梯,如果也没有则设置离当前楼层最近的出发楼层为电梯的目的楼层 desFloor。电梯结束的标志是输入结束且等候队列为空且电梯内为空。

public void run() {
        while (true) {
            //判断是否需要进乘客  电梯内没人且等候队列为空时会关门阻塞
            
            //判断是否需要关门
            
            if (输入结束 && 等候队列为空 && 电梯内为空) {
                break;
            }
            
            if (nowFloor < desFloor) { // up
                move(1); 
            } else if (nowFloor > desFloor) { // down
                move(2);
            }
            
            //判断是否需要开门出乘客
        }
}

​ 电梯的开门关门函数都先判断 isOpen 状态再去操作,避免多余的开关门。电梯开门后先 sleep(400) ,再一次性出、进乘客,可以捎带更多的乘客。因为先出后进,所以乘客进电梯时先判断是否需要开门。

​ UML类图如下:

​ UML协作图如下:

2.2 第二次作业

​ 第二次作业增加了横向电梯,横向电梯的运行策略基本同纵向电梯,不同之处是横向电梯可以循环移动,所以我取消了第一次作业中的 desFloor,用一个整数 next 表示电梯当前的移动状态,正数表示顺时针移动,负数表示逆时针移动,绝对值为移动的距离,每次移动更新 next

​ 每座/每层可以有多部电梯但没有换乘,我采用了均匀分配的原则,用两级生产者-消费者完成分配调度。输入线程根据座/层把请求分配到对应座/层的等候队列,每个座/层的 Distributor 将该座/层的请求均匀分配到该座/层的电梯中,各电梯之间按第一次作业的策略独立运行。

​ UML类图如下:

​ UML协作图如下:

2.3 第三次作业

​ 第三次作业电梯参数可自定义且增加了换乘。我继承官方包的 PersonRequest 实现了一个新类 Passenger,增加了修改出发/目的信息、保存最终目的信息、修改换乘状态等函数以实现换乘。换乘的处理采用实验课代码的流水线模式。对于每条请求,如果不需要换乘,则按第二次作业处理,否则,先根据请求的信息和电梯可停靠信息设置当前目的座/层,并加入对应楼/座的等候队列。需要换乘的乘客在前往初始目的楼层的过程中,每移动一层会查询是否能在该楼层提前换乘。换乘时 Controller 更新请求的出发、目的信息以及换乘状态,并根据当前信息把请求加入对应楼/座。

​ UML类图如下:

​ UML协作图如下

3、 线程安全处理

​ 我在第一次作业中使用了 synchronized,后续作业因为对 Lock 使用不熟悉且担心改动原有代码会出 bug,所以都是对方法无脑加 synchronized

​ 以第三次作业为例,我用到了同步块的地方有(前两次作业做同样处理):

  • 电梯调度器 Schedulertake()put()isEmpty()isEnd()setEnd() 方法。
  • 总等待队列 WaitingQueuetake()put()isEmpty()isEnd()setEnd() 方法。
  • 管理电梯调度器 State 类的 addElv()pickElv()setEnd()contain() 方法。
  • 控制器 ControllerneedChange()addPassenger()setEndTag()getEndTag() 方法。

  可以看出在生产者-消费者模式中,托盘的存取方法状态修改查询方法都需要加锁确保一个时刻只能有一个线程调用。

​ 所有同步块方法中只有取方法中可能会发生阻塞等待,存方法状态修改方法中都需要有 notifyAll() 以唤醒等待的线程,状态查询方法都不需要加 notifyAll(),否则可能造成轮询。

4、 bug分析

  • 第一次作业:

    • 自测时发现电梯在目的楼层让所有乘客出去后,如果这时等候队列为空,电梯会先阻塞,被唤醒后才会关门或接新乘客。但测评机似乎并没有把输出开关门时间间隔太长算作bug,只要间隔大于规定时间即可。应该在电梯阻塞前先判断是否需要关门。
  • 第二次作业:

    • 中测时遇到过无法处理最后一条请求的bug,原因是在 Distributor 中判断结束标志的位置不对,导致 take 了最后一条请求后还没处理就结束了。
    • 强测中出现了轮询的问题,原因是在查询状态方法、以及 take 返回为 null 时加了多余的 notifyAll,导致当等候队列为空时,两个电梯线程会轮流wait、notifyAll,造成轮询。
  • 第三次作业:

    • 中测时遇到了 CTLE 的bug,原因是横向电梯在 A 座也有可能是不可开关门的,但我的写法是凡是在不可停靠的座都不去 take 乘客,这样电梯初始在 A 座且不可开关门时无法阻塞,导致轮询。
    • 强测时出现了 RTLE 的bug,bug修复时抱着试试的态度,把需要换乘的请求在移动过程中的可能更改换乘楼层的判断取消(即完全改成静态拆分)后就不超时了。其实本来以为最初写法会在某些情况改善性能,结果不知道为什么反而会超时。

​ 三次作业互测都没有 hack 成功,也没有被hack。

​ 由于都采用了 synchronized 和均衡的调度策略,性能分并不高。

5、 心得体会

  • 线程安全
    • 线程安全是多线程中最重要的问题之一,对象共享是产生线程安全问题的根本原因,在实现时要理清楚类/对象会被哪些线程共享,线程的哪些操作会产生对象共享,并以此决定哪些方法/代码块需要加锁。
    • 轮询非常消耗 cpu 资源,在多线程中由于不恰当的 notify 很容易造成轮询、死锁、线程无法结束等问题。一定要分析清楚发生阻塞和需要唤醒的逻辑,谨慎使用 waitnotifyAll
    • synchronized 在执行完成或发生异常时会自动释放锁,在语义上很清晰,使用起来方便简单,但是性能低,使用场合有限。Lock 使用灵活,需要手动获取、释放锁。用条件锁、读写锁等可以实现更自由的线程交互,而且在竞争资源激烈时,Lock的性能要优于 synchronized。由于时间原因第二单元我没有使用 Lock,希望以后能通过实践体会到 Lock 的优点。
  • 层次化设计
    • 电梯单元的重点不再是设计结构,我曾试图把横向电梯和纵向电梯的共同点提取出来实现 Elevator 基类,但因为横向电梯和纵向电梯的实现逻辑稍有不同,继承 Elevator 类反而增加了复杂程度。
    • 横向电梯和纵向电梯的调度器都实现了 Scheduler 接口,对于同一座/层统一管理,使得横纵电梯的 Distributor 可以共用。
posted @ 2022-04-30 12:14  wwllll  阅读(39)  评论(0编辑  收藏  举报