2024 BUAA OO Unit2 总结

文章详细描述了一次编程作业中,作者逐步改进电梯系统的设计,包括采用策略模式、两层生产消费者模式,分析了方法复杂度,解决了多线程间的数据冲突和同步问题,强调了线程安全、层次化设计的重要性,并在迭代中处理了更复杂的请求分配策略和电梯运行策略。
摘要由CSDN通过智能技术生成

一、整体分析

架构分析

最后一次作业的 UML 类图如下所示,整个作业完成过程中没有发生太大的变化,在第二次作业中将上锁改为了第一次直接对数据结构上锁改为调用时自己决定上锁,第三次作业中提取了抽象电梯类。电梯的运行(捎带)策略从第一次作业就采用策略模式,每类电梯都有自己的策略类。

采用两层生产消费者模式,分别是 Input - Scheduler 和 Scheduler - Elevator。

在这里插入图片描述

复杂度分析

MethodCogCev(G)iv(G)v(G)
Elevator.Elevator(int, PersonQueue)0111
Elevator.Elevator(int, int, int, int, int)0111
Elevator.getCapacity()0111
Elevator.getDirection()0111
Elevator.getFloor()0111
Elevator.getId()0111
Elevator.getMoveTime()0111
Elevator.getOutsidePersons()0111
Elevator.isOver()0111
Elevator.nextAction()0111
Elevator.setCapacity(int)0111
Elevator.setDirection(int)0111
Elevator.setFloor(int)0111
Elevator.setMoveTime(int)0111
Elevator.setOver()0111
Elevator.setStrategy(Strategy)0111
Input.Input(ArrayList<Request>)0111
Input.run()7334
Main.main(String[])0111
NormalElevator.NormalElevator(int, ArrayList<Request>, PersonQueue, Schedule, PersonQueue)0111
NormalElevator.close()1122
NormalElevator.copy()0111
NormalElevator.doubleCarReset(int, int, int)0111
NormalElevator.getPersonCount()0111
NormalElevator.hasInsideRequestsInDirection(int)0111
NormalElevator.hasInsideRequestsToFloor(int)0111
NormalElevator.hasOutsideRequestsFromFloor(int)0111
NormalElevator.hasOutsideRequestsInDirection(int)0111
NormalElevator.in()3323
NormalElevator.isEmpty()1122
NormalElevator.isReset()0111
NormalElevator.normalReset(int, int)0111
NormalElevator.open()1122
NormalElevator.out()1122
NormalElevator.removeAllPersons()8266
NormalElevator.run()2291012
NormalElevator.updatePosition()1122
NormalElevator.updateState()6234
NormalStrategy.NormalStrategy(NormalElevator)0111
NormalStrategy.canMove()1122
NormalStrategy.canOpen()2313
NormalStrategy.nextAction()8446
Person.Person(int, int, int)0111
Person.clone()0111
Person.getFromFloor()0111
Person.getId()0111
Person.getToFloor()0111
Person.needTransfer(int, int)1112
Person.toString()0111
PersonQueue.PersonQueue()0111
PersonQueue.addPerson(Person)0111
PersonQueue.clear()0111
PersonQueue.clone()1122
PersonQueue.hasPersonsFromFloor(int)0111
PersonQueue.hasPersonsInDirection(int, int, boolean)5125
PersonQueue.hasPersonsToFloor(int)0111
PersonQueue.hasPersonsToTransfer(int, int)0111
PersonQueue.isEmpty()0111
PersonQueue.popPerson()1122
PersonQueue.popPersonFromFloor(int)0111
PersonQueue.popPersonToFloor(int)0111
PersonQueue.popPersonToTransfer(int, int)0111
PersonQueue.size()0111
Schedule.Schedule(ArrayList<Request>)2133
Schedule.addDoubleCarElevator(SingleCarElevator, SingleCarElevator)1122
Schedule.choose(Person)21269
Schedule.getAnotherElevator(SingleCarElevator)1133
Schedule.getElevator(int)0111
Schedule.getRequest()5335
Schedule.handleDoubleCarResetRequest(DoubleCarResetRequest)1122
Schedule.handleNormalResetRequest(NormalResetRequest)0111
Schedule.handlePersonRequest(PersonRequest)5144
Schedule.isOver()5435
Schedule.run()12778
Schedule.setInputOver()0111
ShadowNormalElevator.ShadowNormalElevator(int, int, int, int, ResetState, PersonQueue, PersonQueue, …)1122
ShadowNormalElevator.canOpen()2313
ShadowNormalElevator.in()3323
ShadowNormalElevator.out()1112
ShadowNormalElevator.stimulate(Person)10178
ShadowSingleCarElevator.ShadowSingleCarElevator(int, int, int, int, int, char, PersonQueue, …)0111
ShadowSingleCarElevator.canMove()5326
ShadowSingleCarElevator.canOpen()6346
ShadowSingleCarElevator.in()3323
ShadowSingleCarElevator.out()7216
ShadowSingleCarElevator.stimulate(Person)6145
Signal.setFree()0111
Signal.setOccupied()3223
SingleCarElevator.SingleCarElevator(int, int, int, int, char, ArrayList<Request>, Signal)2113
SingleCarElevator.canAccept(Person)6266
SingleCarElevator.canFinish(Person)3144
SingleCarElevator.close()1122
SingleCarElevator.copy()0111
SingleCarElevator.getKind()0111
SingleCarElevator.getPersonCount()0111
SingleCarElevator.getTransferFloor()0111
SingleCarElevator.hasInsidePersonsInDirection(int)0111
SingleCarElevator.hasInsidePersonsToFloor(int)0111

相比第一单元,这次作业在方法复杂度上有了很好的改善,出现很大复杂度的方法主要是 Elevator 和 Scheduler 的 run 方法,两类电梯的 stimulate 方法以及 Scheduler 中实现分配策略的 choose 方法。

二、迭代分析

第一次作业

设计架构

在第一次作业中,用 waitRequests 存储输入请求中待分配的请求,outPersons 存储已被分配给某个电梯的请求中还没进入电梯的乘客。那么,对于 Input, Scheduler, Elevator 这三类线程来说,Input 和 Scheduler 之间对 waitRequests 存在数据冲突,Scheduler 和 Elevator 之间存在数据冲突,需要对读写这两个数据结构的操作加锁。

比如,Scheduler 会向某个电梯的 outPersons 中放入请求,而电梯会读取 outPersons 并且取出请求(人进入电梯的动作),这时候需要对 outPersonsaddpop 等操作加锁,并且进行 wait-notify 操作。

public synchronized void addRequest(Request request) {
	queue.add(request);
    notifyAll();
}

public synchronized Request popRequest() {
	 while (queue.isEmpty() && !isOver) {
	     try {
	         wait();
	     } catch (InterruptedException e) {
	         e.printStackTrace();
	     }
	 }
	 if (queue.isEmpty()) { return null; }
	 notifyAll();
	 return queue.remove(0);
}

在电梯运行上,需要制定两个策略:捎带策略(电梯本身运行的方式)和分配策略(电梯接收哪些乘客),其中,捎带策略是电梯自身的行为,只需要根据电梯的属性就可以得到,因此适合作为一个策略接口当做电梯的属性,在电梯的 run 方法内每次调用这个策略得到下一步的运行方式,而分配策略需要根据输入请求和所有电梯的状态决定分配给哪辆电梯,因此适合放在 Scheduler 类中完成。在第一次作业中,电梯的捎带策略和分配策略如下:

  • 运行(捎带)策略:Look 策略

    定义电梯的五个运行时动作:

    • 开门 OPEN:电梯中的乘客到达目标楼层,或者有乘客在电梯所处楼层等待并且目标楼层方向与电梯运行方向相同
    • 同向移动 MOVE:电梯中有乘客的目标楼层与目标楼层同向,或者有在等电梯的乘客可以被捎带(乘客所处楼层在电梯运行方向上)
    • 转向移动 REVERSE:电梯中有乘客或者有乘客在等电梯时,如果不满足同向移动条件,就选择转向移动
    • 等待 WAIT:电梯中没有乘客,也没有乘客在等电梯,并且输入还未结束
    • 结束 TERMINATE:电梯中没有乘客,也没有乘客在等电梯,并且输入已结束
    public Action nextAction() {
        if (canOpen()) {
            return Action.OPEN;
        } else if (elevator.hasInsideRequests() || elevator.hasOutsideRequests()) {
            return canMove() ? Action.MOVE : Action.REVERSE;
        } else {
            // If the elevator is empty and no one is waiting
            return elevator.isOver() ? Action.TERMINATE : Action.WAIT;
        }
    }
    
  • 分配策略:乘客指定电梯

    Elevator elevator = elevators.get(request.getElevatorId() - 1);
    elevator.addRequest(request);
    

在这里插入图片描述

bug 分析

第一次作业中未出现bug。

第二次作业

设计架构

在第二次作业中,新增了 RESET 请求,电梯在收到该请求后需要尽快让乘客离开,也就是说,电梯在收到 RESET 请求后需要尽快将 insidePersonsoutsidePersons 中的乘客放回 waitRequests 中,新增了 Elevator,Scheduler,Input 三个线程之间关于 waitRequests 的数据冲突。

同时,乘客不再指定电梯,需要我们自己完成请求的分配策略。按照往年博客,我选择了影子电梯的分配策略:模拟将当前请求分配到各个电梯,计算完成目前所有请求的时间和耗电量,选择最优的电梯分配。为了完成模拟,需要对电梯进行克隆,当然由于分配策略只影响性能并不影响正确性,所以并不需要追求完全的克隆,可以构建一个类 ShadowElevator,里面存储的是计算电梯运行结果所需要的属性,比如:电梯的楼层,移动方向,容量,移动时间,insidePersons 和 outsidePersons。影子电梯的运行采用相同的捎带策略,只是将电梯中 sleep(t) 换为 time += t

电梯的运行(捎带)策略变化不大,只需要新增 RESET 行为,为电梯设置一个属性表示是否需要 RESET,RESET 的具体做法是首先将 insidePersonsoutsidePersons 中的所有请求放入 waitRequests 中,然后设置电梯新的容量和移动时间。

public Action nextAction() {
	if (elevator.isReset()) {
	    return Action.RESET;
	} else if (canOpen()) {
	    return Action.OPEN;
	} else if (elevator.hasInsideRequests() || elevator.hasOutsideRequests()) {
	    return canMove() ? Action.MOVE : Action.REVERSE;
	} else {
	    // If the elevator is empty and no one is waiting for this elevator
	    return elevator.isOver() ? Action.TERMINATE : Action.WAIT;
	}
}

这一过程需要注意对 outsidePersonswaitRequests 两个数据加锁:

public void removeAllPersons() {
	if (!isReset) {
	    throw new RuntimeException("Elevator-" + id + " should not be reset!");
	}
	if (!insidePersons.isEmpty()) {
	    synchronized (waitRequests) {
	        open();
	        while (!insidePersons.isEmpty()) {
	            Person person = insidePersons.popPerson();
	            if (person.getToFloor() != floor) {
	                waitRequests.add(new PersonRequest(floor, person.getToFloor(), person.getId()));
	            }
	            TimableOutput.println("OUT-" + person.getId() + "-" + floor + "-" + id);
	        }
	        waitRequests.notifyAll();
	        close();
	    }
	}
	synchronized (outsidePersons) {
	    while (!outsidePersons.isEmpty()) {
	        Person person = outsidePersons.popPerson();
	        synchronized (waitRequests) {
	            waitRequests.add(new PersonRequest(person.getFromFloor(), person.getToFloor(), person.getId()));
	            waitRequests.notifyAll();
	        }
	    }
	    outsidePersons.notifyAll();
	}
}

在这里插入图片描述

bug 分析

  • RESET 请求优先级:题目要求 RESET 请求需要在 5s 内完成,并且不能有超过两条 ARRIVE,这意味着 RESET 请求 具有相比乘客请求更高的优先级。电梯重置时会把 insidePersonsoutsidePersons 中的请求立刻加入到 waitRequests 中,这导致了如果之后有一条新的 RESET 请求,它的位置可能很靠后,需要经过一段时间才会被 Scheduler 取出。 我认为可以设置两个队列,一个存储 RESET 请求,一个存储 Person 请求,每次优先取出 RESET 请求。
  • 分配策略:这次作业中因为时间不足,没有将请求考虑正在 RESET 的电梯。在后续作业中,我为电梯设置了缓冲区,如果电梯正在 RESET 状态,不能进行 RECIEVE,就将请求放入缓冲区中,等结束后再取出放入到 outsidePersons

第三次作业

设计架构

第三次作业新增了双轿厢电梯,通过 RESET-DCElevator 指令完成。在我的设计中,两个轿厢除了需要考虑不能同时位于换乘楼层外,其他运行规则完全独立,也就是说可以将两个轿厢视作两个线程。这次作业中,我让 Elevator 成为抽象类,实现了 Runnable 接口,第一、二次作业中的电梯作为普通电梯 NormalElevator,双轿厢电梯的一个轿厢作为一类电梯 SingleCarElevator,二者继承 Elevator,并且需要重写 run,setStrategy,open,close 等抽象方法。

具体地,需要处理三个问题:单个轿厢的运行(捎带)策略,如何防止两个轿厢相撞以及如何完成双轿厢请求的模拟过程。

  • 单个轿厢的运行(捎带)策略

    定义单个轿厢的五个运行时动作:

    • 开门 OPEN:电梯中的乘客到达目标楼层,或者有乘客在电梯所处楼层等待并且目标楼层方向与电梯运行方向相同,或者有乘客需要在当前楼层换乘
    • 同向移动 MOVE:电梯中有乘客的目标楼层与目标楼层同向,或者有在等电梯的乘客可以被捎带(乘客所处楼层在电梯运行方向上),或者轿厢处于换乘楼层并且没有人需要进出,那么就直接离开为另一个轿厢腾出位置(根据轿厢类型情况决定离开方向)
    • 转向移动 REVERSE:电梯中有乘客或者有乘客在等电梯时,如果不满足同向移动条件,就选择转向移动,或者轿厢处于换乘楼层并且没有人需要进出,那么就直接离开为另一个轿厢腾出位置(根据轿厢类型情况决定离开方向)
    • 等待 WAIT:电梯中没有乘客,也没有乘客在等电梯,并且未输入或者有请求未完成
    • 结束 TERMINATE:电梯中没有乘客,也没有乘客在等电梯,输入已结束并且所有请求都完成
    public Action nextAction() {
        if (canOpen()) {
            return Action.OPEN;
        } else if (!elevator.isEmpty() || elevator.getFloor() == elevator.getTransferFloor()) {
            return canMove() ? Action.MOVE : Action.REVERSE;
        } else {
            return elevator.isOver() ? Action.TERMINATE : Action.WAIT;
        }
    }
    
  • 如何避免两个轿厢相撞:两个电梯共享一个对象,记录了换乘楼层是否有轿厢占据,通过加锁的方式实现只有一部轿厢可以位于换乘楼层。

    public class Signal {
    	private boolean isOccupied = false;
    	
        public synchronized void setOccupied() {
            while (isOccupied) {
                try {
                    wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            isOccupied = true;
            notifyAll();
        }
    
        public synchronized void setFree() {
            isOccupied = false;
            notifyAll();
        }
    }
    
    Signal signal = new Signal();
    schedule.addDoubleCarElevator(
        new SingleCarElevator(getId(), newCapacity, newMoveTime, transferFloor, 'A', waitRequests, signal),
        new SingleCarElevator(getId(), newCapacity, newMoveTime, transferFloor, 'B', waitRequests, signal)
    );
    
    protected void updatePosition() {
        try {
            sleep(getMoveTime());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        setFloor(getFloor() + getDirection());
        if (getFloor() == transferFloor) {
            occupiedSignal.setOccupied();
        }
        TimableOutput.println("ARRIVE-" + getFloor() + "-" + getId() + "-" + kind);
        if (getFloor() - getDirection() == transferFloor) {
            occupiedSignal.setFree();
        }
    }
    
  • 双轿厢请求的模拟:由于一个请求可能需要换乘无法被一个单轿厢完成,而换乘的请求不一定是被这个双轿厢的另一个轿厢完成,这给影子电梯的模拟带来了挑战(比如通过 1-A 和 2-B的协作完成)。为了简化这个过程,我在实现时规定:一个需要换乘的请求只能由一个双轿厢协作完成(比如只能由 1-A先接受,然后在换乘楼层由 1-B 完成剩下部分)。

    int cost = ((SingleCarElevator) elevator).copy().stimulate(person);
    if (!((SingleCarElevator) elevator).canFinish(person)) {
        cost += getAnotherElevator((SingleCarElevator) elevator).copy()
        		.stimulate(new Person(person.getId(), tmp.getTransferFloor(), person.getToFloor()));
    }
    

在这里插入图片描述

bug 分析

本次作业强测中没有出现bug,在互测中由于 RESET-DCElevator 指令删除普通电梯和新增双轿厢电梯处理不当出现问题。

在处理 Elevator 和 Scheduler 线程的结束条件时,我的做法是判断 waitRequests 和 每个电梯的 insidePersonsoutsidePersons 是否为空并且输入结束:

private boolean isOver() {
	synchronized (waitRequests) {
	    if (!inputOver || !waitRequests.isEmpty()) {
	        return false;
	    }
	}
	for (Elevator elevator : elevators) {
	    if (!elevator.isEmpty()) {
	        return false;
	    }
	}
	return true;
}

有可能会出现请求都被放入创建的 SingleCarElevator 但是还没有把新增的单轿厢加入 elevators 的情况,或者是 isOver() 判断为真后并且已经完成了所有电梯的 setOver() 后才把新增的单轿厢加入 elevators,这会导致单轿厢无法结束的情况。

三、体会

多线程调试

在三次作业中,最常见的问题是 Elevator 线程一直处于 wait 不被唤醒。在定位过程中,我首先在电梯的 run 方法中,每次得到下一状态时进行输入,定位到出现问题无法被唤醒的电梯。

public void run() {
    while (true) {
        Action action = strategy.nextAction();
        TimableOutput.println(getId() + " " + action);
        if (action == Action.TERMINATE) {
        
      	} else if (action == Action.MOVE) {
      	
        } else if (action == Action.REVERSE) {
        
        } else if (action == Action.OPEN) {
        
        } else if (action == Action.WAIT) {
        	TimableOutput.println(getId() + " is over: " + isOver);
        
        } else if (action == Action.RESET) {
        
        }
    }
}

对于电梯一直处于 wait 的情况是由 Scheduler 造成的,主要原因有两个:

  • 没有对 waitRequestsoutsidePersons 进行正确的 wait-notify
  • 对于部分语句没有加锁,导致多线程情况下语句没有按照期望的方式执行。比如在 Scheduler 中判断可以结束后对电梯 setOver 操作发生在 nextAction() 后,此时电梯的行为是 WAIT,有可能 setOver() 对 waitRequests 进行 notifyAll() 时电梯线程未执行 waitRequests.wait(),那么当电梯真正执行 waitRequests.wait() 就会导致没有线程可以将其唤醒,始终处于 wait 状态。为了发现这个问题,我在电梯 WAIT 对应的动作中输出此时电梯的 over 状态以及在 Scheduler 进行 setOver() 时进行输出。

对于 CPU 空转的情况可以使用 IDEA 自带的 IntelliJ Profiler 工具,定位到发生轮询的 run 方法。

线程安全

在第一次作业中,各个线程之间的数据冲突很容易分析,只有 Input - Scheduler 和 Scheduler - Elevator 之间两组,并且不存在复杂的嵌套调用关系,线程结束条件也很简单。从第二次作业开始产生数据冲突的线程变多,并且随着 RESET 的优先级要求让函数调用过程变得复杂,很有可能发生死锁,需要很清楚各个方法内是否存在上锁,并且谨慎地分析各条语句的执行顺序,很有可能出现某个属性在一个线程里先被使用然后才被另一个线程修改导致错误。在对一些共享的数据进行操作时,一定要多考虑一些,不能理所当然地认为代码的执行顺序。

正如荣老师上课说的,多线程的 bug 必须通过阅读代码才能发现,在写代码前一定要在纸上理清各个线程之间的关系。

同时,为了确保线程安全,需要进行充分的测试,对于一个测试数据应该进行多轮测试,在测试时的数据应该在统一时刻有大量的投入,才能发现比较隐蔽的线程安全问题。

层次化设计

在这次作业中,多线程的引入使得层次化设计非常必要。输入必须单独为一个层次并且作为一个线程,而输入的处理和分配需要由 Scheduler 单独完成,而具体业务的完成由 Elevator 完成。

层次化设计使系统的不同部分相互独立,降低模块之间的耦合度,从而提高了代码的灵活性和可维护性。以这三次作业为例,只要输入方式和内容不变,就不用对输入进行更改,比如在三次作业中都是使用 ElevatorInput 从控制台输入转化为 Request 请求放入 waitRequests 中,因此不需要进行更改,每次迭代新增的是 Request 类型,只要在 Scheduler 和 Elevator 中增加对新请求的处理方式。

  • 10
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ChenxuanRao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值