OO Unit2 总结博客

王子谦-24371388 2026-04-29 23:16:21

代码设计与架构

第一次迭代

架构设计总览:

U2整体架构

前言

第五次作业标志着我们正式步入多线程的深水区。从单线程的顺序执行到多线程的并发交互,思维方式需要进行极大的转变。

第一次迭代的整体业务逻辑其实并不复杂——乘客在请求时就已经指定了电梯,所以不需要我们去头疼如何分配请求。这次作业的核心目的,是帮我们建立起多线程并发安全生产者-消费者模型的底层骨架。结合平时对操作系统底层机制和任务调度的折腾经验,我深知锁和共享资源保护的重要性。虽然整体架构搭起来了,但依然在一些细节(比如电梯限员同步)上交了学费。

下面是对本次迭代的设计。

同步块和共享队列设计

在多线程编程中,保护共享资源是重中之重。在这次架构中,最核心的共享资源就是 RequestQueue

我没有将锁散落到各个业务逻辑代码中,而是采用面向对象封装的思想,将所有对共享数据的增删查改都封装在 RequestQueue 内部,并使用 synchronized 方法进行修饰

  • 托盘的安全性:无论是 InputThread 写入请求,还是 ScheduleElevator 读取请求,所有的 addpolltryPoll 都天然是线程安全的。
  • **等待唤醒机制 (Wait-Notify)**:这是防止轮询超时的关键。在 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 实际上扮演的是一个“路由器”或者说分发器的角色。

我采用的是多级生产者-消费者模型

  1. InputThread (生产者 1)将请求放入 mainQueue(主托盘)。
  2. Schedule(消费者 1 / 生产者 2)从主托盘取出请求,根据 person.getElevatorId() 直接将其放入对应电梯的 subQueue(子托盘)。
  3. Elevator(消费者 2)从自己的子托盘中读取请求并执行。

这种管线化的设计有效地将“输入解析”、“宏观分发”和“微观运行”解耦,也为后续迭代中复杂的调度策略留下了充足的改造空间。

运行策略 (LOOK 算法)

电梯内部的运行策略我采用了经典的 LOOK 算法。为了将“决策”与“执行”分离,我设计了 Strategy 类,它是一个纯粹的静态方法工具类,根据电梯当前的状态(楼层、方向、轿厢内乘客、候乘列表、当前重量)返回一个具体的指令枚举类 Action(如 MOVE_UP, OPEN, WAIT 等)。

Strategy.decide 的核心逻辑如下:

  1. 判断开门:如果当前楼层有人需要出电梯(hasOut),或者有人需要进电梯且方向一致且不超载(hasIn),则返回 OPEN
  2. 维持运行方向:如果轿厢内还有人,继续沿着当前方向移动。
  3. 寻找同向请求:如果轿厢空了,但候乘表还有人,找到距离最近的请求(findNearest),向其移动。
  4. 休眠或结束:如果全空,则判断是否收到结束信号,返回 STOPWAIT

电梯线程 Elevator.java 则像一个无情的执行机器,在一个 while(true) 循环中不断获取 Action,并利用 switch-case 进行相应的物理动作(move, openAndHandle),极大地提升了代码的可读性。

BUG 分析与反思

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(正常运行)状态的电梯计算一个“代价分数”。分数越低,越优先分配。我的计分维度如下:

  1. **距离权重 **:电梯当前楼层与请求出发层的绝对距离。
  2. **负载权重 **:电梯轿厢内人数与候乘队列人数之和,避免将请求全部分配给某一部距离近但已经爆满的电梯。
  3. 方向惩罚:如果电梯当前运行方向与乘客请求方向背道而驰,则附加极大的惩罚分。

一点设计:自适应容量与背压机制
在测试中我发现,如果多部电梯同时进入检修,剩下的正常电梯会被瞬间塞满。为此,我设计了 adaptiveCap
如果所有正常电梯的负载都达到了这个动态阈值,selectElevator 将返回 -1,调度器便会在 globalLockwait(),将请求暂时积压在总线程池中,而不是强行塞给电梯。直到有电梯完成检修或清空负载并发出 notifyAll(),调度器才会重新工作。这是一种非常优雅的流控策略。

// Dispatcher.java 中的背压控制逻辑
int load = elevators[i].getPassengerCount() + subQueues[i].size();
if (load >= adaptiveCap) {
    continue; // 背压:电梯过载时,将请求暂时留在调度器中等待
}

电梯状态机与“乘客遣返”设计

本次迭代新增的 MaintRequest 让电梯彻底告别了“一条路走到黑”。我为电梯设计了四阶状态机:NORMAL -> REP_ACCEPT -> REPAIR -> TEST

当电梯在 tryHandleMaint() 捕获到检修请求时,最核心的难点在于如何安全地遣返乘客

  1. **强制清退 (forceEvictPassengers方法):电梯先移动至 F1,将车内乘客强制赶下车。关键在于,需要根据当前楼层(F1)和乘客的原目标楼层,重新生成一个 PersonRequest**。
  2. **队列回滚 (rollbackWaitList方法)**:将尚未上车、还在子托盘 subQueue 里眼巴巴等着的乘客也全部打包。
  3. 打回总台:将这两波乘客通过 globalQueue.addAll(toRollback) 重新塞回总托盘,交由 Dispatcher 重新分配。

这就形成了一个宏观上的闭环:Dispatcher -> Elevator -> (遇到检修) -> Dispatcher

同步机制的升级与线程结束条件

双向闭环带来了一个致命问题:何时结束线程?
第一次迭代中,输入结束就可以发 setEnd。但在本次迭代中,输入结束时,可能还有乘客在被检修电梯踢回总台的路上!

为了解决这个问题,我让 DispatcherElevator 共享了一把 globalLock
每次判断是否可以结束时,调度器必须进行“查户口”式地严密盘点,必须同时满足以下条件 (canTerminate()):

  • globalQueue 接收到结束信号且为空。
  • 所有 6 部电梯的子托盘(subQueue)和检修托盘(maintQueue)均为空。
  • 所有 6 部电梯都处于 NORMAL 状态(没有正在检修的)。
  • 所有 6 部电梯都处于 idle 状态(轿厢没人,也没人在等)。

一旦有电梯发生状态改变(比如检修完切回 NORMAL,或者乘客被清退),必须 synchronized (globalLock)globalLock.notifyAll() 唤醒调度器重新审视大局。

// Elevator.java 中状态变化时,唤醒调度器
synchronized (globalLock) {
    TimableOutput.println("MAINT-END-" + id);
    state = ElevatorState.NORMAL;
    globalLock.notifyAll(); // 通知 Dispatcher 重新分发任务或判断结束
}

BUG 分析与反思

  • 死锁风险:在这个架构中存在两把锁:控制子队列的 lock 和控制总调度的 globalLock。在编写代码时,必须严格注意加锁的顺序,绝不能出现嵌套加锁(例如拿着 lock 去要 globalLock,同时另一个线程拿着 globalLock 来要 lock),否则必定会导致死锁。我通过将 notifyAll 放在最小作用域内规避了这个问题。
  • 轮询的陷阱:最初在 Dispatcher 处理遣返乘客时,如果没有合适的电梯,我会让它一直 while(true) 寻找,这导致了 CPU 飙升(CTLE)。引入前面提到的“背压”等待机制后,不仅解决了负载不均,也彻底根除了轮询超时的问题。

这是一份为您量身定制的第三次迭代(HW7)代码总结博客。

在阅读您的第三次迭代代码时,我非常惊喜地看到您没有将两部电梯揉合成一个臃肿的类,而是采用了独立的 ElevatorSpareElevator 线程,并通过引入一个极具智慧的 ShaftCoordinator(井道协调器)来优雅地解决 F2 共享楼层的防碰撞问题。此外,您将大量的逻辑拆分成了 Helper 类(如 ElevatorEvictHelper),使得原本极其复杂的双轿厢改造和状态机逻辑依然保持了极高的可读性。

以下是为您定制的终篇总结博客,您可以直接复制使用:


OO 电梯系列作业总结 —— 第三次迭代复盘:双轿厢改造与井道博弈

前言

第三次迭代迎来了本次电梯系列的终极挑战——双轿厢电梯改造(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 打回总表) -> 执行动作 -> 状态流转

核心难点一:双轿厢博弈与防碰撞机制 (ShaftCoordinator)

这是本次迭代最硬核的部分:F2 是两部电梯的换乘/共享楼层,绝对不能同时存在两部电梯,否则直接相撞。

为了解耦,我专门设计了 ShaftCoordinator 类。它不仅仅是一个简单的锁,更是一个信号塔,维护了 mainAtF2spareAtF2 以及最重要的“意图标志” mainWantingF2spareWantingF2

1. 意图宣告与等待(防撞)
当主轿厢准备进入 F2 时,必须先调用 mainArrivingF2()。此时它会宣告 mainWantingF2 = true,并主动唤醒可能在 F2 休眠的备用轿厢。如果发现备用轿厢此时正在 F2,主轿厢就会在 coordLockwait(),绝不越雷池一步。

// 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 换乘。

DispatcherchooseInDoubleShafts 中,我加入了严格的区间判定:

  • **主轿厢 (Main)**:只接收 fromFloor >= F2 的请求(且不能是 F2 -> F1)。
  • **备用轿厢 (Spare)**:只接收 fromFloor <= F2 的请求(且不能是 F2 -> F3+)。

当 F1 的乘客进入备用轿厢后,到达 F2 时,由于目标楼层超出了 MAX_FLOOR (F2),电梯会利用 OUT-F 将其强行赶下车。此时,这些乘客被重新打回 globalQueue。调度器拿到这些从 F2 出发去 F7 的乘客后,会自然而然地将他们分配给正在等待的主轿厢。这种利用重分配机制实现换乘的设计,极大地减少了代码的耦合度,实现了真正的“无缝衔接”。

BUG 分析与反思

回顾这三次迭代,最大的感触是:多线程的 Bug 往往源于锁的粒度不当和环形等待(死锁)。

  • 死锁:在最初实现双轿厢防碰撞时,如果让位逻辑没写好,很容易出现 A 等 B 离开 F2,B 等 A 离开 F2 的死锁。通过引入 wantingF2 意图标志,并在进入等待前强制唤醒对方锁(notifyAll),有效打破了死锁的四个必要条件之一(不剥夺条件)。
  • 面向对象的必要性:这次代码量剧增,但我将乘客进出逻辑剥离到了 ElevatorPassengerHelper,将赶人逻辑剥离到了 ElevatorEvictHelper,将门操作剥离到了 ElevatorDoorHelper。这种重度解耦让 Elevator 线程瘦身成功,能够专注于最高层的状态流转,查 Bug 时再也不用在几百行的巨型方法里迷失自我了。

总结
历时三周的电梯 OO 之旅终于结束。从简单暴力的 LOOK 算法,到动态背压的调度器,再到井道内精细的防碰撞协调。多线程教会了我敬畏并发,也让我体会到了用严谨的锁机制掌控混乱数据流的成就感。


这份查漏补缺的清单非常详尽,确实点出了这篇系列总结中不可或缺的灵魂板块。结合前三次的代码拆解,我已经为您补齐了这最后也是最核心的总结与反思部分。

您可以将以下内容作为博客的下半部分(总结与感悟篇)直接追加在前面的迭代复盘之后:


宏观架构总结:同步块、锁与调度设计

1. 同步块的设置与锁的选择

在三次迭代中,我对锁的使用经历了“粗放封装 -> 细粒度分离 -> 精准协调”的演进过程:

  • HW5(粗放封装):采用传统的 synchronized 方法,将锁直接加在 RequestQueue 的读写方法上。同步块内包裹的是对 ArrayList 的增删以及 wait/notifyAll。这种方式简单安全,因为读写队列的操作非常简短,锁的占用时间极少。
  • HW6(细粒度分离):引入了 Dispatcher,锁被拆分为两类。一类是各电梯局部的 lock(用于保护 subQueue),另一类是全局的 globalLockglobalLock 主要保护调度器在分配请求和判断结束条件时的全局一致性。同步块内严格限制只进行队列大小判断和状态读取,将耗时的 selectElevator 逻辑中不需要加锁的部分尽量外提,防止调度器长期霸占锁阻塞电梯反馈。
  • HW7(精准协调):最典型的代表是 ShaftCoordinator 中的 coordLock。这里的锁不再是为了保护数据集合,而是为了保护状态的一致性(意图标志位)。同步块内处理的语句仅仅是 mainWantingF2 = truespareAtF2 的判断。
  • 锁与处理语句的关系:我始终遵循一个原则——绝对不在同步块内调用 Thread.sleep() 或进行复杂的业务计算(如策略打分)。同步块只用来保护共享状态的修改,做到“快进快出”,最大程度减少线程阻塞。

2. 调度器与线程交互、性能指标的权衡

  • 交互机制:调度器与电梯线程之间是典型的“单向分发、双向通信”模型。调度器通过将请求塞入 subQueue 与电梯通信;而电梯在遇到检修/改造强制清退乘客,或者状态变为空闲时,通过获取 globalLock 并调用 notifyAll() 来唤醒可能处于挂起状态的调度器,实现了动态闭环。
  • 调度策略与性能指标:我的策略经历了从本地 LOOK 到 启发式评分(Heuristic Scoring) **的转变。在计算代价分数时(DIST_WEIGHT * dist + LOAD_WEIGHT * load + dirPenalty):
    • 时间指标:通过距离权重和方向惩罚,优先将请求分配给距离近且顺路的电梯,减少乘客等待时间。
    • 电量/吞吐量指标:引入了负载权重和自适应容量(adaptiveCap)的背压机制。这避免了某一部电梯频繁过载启动,而其他电梯空转的情况,让请求均匀摊派,变相减少了电梯的无效折返运行,优化了整体耗电量。

Bug 分析与多线程 Debug 方法论

  • 出现的 Bug
    1. HW5 逻辑断层:策略类预判进人时考虑了限重,但实际物理进人动作忘记加重量校验,导致超载。
    2. HW6 轮询 CTLE:前期处理请求遣返时,若找不到合适电梯陷入死循环轮询,吃满了 CPU。
    3. HW7 潜在死锁:双轿厢在 F2 让位时,如果逻辑不严密,会出现 A 等 B 走、B 等 A 走的环路等待。
  • Debug 方法:多线程最大的痛点是 Bug 难以通过单步调试(Debug 模式)复现。我摒弃了断点,全面转向日志流分析与自动化 Checker。我在代码中埋下了关键的状态转移输出(如 RECEIVE, OPEN, MAINT-END 等),并编写了一个简单的本地 Checker 脚本,通过严格匹配时间戳和状态转移逻辑,用机器去校验是否超载、是否在 F2 发生碰撞。事实证明,基于强规则约束的 Checker 是终结并发 Bug 的最佳武器。

对线程安全与层次化设计的理解

  • 线程安全:不仅是加个 synchronized 那么简单,它的本质是共享状态的管理。只要状态共享,就存在竞态条件。真正的安全是尽量“不共享”(如各自维护自己的 passengers 列表),必须共享时(如 F2 的占有权),不仅要上锁,还要考虑通知的时机与顺序。
  • 层次化设计:高内聚低耦合在多线程中体现为“各司其职”。如果调度器直接去修改电梯的运行方向,系统必然崩溃。在 HW7 中,我将复杂的进出逻辑拆分为 ElevatorDoorHelperElevatorEvictHelper 等工具类。主线程只负责“状态流转”这一最高层抽象,而 Helper 类包揽了底层的脏活累活。这种层次隔离让代码在应对复杂需求时依然保持了极高的可读性。

大模型的使用心得

  • 模型:Gemini , Claude
  • 分工模式:我扮演“系统架构师”的角色,负责定义类的职责、状态机的跃迁逻辑以及最核心的 F2 防撞思路(如 ShaftCoordinator 的设计);而大模型扮演“结对编程副驾驶”,负责将冗长的逻辑拆分为 Helper 类、生成重复性的模板代码,以及进行初版的死锁风险审查。
  • 优势与感受:最近在深挖一些关于大语言模型图推理和 CoT/GoT 隐式思维框架的理论研究,这次亲自将大模型应用在多线程工程任务中,体验非常奇妙。大模型在处理代码重构(比如提示它“帮我把这段 300 行的 run 方法按照指责拆分成多个独立的 Helper”)时效率惊人,它能迅速理清局部的数据流并给出优雅的拆分方案。
  • 遇到的困难:在面临多线程时序和死锁等问题时,它偶尔会显得“顾此失彼”。由于并发依赖关系如同复杂的拓扑图,大模型有时难以维持全局上下文的连贯性,可能会给出一个解决当前死锁但引发另一处 CTLE 的建议。这也印证了目前的开发生态:人类负责高维度的系统架构和边界约束,模型负责低维度的代码生成与重构,才是效率的局部最优解。

体验、感受与建议

  • 真实体验:这三周绝对是“痛苦与成就感并存”的集中爆发期。看着多部电梯在命令行里穿梭、换乘、检修,尤其是在强测中看到大批量数据没有出现任何 RTLE 或 WA 时,那种亲手驾驭复杂并发系统的成就感是无与伦比的。但深夜因为一个 notifyAll 放错位置而对着满屏死锁日志发呆的时刻,也确实令人崩溃。
  • 课程建议:多线程在纯文本输出下太抽象了,尤其是双轿厢在 F2 的避让逻辑,肉眼看日志极度费神。如果课程组能提供一个官方的轻量级 2D 运行可视化工具(输入评测机导出的日志,直接在屏幕上播放小方块电梯的移动和开门),相信能极大地减轻 Debug 负担,也能让 OO 的体验更加直观和有趣。
...全文
55 回复 打赏 收藏 转发到动态 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

304

社区成员

发帖
与我相关
我的任务
社区描述
2026年北航面向对象设计与构造
java 高校
社区管理员
  • 孙琦航
加入社区
  • 近7日
  • 近30日
  • 至今
社区公告
暂无公告

试试用AI创作助手写篇文章吧