301
社区成员
发帖
与我相关
我的任务
分享总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句之间的关系
在三次作业中,都选择了互斥锁、对象内置锁来处理多线程环境中可能存在的共享资源竞争。选择内置锁简单有效,使用synchronized关键字得到同步块,易于处理。
在三次作业中,主要加锁的对象是电梯、策略类和控制器。
为了维护电梯内部行为的原子性,给电梯的每个具体行为加锁,使用synchronized确保电梯的所有行为都不受干扰。电梯的开关门、移动和进出乘客都位于同步块中,避免控制器或者策略类给电梯新增候乘乘客或者修改状态,包括move() 方法,doorOpen() 和 doorClose() 方法和passengersOut2() 和 passengersIn2() 方法。
move() 方法中的同步块:
使用 synchronized 来确保当电梯移动时,不会有其他线程可以修改与电梯位置相关的任何状态。
同步块保证了电梯在完成移动前,其状态不会被其他线程干扰。
doorOpen() 和 doorClose() 方法:
这些方法同样使用 synchronized,主要是为了确保电梯在开门或关门操作时,没有其他操作可以介入。这对于维护操作的原子性很重要,特别是在涉及到电梯状态改变的情况下(如电梯开门时不应该同时移动)。
passengersOut2() 和 passengersIn2() 方法:
同步块确保在乘客进出电梯时,电梯的乘客列表 persons 和乘客数 size 的修改是原子性的。
策略类负责根据电梯的状态,决定电梯的行为。所以也需要对方法加锁,确保执行的同步。防止在判断过程中电梯的状态发生变化,产生错误决策。
zero2zero()
判断电梯是否应该继续处于等待状态。使用 synchronized 确保在访问和判断状态时,这些状态不会被其他线程改变。
needOpen()
判断电梯是否需要从等待状态转换到开门状态。这依赖于是否有乘客需要在当前楼层进出。同步保证了判断逻辑的一致性,防止在决定开门的同时,电梯状态被其他线程修改。
needOut() 和 needIn()
这两个方法检查是否有乘客需要在当前楼层出电梯或进电梯。这些操作需要访问电梯乘客列表和控制器的等待列表,两者都是共享资源。同步确保了在读取这些列表时不会遇到并发修改的问题。
setDirection() 和 setDirection(int direction)
这些方法设置电梯的移动方向。方向的设置基于多种因素,如当前主请求的位置和电梯的当前楼层。同步锁确保在设置方向时,关联的状态不会由其他线程改变。
控制器类需要控制乘客的分配和AB轿厢的碰撞躲避。在电梯进行乘客分配时,需要对每个电梯相应的候乘表加锁,确保添加乘客时,候乘表不被电梯使用。
控制器也需要通过电梯的实时状态进行判断,这里参考观察者模式,让电梯发生状态变化时向控制器更新,电梯写状态需要加锁,但控制器读取状态不需要加锁,这是因为6个电梯的状态由电梯维护,控制器只需要读取最新结果即可。
isLive(int i)
检查是否有必要继续运行指定的电梯(电梯 i)。如果停止标志 stop 被设置为 1 并且指定电梯的候乘列表为空,则电梯终结。
getWaitingList()
返回候乘表的引用。使用 synchronized 以确保在多线程环境下安全地返回共享对象的引用。
addRequest(PersonRequest request)
向候乘表中添加新的乘客请求。使用 synchronized 来保证更新候乘表的操作是原子的,且在添加请求后,通过 notifyAll()唤醒等待的线程。
removeRequest(int floor, PersonRequest person)
从指定楼层的等待列表中移除特定乘客。同步确保移除操作不会与其他线程(如添加请求)的操作冲突。
stop()
设置停止标志并通过 notifyAll() 唤醒所有等待的线程。同步确保了 stop 标志的设置和读取都是原子的。
waitForReq()
让当前线程在此对象的监视器上等待,直到其他线程调用 notify() 或 notifyAll()。 wait() 必须在同步块内调用,以确保调用 wait() 时持有对象锁,防止释放未持有的锁。
总结分析三次作业中的调度器设计
调度器与调度策略
调度器通过观察者模式获知电梯的情况。具体而言,电梯作为状态机,在每一次出现状态变化时,都要发送当前状态给控制器,供控制器进行乘客分配。
controller.updateEleStates(new ElevatorState(id, floor, state, size, strategy.getDirection(), strategy.getMaster(), MastersInside(),type,interchangeFloor));
public synchronized void updateEleStates(Elevator.ElevatorState state) { //更新全部电梯的状态
elevatorStates.replace(new ElevatorKey(state.getId(), state.getType()),state);
}
调度器优先选择正在wait的电梯分配任务。若多个电梯均在wait,则选择等待队列中人数最少的电梯分配任务。若电梯均在工作,则随机分配乘客,这样的分配方式是最简单的,但也会导致效率低下。调度器将乘客添加到电梯的候乘表中即可。随机分配的调度策略难以应对时间、电量的要求。
结合线程协同的架构模式(如流水线架构),分析和总结
三次作业架构设计的逐步变化和未来扩展能力,画UML类图
第一次作业的架构延续了整个单元,即控制类负责一级调度,将乘客分配给不同电梯,策略类负责二级调度和电梯控制,电梯负责执行物理行为。电梯通过模拟状态机来实现。
每次新增的功能主要是新状态reset功能。电梯和策略类的工作逻辑没有太大变化,电梯执行,策略决定。而控制器类的变化比较大,主要变化是分配方法的变化。
当前的电梯,控制器通过<ID,Type>唯一标记电梯,通过allocate()方法分配input和elevator产生的新乘客。而电梯类根据策略类执行最基本的行为。所以扩展能力较强,只需要修改个别函数和部分边界条件判定即可。
第五次作业类图

第六次作业类图

第七次作业类图
画UML协作图(sequence diagram)来展示线程之间的协作关系
识别出三次作业稳定的内容和易变的内容,并加以分析
三次作业中,比较稳定的是电梯类的基本运行动作和策略类,以及储存等待成员的WaitingList类。
电梯是一个有限状态机,根据策略类的指令切换不同状态并执行不同的行为。等待状态、移动状态和开关门的代码逻辑不需要修改太多,同时判断行为的对应策略也不需要修改。这些内容之所以不需要修改,是因为电梯的功能修改主要是新状态和电梯新成员的分配,而电梯基本的运行是贯穿于始终的。电梯的二级分配策略由策略类执行,在三次作业中都是LOOK算法,不需要修改。
三次作业中,易变的内容是controller类和reset操作。
由于两次迭代都有新的reset操作,所以电梯的reset操作每次都需要修改,即新增功能。而由于reset操作是新的输入方式,所以controller也需要调整新请求的分配方式。包括因为迭代而产生的新的分配策略调整。reset也导致了电梯可能会产生Input类的操作,也就是向电梯输入新乘客。此外,由于电梯分裂,电梯ID已经无法唯一标记某个电梯了,需要把原本的哈希表由ID作为键,改为由自定义的电梯键作为键,并新建elevatorKey类。
分析自己在第三次作业中是如何实现双轿厢的两个轿厢不碰撞的
实现思路:避免碰撞的逻辑是如果电梯位于交换层则禁止对位电梯抵达交换层,离开交换层后则允许抵达。由于同一电梯井中的两个轿厢需要通过获知对方状态,来决定能否抵达交换层,所以由controller来实现控制。
具体措施:首先,将交换层视为一种资源,同时仅能由同电梯井的一台电梯访问,使用信号量Semaphore实现。于是在controller中设置哈希表储存6个电梯井的交换层信号,并随电梯初始化:
private ConcurrentHashMap<Integer, Semaphore> interchangeSemaphores = new ConcurrentHashMap<>();
//在构造函数中调用
private void initSemaphores() {
for (int id = 1; id <= 6; id++) {
interchangeSemaphores.put(id, new Semaphore(1));
}
}
然后,在双轿厢电梯即将抵达交换层时,判断对位电梯是否位于交换层,若是,则需要等待。可以通过尝试获取信号量来实现,若成功获取信号量则说明对位电梯不处于交换层,失败则继续等待。
boolean willBeInter = (nextFloor == interchangeFloor);
if ((key.getType() == 'A' || key.getType() == 'B') && willBeInter) {
while (!controller.tryEnterInterchangeFloor(id)) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
其中,controller.tryEnterInterchangeFloor(id)的作用是试图获取代表交换层的信号量:
// 尝试进入交换层
public synchronized boolean tryEnterInterchangeFloor(int elevatorId) {
Semaphore semaphore = interchangeSemaphores.get(elevatorId);
return semaphore != null && semaphore.tryAcquire();
}
若电梯离开交换层,则应当释放信号量,允许另一电梯进入交换层:
//elevator离开交换层
if ((key.getType() == 'A' || key.getType() == 'B')
&& pastFloor == interchangeFloor) {
controller.leaveInterchangeFloor(id);
}
//控制器释放信号量
public synchronized void leaveInterchangeFloor(int elevatorId) {
Semaphore semaphore = interchangeSemaphores.get(elevatorId);
if (semaphore != null) {
semaphore.release();
}
}
最后,为避免电梯终结时位于交换层而永远占用交换层,令电梯终结时额外判断当前楼层,若位于交换层则需要继续移动。
分析自己程序出现过的bug以及自己面对多线程程序的debug方法
没有接上乘客
在分裂电梯后,先初始化了电梯,再初始化等待队列。初始化电梯后已经可以分配成员,但无法正确加入等待队列,乘客也无法真正进入电梯
电梯等待死锁
一开始没有用信号量控制,而是判断对位电梯是否要进入交换层,若是则等待。在这种情况下遇到两电梯同时要进入交换层,于是电梯无限等待下去。
电梯线程过早终止
原本只有input线程会产生乘客,所以在input不再输入新乘客后,电梯等待队列清零即可让电梯准备终结。但引入reset后,电梯由于reset命令或者乘客换乘而产生新乘客加入到控制器中,所以此时保持原有逻辑会让电梯过早终止,而在新乘客产生后无电梯可乘。所以改为所有电梯空闲后全体电梯一起终结。
空指针问题
为了适应电梯方向的判定逻辑修改,所以修改了主请求的设置方式和判定位置。但原本选择主请求、使用主请求时可以保证主请求不为空,修改后可能存在主请求未赋值便使用的情况,导致抛出空指针异常。因为在迭代前的代码,存在多处主请求的赋值或调用,根据不同情况设置了不同的前置条件。迭代后将代码归为一处,但缺少了前置条件的判断。
AB电梯徘徊
分裂后的AB电梯出现在交换层和相邻层之间无限徘徊的bug。这是由于电梯分裂后出现了交换层,策略采取遇到交换层后强制修改方向。而电梯的实际方向是由主请求乘客决定的,所以原有的主请求选择策略无法适应,存在误将无法到达的乘客列为主请求的非法行为。
常用debug方法
private static final boolean isDebug = false;
//打印信息
if (Main.isDebug()) {
System.out.println("");
}
通过打印电梯交互数据和当前状态,判断电梯的bug产生原因。直接打印信息可以清楚显示出现问题的电梯的状态,如主请求乘客,当前楼层,当前方向等,然后对比预期状态,可以准确定位问题。打印是确定问题所在的主要方式,因为无论是控制器、电梯、判断逻辑、多线程还是状态转移的逻辑,通过打印信息都可以唯一定位,而且实现相对简单,在重要位置添加即可。定位问题后无论是复现还是修复都是非常容易的,debug的主要难点在于确定bug的位置。
多次提交
由于多线程的存在无法复现的问题,所以为了区分一个bug是由于多线程而导致的,还是由于设计原因导致的,可以通过多次提交代码,观察报错来区分。这种方法可以有效确定debug的方向。
面向评测机的debug
通过使用同学的评测机,进行大量测试,然后打印错误信息,根据报错查找bug。
心得体会。
线程安全
由于没有考虑太多性能问题,所以对大部分方法都设置了同步,通过加锁确保了线程安全。在控制器中维护6个电梯的状态,只有电梯写状态需要加锁,而电梯读状态并不需要加锁。
尽管如此,对于电梯和控制器,以及电梯之间的资源竞争问题的思考,也让我更深刻地了解了多线程中对同一资源访问带来的隐患。在多线程环境下,管理共享资源的访问非常关键。在三次作业中,电梯的控制依赖于多个线程之间对共享资源(电梯内部状态、请求队列等)的访问和修改。如果不加锁或者错误判断电梯的运行逻辑,很容易引发数据不一致、死锁或性能问题。
最后我学到了如何选择合适的锁粒度。使用对象内部锁可以防止多线程同时执行某些特定操作,但过度使用可能导致效率低下。在不同的情况下,应该选择不同大小的同步块,而不是直接将整个函数设为同步块。
层次化设计
通过明确的类和接口划分,如 Controller、Elevator 和 Strategy,实现了职责的清晰分离。电梯只执行物理行为,策略类只负责单电梯的行为判断,控制器只负责分配乘客,接受input的输入。
由于接口和分层定义清晰,所以便于模块替换,而不会影响其他的模块。所以可以替换不同的调度策略但不影响整个项目的运行。而在单个控制类中,也可以灵活切换分配函数实现不同策略的分配。
类似的代码可以灵活复用,比如resetRequest,从第二次到第三次作业,只需要做少量的修改,便可以让新类参考复用原有类的代码。可见层次化设计对于整个项目的清晰度、可维护性都至关重要。