OO 第二单元总结博客

黄扬淞-ZC061023 2026-04-29 22:38:26

OO 第二单元总结博客

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

三次作业锁结构演进

三次作业的同步设计随着架构复杂度的增加而逐步精细化。

HW5:锁粒度较粗,WaitQueue 与各 ElevatorQueue 均以 this 作为锁对象,同步方法覆盖整个方法体。各对象独立加锁,不存在跨对象的嵌套锁链,死锁风险极低。

HW6:引入 Scheduler 后,出现了跨对象的锁调用链:

Scheduler.dispatch()(持 Scheduler.this 锁)
    └─→ ElevatorQueue.offer()(持 ElevatorQueue.this 锁)

所有线程的加锁顺序均为"先 Scheduler 后 ElevatorQueue",方向一致,不存在死锁风险。dispatch() 内的 wait() 是关键设计——调用 wait() 的瞬间线程释放 Scheduler 锁并挂起,其他线程得以进入同步区执行 notifyAll() 唤醒它:

// dispatch() 中等待可用电梯(hw6 Scheduler.java:103-114)
private synchronized void dispatch(PassengerTask task) {
    int bestId;
    while ((bestId = chooseBestElevator(task)) == -1) {
        wait();  // 释放锁,等待 elevatorAvailable / onPassengerBoarded 唤醒
    }
    TimableOutput.println("RECEIVE-" + task.getPersonId() + "-" + bestId);
    elevatorQueues.get(bestId).offer(task);
}

HW7:新增 ElevatorShaft 作为第三个锁对象,形成三层嵌套链:

ElevatorShaft.acquireF2()(ElevatorShaft 锁)
    └─→ mainQueue.setBackupNeedsF2(true)(ElevatorQueue 锁)

固定的锁顺序(ElevatorShaft → ElevatorQueue)避免了死锁。同时,backupNeedsF2 字段被声明为 volatile,允许 ElevatorThread 无锁读取,避免为了一次读操作进入同步块带来的开销:

// ElevatorQueue.java(hw7:13)
private volatile boolean backupNeedsF2 = false;

// 无 synchronized,volatile 保证可见性
public boolean isBackupNeedsF2() { return backupNeedsF2; }

同步块范围与处理语句的关系

同步块的范围取决于需要保持原子性的最小操作集合。以 dispatch() 为例,RECEIVE 输出与 offer() 必须在同一锁保护下:

// 两步必须原子——中间不能被其他线程插入
TimableOutput.println("RECEIVE-" + task.getPersonId() + "-" + bestId);
elevatorQueues.get(bestId).offer(task);

若两步之间被打断,可能出现 RECEIVE 已输出但乘客未进入队列的窗口,导致电梯判断空闲并退出,乘客永久丢失。

相反,Thread.sleep() 不能放在同步块内——电梯移动的 400ms 若持锁,会阻塞所有试图访问队列的线程,严重降低吞吐量。因此电梯线程的主循环本身不加锁,仅在操作共享队列时进入各对象的 synchronized 方法。


二、调度器设计与线程交互

调度器架构演化

img

HW5DispatchThread 是纯粹的路由管道,无调度逻辑,按请求携带的电梯 ID 分配。

HW6Scheduler 合并了原来的 WaitQueue + DispatchThread,同时实现 SchedulerCallback 接口,承担正向分配与反向事件处理两个职责。所有对 Scheduler 状态的访问均通过 synchronized 方法序列化,逻辑清晰。

HW7Map<Integer, ElevatorQueue> 升级为 ElevatorShaft[],管理单元从"单部电梯"变为"井道"。新增 backupCarActivated / backupCarDeactivated 回调,用于在备用轿厢状态变化时唤醒正在 wait()dispatch() 重新尝试分配。

线程交互时序(以普通乘客请求为例)

img


三、调度策略分析

三次作业策略对比

HW5:无调度策略,按 getElevatorId() 固定分配。

HW6:就近优先,负载次要。核心打分公式:

int score = distance * 10 + load;

系数 10 的含义:距离差 1 层相当于等待队列中多 10 个乘客才被追平,确保就近优先主导,负载项仅在距离相同时起作用。

同时引入队列硬上限(MAX_QUEUE_SIZE = 8),防止大量乘客因 MAINT 重调度全部涌入唯一可用的电梯:

// ElevatorQueue.java(hw6:89-91)
public synchronized boolean canReceive() {
    return canReceive && waiting.size() < MAX_QUEUE_SIZE;
}

配合 onPassengerBoarded 回调,当乘客上车释放队列空位时立即唤醒 Scheduler 重新评估,避免不必要的调度延迟。

HW7:在 HW6 基础上增加方向惩罚项

// Scheduler.java(hw7:138-144)
private int score(ElevatorQueue q, int fromIdx) {
    int dist = Math.abs(q.getCurrentFloor() - fromIdx);
    int dir = q.getDirection();
    boolean movingAway = (dir == 1 && fromIdx < q.getCurrentFloor())
            || (dir == -1 && fromIdx > q.getCurrentFloor());
    int penalty = movingAway ? 110 : 0;
    return dist * 10 + penalty + q.getLoad();
}

方向惩罚 110 分相当于 11 层距离,有效避免将乘客分配给正在反向运行的电梯。同时分配时考虑主/备轿厢的服务范围(主轿厢 F2-F7,备用轿厢 B4-F2),在 F2 起点时按目的地方向选择轿厢类型。

策略对多性能指标的适应

性能指标对应策略
总运行时间就近分配减少空驶;方向惩罚减少反向接客
乘客等待时间distance * 10 主项,优先接近乘客的电梯
并发吞吐量队列上限 + onPassengerBoarded 均衡负载,避免单部电梯过载
MAINT/UPDATE 期间drainAll + redispatch 确保乘客无缝转移到其他可用电梯

四、Bug 分析与多线程调试方法

典型 Bug 记录

Bug 1:主轿厢与备用轿厢在 F2 死锁(HW7)

现象:程序挂起,不退出。

原因:备用轿厢调用 acquireF2() 时发现 f2Occupied=true(主轿厢占用 F2),设置 backupNeedsF2=true 后进入 wait()。但主轿厢的 waitForTask() 循环条件中未包含 backupNeedsF2,即使信号已设置,主轿厢也不会从 wait() 中醒来执行让步动作——双方永久互等。

修复:在 ElevatorQueue.waitForTask() 的 while 条件中增加 !backupNeedsF2hw7 ElevatorQueue.java:167),使主轿厢在收到信号时返回并执行 F2→F3 避让移动。

Bug 2:MAINT1-BEGIN 输出时序错误(HW6)

现象:checker 报"MAINT1-BEGIN 后仍存在未结束的 RECEIVE"。

原因:最初将 drainAll → redispatch 放在 MAINT1-BEGIN 输出之后。redispatch 触发 Scheduler 为这些乘客输出新 RECEIVE,其时间戳晚于 MAINT1-BEGIN,checker 认为这些 RECEIVE 是在检修开始后发出的,判违规。

修复:调整顺序,先 drainAll → redispatch,待所有重调度 RECEIVE 输出完毕后,再输出 MAINT1-BEGIN

Bug 3:F2 互斥释放时序过早(HW7)

现象:checker 报两辆车的 ARRIVE-F2 时间戳相同或重叠。

原因:早期实现在 ARRIVE 输出之前调用了 releaseF2(),导致等待中的另一辆车提前获锁,在前者 ARRIVE 输出的同一时刻也完成了 sleep 并输出 ARRIVE。

修复:严格保证 releaseF2()ARRIVE 输出之后调用(hw7 BackupCarThread.java:355-358)。

多线程调试方法

  1. 时间戳重建时序:利用 TimableOutput 的时间戳对 checker 报错的输出序列进行排序,还原事件的实际发生顺序,定位违反了哪条约束。

  2. 最小化复现:构造仅 12 部电梯、23 个乘客的最小输入,排除并发随机性,使 bug 必现。

  3. stderr 打印线程状态:在关键路径临时加入 System.err.println() 输出,不影响 checker 对 stdout 的检测,可以观察各线程的状态变化顺序。

  4. 手动绘制锁调用图:列出所有持锁路径,检查是否存在两条路径以相反顺序请求同一对锁。

  5. sleep 放大竞争窗口:在怀疑存在竞争的位置临时插入短暂 sleep,将偶发 bug 变为必现,便于稳定复现后再定位根因。


五、线程安全与层次化设计理解

线程安全

三次作业中线程安全的核心思路是以对象为边界构造 Monitor:每个共享对象(ElevatorQueueSchedulerElevatorShaft)封装自己的状态,对外只暴露 synchronized 方法,外部线程无法绕过同步直接读写内部字段。

volatile 的使用是对"仅需可见性而无需互斥"场景的精细化处理。HW7 中 ElevatorShaft.doubleModeElevatorQueue.backupNeedsF2 被声明为 volatile,主轿厢可以在不持任何锁的情况下读取,避免了因持锁读取带来的不必要竞争。

终止条件的正确设计同样关键。ScheduleractivePassengers 计数器精确追踪"还有多少乘客尚未到达终点",而非简单地等待输入结束。redispatch 不改变计数,passengerArrived(OUT-S)才减一,保证了 MAINT 重调度等场景下计数的正确性。

层次化设计

三次作业的层次结构随迭代逐步清晰:

HW5: InputThread → WaitQueue → DispatchThread → ElevatorQueue → ElevatorThread
     (单向管道,无反向通信,无抽象层)

HW6: InputThread → Scheduler ↔ ElevatorQueue ↔ ElevatorThread
     (Scheduler 作为双向通信枢纽,引入 SchedulerCallback 接口解耦反向依赖)

HW7: InputThread → Scheduler ↔ ElevatorShaft ↔ ElevatorThread / BackupCarThread
     (ElevatorShaft 新增隔离层,主/备轿厢通过 Shaft 协调,不直接感知对方)

层次化最直接的收益是:ElevatorThreadBackupCarThread 互不可见,它们各自只依赖 ElevatorShaft(F2 互斥)和 SchedulerCallback(事件上报)。"两辆车不能同时在 F2"这一约束被完整封装在 ElevatorShaft.acquireF2/releaseF2 内,而非散布在两个线程的代码中,修改或 debug 时只需关注一个文件。

SchedulerCallback 接口同样是层次化的体现——ElevatorThread 依赖的是接口而非 Scheduler 具体类,彻底切断了电梯线程对调度器内部状态的直接访问,杜绝了绕过锁直接修改 Scheduler 字段的可能。


六、大模型使用心得

使用的模型:Claude(claude-sonnet-4-6)

分工方式

整体上以自己写为主,大模型更多扮演"方案参谋"和"文档助手"的角色。

具体来说:在开始每次作业前,会用大模型讨论架构方案——列出几种可能的类设计,分析各自的优劣,最终由自己拍板选择哪一种。确定方向后,有时会让大模型生成初始的代码框架(类的骨架、字段声明、方法签名),作为脚手架再自己填充逻辑。核心逻辑(LOOK 调度算法、MAINT 状态机的各阶段流程、F2 互斥协议)基本都是自己写的,因为这些部分需要对题目约束有精确的理解,照搬生成的代码很容易出问题。

此外,大模型在整理设计文档(design.md)方面帮了不少忙——把讨论中的结论整理成表格、状态机图和边界情况清单,省去了很多文字组织的工作。

大模型在多线程任务上的优势

  • 方案推演:能快速给出"如果这样设计,可能在这个场景下出问题"的分析,对识别边界情况有帮助。
  • 知识查询volatilesynchronized 的语义区别、wait() 释放锁的时机、Java 内存模型的可见性保证,这些查文档很麻烦的问题问大模型很快能得到清晰解释。
  • 代码审查:把某段自己不确定的代码贴给它,让它说说有没有线程安全隐患,有时能发现自己遗漏的边界情况。

会遇到的困难

  • 无法运行验证:大模型只能静态推理,对于"这段代码在高并发下会不会竞争"这类问题,推理结果并不总是准确,还是需要自己测试验证。
  • 上下文越长越容易漂移:一次会话讨论的内容多了之后,有时生成的代码会忽略前面已经确认过的约束,需要反复确认。
  • 对题目细节不了解:官方包的具体行为(比如读取 MaintRequest 时自动输出 MAINT-ACCEPT)、checker 的具体检测规则,大模型并不清楚,这些必须自己读题目和手册。

使用感受

大模型在这类工程任务里更像一个可以快速讨论的人,而不是能替你写完所有代码的工具。用它来理清思路、查概念、生成框架代码,效率确实比自己从头查资料快;但核心逻辑的正确性还是要靠自己把关,直接把生成的代码复制进去用往往会埋坑。找到这个平衡点之后,配合使用的体验还是比较顺畅的。


七、二单元体验与建议

真实感受

二单元的难度曲线比较陡。HW5 感觉只是"用多线程写了个程序",HW6 开始才真正进入"设计并发系统"的状态——SchedulerCallback 接口的引入、activePassengers 计数器的正确维护、wait/notifyAll 的时序关系,每一个细节都需要仔细推敲,随便漏掉一个就可能挂机或者 WA。

HW7 的双轿厢是整个单元最难的部分。F2 互斥协议不是很长的代码,但需要同时在脑子里追踪主轿厢、备用轿厢、Scheduler 三个线程的状态,稍微想偏一步就会引入死锁或竞争条件。这个过程中对"为什么要这样设计"的理解比"代码怎么写"更重要。

总体来说,这个单元让我对多线程从"知道有锁"变成了"知道什么时候该用锁、用什么粒度的锁、怎么避免死锁",收获确实比较大,但强度也确实比较高。

建议

  1. 增加典型并发错误的分析练习:课程可以提供几个"看上去正确但有隐藏竞争条件"的代码片段,让学生分析问题所在。这类练习比单纯写作业更能建立并发直觉。

  2. 优化 checker 报错信息:目前 checker 报错有时只显示违规输出,不说明违反了哪条约束(比如"RECEIVE 未结束时出现了 MAINT1-BEGIN")。更详细的报错信息能大幅降低 debug 成本,特别是对 HW7 这种约束复杂的作业。

  3. HW7 指导书可以补充 F2 互斥的实现提示:"两辆车不能同时在 F2"的要求表述简短,但实现细节(acquireF2 的调用时机、releaseF2 的调用时机、避让移动的合规前提)相当复杂。哪怕在指导书里加一段"需要注意的实现要点",也能帮助学生少走很多弯路。

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

304

社区成员

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

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