BUAA_OO Unit 2 Summary

OO Unit 2 Summary


OO第二单元的主题是多线程程序设计。

经过第一单元的训练后,我们对面向对象的基本设计方法已经有所了解。因此在这一单元中,我们关注的重点是更加抽象的线程交互设计,以及随之而来的种种线程安全问题。动态并发运行的电梯系统也更加符合生产实际,解决问题的过程令我受益匪浅。

整体设计

class

在这里插入图片描述

经过迭代后的Hw7最终设计,由于本单元没有经历重构,以下各次作业中的结构均可在此UML图中找到痕迹,因此我将它置于文章第一部分,便于查阅。

在这里插入图片描述

经过三次迭代后的类数量较多,并且存在一个线程类的属性是另一个线程类的情况,线程交互难以分析,容易出错,存在架构上的优化空间。

sequence

本单元作业的时序图,展示了电梯系统处理乘客请求的完整服务流程,包含了各线程的启动与终止方法。

在这里插入图片描述

系统策略

LOOK

在电梯的运行策略上,我采用的是LOOK算法,它是SCAN算法的优化版。其基本思想是判断电梯的移动方向,在过程中自动完成上下人,而不是机械地指定起点与终点。这样可以避免不必要的电梯移动。实现如下:

public int look(int floor, int num, int direction) {
    if (num > 0) {
        return 1; // move
    } else {
        if (requestQueue.isEmpty()) {
            if (requestQueue.isEnd()) {
                return -1; // end
            } else {
                return 0; // wait
            }
        } else {
            if (sameDirection(floor, direction)) {
                return 1; // move
            } else {
                return 2; // reverse
            }
        }
    }
}

需要说明的是reverse状态的含义,它代表着当前电梯运动方向的前方已经没有任何等待中乘客,也就是说电梯的后方还存在着乘客等待,此时需要掉头改变电梯运动方向。

电梯在到达每一层时,首先判断当前层是否有乘客的上下需要,以进入电梯的inElevator()方法为例,电梯只接受当前层的同向乘客请求

private void inElevator() {
    if (requestQueue.isEmpty()) {
        return;
    }
    for (Request request : requestQueue.getRequests()) {
        if (request.getFromFloor() == floor 
            && sameDirection(request) && num < 6) {
            if (!open) {
                openElevator();
            }
            TimableOutput.println("IN-" + request.getId() + "-" 
                                    + floor + "-" + id);
            personQueue.addRequest(request);
            requestQueue.removeRequest(request);
            num++;
        }
    }
}

完成后调用LOOK算法,返回合适的运行状态,进入下一轮运行。

ConditionRandom

LOOK算法的优势在于运行过程中自动捎带乘客,使同一方向的请求能够高效地得到解决。因此,为了适应LOOK算法的特性,我在调度策略上没有采用大量耗电的自由竞争和摆烂的随机分配,而是自己设计了一个先匹配后随机的分配方式,三次作业的性能表现尚可,优于自由竞争

首先列举最优电梯应满足的条件:

  • 可直达

  • 请求方向与电梯同向

  • 接人方向与电梯同向

  • 到达时间最小:elevator.getSpeed() * Math.abs(distance)

  • 该电梯的待服务乘客小于当前电梯压力

    • 待服务乘客:电梯内人数 + 电梯等待队列中人数

    • 当前电梯压力:Math.max(waitQueue.getSize() / elevatorNum, elevator.getCapacity())

      (该条件十分重要,可以避免将同质化请求全部分配给单部最优电梯)

可见,最优电梯的条件十分苛刻,如果匹配成功将大大提升运行效率,但能否满足是个未知数。因此,对于匹配失败的请求,将在能直达的电梯中随机分配,保证基础的运行要求。

架构迭代

Hw5:实时电梯系统

设计思路

本次作业与以往不同,电梯系统中多部电梯同时服务于乘客请求,这要求我的程序不再是顺序执行,而是并发执行。因此,我给每部电梯建立了一个单独的线程,共同处理请求,效率较高。

我从生产者-消费者模型入手,输入线程Input和调度线程Schedule共同作为生产者,经过两级传递,将请求分配给特定电梯;电梯线程Elevator作为消费者,处理请求;而RequestQueue类作为传送带,其不再需要extends Thread成为线程,各线程拥有该类的静态对象

对于电梯系统的实时特性,我使用了sleep(time)方法,来模拟实际场景中电梯运行的耗时。我将电梯的各行为如:开门、关门、移动、上人、下人等都各自建立了一个方法,是否必要的判断封装在方法内部。这样在run()方法中按逻辑调用即可,不需冗余的判断,十分清晰。

private void openElevator() {
    if (!open) {
        TimableOutput.println("OPEN-" + floor + "-" + id);
        open = true;
        try {
            sleep(200);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
结构分析

主类中的初始化过程如下,waitQueue作为线程之间的共享对象,传入各线程成为类的属性,便于直接提取访问。

ArrayList<Elevator> elevators = new ArrayList<>();
RequestQueue waitQueue = new RequestQueue();

for (int i = 1; i <= 6; i++) {
    RequestQueue elevatorQueue = new RequestQueue();
    Elevator elevator = new Elevator(i, elevatorQueue);
    elevators.add(elevator);
    elevator.start();
}

Schedule schedule = new Schedule(waitQueue, elevators);
schedule.start();

Input inputThread = new Input(waitQueue);
inputThread.start();

RequestQueue类的对象涵盖了各线程中的请求队列,按具体意义可分为以下几种:

  • waitQueue 等待队列
  • elevatorQueue 电梯队列
  • personQueue 乘客队列

在本次作业中,输入的乘客请求可在这三层结构中进行单向流水线传输

具体来说,输入线程将外部请求加入等待队列;调度线程将请求从等待队列,分配给特定电梯的请求队列;电梯线程在状态匹配的前提下,将请求从电梯的等待队列加入乘客队列;最后乘客到达目的地,电梯乘客队列移除该请求,完成了单个乘客服务的全过程

在过程中可以大量复用RequestQueue类中的各synchronized方法,保证线程安全的同时,程序十分简洁清楚。

Hw6:支持电梯的增加与维护

设计思路

本次作业主要新增的内容为增加电梯维护电梯

新增电梯的需求较为简单,构造新电梯对象后通过elevator.start()运行线程,加入调度器中的电梯集合即可。注意到新增电梯有起始楼层、满载人数、移动一层的时间等参数,需要修改Elevator类的构造函数和运行时间有关的参数,如: sleep((long) (1000 * speed));

维护电梯时可包含以下步骤,特别注意先后顺序,否则可能出现乘客丢失:

  • 从调度线程中的电梯集合移除,不再分配请求给该电梯
  • 清空该电梯请求队列,重新加入等待队列
  • 正常运行一轮,但此时只出不进
  • 放出电梯内所有乘客,重新加入等待队列
  • 关门,退出线程

由于课程组友善地规定下达维护请求后仍可移动两层,因此不用立即放出乘客,在不允许乘客进入(请求队列已清空)的基础上,正常运行一轮后再强制放出所有乘客,并在该方法中添加notifyAll()唤醒调度线程

waitQueue.addAllRequests(out);

最后还需要将电梯系统的维护数-1,在妥善处理完二次请求后再结束输入线程。

结构分析

本次迭代共新增3个类。

由于输入新增了ADD与MAINTAIN指令,在层次化设计上使3种请求分别建立类Person,AddElevator,Maintain,它们共同继承于Request类。这样就可以很自然地把它们作为请求传入调度器统一处理。判断子类型即可。

if (request instanceof PersonMap) {
    allocateRequest(request);
} else if (request instanceof AddElevator) {
    addElevator(request);
} else if (request instanceof Maintain) {                           
    maintainElevator(request);
}

由于新增电梯不一定在1层,因此原有的电梯初始方向全部为1(up)不再适用。这里自然地将初始方向更改为0,使wait状态的电梯能够接取任意方向的乘客请求,提高系统适应性。注意需要将判断同向的条件由>0改为≥0,以适配优化。

Hw7:存在电梯服务限制

设计思路

由于电梯可达性的限制,一个请求可能需要多部电梯为其服务,并且不能简单的将请求直接拆分后分配,因为电梯到达时,乘客可能并未完成前置路径

因此,需要将在请求内部存储其路径序列,每走完一段就取出它的首段路径,将剩余序列重新放回调度队列。
在分配请求时,若发现当前请求的首段路径无法直达,直接取出该请求的起点与终点,重新搜索该请求的最短路径,并覆盖原请求,将其再次传入分配请求函数。

在新增和维护电梯时,需要实时更新电梯系统图的邻接矩阵,以便接下来寻找最新的换乘路径。

本次作业中,不只有电梯的维护需要将乘客放回调度队列,频繁的换乘操作也要经历该过程。为了使调度线程不过早结束,我发现可以将电梯系统的维护数迭代为乘客数,乘客到达真正终点时-1,并同样在数量清零时结束线程,十分自然。

结构分析

本次迭代共新增3个类。

新增了Floor类,其拥有信号量属性,用来控制每层楼的服务电梯数量。通过ArrayList结构,使每部电梯拥有floors楼层表属性即可。

ArrayList<Floor> floors = new ArrayList<>();
for (int i = 1; i <= 11; i++) {
    Floor floor = new Floor(i);
    floors.add(floor);
}

对于电梯是否属于只接人类型的判断,只需在outElevator方法中观察有无下电梯的乘客,一旦发现就将判断条件设置为假即可。

新增了AccessMap类,其中的属性二维数组int[][] graph记录着电梯系统的可达矩阵,封装好的dijkstra算法可利用该矩阵寻找电梯系统图的最短路径

新增了PersonMap类,作为请求的基本单位,其由划分好的多段路径序列组成,记录着乘客的未来换乘信息

PersonRequest personRequest = (PersonRequest) request;
PersonMap person = new PersonMap(personRequest.getPersonId());
Transfer transfer = new Transfer(personRequest.getFromFloor(), 
personRequest.getToFloor());
person.addTransfer(transfer);
requestQueue.addRequest(person);

线程安全

synchronized

多线程程序中的线程安全问题主要体现在对于共享对象的读写。由于无法控制访问的顺序,JVM规定每个对象只有一个,拿到锁的线程才可访问该对象。

为确保线程安全,分析线程之间需要共享访问哪些对象,将该对象所属类中所有方法进行synchronized保护,使得同一时间只有一个线程访问该对象。

本电梯系统中的线程包括输入线程,调度线程,电梯线程,共享访问的对象是等待队列与电梯的请求队列。因此,只需将队列类RequestQueue中所有方法加上synchronized关键字即可。

退出同步块时自动让出锁,其它线程此时可以进入,但处于wait状态的线程只能被notify()/notifyAll()唤醒。由于notifyAll()使用难度低,本单元中均使用notifyAll()。

notifyAll()

wait()可以使线程进入等待状态,避免空转线程浪费CPU资源。notifyAll()与wait()配合,用于唤醒所有等待中的线程,只能由当前锁的获取者发起,所以要放在synchronized块中,但不必是最后一句,退出块时才会执行。

notifyAll()的使用需要对线程交互极为了解,使用的多与少都会带来严重的后果,分别是浪费CPU资源和线程始终处于wait状态,未能唤醒。

下面具体分析RequestQueue类中含有notifyAll()的方法,及其所属对象

  • addRequest():等待队列与电梯队列。有新请求时唤醒调度线程与电梯线程
  • addAllRequests():等待队列。维护时将分配给电梯的请求放回,唤醒调度线程
  • setEnd():等待队列与电梯队列。唤醒调度线程与电梯线程,使之结束
  • endProcess():等待队列。乘客送达终点后唤醒输入线程,使之结束

Semaphore

信号量顾名思义,可以控制访问该对象的线程数量,与同时只能有一个线程访问对象,其余全部无法进入的设计方法相比,适用场景较为灵活。首先让类拥有信号量属性

private final Semaphore inService;
this.inService = new Semaphore(4); // allow 4 threads

获取对象:

public void acquireService() {
    try {
        inService.acquire();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

释放对象:

public void releaseService() {
    inService.release();
}

debug

在程序结构尚不完善的初步测试期,我仍由于多线程的复杂性出现了各种各样奇怪的错误。

Hw5

当时初步接触电梯的上人操作,需要对该电梯的请求队列进行遍历,符合捎带条件的乘客上电梯。由于在遍历Arraylist的过程中进行了删除操作,出现了线程安全问题。我采用了遍历时先克隆当前请求队列的做法,并一直沿用。

public synchronized ArrayList<Request> getRequests() {
    if (requests.isEmpty() && !isEnd) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    return new ArrayList<>(requests);
}

由于对getRequests()同步块中的wait()用法不熟悉,我在以下代码又出现了逻辑上的问题。

if (waitQueue.isEmpty()) {
        continue;
}
ArrayList<Request> requests = waitQueue.getRequests();

应当将两句顺序调换,这样可以在等待队列为空时,通过wait()阻塞该线程,避免while块中程序空转,浪费大量CPU资源。

Hw6

本次作业中,我出现了电梯维护后多移动一层,以及其中乘客无法再次分配的问题。

前者的原因是在接到维护请求后,我将电梯的可运行属性置为2,但由于电梯线程正好处于移动状态,具体操作为sleep(time),在此期间不可写,唤醒后才可更改属性。

后者我一开始百思不得其解,后来发现此处没有错误,可以将乘客放回调度队列,但是调度队列在输入线程结束后提前终止。对此,我在队列类中新增了维护数量属性,只有当电梯全部维护完毕,调度队列的该属性为0时,输入线程才结束,将调度队列的end属性设为true。

Request request = elevatorInput.nextRequest();
if (request == null) {
    while (requestQueue.getMaintain() > 0) {
        requestQueue.waitRequest();
    }
    requestQueue.setEnd(true);
    break;
}

Hw7

由于此前架构良好,因此沿用以前的部分,在这一方面并没有出现错误。新增代码错误的原因是Dijkstra算法掌握不牢,更新最短路径时位置数组pos[]没有更新。由于属于图算法的知识,在此不赘述。

得力于同学分享的自动化测试工具,我在本单元的三次作业提交中均未出现bug。但是工具中的数据生成器通常只能生成均匀分布的输入,因此,手动构造极端数据的方法能够找出程序中的很多漏洞。

例如,使电梯系统承受大量下行请求压力,具体操作可在同一时间投放大量11层到1层的请求,如果调度策略不当,可能会将所有请求指派给一部电梯,造成超时。在Hw6中构造出维护的电梯中仍有乘客的情况,使电梯将乘客放回调度器,挑战了新架构的稳定性。在Hw7中首先维护初始的所有电梯,随后构造部分楼层可达的电梯,使所有乘客被迫换乘,很好的检查了换乘图算法部分的正确性。

心得体会

在这一单元的三次作业中,我均取得了95+的好成绩。正是由于上一单元的成绩不尽人意,我在这三周投入了大量的时间在我的电梯系统上,我认真的思考了线程之间的交互问题,乘客各种的可能情况。不论是架构的设计还是调度策略的调整,我仔细的思考每一行代码的作用,力求写出精简而优美的代码。虽然能力所限,但经过踏实努力得来的成果令我十分自豪。

在此也要感谢讨论区同学们的帮助,你们无私分享的评测机帮我避免了很多错误,今后要更加细致,测试确实是我的弱项,希望在以后能够补上这块短板。同时也要加强代码和思维基本功的训练。

Reference Tools

本文章撰写时使用的辅助工具(按出现顺序排列):

  • Statistic
  • PlantUML
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值