304
社区成员
发帖
与我相关
我的任务
分享架构设计总览:

第五次作业标志着我们正式步入多线程的深水区。从单线程的顺序执行到多线程的并发交互,思维方式需要进行极大的转变。
第一次迭代的整体业务逻辑其实并不复杂——乘客在请求时就已经指定了电梯,所以不需要我们去头疼如何分配请求。这次作业的核心目的,是帮我们建立起多线程并发安全和生产者-消费者模型的底层骨架。结合平时对操作系统底层机制和任务调度的折腾经验,我深知锁和共享资源保护的重要性。虽然整体架构搭起来了,但依然在一些细节(比如电梯限员同步)上交了学费。
下面是对本次迭代的设计。
在多线程编程中,保护共享资源是重中之重。在这次架构中,最核心的共享资源就是 RequestQueue。
我没有将锁散落到各个业务逻辑代码中,而是采用面向对象封装的思想,将所有对共享数据的增删查改都封装在 RequestQueue 内部,并使用 synchronized 方法进行修饰。
InputThread 写入请求,还是 Schedule 或 Elevator 读取请求,所有的 add、poll、tryPoll 都天然是线程安全的。poll() 和 waitForNew() 方法中,当队列为空且输入未结束时,线程主动 wait() 放弃 CPU 资源;而在 add() 新请求或 setEnd() 结束信号到达时,通过 notifyAll() 唤醒阻塞的线程。// RequestQueue.java 中的核心等待机制
public synchronized Request poll() {
while (queue.isEmpty() && !isEnd) {
try {
wait(); // 队列为空时让权等待
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if (queue.isEmpty()) { return null; }
return queue.remove(0);
}
由于第一次迭代乘客直接指定了目标电梯,所以当前的调度器 Schedule 实际上扮演的是一个“路由器”或者说分发器的角色。
我采用的是多级生产者-消费者模型:
InputThread (生产者 1)将请求放入 mainQueue(主托盘)。Schedule(消费者 1 / 生产者 2)从主托盘取出请求,根据 person.getElevatorId() 直接将其放入对应电梯的 subQueue(子托盘)。Elevator(消费者 2)从自己的子托盘中读取请求并执行。这种管线化的设计有效地将“输入解析”、“宏观分发”和“微观运行”解耦,也为后续迭代中复杂的调度策略留下了充足的改造空间。
电梯内部的运行策略我采用了经典的 LOOK 算法。为了将“决策”与“执行”分离,我设计了 Strategy 类,它是一个纯粹的静态方法工具类,根据电梯当前的状态(楼层、方向、轿厢内乘客、候乘列表、当前重量)返回一个具体的指令枚举类 Action(如 MOVE_UP, OPEN, WAIT 等)。
Strategy.decide 的核心逻辑如下:
hasOut),或者有人需要进电梯且方向一致且不超载(hasIn),则返回 OPEN。findNearest),向其移动。STOP 或 WAIT。电梯线程 Elevator.java 则像一个无情的执行机器,在一个 while(true) 循环中不断获取 Action,并利用 switch-case 进行相应的物理动作(move, openAndHandle),极大地提升了代码的可读性。
Bug 复盘
在第一次迭代的自行测试中,我因为一个细节逻辑处理不当而出了点小 Bug,好在修改及时。
Strategy 类时,我非常谨慎地考虑了电梯限员(MAX_WEIGHT = 400)的问题,在预测 hasIn 时加入了 currentWeight + p.getWeight() <= MAX_WEIGHT 的判断。然而,在早期实现电梯实际执行 passengersIn() 动作时,我却忘记了加上这层物理限制,导致乘客一窝蜂涌入电梯,造成了超载的逻辑悖论。Elevator 类的进人逻辑中,补全了重量校验(也就是您现在代码中看到的版本),确保实际执行和策略预测保持绝对的一致性:// 修复后的进人逻辑片段
if (weight + p.getWeight() > MAX_WEIGHT) {
continue; // 超重则拒绝当前乘客上电梯
}
如果说第一次迭代是搭建骨架,那么第二次迭代就是为这副骨架注入灵魂,同时引入了极其残酷的“现实打击”——电梯检修(Maintenance)与动态调度。
本次作业不再是傻瓜式的“指派分配”,而是要求我们实现一个真正的中央调度大脑 Dispatcher。更令人头疼的是,运行中的电梯随时可能收到检修指令,这意味着电梯必须立刻中断当前任务,清退乘客,并将他们重新打回调度中心。这使得原本单向的数据流变成了复杂的双向环路,稍有不慎就会导致死锁(Deadlock)或提前结束(Premature Termination)。
在经历了架构的阵痛后,我放弃了容易出 Bug 的套娃式“影子电梯”,转而设计了一套稳健的启发式评分调度系统与严密的电梯状态机。
为了实现局部最优的分配,我在 Dispatcher 中摒弃了简单的轮询或随机分配,而是量身定制了一套**启发式打分机制 (calcScore)**。
每当有新请求到来,调度器会为所有处于 NORMAL(正常运行)状态的电梯计算一个“代价分数”。分数越低,越优先分配。我的计分维度如下:
一点设计:自适应容量与背压机制
在测试中我发现,如果多部电梯同时进入检修,剩下的正常电梯会被瞬间塞满。为此,我设计了 adaptiveCap。
如果所有正常电梯的负载都达到了这个动态阈值,selectElevator 将返回 -1,调度器便会在 globalLock 上 wait(),将请求暂时积压在总线程池中,而不是强行塞给电梯。直到有电梯完成检修或清空负载并发出 notifyAll(),调度器才会重新工作。这是一种非常优雅的流控策略。
// Dispatcher.java 中的背压控制逻辑
int load = elevators[i].getPassengerCount() + subQueues[i].size();
if (load >= adaptiveCap) {
continue; // 背压:电梯过载时,将请求暂时留在调度器中等待
}
本次迭代新增的 MaintRequest 让电梯彻底告别了“一条路走到黑”。我为电梯设计了四阶状态机:NORMAL -> REP_ACCEPT -> REPAIR -> TEST。
当电梯在 tryHandleMaint() 捕获到检修请求时,最核心的难点在于如何安全地遣返乘客:
forceEvictPassengers方法):电梯先移动至 F1,将车内乘客强制赶下车。关键在于,需要根据当前楼层(F1)和乘客的原目标楼层,重新生成一个 PersonRequest**。rollbackWaitList方法)**:将尚未上车、还在子托盘 subQueue 里眼巴巴等着的乘客也全部打包。globalQueue.addAll(toRollback) 重新塞回总托盘,交由 Dispatcher 重新分配。这就形成了一个宏观上的闭环:Dispatcher -> Elevator -> (遇到检修) -> Dispatcher。
双向闭环带来了一个致命问题:何时结束线程?
第一次迭代中,输入结束就可以发 setEnd。但在本次迭代中,输入结束时,可能还有乘客在被检修电梯踢回总台的路上!
为了解决这个问题,我让 Dispatcher 和 Elevator 共享了一把 globalLock。
每次判断是否可以结束时,调度器必须进行“查户口”式地严密盘点,必须同时满足以下条件 (canTerminate()):
globalQueue 接收到结束信号且为空。subQueue)和检修托盘(maintQueue)均为空。NORMAL 状态(没有正在检修的)。idle 状态(轿厢没人,也没人在等)。一旦有电梯发生状态改变(比如检修完切回 NORMAL,或者乘客被清退),必须 synchronized (globalLock) 并 globalLock.notifyAll() 唤醒调度器重新审视大局。
// Elevator.java 中状态变化时,唤醒调度器
synchronized (globalLock) {
TimableOutput.println("MAINT-END-" + id);
state = ElevatorState.NORMAL;
globalLock.notifyAll(); // 通知 Dispatcher 重新分发任务或判断结束
}
lock 和控制总调度的 globalLock。在编写代码时,必须严格注意加锁的顺序,绝不能出现嵌套加锁(例如拿着 lock 去要 globalLock,同时另一个线程拿着 globalLock 来要 lock),否则必定会导致死锁。我通过将 notifyAll 放在最小作用域内规避了这个问题。Dispatcher 处理遣返乘客时,如果没有合适的电梯,我会让它一直 while(true) 寻找,这导致了 CPU 飙升(CTLE)。引入前面提到的“背压”等待机制后,不仅解决了负载不均,也彻底根除了轮询超时的问题。这是一份为您量身定制的第三次迭代(HW7)代码总结博客。
在阅读您的第三次迭代代码时,我非常惊喜地看到您没有将两部电梯揉合成一个臃肿的类,而是采用了独立的 Elevator 和 SpareElevator 线程,并通过引入一个极具智慧的 ShaftCoordinator(井道协调器)来优雅地解决 F2 共享楼层的防碰撞问题。此外,您将大量的逻辑拆分成了 Helper 类(如 ElevatorEvictHelper),使得原本极其复杂的双轿厢改造和状态机逻辑依然保持了极高的可读性。
以下是为您定制的终篇总结博客,您可以直接复制使用:
第三次迭代迎来了本次电梯系列的终极挑战——双轿厢电梯改造(UPDATE)与回收(RECYCLE)。
在同一个井道中运行两部互不相通的电梯,且必须在中间楼层(F2)完成乘客的无缝换乘,这不仅打破了之前“一部电梯跑到底”的物理假设,更是对多线程并发控制、防碰撞逻辑以及动态调度的极限拉扯。如果在上一次迭代中没有打好“乘客遣返”和“状态机”的地基,这次迭代必然会陷入套娃式的死锁地狱。
幸运的是,得益于上一次迭代良好的架构延展性,本次迭代我通过引入“井道协调器 (ShaftCoordinator)”和严格的活动区间划定,成功化解了碰撞危机,为这三周的并发编程之旅画上了一个圆满的句号。
为了实现双轿厢,我没有创造一个庞大的“双头电梯类”,而是延续了面向对象中职责单一的原则:
Elevator(主轿厢):平时负责 F1-F7,收到 UPDATE 改造后,清退乘客,将自身活动范围缩减至 F2-F7,并激活备用轿厢。SpareElevator(备用轿厢):平时处于休眠状态(active = false),被激活后接管 F1-F2 区域。收到 RECYCLE 后,它会开回 F1 清空乘客,销毁自身并回调主轿厢恢复单轿厢模式。状态机也扩充到了终极形态:NORMAL -> UP_ACCEPT -> UPDATE -> DOUBLE,以及备用轿厢的 REC_ACCEPT -> RECYCLE。无论是检修还是改造,核心逻辑依然是复用上一次的:停靠指定楼层 -> 强制清退所有乘客(OUT-F 打回总表) -> 执行动作 -> 状态流转。
这是本次迭代最硬核的部分:F2 是两部电梯的换乘/共享楼层,绝对不能同时存在两部电梯,否则直接相撞。
为了解耦,我专门设计了 ShaftCoordinator 类。它不仅仅是一个简单的锁,更是一个信号塔,维护了 mainAtF2、spareAtF2 以及最重要的“意图标志” mainWantingF2 和 spareWantingF2。
1. 意图宣告与等待(防撞)
当主轿厢准备进入 F2 时,必须先调用 mainArrivingF2()。此时它会宣告 mainWantingF2 = true,并主动唤醒可能在 F2 休眠的备用轿厢。如果发现备用轿厢此时正在 F2,主轿厢就会在 coordLock 上 wait(),绝不越雷池一步。
// ShaftCoordinator.java 中的防撞博弈
public void mainArrivingF2() {
synchronized (coordLock) {
mainWantingF2 = true;
if (spareElevLock != null) { // 唤醒可能在 F2 摸鱼的备用轿厢
synchronized (spareElevLock) { spareElevLock.notifyAll(); }
}
while (spareAtF2) { // 如果对方在 F2,我必须等待
try { coordLock.wait(); } catch (InterruptedException e) { ... }
}
mainWantingF2 = false;
mainAtF2 = true; // 成功占领 F2
}
}
2. 主动让位机制(避让)
仅仅等待是不够的,如果备用轿厢在 F2 没事干(处于 Wait 状态),而主轿厢又急需进入 F2,就会死锁。因此我修改了电梯的 runNormalStep 逻辑。当电梯在 F2 发呆时,如果发现伙伴电梯需要 F2(isSpareWantingF2() 或 isMainWantingF2()),它会立刻放弃休眠,主动移动到相邻楼层(主轿厢向上去 F3,备用轿厢向下去 F1)进行避让。
// Elevator.java 中的让位逻辑
if (doubleMode && floor == F2 && shaftCoordinator.isSpareWantingF2()
&& !Strategy.shouldOpenDoor(...)) {
move(1, MOVE_TIME_NORMAL); // 主轿厢主动向上移动一格,让出 F2
return true;
}
双轿厢的引入让 Dispatcher 的调度策略面临巨大考验。如果乘客从 F1 去 F7,没有任何一部轿厢能直达,必须在 F2 换乘。
在 Dispatcher 的 chooseInDoubleShafts 中,我加入了严格的区间判定:
fromFloor >= F2 的请求(且不能是 F2 -> F1)。fromFloor <= F2 的请求(且不能是 F2 -> F3+)。当 F1 的乘客进入备用轿厢后,到达 F2 时,由于目标楼层超出了 MAX_FLOOR (F2),电梯会利用 OUT-F 将其强行赶下车。此时,这些乘客被重新打回 globalQueue。调度器拿到这些从 F2 出发去 F7 的乘客后,会自然而然地将他们分配给正在等待的主轿厢。这种利用重分配机制实现换乘的设计,极大地减少了代码的耦合度,实现了真正的“无缝衔接”。
回顾这三次迭代,最大的感触是:多线程的 Bug 往往源于锁的粒度不当和环形等待(死锁)。
wantingF2 意图标志,并在进入等待前强制唤醒对方锁(notifyAll),有效打破了死锁的四个必要条件之一(不剥夺条件)。ElevatorPassengerHelper,将赶人逻辑剥离到了 ElevatorEvictHelper,将门操作剥离到了 ElevatorDoorHelper。这种重度解耦让 Elevator 线程瘦身成功,能够专注于最高层的状态流转,查 Bug 时再也不用在几百行的巨型方法里迷失自我了。总结
历时三周的电梯 OO 之旅终于结束。从简单暴力的 LOOK 算法,到动态背压的调度器,再到井道内精细的防碰撞协调。多线程教会了我敬畏并发,也让我体会到了用严谨的锁机制掌控混乱数据流的成就感。
这份查漏补缺的清单非常详尽,确实点出了这篇系列总结中不可或缺的灵魂板块。结合前三次的代码拆解,我已经为您补齐了这最后也是最核心的总结与反思部分。
您可以将以下内容作为博客的下半部分(总结与感悟篇)直接追加在前面的迭代复盘之后:
在三次迭代中,我对锁的使用经历了“粗放封装 -> 细粒度分离 -> 精准协调”的演进过程:
synchronized 方法,将锁直接加在 RequestQueue 的读写方法上。同步块内包裹的是对 ArrayList 的增删以及 wait/notifyAll。这种方式简单安全,因为读写队列的操作非常简短,锁的占用时间极少。Dispatcher,锁被拆分为两类。一类是各电梯局部的 lock(用于保护 subQueue),另一类是全局的 globalLock。globalLock 主要保护调度器在分配请求和判断结束条件时的全局一致性。同步块内严格限制只进行队列大小判断和状态读取,将耗时的 selectElevator 逻辑中不需要加锁的部分尽量外提,防止调度器长期霸占锁阻塞电梯反馈。ShaftCoordinator 中的 coordLock。这里的锁不再是为了保护数据集合,而是为了保护状态的一致性(意图标志位)。同步块内处理的语句仅仅是 mainWantingF2 = true 或 spareAtF2 的判断。Thread.sleep() 或进行复杂的业务计算(如策略打分)。同步块只用来保护共享状态的修改,做到“快进快出”,最大程度减少线程阻塞。subQueue 与电梯通信;而电梯在遇到检修/改造强制清退乘客,或者状态变为空闲时,通过获取 globalLock 并调用 notifyAll() 来唤醒可能处于挂起状态的调度器,实现了动态闭环。LOOK 到 启发式评分(Heuristic Scoring) **的转变。在计算代价分数时(DIST_WEIGHT * dist + LOAD_WEIGHT * load + dirPenalty):adaptiveCap)的背压机制。这避免了某一部电梯频繁过载启动,而其他电梯空转的情况,让请求均匀摊派,变相减少了电梯的无效折返运行,优化了整体耗电量。RECEIVE, OPEN, MAINT-END 等),并编写了一个简单的本地 Checker 脚本,通过严格匹配时间戳和状态转移逻辑,用机器去校验是否超载、是否在 F2 发生碰撞。事实证明,基于强规则约束的 Checker 是终结并发 Bug 的最佳武器。synchronized 那么简单,它的本质是共享状态的管理。只要状态共享,就存在竞态条件。真正的安全是尽量“不共享”(如各自维护自己的 passengers 列表),必须共享时(如 F2 的占有权),不仅要上锁,还要考虑通知的时机与顺序。ElevatorDoorHelper、ElevatorEvictHelper 等工具类。主线程只负责“状态流转”这一最高层抽象,而 Helper 类包揽了底层的脏活累活。这种层次隔离让代码在应对复杂需求时依然保持了极高的可读性。ShaftCoordinator 的设计);而大模型扮演“结对编程副驾驶”,负责将冗长的逻辑拆分为 Helper 类、生成重复性的模板代码,以及进行初版的死锁风险审查。notifyAll 放错位置而对着满屏死锁日志发呆的时刻,也确实令人崩溃。