OO第二单元总结
一、任务概览
在本单元中,我们的任务是利用多线程实时模拟电梯
作业 | 要求 |
---|---|
1 | 六部电梯,处理任意起始和终止位置的乘客请求 |
2 | 新增维护电梯,增加电梯的请求,乘客被迫下电梯后有可能还未达到目的地 |
3 | 增加电梯可达性限制,楼层最大停靠电梯数限制,可能出现请求无法直达的情况 |
二、思路分析
第一次作业
架构
根据要求,我们的程序需要接收不定时的接收请求并处理,这是一个标准的生产者-消费者模式,生产者线程负责“生产数据”,将这些数据写入某个容器里,消费者线程从这个容器里取出数据并处理。对应到此次作业里,生产者线程需要接收输入,将输入放到一个请求队列里,由电梯线程取出请求完成移动,开关门等操作
对于输入线程
public void run() {
ElevatorInput elevatorInput = new ElevatorInput(System.in);
while (true) {
PersonRequest req = elevatorInput.nextPersonRequest();
if (req == null) {
waitQueues.forEach(waitQueue -> waitQueue.setEnd(true));
break;
} else {
// 写入请求队列
}
}
}
对于请求队列,该类最核心的属性是一个hashmap以每个请求的起始楼层为索引存储各个请求,核心操作是添加请求与取出请求,另外,电梯线程还需要读取一个end标记来判断输入是否已经结束
public class RequestTable {
private HashMap<Integer, ArrayList<Person>> requests;
private boolean end;
public synchronized boolean isEnd() {}
public synchronized boolean isEmpty() {}
public synchronized void setEnd(boolean end) {}
public synchronized void addRequest(Person person) {}
public synchronized Person getRequest(int curFloor, boolean dir) {}
}
其中Person类用来存储每个请求,包含了id,起始位置,终止位置等信息
对于电梯线程,每个电梯都拥有一个请求队列,同时还要有当前楼层,方向,电梯里的乘客三个属性记录当前时刻电梯的状态,run函数中,每次先获取当前应当执行的动作,接下来执行对应的函数即可
public class Elevator implements Runnable {
private int id;
private RequestTable requests;
private int curFloor;
private boolean dir;
private HashSet<Person> inElevator;
@Override
public void run() {
while (true) {
Action action = getAction();
if (action == Action.OVER) {
return;
} else if (action == Action.MOVE) {
move();
} else if (action == Action.REVERSE) {
dir = !dir;
} else if (action == Action.OPEN) {
inAndOut();
} else {
requests.waitInput();
}
}
}
public Action getAction() {}
public boolean hasIn() {}
public boolean hasOut() {}
public boolean hasReqInCurDir() {}
public void move() {}
public void inAndOut() {}
}
调度策略
getAction这个函数就是所谓的电梯运行策略了,它需要根据根据当前电梯的运行状态和请求表中未处理的请求综合判断电梯当前应当执行的操作,我使用的是LOOK策略,用一个枚举变量表示接下来要进行的操作
public Action getAction() {
if (hasOut() || hasIn()) {
return Action.OPEN;
}
if (inElevator.size() != 0) {
return Action.MOVE;
} else {
if (requests.isEmpty()) {
return requests.isEnd() ? Action.OVER : Action.WAIT;
} else {
return hasReqInCurDir() ? Action.MOVE : Action.REVERSE;
}
}
}
-
首先判断当前需不需要开门
-
如果有人要出电梯,即当前电梯停靠的楼层和某个电梯里的人的目的地一致,那就应该开门让这个人下去
-
如果有人要进电梯,即当前电梯停靠的楼层和某个请求队列里的人的出发地一致,并且电梯里的人数小于最大容量,那就应该开门让这个人进来
-
注意应当先判断有没有人需要出去,如果先判断有没有人需要进来,若没有,且电梯满员,即使有人要进入,也不应该开门。若先判断有没有人要进,如果有,根据短路原则,不会再判断是否有人要出,直接返回开门,此时电梯满员且无人出,那么无人能进,电梯将无限开关门
-
-
如果不需要开门且电梯里有人,说明此人还未送达目的地,则电梯继续移动
-
如果既不需要开门且电梯里无人
-
如果当前请求队列为空,再判断请求队列是否已经被打上了结束标记,是则电梯线程结束,否则等待新的请求加入队列
-
如果请求队列不空,则判断当前电梯运行方向上是否还有新的请求,是则按原方向移动,否则掉头
-
在这里我并没有将运行策略封装到一个策略类里,这主要是由于电梯里的状态是存储在电梯线程对象里的,策略类需要根据这些状态判断电梯接下来要进行的动作,策略类需要实时读取,且判断操作并不长,就没必要再单开一个类了。缺点是可拓展性比较差,需要多种运行策略的时候耦合度就会比较高
我并没有设计调度器,或者将为调度器单独开一个线程,经过尝试收益好像不大,且线程间的协作会变得比较复杂
由于每个电梯都单独拥有一个请求表输出线程接收到新请求后直接随机将其放到一个请求队列里即可,这样保证每个请求只会有一个电梯去处理,在电量上表先比较好,另外对于大量的随机数据,可以保证每个电梯处理的请求量大致相同,效果也算不错
缺点是,当电梯并不具备判断最佳路线的能力,例如电梯在5楼接了一个人去往1楼,而接下来6楼出现了若干去往一楼的请求,那么电梯会继续去往一楼,而不是暂时前往6楼接上更多的人。面对这样的数据时表现较差,但这也符合实际电梯运行时有先来后到的情况
锁的设计
请求与队列作为共享对象,实际上,由于我的每个电梯线程都单独拥有一个请求队列,所以每个请求队列都被两个线程共享,我将其所有方法都设为同步的
输入线程写入新请求的时候,或电梯线程取出新请求时,都需要notifyAll,同时写入结束标记时,也需要notify唤醒正在等待的电梯线程
第二次作业
架构
本次作业最主要的变化是是,需要增加了MAINTAIN和ADD两种操作,只需要在原有类的基础上增加一些新的操作就可以完成要求
对于ADD操作,电梯线程对象需要增加最大容量,运行速度两种属性,在构造函数中实现
对于MAINTAIN操作,电梯需要通过读取请求队列中的maintain标记来判断当前是否需要停靠,在上次作业中的getAction函数的基础上多判断判断一下即可,应当首先判断是否需要maintain
接下来分析停靠以后需要进行什么操作,首先电梯应该应当放出所有乘客,如果停靠的楼层恰好是某些乘客的目的地,则不需要进行任何操作,但剩下的乘客如果还未到达目的地,就需要更改其起始位置,将其作为一个新的请求,重新将其分配置一个请求队列中,那么我们就需要一个全局的请求队列halfWayOut,电梯线程将半途出来的人写入这个队列,由输入线程重新将其分配。
需要注意的是,半途而出的人来自于两处,一是电梯里还未到到的目的地的人,二是电梯的请求队列中还未被处理的请求,两部分请求都要在电梯maintain时写入这个容器,等待重新分配
调度策略
我直接将重新分配请求的工作交给了输入线程,输入结束后可以保证不会在有新的请求被装回halfWayOut表,此时进行最后一次rejoin操作,此后所有请求已经被分配至各个电梯的请求队列中,等待电梯线程处理完即可
// InputThread
public void run() {
ElevatorInput elevatorInput = new ElevatorInput(System.in);
while (true) {
rejoin();
Request req = elevatorInput.nextRequest();
if (req == null) {
maintainQueues.values().forEach(waitQueue -> waitQueue.setEnd(true));
try {
sleep(1200);
} catch (InterruptedException e) {
e.printStackTrace();
}
rejoin();
waitQueues.values().forEach(waitQueue -> waitQueue.setEnd(true));
break;
} else {
// 分别处理不同种类的请求
}
}
}
public void rejoin() {
if (waitQueues.size() > 0) { //当前还存在正在运行的电梯,指导书保证了不会关停所有电梯
Person person;
while ((person = halfwayOut.getRequest()) != null) {
getQueue().addRequest(person); // 重新选择请求队列分配出去
}
}
}
这样的设计有可能出现半道而出的人不能被及时重新分配,直到最后结束的时候才被重新分配。
另外,由于每个人要乘坐的电梯编号已经被提前固定了,例如当前先接收了50个请求,接着增加一部新电梯,按照我的设计,前50个人只会选择当时正在运行的电梯,新增的电梯不能得到有效利用。这是影响性能得分的主要因素
第三次作业
架构
这次作业主要有两个变化,一个是增加了同一楼层最多停靠电梯数的限制,一个是电梯可达性的限制,即电梯并不能停靠在任意楼层,且可能出现需要换乘才能到达目的地的情况(关停所有初始电梯)
对于这次第一个停靠数量的限制,我的做法是增加一个所有电梯线程都共享的对象,这个对象记录了当前时刻所有楼层停靠电梯的信息,外部可以调用函数判断是否可以开门,开门时先将自己的编号写入,关门离开后在将自己的编号删除。
public class ArrivalTable {
private final HashMap<Integer, HashSet<Integer>> serving;
private final HashMap<Integer, HashSet<Integer>> entering;
public synchronized void serveAt(int curFloor, int id) {
serving.get(curFloor).add(id);
notifyAll();
}
public synchronized void enterOnlyAt(int curFloor, int id) {
entering.get(curFloor).add(id);
notifyAll();
}
public synchronized void serveFinish(int curFloor, int id) {
serving.get(curFloor).remove(id);
notifyAll();
}
public synchronized void enterFinish(int curFloor, int id) {
entering.get(curFloor).remove(id);
notifyAll();
}
public synchronized boolean serviceable(int curFloor) {
return serving.get(curFloor).size() < 4;
}
public synchronized boolean enterable(int curFloor) {
return entering.get(curFloor).size() < 2;
}
public synchronized void waitFinish() {}
}
电梯需要开门时先判断能否开门,不能时wait
public void inAndOut() {
boolean out = hasOut();
synchronized (arrivalTable) {
while (!arrivalTable.serviceable(curFloor) ||
(!out && !arrivalTable.enterable(curFloor))) {
arrivalTable.waitFinish();
out = hasOut();
}
arrivalTable.serveAt(curFloor, id);
if (!out) {
arrivalTable.enterOnlyAt(curFloor, id);
}
}
// 完成出入操作
if (!out) {
arrivalTable.enterFinish(curFloor, id);
}
}
调度策略
接下来处理电梯可达性的限制,上一次作业中,我将重新分配请求这一操作放在输入线程中进行,但这次作业中此类场景出现的频率大大提高了,上次作业中请求不能的到及时分配的影响会变大。考虑到重新分配这一操作,本质是将请求从一个队列里搬到另外一个队列里,那么能否不搬运呢?其实只要更改一下架构,不再让每一个电梯拥有一个请求队列,全局只设一个请求队列,在Person类中增加一项电梯id,表示它应当乘坐的电梯编号,同时还要记录其真正的起点终点和当前阶段的起点终点,以便判断是否完成了请求。每个电梯只能接选择坐这个电梯的人,一样可以避免自由竞争,不会出现所有电梯一起去找一个请求的状况
其实第二次作业中也可以直接采取这样的设计,每个人都应该选择一部电梯,那么应当选择哪一部呢?上两次作业中直接随机分配即可,但这次作业需要考虑可达性的问题。我的方法是建一个图,用邻接矩阵存储任意两层之间可以到达的电梯编号,通过bfs找出一条路径,将路径装入Person类,寻路时引入一些随机性,确保不会扎堆。这个类可以作为请求表类的一个属性,随时为进入表的请求规划路径
ADD和MAINTAIN实际上就是更新这个图,为其增加或删除边
public class Graph {
private HashSet<Integer>[][] graph = new HashSet[12][12]; //g[i][j]表示可以从i到j的所有电梯
private final ArrayList<Integer> order; // 1-11的一个排列
public synchronized void add(int id, int accessible) {}
public synchronized void delete(int id) {}
public synchronized ArrayList<Integer> getWay(int fromFloor, int toFloor) {
// BFS,遍历的时候可以随机打乱1-11的顺序遍历,使用Collections.shffle
}
}
但是规划好的路径不能保证一定能将人送达,因为路径所以依赖的电梯有可能被关停,那么只要人从电梯里出来,我们就为其重新规划一条路径。
我仍然没有开调度器线程,因为我觉得规划路径有点类似于“组合逻辑”,人只要进入请求队列,就应当规划好起始楼层,结束楼层,以及要乘坐的电梯编号。
我的电梯线程中,并没有判断电梯的可达性,这个限制通过规划路径来保证,既然这个人要坐某部电梯,就一定保证这部电梯可以在起点和终点处开门,同理,这部电梯如果不能在这个人当前阶段的起点和终点开门,那么规划路径时一定不会选择这部电梯作为当前阶段乘坐的电梯。
终止条件
如果输入终止,为请求队列搭上了结束标记,电梯线程就可以停止了吗?
答案是不行,因为其它请求可能需要这部电梯才能到达最终目的地。办法是设置一个信号量,可以手写也可以用现成的,当所有请求都被处理完后,再打上结束标记
public class RequestCounter {
private int cnt;
public RequestCounter() {
cnt = 0;
}
// 每完成一个请求执行一次
public synchronized void release() {
cnt++;
notifyAll();
}
// 输入结束后,执行count次,count为总请求数
public synchronized void acquire() {
while (true) {
if (cnt > 0) {
cnt -= 1;
break;
}
else {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
}
三、遇到的BUG
最主要的bug来自于线程安全
public void run() {
while (true) {
Action action = getAction();
if (action == Action.OVER) {
return;
} else if (action == Action.MAINTAIN) {
maintain();
return;
} else if (action == Action.MOVE) {
move();
} else if (action == Action.REVERSE) {
dir = !dir;
} else if (action == Action.OPEN) {
inAndOut();
} else {
halfwayOut.waitInput();
}
}
}
这是电梯线程的run方法,设想如下情形:现在getAction执行完毕,获得的操作操作是WAIT,紧接着在执行到waitInput之前,请求队列被打上了maintain标记, 并notify了电梯线程,问题是,此时电梯还没进入wait状态,notify就无效了。
解决办法是进入else后锁住请求队列,再获取一次action。
另外就是容器的读写,很多时候都需要遍历容器查找,如果此时向容器里写入,那么就会造成迭代器出错,报出currentmodify异常,解决办法是检查锁的设置是否避免了这种冲突,防止同时读写。列出所有可能存在读写冲突的容器,一次检查关于它的所有操作是否及时上锁
对于调试,一是使用jconsole检测死锁,另外就是在流程的各个部分都输出一些信息,判断执行的顺序,否则仅仅依靠要求输出的信息,很难判断到底是执行到哪里出现了问题
四、心得体会
本次作业迭代的过程中,我的线程类型始终没有发生变化,即输入线程作为生产者,电梯线程作为消费者,同时后两次作业中电梯线程也可以作为生产者。真正变化的是线程之间共享的数据
-
第一次作业只有一个请求队列作为共享数据
-
第二次作业新增了一个请求队列用来存储半途出来的人
-
第三次作业增加了用以判断能否停靠的对象和判断是否完成所有请求的信号量
每增加一些共享数据,就增加了一种线程之间交互的模式,用以完成指导书中不断新增的各种机制
新增一些共享数据远远比新增一种线程容易,应当优先考虑通过增加线程间协作的方式来完成要求,而不是新开一种线程
同时,每增加一些共享数据,就要额外考虑一些线程安全问题,即新增的容器由谁读谁写,在那里读写,什么时候使用wait/notify交互