BUAA OO 2023 第二单元总结

严皓钧-21371372 学生 2023-04-13 23:35:55

BUAA OO 2023 第二单元总结

一、前言

终于结束了。过去的这一个月,这门课程给我的OO震撼更甚于第一单元化简表达式。在第一单元的时候,我是处于不知道有多难的状态,因为我连Java基础的语法都还不会,我只知道这很难。在第二单元的时候,通过第一单元的训练已经基本掌握了Java的基础语法和IDEA调试功能,在面对电梯多线程编程问题时我的状态变成了我知道有多难了。无论结果如何,起码总算是熬过去了。这篇博客将尽可能对每次作业中我的设计思路、架构进行记录,并且总结分析一下Java多线程编程中较为重要的知识点。

本篇博客的主体内容将分为以下五个部分:

  • 第五次作业
  • 第六次作业
  • 第七次作业
  • 作业小结
  • 心得体会

在前三部分中将通过协作图等方式着重于分析自己的架构设计思路,进程协作时序等,第四部分中对三次作业进行一定的总结,在第五部分中将分享整个单元下来的心得体会。

二、第五次作业

2.1 任务分析

本次作业需要模拟一个多线程实时电梯系统。在本系统中存在着6部电梯,可以在新主楼1-11层之间运行。本次任务的主要目标是模拟电梯的上下行,开关门,以及模拟乘客的进出。

我认为在电梯调度作业中与现实电梯最大的一个不同是:我们编写程序时是处于一种“上帝视角”,可以知道这个乘客在哪层上电梯并且要去哪一层,而现实的电梯并不能提前知道这个乘客要去哪一层。这个不同其实就引申出了许多不同的电梯调度方法,这其实也导致了本单元的作业上限十分高

2.2 架构设计

第五次作业的UML类图如下图所示:

img

类的设计角度来看,Elevator类和Passenger类是对第二单元电梯问题中的“对象”比较直观的建模,因为电梯无论如何调度如何迭代都好, 其实主体就是人和电梯。除此之外,我也将电梯应该决策前往哪个楼层的部分解耦出来当做一个Strategy类,这样做的好处是电梯类就只用充当“身体”的部分,负责执行Strategy“大脑”指定去的楼层就好了。

整体架构的运行流程是在MainClass中开启InputThread线程以及初始的6部电梯的线程,然后由InputThread接收乘客的请求并放到特定电梯的托盘PassengerQueue中。Elevator线程只需要处理自己的PassengerQueue中的乘客,并且通过Strategy的getTargetFloor获取电梯应该前往的楼层。Elevator的open,close等行为均通过内部的方法进行建模,从而模仿电梯接送乘客的场景。

架构模式角度来看,我在第二单元三次作业均采用的是生产者-消费者模型,主要便是围绕电梯问题中的生产者、消费者以及托盘进行建模:

  • 生产者:InputThread线程
  • 消费者:Elevator线程
  • 托盘:共享资源PassengerQueuue

不同于一般的生产者消费者模型,我在HW5中采用的其实是一个生产者,多个托盘以及多个消费者,生产者采用一定的策略(其实就是调度器设计策略)将乘客放到托盘中,生产者-消费者模型的架构模式示意图如下所示:

img

这样的架构模式其实在细节方面处理的不是很好,因为一旦乘客被分配到PassengerQueue托盘之中,或者已经进入到Elevator之中了,其实就不可能再进入到别的PassengerQueue之中。HW5的架构设计其实可拓展性方面较差,因此我也在HW6中改进了生产者-消费者模型的架构,增强其拓展性以适应迭代要求。

2.3 协作图

第五次作业的UML协作图如下图所示:

img

由于第一次作业需要实现的功能较为简单,并没有较多花里胡哨的功能,因此只需要正确处理各个线程之间的协作关系以及Elevator的任务执行即可。

同时,线程结束的标志应该是InputThread中request == NULL,在条件满足的时候即可对每个PassengerQueue执行setEnd方法,从而进一步将结束信号传递到Elevator线程之中。

2.4 同步块与锁设计

在Java中,要解决线程不安全最简单的方式就是使用synchronized关键字,执行对共享资源上锁的功能。在对共享资源进行上锁后,就相当于可以“独享”这个共享资源,在执行完自己的操作后再释放这个锁,表示自己已经用完这个共享资源了。

在第五次作业中,本人synchronized关键字的使用方式主要有以下两种:

//锁方法
synchronized void method() {
  /*
    要对共享资源执行的操作
  */
}

//锁对象
synchronized (object){
  /*
  要对共享资源执行的操作
  */
}

第一种方式是锁方法,经常用于“生产者-消费者“模型的托盘类当中,在本人的电梯调度设计架构中也就是PassengerQueue类。这样做的原因是将各个进程会十分经常地访问托盘类,因此将托盘类中的方法设置为同步方法即可封装为线程安全的类,也可以使得在访问共享资源时的代码更加简洁。

第二种方式是锁对象,我主要是用来补充第一种方法,用来锁一些非PassengerQueue类的共享变量,使用的次数相对较少。

2.5 调度设计

在调度设计部分,我主要是划分成了两个问题来解决:

  • 输入线程按照什么策略把读到的乘客放到PassengerQueue之中。
  • 电梯按照什么调度方式去接自己的PassengerQueue中的乘客以及去送自己电梯内的乘客。

针对第一个问题,我实现的调度功能是RoundRobin轮转分配,其实也就是指导书中的基准调度方式。在InputThread线程获得一个新的乘客时,就轮流分配到各个电梯的PassengerQueue中。这种方式最大的好处就是足够简单,仅需要两行代码就可以实现分配的功能!实现层面上的简单也保证了基本不可能在这里出现bug。就本人强测的成绩来看,轮转分配这种最朴素的调度方式也不一定差,在较为随机的数据之中也能表现出挺不错的性能。虽然在系统运行时间上可能会较为欠缺,但是其实可以保证不会有过长的最长等待时间与较少的耗电量。

cnt  = (cnt + 1) % processingQueues.size();
processingQueues.get(cnt).addPassenger(passenger);

由架构设计部分中也可以看到,我本次作业并没有单独设置一个Scheduler调度器线程,原因也是当时还不是很了解调度器的概念,所以我理应单独拿出来设计成类的调度器部分就被我耦合在了InputThread线程之中了。

针对第二个问题,我采用的是大家最广泛采用的LOOK调度算法,并且也一直延续到第七次作业均没进行修改。我在此引用学长博客中已经将LOOK调度算法描述的十分清晰的段落来介绍LOOK调度算法的实现。

  1. 当电梯有乘客时,以乘客中目标楼层距离当前楼层最远的请求为主请求确定目标楼层

  2. 当电梯没有乘客时,首先按照原方向,寻找距离当前楼层最远的有请求的楼层,找到则确定为目标楼层,这次寻找最终结果有可能会是当前层。如果寻找无果(沿方向的所有层包括本层都没有请求)则改变方向,继续寻找。若最后没有找到,则可以返回一个标志结束的值表示电梯进入空闲。

    ——引用自「BUAA OO Unit 2 HW8」第二单元总结

至于说其他的调度算法,实现不一定有LOOK简单,而且性能也不一定会比LOOK好。在这个问题上很难有一种绝对更优的调度算法,因此LOOK是一个易实现、性能有保障、难出bug的调度方式。

2.6 bug分析

我在第一次提交中测的时候,好家伙只过了两个点,剩下的点全部报错CTLE。其实这个报错很好地承接了调度设计引用部分中学长提到的“若最后没有找到,则可以返回一个标志结束的值表示电梯进入空闲”。在我的初次设计之中,电梯在没有找到合适的目标楼层时,并没有进入空闲状态,而是不断的执行while(true)循环导致轮询。换句话说,其实就是电梯线程没有暂时放弃passengerQueue共享资源。针对轮询问题,只要明确轮询的地点,分析应该暂时放弃哪个共享资源即可解决。

在分析轮询的发生地点时,我采用的策略是在不同线程的while(true)中加入System.out.println("test"),这样便可高效地定位到轮询的位置,因为发生轮询的循环会在终端中疯狂打印test。

在成功定位到轮询的bug后,需要采用wait-notify机制解决轮询。结合下图理解,现在是Owner线程占有着共享资源并进行相对应的操作。如果Owner线程发现要执行操作的条件不满足(比如共享资源的某个属性是否为True),便调用 wait 方法,进入到WaitSet之中编程Waiting的状态,即先暂时不占用共享资源。这样别的线程就可以先访问这个共享资源。等新的Owner线程调用notifyAll方法时唤醒WaitSet中的线程,让这些线程进入 EntryList 重新竞争成为Owner。因为条件不满足的时候,一开始的Owner线程就先去wait了,不会一直询问条件满不满足,条件满不满足,条件满不满足。。。等别的线程对共享资源进行了操作(e.g.修改值),才可能使得原本的条件变为满足,所以notifyAll的时候才会唤醒初始的线程看看条件是否满足。

img

三、第六次作业

3.1 任务分析与迭代要求

本次作业相对于第五次作业的迭代要求,主要是增加了以下两点:模拟电梯系统扩建和日常维护时乘客的调度

  • 增加电梯请求:在第五次作业中,新主楼就只有6层电梯。但是在第六次作业中可以动态地增加新的电梯;除此之外,电梯在初始化与新增的时候也可以自定义速度、容量以及初始楼层,这部分只需要修改Elevator类的接口即可,实现较简单。
  • 日常维护请求:其实维护请求相当于“减少”电梯请求,只不过比增加电梯请求的难度更高,因为涉及到了释放乘客、请求队列的问题。

总而言之,本次作业的主要目的就是实现电梯数量的动态增减

3.2 架构设计

第六次作业的UML类图如下图所示:

img

第六次作业中的架构设计基本延续第五次作业的生产者-消费者模型,但是为了应对迭代的需求中的Maintain做出了相应的架构改进。针对增加电梯的请求,现在InputThread类也可以创建新的电梯进程,这部分比较简单所以不在此赘述。我想着重分析下Maintian请求对于架构设计的影响。目前我的生产者-消费者模型的架构模式示意图如下所示:

img

与第五次作业中的架构模式示意图相比,最明显的区别就是增加了一个waitQueue的托盘,这样的设计其实已经不是传统的生产者-消费者模型了,而是变成了具有二级托盘的生产者-消费者模型。InputThread将读入的乘客请求加入到共享资源waitQueue中,然后由本次作业中新设计的调度器Scheduler类从waitQueue中取出请求再放入到不同的processingQueue中

这样子设计的原因是,当一部电梯需要执行Maintain的时候,需要将这部电梯内的乘客及其processingQueue中的乘客作为新的乘客请求进行处理,那么也就需要一个可以被各部电梯共享的资源,这显然是第五次作业的架构无法满足的,因此新增了一个托盘waitQueue来应对这样的迭代需求。

3.3 协作图

第六次作业的UML协作图如下图所示:

img

可以明显看到第六次作业的UML协作图比第五次作业中的协作图增加了较多内容,主要是新增电梯线程的时序部分以及执行电梯日常维护的时序部分。值得一提的是,本次作业中的结束信号不再是request == NULL,因为存在已经没有新输入但是有电梯正在维修的情况。所以结束信号应该是:没有电梯正在维修 && 没有新的输入,如果不满足条件,就暂时放弃对共享资源的占有以避免轮询。

3.4 同步块与锁设计

在第六次作业中,本人并未修改同步块与锁的设计,仍是沿用第五次作业中的synchronized方法对共享资源进行上锁,故不在此赘述。

3.5 调度设计

在本次作业中,我加入了对调度器类Scheduler的设计,对waitQueue以及各个电梯的processQueue这些共享资源进行统一管理。设计一个对共享资源进行管理的调度器在我看来主要有以下两个好处:

  • 可以设计更加复杂的调度算法
  • 可拓展性更强

由于RoundRobin轮转调度在第五次作业的强测中表现的还不错,所以在第六次作业中我也没有修改调度的算法设计,但是需要特别判断某个processingQueue是否已经被日常维修了。

3.6 bug分析

很不幸地,在第六次作业中被强测查出来了一个线程安全方面的bug。这个bug这是第五次作业中遗留的,但是并没有在第五次作业的强测中暴露出来,也不知道是该庆幸还是难过。

在PassengerQueue类中,我设计了getPassengers的同步方法,返回的是该processingQueue中的ArrayList<Passenger>,代表处理队列中的乘客列表。但是我在电梯类中执行hasPassengerIn方法判断有无人需要上电梯时,对processingQueue.getPassengers获得的乘客列表用iterator遍历时并没有对processingQueue进行上锁,导致产生ConcurrentModificationException错误。

这是因为尽管getPassengers是同步方法,但是在获得了ArrayList后使用iterator进行遍历时是没有被锁保护的,会导致多个线程同时访问processingQueue共享资源。解决方法也十分简单,只需要在遍历前对processingQueue进行加锁即可。这样的bug也反映出在Java多线程编程中线程安全的棘手以及对基础知识掌握的重要程度。

四、第七次作业

4.1 任务分析与迭代要求

本次作业的迭代相对于第六次作业增加了以下两点:电梯可达性以及“电梯调度逻辑合理”

  • 电梯可达性:之前的电梯都是1-11楼全部可达,这次电梯变成具有可达楼层的限制了。这意味着原本对于一个请求可以一步到位,而现在可能存在必须将其交给不同的电梯来送达的情况。
  • 电梯调度逻辑合理:本次作业引入了“服务中”以及“只接人”的两个概念,我原本以为这个迭代是需要重新选择调度策略,RoundRobin轮转策略要被淘汰了,结果发现好像不是这样的(捂脸)。这也是后续OO的第四次上机实验中涉及的,OS中学到的信号量机制就可以很好地解决这个问题。

4.2 架构设计

第七次作业的UML类图如下图所示:

img

在第七次作业中,我设计了新的类ServiceTable来解决“服务中”与“只接人”的数量限制问题。ServiceTable的两个属性是由Integer(楼层)映射到Semaphore信号量(数量限制)的HashMap,设计一个新的类并将其作为电梯线程的共享资源可以较为轻松地解决这个迭代问题。

值得一提的是,在Passenger类的设计上,我做出了一点修改:将toFloor的含义修改为需要前往的楼层,而destination的含义是乘客最终要到达的楼层。做出这样的修改主要是为了满足本次迭代中换乘的要求。

整体上依旧保持了第六次作业中的带有二级托盘的生产者-消费者模型,可见在第六次作业中完善的整体架构还是具有很强的可拓展性的。

4.3 协作图

第七次作业的UML协作图如下图所示:

img

第七次作业的协作图中主要增加了电梯在开关门时向ServiceTable申请开门资源以及释放资源的过程,要注意的是需要先申请开门资源,再执行开门操作;先执行关门操作,再释放资源。电梯进程在放出乘客时需要特判乘客前往的楼层与终点楼层是否一致,如果不一致需要重新加入到waitQueue之中

在结束信号方面,本次作业又需要进行一定的修改。因为当所有电梯均不处于正在维修 && 没有新输入无法满足这次作业的情况:有可能某部电梯还在运送一个需要换乘的乘客,这个时候关闭电梯线程会导致这个乘客无法到达终点站。因此,本次作业的结束信号我修改为没有新的请求 && 服务中乘客数为0,当InputThread收到新的乘客请求时将服务中乘客数加一,当电梯开门放人的时候如果乘客已到达终点楼层则将服务中乘客数减一。

4.4 同步块与锁设计

在第七次作业中,由于题目中增加了同层电梯“服务中”与“只接人”的迭代要求,其实理解下就是“申请资源”和“释放资源”的操作。这样就十分自然地联想到OS学到的信号量机制,并且Java也已经帮我们实现了Semaphore类了。我们只需要使用其acquire方法与release方法便可以申请“开门资源”与释放“开门资源”,并且在资源不足时自行等待。

Semaphore semaphore = new Semaphore(10); //10个单位的资源
semaphore.acquire(); //申请资源
semaphore.release(); //释放资源

4.5 调度设计

本次作业增加了电梯可达性要求,无疑给调度器的设计提高了很大的难度。本人经过清明节一天的挣扎与摸索,最终还是选择采用广度优先搜索(BFS)+动态分配电梯的方式来实现换乘的功能。

我的调度设计是:

  • 在InputThread和Scheduler中加入了一个accessMap邻接矩阵(不考虑平行边,只考虑可达性!),在需要添增电梯或维护电梯时对accessMap进行更新。
  • 在Scheduler中首先轮转遍历processsingQueue判断是否可达,如果可达则按照前两次作业中的RoundRobin轮转进行分配。如果不可达,则利用accessMap采用BFS算法找出最短路径,并且将路径的第一个位置与第二个位置作为该乘客当前的楼层与前往的楼层,修改完乘客的信息后再采用轮转方式分配到processingQueue之中。

这样动态调度设计相对于静态规划分配好电梯的好处是:不需要在某一时刻就决定该乘客将来换乘的所有电梯,只需要决定乘客接下来要乘坐的电梯是哪一部就行了。在我看来,对未来电梯的状况进行静态的预测并且分配一方面实现起来比较麻烦,另一方面很难确定把乘客的需求拆分给哪些电梯。但是如果只决定乘客下一步要乘坐的电梯,这与我一直采用的轮转分配方法逻辑上更加连贯,同时也是我将未来的事情留给未来再处理的一种想法。

4.6 bug分析

经过课下自动评测机的高强度测试以及大佬在讨论区中分享的测试数据的检验,我的程序顺利地通过第七次作业强测并取得十分满意的成绩。但其实程序还是有bug的.....

在进行课下测试的时候,我发现会有1/1000左右的概率发生TLE的情况,但是时间戳并没有超过时间限制,这大概率是因为发生了死锁。但是本人的能力有限,无法在短时间内解决多线程编程死锁的问题(而且1/1000的概率还是很值得冒险的吧。

4.7 可拓展性分析

在本部分中,我将从功能设计和性能设计的平衡方面分析第三次作业架构的可拓展性。

首先在功能层面,第三次架构支持的功能有乘客接送、增加电梯、电梯维护、乘客中转等,可以说基本满足了现实生活中正常电梯系统的功能。在性能设计方面,由于本人采用的主要方法有LOOK调度以及轮转分配方法,性能特点为系统总体耗电量较少,最长等待时间较为平均,但是系统结束时间会相对较长

在未来增加电梯功能与提高系统总体性能方面,我认为可以拓展的有以下几点:

  • 支持维修的电梯维修完成后重新接载乘客:可以设计一个新的共享资源(不妨称为维修表)用来记录被维修电梯的信息(如维修时停靠的楼层、运行速度等)。当一部电梯需要被维修的时候,仅需把信息写入到维修表中,在需要重新开启的时候就在维修表中找寻电梯id对应的信息,并且重新创建其处理队列和开启电梯线程即可。
  • 改进乘客分配的方式:其实如何分配乘客还是非常困难的一件事情。因为我已经设计了Scheduler类,所以也可以较为方便地修改分配乘客的方式。可能的分配方法有:1.影子电梯找局部最优 2.自行设计电梯评价指标,分配给评分高的电梯。
  • 为不同类型的电梯安装不同的调度策略:因为电梯之间存在运行速度、载客量等差异,不同电梯的性质并不一样。我认为尽管不存在绝对更优的调度策略,但是可能存在相对更优的策略,或者说是更适合这部电梯的特性的策略。因此可以为不同电梯安装不同的调度策略来改善单部电梯的性能。

五、作业小结

这部分主要分析三次作业稳定的内容和易变的内容。

5.1 稳定的内容

  • 生产者-消费者模型架构:这部分肯定是不会有大改的,不然不就是重构了吗(捂脸)。果然经典的模型的适应能力还是很强的。
  • 电梯自身的调度策略:这部分主要是因为没有什么特别好改的,做到简洁、正确、有效即可。
  • 线程安全的设计:这部分算是相对稳定的,虽然说有比synchronized性能更好的锁,但是我觉得连贯地用简单的synchronized就挺好的。

5.2 易变的内容

  • 线程结束信号:结束信号真的是让我印象深刻。。。每次作业的结束信号我都是用不同的条件实现的,而且为了一个结束信号常常还需要增加别的类的属性与方法。
  • 乘客分配策略:这部分其实是主要受电梯方面的影响很大。一旦电梯maintain了,就不能分配这部电梯。一旦电梯残疾了,又不能直接简单地轮转分配,还要先找出可达路径。乘客的分配策略会受电梯的属性与状态影响较大。

六、心得体会

无论第二单元的结果如何,起码总算是熬过了。简单分享一下自己的心得体会吧。

6.1 线程安全

这个单元的作业其实给我带来的不仅仅是电梯调度方面的线程安全知识,我还顺带了解到了很多现实生活中多线程情境(比如演唱会抢票)这样并发度极高的情景。生活中很多看起来很理所当然的事情,其实背后涉及到的多线程问题是十分复杂的,能够实现多线程抢票且不出现线程安全问题也是一个很复杂的项目。

6.2 层次化设计

无论是第五次作业中的传统生产者-消费者模型还是后来的带有二级托盘的生产者消费者模型,其实都体现了层次化设计的思想。对于每一层次的类/代码,应该只负责这个层次所需要完成的事情。比如说最后的电梯线程层次,就只需要负责接收命令并执行接送乘客的任务,但是命令的产生并不需要电梯层次来负责。在第二单元中,特别是采用了生产者-消费者模式进行编程时,层次化设计的感觉会更加显著。

6.3 一些碎碎念

其实第二单元最大的心得体会就是不会的知识太多,挑战太大。这个月学到的知识可能比某些课程一个学期学到的还多,而且留给自己消化与编程的时间其实特别赶。周二到周日担心自己能不能写出来,写出来之后担心强测会不会寄,然后又担心下一次作业自己能不能写出来,总之每天头上都悬挂着一把剑,挺难受的一个月捏。

在最后,特别感谢吴中源学长以及助教为我提供的帮助与指导,也十分感谢和我讨论电梯调度问题的同伴们。这次单元的作业更加让我明白了自己的力量是多么的渺小,必须需要多和他人进行沟通,在不断的讨论中产生出解决方案。希望自己在之后的作业中能够更加游刃有余地完成吧。

...全文
165 回复 打赏 收藏 转发到动态 举报
AI 作业
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

442

社区成员

发帖
与我相关
我的任务
社区描述
2023年北京航空航天大学《面向对象设计与构造》课程博客
java 高校 北京·海淀区
社区管理员
  • 被Taylor淹没的一条鱼
  • 0逝者如斯夫0
  • Mr.Lin30
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧