其他分享
首页 > 其他分享> > BUAA OO-Course 2022 Unit2 Summary

BUAA OO-Course 2022 Unit2 Summary

作者:互联网

第一次作业(hw5)

任务说明

本次作业的任务为对A、B、C、D、E五座大楼中的电梯进行实时调度。每一座楼中有一个电梯,可以在1-10楼之间运行,需要耗费时间的操作为上楼、下楼、开门、关门(乘客进出不需要时间,但需要在开关门之间完成)。

代码架构模式与调度策略

UML类图

UML协作图

代码架构模式(含同步块设计)

|- Main: 主类
|- InputThread: 输入类,一个线程对象
|- DistributeThread: 分配器,一个线程对象
|- ElevatorThread: 电梯线程,共五个线程对象
	|- ElevatorState: 电梯运行状态
|- Output: 输出类
|- PersonRequestQueue: 共享对象,将多个PersonRequest对象封装成一个队列。程序中该类有三种不同的对象
|- PersonRequest: 官方输入包提供,记录每个请求

程序的架构主要参考了上机实验课提供的策略。首先在main方法中启动一个InputThread对象,不断读入信息,接着启动五个电梯线程ElevatorThread对象和一个分配器DistributeThread对象。其中,有两个重要的共享对象实例:PersonRequestQueue waitqueueArrayList<PersonRequestQueue> outsides。输入线程和分配器共享waitqueue对象,而五个电梯线程分别与分配器共享outside(outsides中的元素)对象。输入线程不断从读取输入信息,将得到的用户电梯请求存入waitqueue并通知分配器,而分配器则在得到通知后从waitqueue中读取请求,并根据请求所在的大楼编号将该请求存入outsides中对应的outside中,然后通知电梯线程,电梯线程得到通知后则进行调度处理。

可以看出,代码在进行对不同大楼电梯的请求进行分配时采用的是生产者-消费者模式,两对生产者-消费者关系如下:

由于不同线程是通过共享对象进行通信的,因此notifyAll()wait()方法都需要在PersonRequestQueue类的共享对象实例的方法调用,而不应该在电梯线程或分配器中使用。例如,当分配器发现waitqueue等待队列中没有新的请求时,应在它调用的PersonRequestQueue.getOneRequest()中使用wait();并且,如果其被唤醒后对共享对象进行了修改,则应在退出方法前调用notifyAll(),通知其他引用这个共享对象并可能在wait状态的线程:(这也是本次作业同步块设计的一个要点,即调用wait()方法的monitor应该和同步对象相同,故我在该共享对象中调用wait()

/* in class PersonRequestQueue */
public synchronized PersonRequest getOneRequest(boolean remove) {
    if (/* 没有需求且未终止 */) {
        try {
            wait();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    // do something	
    notifyAll();
    return personRequest;
}

这样才能保证不同线程之间的正常交互,既不浪费CPU资源,同时也不浪费时间。

调度策略

本次作业基本上采用的是look策略(跟现实情况基本相同),即当电梯里没人时,外面有请求则前往请求所在楼层,否则挂起;当电梯里有人的时候,则按照最开始进入电梯的人的方向运行,中途经过的楼层如果有人在外面等并且他走的方向与电梯当前方向相同,则让他进来,否则不让他进来。而在电梯最后一个人出去后,如果当前前方有请求则沿着原方向进行;如果当前方向前方没有请求但是反方向有请求则立刻改变方向(不用到楼顶或楼底);否则如果什么请求都没有则挂起。

我在设计策略时,直接将其写在了ElevatorThreadrun()方法中。这里具体分析一下:

public void run() {
    while (true) {
        if (/* 符合终止条件 */) {
            return;
        }
        if (innerCanGoOut()) {
            openAndClose();
            /* 包含让电梯里的人出去和让外面的人进来两个操作。
            在让电梯里的人出去后,需要重新判断outerCanGetIn() */
        } else if (outerCanGetIn()) {  
            // outerCanGetIn()包含判断是否已经满载
            openAndClose();
        } else if (!isInnerEmpty() || hasRequestAhead()) {
            moveAlongOriginalDirection();  // 沿着原方向前进
        } else if (!isOutsideEmpty()) {
            changeDirection();
        } else {
            // ...
            elevatorState = ElevatorState.Hung;  // 挂起
            outside.outsideWait();  // 在PersonRequestQueue.outsideWait()调用wait() 
    	}
	}
}

可以看到调度代码采用了几个if-else的的罗列,这里的条件是有先后顺序的。

复杂度分析

第一次作业复杂度分析表格[1](为了观感将其放在最后,可点击链接进行查看和返回)

本次作业除了ElevatorThread以外的类复杂度都较低,而ElevatorThread的复杂度主要集中在调度过程的判断上。run方法的条件判断分支较多导致复杂度较高,而hasRequestAhead()则由于先对边界条件(电梯是否到顶楼或底楼、当前电梯是否挂起、外面请求是否为空)进行了一个判断后再开始用循环迭代分析。由于这些边界条件基本上都是必要的,可以有效防止电梯超出楼层或者跑到地下等情况,所以暂时找不到降低其复杂度的办法。

总体上来说,复杂度还是可以接受的。

BUG分析

一些收获

其实在上次作业就提到了并发修改异常ConcurrentModificationException并不一定每次都会产生。这里就多线程与其的关系做一些补充。


第二次作业(hw6)

任务说明

本次作业在上次作业A、B、C、D、E五座楼分别各一台电梯的情况下有所增加。

第一个变化:原先每座楼电梯的数量由固定的1个转化为不固定的多个——初始时各座默认一台,随后根据输入数据ADD-building-电梯ID-楼座ID动态增加电梯数量。

第二个变化:在不同楼座的相同楼层间增加了横向环形电梯,该电梯可以沿着A→B→C→D→E→A(在本文定义为向右)或A→E→D→C→B→A(在本文定义为向左)方向运行。

在这次作业中规定了输入的请求要么在同一楼座内,要么在不同楼座的同一楼层内(即不需要换乘电梯)。

代码架构模式与调度策略

UML类图

UML协作图

代码架构模式

|- Main: 主类
|- InputThread: 输入类,一个线程对象
|- DistributeThread: 分配器,一个线程对象
|- VerticalElevator: 纵向电梯线程,初始时共五个线程对象,之后可在InputThread中动态增加
|- HorizontalElevator: 横向电梯线程,初始时不创建,之后可在InputThread中动态增加
	|- ElevatorState: 电梯运行状态
	|- Building: 当前(横向)电梯所处楼座,并有移动楼座、判断最短距离以及方向的方法
|- Output: 输出类
|- PersonRequestQueue: 共享对象,将多个PersonRequest对象封装成一个队列。

# 下面两个没有放在UML图中,由官方包提供:
|- PersonRequest: 官方输入包提供,记录每个乘客请求
|- ElevatorRequest: 官方输入包提供,记录增加电梯的请求

本次作业在上次作业的基础上进行,同样继续使用生产者-消费者模式。由于我粗暴地位横向电梯单独开了一个类,生产者-消费者的对应关系就从两对变成了下面三对:

关于生产者与消费者线程之间的通信以及同步处理基本与第一次作业相同,这里不再赘述。

从UML类图中可以看到新增了一个Building类,这个类内部只有一个char类型的属性,用于存放楼座对应的字符。由于横向电梯从A到E五个楼座可以循环移动,在Building类中封装了向左、向右等移动方法,增强了代码可读性。比如,电梯向左移动一个楼座的行为可以封装如下:

public void goLeft() {  // A → E → D → C → B → A
    if (building == 'A') {
        building = 'E';
    } else {
        building -= 1;
    }
}

当然,这个封装非常容易实现。

调度策略(含同步块设计)

调度策略是本次作业的重点,主要为:

首先说明本次作业中单部横向电梯调度的思路。

与单部纵向电梯调度类似,横向电梯借鉴了纵向电梯的代码,如下所示:

// 纵向:具体说明见第一次作业的调度策略部分
public void run() {
    while (true) {
        if (/* 符合终止条件 */) {
            return;
        }
        if (innerCanGoOut()) {
            openAndClose();
        } else if (outerCanGetIn()) {  
            openAndClose();
        } else if (!isInnerEmpty() || hasRequestAhead()) {
            moveAlongOriginalDirection();
        } else if (!isOutsideEmpty()) {
            changeDirection();
        } else {
            /* 电梯挂起,等待 */
    	}
	}
}
// 横向
public void run() {
    while (true) {
        if (/* 符合终止条件 */) {
            return;
        }
        if (innerCanGoOut()) {
            openAndClose();
        } else if (outerCanGetIn()) {
            openAndClose();
        } else if (!isInnerEmpty() || hasRequestAhead()) {
            moveAlongOriginalDirection();
        } else if (!isOutsideEmpty()) {
            setDirectionByOutsiders();  // difference here!!!
        } else {
            /* 电梯挂起,等待 */
        }
    }
}

从顶层的架构(run方法内)看,两种电梯调度基本相同,但在一些细节上不同之处还是很明显的。首先,对于一部横向电梯,在到达一个楼层后,先判断里面的人是否可以出去,再判断外面的人是否可以进来,如果上面两个条件均不满足,则判断电梯里是否还有人或者电梯前方是否还有需求,如果满足则沿着电梯运行方向继续移动一个楼座,如果仍不满足,则判断电梯外是否还有需求,有的话则根据等待者重新设置电梯方向

可以看出,横向电梯的调度中没有了“反转方向”的行为。在横向电梯调度中,不同之处即为上面加粗的两个点,分别对应hasRequestAhead()setDirectionByOutsiders()两个方法。

对于判断电梯前方是否还有需求hasRequestAhead()方法而言,首先需要定义什么叫做RequestAhead。由于电梯是环形的,电梯一直沿着一个方向都可以接到请求,但这显然会导致时间巨额开销。我这里定义的ahead是指前方请求所在楼座距离当前电梯所在楼座不超过2(A到D和C的距离均为2,方向分别为向左、向右)。比如,当前电梯在A座,运行方向向右,而外面有个起始楼座在E的请求。尽管电梯只要向左移动一座就能接到,但是由于其不在电梯运行方向上,该E座的请求不能算作Request Ahead

而对于根据等待者重新设置电梯方向setDirectionByOutsiders()方法来说,由于该方法的执行条件是在电梯内已经没有请求的前提上,因此只需要根据外面的请求来决定方向。在该方法中,通过调用Building类的静态方法Building.getMinDistance()来判断乘客所在楼座距离当前电梯楼座的距离,进而判断电梯应当向哪边移动。

第二部分的调度问题为多部同一楼座的纵向电梯或同一楼层的横向电梯的调度

与上一次作业相比,该作业有一个重大的不同。对于同一楼座的多部电梯,由于我采用的是自由竞争的策略,即共享同一个等待队列,在读取请求时容易发生数据冲突。为此,我在对一部电梯判断当前楼层(或楼座)外面的人能否进电梯时,如果能进,直接将他们从共享的等待队列中移除,并加入到本电梯私有的候乘队列,该方法自然也必须进行同步,如下所示:(如下也是本次作业同步块设计的重点,但是如下设计会导致性能上的些许下降)

/* 以纵向电梯为例,该方法在指定楼层尽可能的读取请求,从共享队列中删去后并返回 */
// PersonRequestQueue.getAllFromFloorAt()
public synchronized ArrayList<PersonRequest> getAllFromFloorAt(
    int floor, int maxNum, ElevatorState direction) {
    // floor:起始楼层为floor的需求
    // maxNum:最多返回的人数
    ArrayList<PersonRequest> outsiders = new ArrayList<>();
    int inNum = 0;
    for (int i = 0; i < personRequests.size(); i++) {
         PersonRequest req = personRequests.get(i);
         ElevatorState outsiderDirection =
             (req.getToFloor() > req.getFromFloor()) ?
                     ElevatorState.Up : ElevatorState.Down;
         if (req.getFromFloor() == floor && (outsiderDirection == direction)) {
             outsiders.add(req);
             personRequests.remove(i);
             i--;
             inNum++;
             if (inNum >= maxNum) {
                 break;
              }
         }
    }
    return outsiders;
}

该同步方法设在共享对象所处的类中,传入电梯所在楼层floor,电梯最大可进人数maxNum以及当前电梯的状态(方向)direction,从多部电梯共享的等待队列personRequests中读取尽可能多的请求,删去这些请求后将它们加入outsiders并返回。这就保证了多部电梯不会同时读到相同的请求,避免了"一个人同时进两步电梯"的错误。

当然,由于该同步方法较复杂,电梯的性能会有所下降。

复杂度分析

第二次作业复杂度分析表格[1:1](为了观感将其放在最后,可点击链接进行查看和返回)

与第一次作业相比较,第二次作业的复杂度有所增加。在类复杂度上仍然主要是集中在电梯类,而方法复杂度上,由于增加了多部电梯共享同一等待队列的自由竞争策略,在判断请求相关的方法上需要进行同步,因此,这些方法的复杂度有所提升。

BUG分析


第三次作业分析(hw7)

任务说明

本次作业主要有以下两个不同点:

具体来说,初始时在五座分别有一个纵向电梯,同时还有一个在1层的可达所有楼座的横向电梯。在程序运行中间可以增加定制化的电梯。

代码架构模式与调度策略

UML类图

UML协作图

下图省略了构造方法、getter/setter方法

代码架构模式(含同步块设计)

package io: 输入输出相关
	|- Main: 主类,负责处理输入,以及当输入结束时设置完成标志
	|- Ouput: 输出类
	
package control: 控制相关的类
	|- Controller: 负责分配请求,对需要换乘的请求拆分成多步请求
	|- Counter: 计数器,负责在输入完成后判断是否所有请求都完成
	|- HorizontalMap: 负责记录横向电梯的可达楼座信息
	
package elevator: 电梯相关类
	|- Building: 作为电梯类的一个属性,记录电梯当前所处楼座和可达楼座信息
	|- ElevatorState: 枚举类,代表电梯"上升、下降、挂起、向左、向右"五个状态
	|- HorizontalElevator: 横向电梯类
	|- VerticalElevator: 纵向电梯类
	
package requestes: 请求相关类
	|- HorizontalReqQueue: 横向电梯等待队列类
	|- VerticalReqQueue: 纵向电梯等待队列类
	|- Req: 请求类,对官方包PersonRequest的封装

对本次作业,我进行了重构。下面先对各个类进行进一步说明:

采用单例模式的类有:

Ouput,Controller,Counter,HorizontalMap

作为线程的类有:

Main(主线程),HorizontalElevator,VerticalElevator

由于本次作业涉及java类较多,我将类分到了四个包中,以方便对他们进行管理。与前两次作业结构上相似的类为两个电梯类以及Building楼座信息记录的类;而前两次作业中的输入线程类Input、分配线程类DistributeThread则被删去,由主类Main处理输入,由单例模式的控制器Controller将请求分配i.e.调用addRequest()到合适的队列。

另外,上一次作业中的共享队列的类为PersonRequestQueue,其内部简单地用ArrayList实现,该类作为纵向电梯、横向电梯同一的共享队列;而本次作业则将共享队列类进行细分,分为横向电梯请求队列HorizontalReqQueue和纵向电梯请求队列VerticalReqQueue,其内部改用HashMap<>实现,加快了查找速度,具体如下:

/* in class HorizontalReqQueue */
// 横向电梯的等待队列
// 即:请求所在楼座为键,请求为值
HashMap<Character, ArrayList<Req>> horizontalReqs;
/* in class VerticalReqQueue */
// 垂直电梯的等待队列
// 即:请求所在楼层为键,请求为值
HashMap<Integer, ArrayList<Req>> verticalReqs;

由于本次作业出现了一定需要换乘电梯的情况,我对乘客请求PersonRequest类进行了封装,在Req类中,记录下面几个信息:当前乘客所处楼座和楼层、乘客最终目的楼座和楼层、乘客当前子请求(即每一步请求)的目的楼座和楼层。其中,最后两个量需要由控制器设置(具体见下面调度策略分析)。

此外,在程序运行过程中会出现一个问题,由于每个请求都有可能需要换乘,当输入停止并设置相关标志后,可能导致每个请求后续步骤需要使用到的电梯停止,造成某些请求无法完成。对此,我采用了研讨课上大家提出的方法,设置一个计数器,投入一个请求就加一,一个请求完成就减一,在输入停止后判断其值是否为0,如果不为零,就不能让电梯停止。计数器实现如下:(这是本次作业新增的一个同步块设计

public class Counter {
    // ......
    private int count;
    // ......
    
    public synchronized void acquire() {
        count++;
    }
    
    public synchronized void release(int num) {
        count -= num;
        if (count == 0 && inputEnd) {
            notifyAll();
        }
    }
    
    public synchronized boolean allReqFinished() {
        // 仅在主线程调用(输入完成后启动)
        inputEnd = true;
        while (count != 0) {
            try {
                wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return true;
    }
}

主线程在输入结束后调用单例模式类CounterallReqFinished()方法并阻塞,直到最后一个请求完成后将其唤醒,返回true后在主线程设置结束标志。

那么,还剩下的一个类HorizontalMap具体的功能与实现又是怎样的呢?

前面提到,该类主要记录横向电梯的可达楼座,每个横向电梯在其构造方法内,调用单例模式类HorizontalMap相应增加电梯的方法:

HorizontalMap.getInstance().addElevator(floor, msg);

这样方便记录所有横向电梯的可达情况,方便在Controller类在调度时直接调用。

由于所有电梯线程并发的访问该对象的方法并改变其数据,HorizontalMap类同样也需要进行同步,其同步块设计如下:

public class HorizontalMap {
    // ......
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    public boolean canReach(int floor, char from, char to) {
        lock.readLock().lock();
        // 在floor层,从from座到to座是否有**直达**电梯
        // ......省略多个return语句,在return前也需要释放读锁
        lock.readLock().unlock();
        return false;
        
    }
    /* 上面提到的在每个横向电梯的构造方法内调用的方法 */
    public void addElevator(int floor, int switchInfo) {
        lock.writeLock().lock();
        // ......
        lock.writeLock().unlock();
    }
    
}

这里没有简单地用synchronized方法,而是采用了课上所介绍的可重入读写锁,使得在调用canReach()只读方法时可以有多个线程并发调用,提高了代码可读性和效率。

调度策略

就横向电梯而言,相同楼层的电梯仍然共享同一个等待队列,即仍然采用自由竞争策略(而我由于处置不当导致了轮询,具体见bug分析)。

由于时间等各种原因,本次作业的调度采用了相对简单地思路进行,具体的策略描述如下(仅列出横向调度):

该策略十分简单粗暴,具有如下不足:

复杂度分析

第三次作业复杂度分析表格[1:2](为了观感将其放在最后,可点击链接进行查看和返回)

在类复杂度上,复杂度大的类还是两个电梯,与上次作业差不多。而方法圈复杂度上,除了电梯获取请求的相关方法复杂度较高,控制器中规划请求路线和调度的复杂度也较高。总体上来说还可以接受。

BUG分析

总结

本次多线程单元还算满意的结束了,但在知识点方面还是有许多遗憾。

在设计模式方面,只掌握了几个最基本的设计模式:生产者-消费者模式、观察者、单例模式、流水线模式、主从模式,还有很多没有学。

在java多线程机制上,较为熟练地掌握了synchronized同步机制下线程通过wait()notifyAll()通信,初步学会了Lock的使用。但没有学习线程池、信号量以及java提供的一系列线程安全容器的实现。

最后特别感谢这位同学提供的评测机,对我课下bug修复提供了极大帮助!!!


附录

hw5复杂度分析表格


hw6复杂度分析表格


hw7复杂度分析表格


  1. 复杂度分析表见上↑ ↩︎ ↩︎ ↩︎

标签:OO,请求,复杂度,BUAA,Summary,楼座,电梯,线程,4.0
来源: https://www.cnblogs.com/NormalLLer/p/16207457.html