BUAA_OO_2022 第二单元总结
BUAA_OO_2022 第二单元总结
O.前言
本单元内容主要是关于多线程电梯的设计,从最初的单向纵向电梯逐步迭代到可支持换乘、可定制电梯。个人认为其中的设计重点和易错点主要是共享对象和调度策略的设计。
一、三次作业架构
1.1 hw5分析
1.1.1 hw5 要求分析
本单元第一次作业,要求设计一个多部电梯的实时模拟系统,共A、B、C、D、E五座楼,每座楼共10层,每座楼只有一部纵向运输的电梯。
1.1.2 hw5 设计分析
在本次作业中,根据需求很明显可以使用生产者-消费者模式,即Reader类为生产者,EleController类为消费者,二者之间将请求队列 作为共享对象,在每次电梯类被唤醒后,将全部的请求转移到电梯的内部队列FloorReqQue。
而为了实现线程安全,我根据实验课的代码设计了ReqQueue的线程安全队列,来管理请求, 代码大致如下:
public ReqQueue() {
this.reqQueue = new ArrayList<>();
}
public synchronized void add(PersonRequest req) {
notifyAll();
reqQueue.add(req);
}
public synchronized PersonRequest getFirst() {
PersonRequest temp = reqQueue.get(0);
reqQueue.remove(0);
notifyAll();
return temp;
}
public synchronized ArrayList<PersonRequest> getAll() {
ArrayList<PersonRequest> temp;
temp = (ArrayList<PersonRequest>) reqQueue.clone();
reqQueue.clear();
notifyAll();
return temp;
}
·······
而至于为什么不用BlockingQueue、ConCurrentHashMap 等容器来实现安全的线程通信。个人认为有以下两点原因:
-
首先,BlockingQueue等容器不支持写入null的操作,因此对于线程结束的判别,就可能需要在添加其他共享对象,这就为线程结束的判别和线程安全的设计带来了隐患。
-
其次,对于BlockingQueue等容器内置的方法相对比较局限,而如果我们自行实现线程安全类,可以较便捷的新增各类方法,增加了程序的可拓展性。
而对于电梯类的设计,我使用了EleController、FloorReqQue、Elevator三个类来实现,以EleController为中心,各部分功能如下:
- EleController: 负责层间运行,决定运行方向,根据FloorReqQue的状态决定开关门和上下人。
- FloorReqQue:其内部管理着两个队列,一个是电梯内部的请求队列,一个是电梯外部楼层间的请求队列。
- Elevator:负责输出电梯信息
而关于电梯运行策略,采取LOOK策略:即在运行方向上,每运行到一层判断该层的等待队列,允许同方向的请求进入电梯;如果电梯内无请求运行方向上电梯外楼层中无请求,则反转方向。若该电梯的队列为空且输入标志已经停止,则线程结束。
1.1.3 UML协作图
可以看粗这次作业中我把所有的线程都放在了Main中初始化,这使得层次有些杂乱。
1.1.4 调度分析
在写本次作业的时候,其实对调度器并没有什么太大想法。不过写了Schedule类来实现请求的分发,而在这之后的作业中,我重写了一部分方法,让它承担起了调度的作用。
1.1.5 单例模式和java内存模型的简述
由于官方包的线程输出是不安全的,因此我采用了单例模式进行封装。其大致实现就是我们创建一个类,而这个类有它的私有构造函数和本身的一个静态实例,在调用时仅需要访问某个指定方法得到这个类中的实例即可:
public class OutputThread {
private static final OutputThread OUTPUT = new OutputThread();
private OutputThread() {
}
public static OutputThread getInstance() {
return OUTPUT;
}
public synchronized void print(String str) {
TimableOutput.println(str);
}
}
如上述代码所示,我采用的是饿汉式,本来是想用双检锁的形式(看着比较高级),但是后来发现双检锁中在getInstance()方法中也要加上synchronized关键字,个人感觉跟在print()上加在性能方面基本一致,于是乎就采用了实现相对简单的饿汉式。
但是在看双检锁的过程中,我发现了其使用了volatile关键字,查阅相关资料发现,其跟java 内存模型有着比较大的关联,同时也相对涉及到了我们多线程不稳定的一些相关因素。
voliatile 关键字的作用简单来说大致为两个:
1. 禁止指令重排序
2. 保证线程可见性,即某一线程修改了某一变量,另一线程立即可见。
而我们是如何实现的这些的,就要涉及到JMM,即java的内存模型。它的概念是:Java 内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节。个人理解来说,它就是一套规矩,定义了线程之间如何操作内存,其并不同于我们说的实际物理内存中的堆、栈等。而这个规矩形象来说长什么样,大致如下图:
(图自网络)
对于每一个线程,它们都有一个自己的工作副本,这里面存储着,这个线程所需要的相应的数据信息,每一个线程只能对自己的工作副本进行操作,不可以对其他线程的工作副本进行操作(可以类比于多核CPU和它们的cache的结构)。
JMM也定义了8种基本操作,包括read、load、assign、lock、unlock、store、write、use。并且对于这些操作也有着一些约束,比如不允许 read 和 load、store 和write 操作之一单独出现、对一个变量执行 unlock 操作之前,必须先把此变量同步到主内存中等。
而对于volatile的实现大致可以概括为,当我们需要使用该变量时(use),必须从主内存中read -> load 这个变量当需要使用;当线程工作内存中这个变量被赋值时(assign),那么立刻store–>write这个变量。并且在每个volatile操作前会加入内存屏障,来防止指令重排。而正是这样的操作保证了在线程之间这个变量的可见性,以及不可被重排的特性。
正是这样原子性的操作使得java可以高效且正确的实现程序内存管理和运行,而理解了这些原子性操作,我认为是对我们多线程编程是有很大帮助的。当然,上述提到的JMM只是很笼统、相当少的一部分。(毕竟篇幅有限,个人理解能力也有限)
如果想看看更多的话可以看看这几篇文章:
https://blog.csdn.net/ChineseSoftware/article/details/119212455
https://zhuanlan.zhihu.com/p/58387104
https://blog.csdn.net/weixin_44396161/article/details/108587623
1.2 hw6 分析
1.2.1 hw6 要求分析
在第二次作业中,新增了横向环形运行电梯,并新增电梯申请请求,每层每座可以不止有一台电梯在运行。
1.2.2 hw6 设计分析
在本次作业中为实现横向电梯,对EleController进行了抽象和派生,子类EleControllerLen、EleControllerCro分别对应纵向和横向电梯。而对于每个电梯的内部队列,纵向、横向电梯分别有自己的队列EleWaitingQuesLen,EleWaitingQuesCro(其实这部分我原本也是想要利用上一次作业中的FloorReqQue来进行派生的。但奈何protected关键字被ban掉了,使得子类访问成员变量就要用getter、setter提供的对应的方法,修改的地方颇多,懒癌突发)。
而相应的横向电梯的look策略与纵向电梯稍有不同。即当电梯内无人时,将查找当前运行方向上做多两座有无请求,如没有则转变运行方向。
对于新增的电梯请求,则是利用工厂类来构建相应的电梯。
对于电梯调度,我采用了调度器的方式实现,对于每一个EleController都有一个getWeight()函数来获取相应权重,Schedule以此权重来对请求进行分配。
1.2.3 UML协作图
1.2.4 调度分析
在本次作业中,我采用了调度器分配的方式来进行。而相应的权重函数参考了lxh助教的博客。
纵向电梯:
public int getWeight(int from, int dst) {
int weight;
weight = floorReqQue.getInnerReqNum() + floorReqQue.getOuterReqNum()
+ getWaitingReq().getSize() + Math.abs(from - getCurFloor());
weight = ((from - getCurFloor() >= 0) == isDir()) ? weight : weight * 2;
return weight;
}
横向电梯:
public int getWeight(int from, int dst) {
int weight = eleWaitingQuesCro.getInnerReqNum() +
eleWaitingQuesCro.getOuterReqNum() + getWaitingReq().getSize();
if (from == tempBuilding) {
return weight;
} else {
int distance = isDir() ? (from - tempBuilding + 5) % 5 :
5 - (from - tempBuilding + 5) % 5;
weight += distance;
return weight;
}
}
个人认为其核心在于要尽量将请求分散的均匀一点,最后强测得分还不错(大概时助教手下留情了吧)。
1.3 hw7 分析
1.3.1 hw7 要求分析
本次新增可换乘的请求,并可定制电梯运行速度、容量等。
1.3.2 hw7 设计分析
对于电梯定制,实现相对比较简单。
- 电梯速度:在EleController中增加velocity成员变量。
- 电梯容量和可开关门楼座设计:在EleWaitingQuesCro中添加capacity、switchInfo成员变量即可。
而对于可换乘请求的实现:
首先我对官方包中的PersonRequest类做了继承,子类为ExpandPersonReq。
public class ExpandPersonReq extends PersonRequest {
private int curFromFloor;
private char curFromBuilding;
private int curToFloor;
private char curToBuilding;
private ArrayList<Integer> floorRoutine = new ArrayList<>();
private ArrayList<Character> buildRoutine = new ArrayList<>();
private int step;
private boolean routined = false;
private boolean arrived;
public ExpandPersonReq(int fromFloor, int toFloor, char fromBuilding,
char toBuilding, int personId) {
super(fromFloor, toFloor, fromBuilding, toBuilding, personId);
curFromBuilding = fromBuilding;
curFromFloor = fromFloor;
}
....
public void finishStep() {
curFromFloor = curToFloor;
curFromBuilding = curToBuilding;
curToBuilding = buildRoutine.get(step);
curToFloor = floorRoutine.get(step);
step++;
if (step == buildRoutine.size()) {
arrived = true;
}
}
}
在这其中curToFloor记录目标楼层,curToBuilding记录目标座。而floorRoutine、buildRoutine记录规划好的路径。如果这个请求需要多步才可以完成,每次电梯开门后,将该请求发送回Schedule中的总请求队列中,并调用finishStep()方法,更新当前请求的初始位置和目标位置。
而为了实现请求路径的规划,新增Routine类,对每一个在收到请求时,对每一个请求类进行路径规划。
if (eleController.canArrive(toBuildingi) &&
eleController.canArrive(fromBuildingi)) {
//
distance = dstVelo * Math.abs(floor - req.getRealToFloor()) +
fromVelo * Math.abs(floor - req.getRealFromFloor()) +
buildingDis * eleController.getVelocity();
synchronized (eleController) {
distance += ((EleControllerCro) eleController).getNum();
}
规划路径的大致方法与基准策略相似,不过是寻找运行实际的最短路径,同时要在加上电梯内的请求个数。
1.3.3 UML协作图
1.3.4 调度分析
调度方式基本没有变化,但是由于电梯可以将指令发回,因此进程结束的标志需要进行改动。我的实现方式是只修改了Schedule类的结束标志,由以null判定结束,改为设置count和ReadEnd共同判定进程是否结束。其中count记录会被抛回的请求数量,ReadEnd记录读入进程是否结束。
public void run() {
while (!readEnd || !allRequests.isEmpty() || count > 0) {
//从输入获得 requests
synchronized (allRequests) {
while (allRequests.isEmpty()) {
try {
allRequests.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Request request = allRequests.getFirst();
if (request == null) {
readEnd = true;
continue;
}
··········
}
setEnd();
}
二、锁和锁的设置
2.1 锁
对于多线程实现安全的同步机制,最重要的就是使用锁机制。比较简单的是使用synchrnoized关键字,再或者就是使用java 提供的各种lock机制。
synchrnoized的用法:
public synchronized void method() {
// 锁住的是调用该方法的实例
}
public static synchronized void method() {
// 锁住的是调用该方法的类
}
synchronized (obj) {
....
} // 锁住的是obj这个对象
synchronized (obj.class) {
// 锁住的是obj整个类
}
对于每个线程,只有获取锁之后,才有访问该加锁对象的权力。而为了避免忙等,我们使用了wait - notiyAll()方法,来更充分使用CPU资源。每次wait之后,该线程进入等待队列,放出锁的占有权,其他线程将竞争该锁,只有notifyAll(),或者notify()可以唤醒线程,重新进入EntryList参与锁的竞争。大致过程如下图:
lock的用法:
Lock lock = ReentrantLock();
lock.lock();
try {
.....
} catch (Exception e) {
handException(e);
} finally {
lock.unlock();
}
由于Lock 类的锁多种多样,因此使用lock机制将更灵活的可以实现线程间的同步与互斥,并获得更好的性能。
2.2 锁的设置
-
hw5:共享对象是Reader和Schedule之间的allRequests,Schedule与EleController的waitingRequests,因此加锁对象即为allRequests、waitingReqQue;
-
hw6:因为Schedule需要获得每个EleController的权重,因此在调用getWeight()时,需要对当前访问的EleController的对应实
例进行加锁,其他共享对象不变。
-
hw7:在规划路径时,也需要获得EleController的请求数目需要对当前访问的EleController的对应实例进行加锁,其他共享对象
不变
三、bug分析
前两次作业在强测和互测中均未发现bug。
而在第三次作业中由于电梯可达性判断中出现了问题而产生了bug(相当愚蠢的bug)。
四、思考与感受
1.在本单元的学习中我初步的认识了多线程编程,可以说是打开了新世界的大门,相当有趣。
2.进一步体会到了面向对象编程思维的重要性。而且在本单元并没有出现大规模重构的现象,有一定的进步。在未来的学习中还是要多多学习、使用好的设计模式,进一步理解SOLID设计原则。
3.debug很重要!!本单元测试部分大多数是用手捏的数据测的,强度不是很高,覆盖也不全面,导致最后一次作业出现了极其愚蠢的bug,强测白白寄了好几个点,以后还是要勤动手,写写评测姬,有事没事,多做测试。
4.多思考、多交流才是王道,只有在交流中,我们才能有更好的办法去解决问题。