2026-OO-U2-总结

肖鹏-24231219 2026-04-25 17:07:48

OO U2 Summary

目录

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

项目中的锁对象一览

项目里存在三种不同粒度的锁:

锁对象类型保护的数据
this(ElevatorCar)对象内置锁轿厢全部状态字段
this(Dispatcher)对象内置锁pendingTasks、计数器、inputEnded
f2Lock(ElevatorShaft)专用 Object 锁f2Occupied 标志

ElevatorCar 的锁设计

ElevatorCar 几乎所有字段都通过 synchronized(this) 保护,但同步块的粒度经过了精心的拆分。以 normalStep() 为例:

private boolean normalStep() {
    boolean shouldOpen;
    synchronized (this) {                        // 块①:只读决策
        shouldOpen = strategy.shouldOpenAtCurrentFloor(
            onBoard, waitQueue, currentFloor, direction);
    }
    if (shouldOpen) {
        openAndExchange();                        // 块外:耗时操作
        return true;
    }
    Direction nextDirection;
    synchronized (this) {                        // 块②:只读决策
        nextDirection = strategy.decideMoveDirection(...);
        direction = nextDirection;
    }
    if (nextDirection == Direction.IDLE) {
        return false;
    }
    moveOneFloor(nextDirection);                 // 块外:sleep 在锁外
    return true;
}

锁与语句的关系:同步块只包含对共享状态的读取和轻量赋值sleep()TimableOutput.println()shaft.enterFloor2() 这类耗时或可能阻塞的调用全部在锁外执行。这是一个关键设计决策——如果把 sleep(400ms) 放进同步块,Dispatcher 调用 assignTask() 时就会被阻塞长达 400ms,严重影响调度实时性。

moveOneFloor() 中的锁使用同样体现了这一原则:

private void moveOneFloor(Direction moveDirection) {
    // 锁外计算 nextFloor,锁内只做边界检查
    synchronized (this) { ... }

    sleepMs(speedMs);          // sleep 在锁完全释放后

    synchronized (this) {      // 重新加锁更新状态并打印
        currentFloor = nextFloor;
        TimableOutput.println(...);
        notifyAll();
    }
}

两次加锁之间有一个完整的 sleep 窗口,这段时间内 Dispatcher 可以自由调用 getCurrentFloorSnapshot()assignTask(),不会被阻塞。

f2Lock 的独立存在意义

F2 互斥锁选择了一个专用的 Object 而非复用 ElevatorShaft.this,这有明确的理由。两辆轿厢在等待 F2 时需要互相阻塞对方,如果用 ElevatorShaft.this 作为锁,那么 getState()setState() 这些频繁被调用的方法也会因为一辆轿厢在 F2 开门而被阻塞,造成不必要的争用。专用锁把"F2 物理互斥"这一职责从"井道状态读写"中完全剥离出来,两把锁互不干扰。

openAndExchange() 中锁的分段

private void openAndExchange() {
    openDoor();                              // 可能阻塞在 enterFloor2()

    List<PassengerTask> transferTasks = new ArrayList<>();
    List<PassengerTask> completedTasks = new ArrayList<>();

    synchronized (this) {                   // 统一修改 onBoard/waitQueue
        // 下客、上客逻辑
        notifyAll();
    }

    for (PassengerTask task : transferTasks) {
        dispatcher.requeue(task);           // 锁外调用 Dispatcher
    }
    for (PassengerTask ignored : completedTasks) {
        dispatcher.markCompleted();         // 锁外调用 Dispatcher
    }

    sleepMs(400);
    closeDoor();
}

这里有一个重要的设计:下客和上客在同一个同步块内完成,保证了"这次开门的乘客交换"是原子的——不会出现下客完成一半、Dispatcher 看到一个中间状态然后做出错误分配的情况。而 dispatcher.requeue() 则故意放在锁外,因为 Dispatcher 有自己的锁,两个锁嵌套会引入死锁风险。

二.调度器设计与线程交互分析

Dispatcher 的角色定位

Dispatcher 是整个系统的中央协调者,但它本身并不拥有任何物理资源,只维护三类信息:

private final List<PassengerTask> pendingTasks;  // 待分配队列
private boolean inputEnded;                       // 输入是否结束
private int totalTasks;                           // 已提交总数
private int completedTasks;                       // 已完成总数

totalTasks - completedTasks 就是系统中"在途"的任务数,这是判断能否退出的核心依据。

与各线程的交互方式

与 InputThread 的交互:InputThread 是生产者,通过两个方法向 Dispatcher 注入数据:

dispatcher.submit(task)        // 新乘客到来
dispatcher.markInputEnded()    // EOF,输入结束

两个方法都是 synchronized 的,调用后执行 notifyAll() 唤醒 Dispatcher 主循环。这是一个标准的生产者-消费者通知模式,InputThread 不等待任何返回值,提交即走。

与 ElevatorCar 的交互是双向的,这是设计中最复杂的部分:

DispatcherElevatorCarassignTask(task)           分配任务,写入 waitQueue
  requestStop()              系统退出时通知

ElevatorCarDispatcherrequeue(task)              中转或被迫下车后重新排队
  markCompleted()            乘客到达终点
  notifyAvailability()       特殊流程结束,通知重新选车

notifyAvailability() 是一个值得关注的细节。当一辆轿厢从维修/升级/回收中恢复,重新变为 accepting = true,它调用此方法通知 Dispatcher:候选集变大了,之前因为找不到合适轿厢而被搁置在队首的任务现在可能有车接了。没有这个通知,Dispatcher 会一直等到下一次自然唤醒才重试。

Dispatcher 主循环的等待策略

while (true) {
    PassengerTask task;
    synchronized (this) {
        while (pendingTasks.isEmpty() && !shouldStopDispatcher()) {
            wait();
        }
        if (shouldStopDispatcher()) break;
        task = pendingTasks.remove(0);
    }

    ElevatorCar chosen = chooseCar(task);
    if (chosen == null) {                    // 暂时没有可用轿厢
        synchronized (this) {
            pendingTasks.add(0, task);       // 放回队首(保持优先级)
            wait();                          // 等待轿厢状态变化
        }
        continue;
    }
    // 分配
}

chooseCar 返回 null 时,任务被放回队首而非队尾,这保证了先到的乘客不会因为反复重试而被后来者抢先。这是一个简单但有效的公平性保证。

调度策略如何适应时间性能指标

时间性能指标通常关注两点:乘客平均等待时间总运行时间。评分函数的三项分别对应不同的优化目标:

距离项 ×10 直接最小化接客前的空跑时间,这是对等待时间最直接的影响因素,因此权重最高。负载项 ×4 防止热点轿厢堆积任务,把任务分散开来,避免某些乘客因为反复排在繁忙轿厢后面而等待过长。中转惩罚 +20 让系统优先选择可以直达的轿厢,减少因中转引入的额外时间。

但这个评分函数是静态快照评分,存在一个固有的局限:getCurrentFloorSnapshot() 拿到的是轿厢当前位置,而不是它完成所有任务后的预期位置。一辆轿厢可能此刻在 F1,但已经有 10 个任务要送到 F7,仍然会因为当前距离近而被优先选中。负载项 ×4 是对这一问题的部分补偿,但不能完全消除。这也是这类贪心调度策略的典型权衡:计算简单、响应实时,但牺牲了对未来状态的预判能力。

三.线程安全与层次化设计

线程安全的三个层次

第一层:数据安全,确保共享变量不被并发读写撕裂。项目用 synchronized 块而非 volatileAtomic 类来实现,原因是需要保护的往往不是单个变量,而是一组相关变量的复合操作。比如 assignTask() 中同时修改 waitQueuedirection,必须原子完成;用 volatile 只能保护单个变量,无法保证复合操作的原子性。

第二层:状态一致性,确保对象在任意时刻暴露给外部的状态都是合法的。isIdleForShutdown() 是一个典型例子:

public synchronized boolean isIdleForShutdown() {
    return !inSpecialFlow && waitQueue.isEmpty()
        && onBoard.isEmpty() && pendingMaint == null
        && !pendingUpdate && !pendingRecycle;
}

这六个字段必须在同一个锁的保护下一起检查,否则可能出现"waitQueue 刚清空、onBoard 还没清空"时被 Dispatcher 误判为空闲的情况。

第三层:协调正确性,确保线程间的等待-通知语义不会出现遗漏唤醒或虚假退出。项目中所有 wait() 都套在 while 循环里而非 if 语句里:

while (pendingTasks.isEmpty() && !shouldStopDispatcher()) {
    wait();
}

这是 Java 多线程的一个基本要求——wait() 可能因为 notifyAll() 而被唤醒,但唤醒时条件不一定真正满足(其他线程可能先一步拿走了任务),必须重新检查条件。用 if 代替 while 会导致在条件不满足时继续执行,产生难以复现的逻辑错误。

层次化设计的体现

项目在结构上形成了清晰的三层:

InputThread / 外部请求
        ↓ submit / requestMaint / requestUpdate / requestRecycle
    Dispatcher(调度层)
        ↓ assignTask / requestStop
    ElevatorCar(执行层)
        ↓ enterFloor2 / leaveFloor2
    ElevatorShaft(资源层)

每一层只与相邻层通信,跨层调用被设计上隔离了。InputThread 不直接操作 ElevatorCar,ElevatorCar 也不直接解析输入请求。这种分层带来了一个重要的线程安全保证:锁的作用域天然地限制在层内。ElevatorCar 的 this 锁只保护轿厢自身状态,Dispatcher 的 this 锁只保护调度队列,两者没有嵌套,死锁风险被架构层面消除了。

唯一需要跨层调用的地方——ElevatorCar 调用 dispatcher.requeue()dispatcher.markCompleted()——被刻意放在 ElevatorCar 的同步块外部,就是为了避免"持有 Car 锁的同时去争抢 Dispatcher 锁",这是死锁的经典成因。这个细节说明设计者在确定同步块边界时有意识地考虑了锁的获取顺序。

四.出现过的bug和debug方法

  • 维修状态时需要在指定的状态之后才能再次分派任务,这个约束没处理导致输出有误
    debug:提交评测之后给的报错信息发现的问题
  • 特殊的几个状态(维修等)直接将电梯设置为不可接受任务,导致一段时间内的输入全被分派给一个电梯,超时
    debug:在特殊状态转移时,可以让电梯接受任务,只不过在分派器计算每台电梯评分时多一个关于特殊状态的惩罚项。

五.大模型的使用心得

  • claude:辅助设计框架结构
  • copilot:根据框架生成具体的代码
  • 优势:对于同步块,会选择适当的锁对象,避免死锁和嵌套锁,并且把耗时操作放在锁外,避免不必要的阻塞。
  • 问题:总是忽略一些细节内容,比如状态转移中,请求应该在某个状态之后才能被重新分派,它忽视了这个约束,导致了逻辑错误。
  • 感受:效率大大提高,代码质量也肯定比作为初学者的学生高。重点是它能直接根据我的通俗的描述,理解到对应的专业术语要求从而给出合理的答复(否则会经历查找各种资料极其费时的操作);缺点是如果采用集成插件或者cli工具,会更偏向于代码任务,对于与其交流、设计框架不太方便,这一部分还是需要agent交互更好,也才能有学习的效果。还是需要一定的任务解耦。

六.建议与感受

首先就是多线程难度陡升,需要更多的时间去理解线程安全的原理和实现,可以在oopre课程里涉及一点相关的知识,可能能帮助更快上手。然后是给一些多线程debug的知识技巧补充

...全文
34 回复 打赏 收藏 举报
写回复
用AI写文章
回复
切换为时间正序
请发表友善的回复…
发表回复

309

社区成员

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

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