第二单元博客:Unit2总结

娜仁丽丽-70066008 2026-04-29 23:48:26

一、同步块与锁的使用

锁的选择

三次作业中,我主要使用了两种同步机制:对于共享队列类(RequestQueue、ElevatorQueue),几乎所有公开方法都用 synchronized 修饰,锁对象是队列实例本身;对于多线程共享的状态类(ElevatorShaft),则使用 synchronized 代码块,对具体的状态转换操作加锁。内置的 synchronized + wait()/notifyAll() 已经足够满足本单元的需求。

以 HW6 的 RequestQueue为例,它是 InputThread 和 Dispatcher 之间的共享缓冲区,所有的 addRequest()poll()setInputFinished() 都是 synchronized 方法,并在 poll() 内部通过 wait() 阻塞等待,在生产者写入时通过 notifyAll() 唤醒消费者。这是标准的生产者-消费者模式,锁的粒度较细,每个队列实例只保护自己的状态。

HW7 中引入了 ElevatorShaft 类,它是被多个线程同时访问的共享对象:Dispatcher 线程会调用 assignPassenger()estimateCost(),主轿厢线程和备用轿厢线程都会读写 shaft 的状态(NORMAL/DOUBLE/UPDATE/RECYCLE 等)。这里用 synchronized 代码块对每个状态转换方法单独加锁,而不是锁住整个 run() 循环,目的是让不同的操作可以在各自锁范围内完成,不互相阻塞。

锁与所保护语句的关系

选择锁的粒度时,核心原则是:锁的范围要刚好覆盖所有需要原子性保证的语句,不多也不少

在 RequestQueue 中,wait() 必须在 synchronized 方法内调用,否则会抛 IllegalMonitorStateException。而判断队列是否为空的条件检查也必须在同一个锁范围内完成,否则会出现经典的 check-then-act 竞争条件:线程 A 检查发现队列非空,准备取值,但在取值之前线程 B 已经取走了最后一个元素。将判断和取值放在同一个 synchronized 方法里,这个问题自然消失。

在 ElevatorShaft 中,canMoveTo(floor) 方法(用于检查两个轿厢不能同时在同一楼层)本质上是一个读操作,而 setMainCabFloor(floor) 是写操作。两者都加了 synchronized,保证了"检查位置是否冲突"和"更新位置"这两步不会被其他线程打断,从而避免两个轿厢同时通过同一楼层的情况。

二、调度器设计

三次作业的调度器演进

HW5 的调度器相当简单:输入 API 直接提供了每个乘客对应的电梯编号 getElevatorId(),Dispatcher 只需要读取这个字段,输出 RECEIVE,然后将乘客转入对应电梯的私有队列。没有调度决策,本质上只是一个路由器。

HW6 移除了HW5中提供的电梯编号,调度决策完全由程序自己做。本作业主要:

  1. 实现了 ALS(Adaptive Look-ahead Scheduling)风格的代价函数:对每部电梯估算从当前位置到达乘客上车楼层的时间 + 送达目的楼层的时间,选代价最小的电梯派发。

  2. 同时引入了重派机制reassign()当电梯进入 REPAIR 状态、需要清空车厢时,已分配给它的乘客被退回 Dispatcher 重新排队。

HW7 在 HW6 的基础上新增了双轿厢模式(DOUBLE),调度器需要同时感知每个井道的状态。对于跨 F2 的行程(如 B1→F5),应优先分配给处于 DOUBLE 模式的井道,由备用轿厢完成下半段(B4→F2),主轿厢完成上半段(F2→F7)。

调度器与线程的交互

Dispatcher 本身是一个独立线程,通过两个方向的阻塞队列与其他线程交互:

  • 与 InputThread 的交互:InputThread 是生产者,向 RequestQueue 写入请求;Dispatcher 是消费者,调用 poll() 阻塞等待新请求。

  • 与 Elevator 线程的交互:Dispatcher 是生产者,向每部电梯的私有队列写入分配好的乘客;Elevator 线程是消费者,从自己的队列里取乘客执行。

HW7 中还增加了反向的交互:电梯线程可以调用 dispatcher.requeue() 将乘客退回给 Dispatcher,触发重派。这让线程间的依赖关系变得更复杂,也是 HW7 中出现最多 bug 的地方之一。

调度策略与性能指标

HW6 和 HW7 的 ALS 代价函数在实践中有一个关键细节:对 DOUBLE 模式的井道,不能简单地把两段行程的代价相加后与单轿厢井道比较,因为单轿厢的代价只包含一段直达行程,而双轿厢的代价包含两段,量纲不对等。

正确的做法是:对 DOUBLE 井道只计算第一段的代价(备用轿厢到上车楼层再到 F2),第二段由主轿厢完成,属于不可避免的固定开销,不纳入比较。修正后调度器可以正确识别 DOUBLE 井道处理跨范围行程的优势,乘客 98(B1→F5)被正确分配到处于 DOUBLE 模式的 1 号井道,而不是普通的 2 号电梯。

三、Bug 与 Fix

出现的 Bug

Bug 1:程序不终止 / 线程永久阻塞

这是三次作业中最反复出现的问题。根本原因是线程退出条件不完整。

HW7 最典型的案例:Dispatcher 在 inputFinished && pendingPassengers.isEmpty() 时退出,随即对所有井道队列调用 setInputFinished(),导致正在处理 UPDATE(需要约 6 秒)的电梯线程提前看到终止信号,从 getPassenger() 返回 null,误以为没有更多工作,直接退出。但此时 RECYCLE 请求还在 t=20s 等待发送,程序表面上结束了,实际上还有未完成的任务。

解决方案:引入 specialRequestActive 标志位,当电梯正在执行 UPDATE/RECYCLE/MAINT 状态机时,getPassenger() 不返回 null,保证线程不会提前退出。Dispatcher 侧的退出条件也改为轮询检查:

inputFinished && pendingPassengers.isEmpty() && queue.isEmpty() && allShaftsIdle()`

Bug 2:双重开门(double open at F2)

备用轿厢在 F2 执行阶段性下客 OUT-F 后,调用 dispatcher.requeue() 将乘客重新派发,Dispatcher 足够快时会在同一个门开关周期内把乘客添加回同一部电梯的队列。下一次循环 shouldOpenDoor() 发现乘客在 F2 等待,再次开门,造成输出中 OPEN-F2 出现两次。修复方式是将 boardPassengers() 移到 alightPassengers() 之后,在同一次开门周期内完成上下客,避免跨周期重入。

Bug 3:estimateCost 读取了未初始化的字段

estimateCost() 读取 p.getLegFrom()p.getLegTo()
但这两个字段在 assignPassenger() 内部才会被配置(setupDirectLeg() / setupTransferFirstLeg())。
调度器在选井道时调用 estimateCost(),此时 leg 字段还持有原始全程值(B1, F5),DOUBLE 井道的范围判断失败,直接返回 MAX_VALUE,导致 DOUBLE 井道永远输给普通井道。

Bug 4:乘客被加入队列两次(double IN/OUT-S)

alightPassengers() 内部调用了 shaft.assignPassenger(p),其中已包含 queue.addPassenger(p);同时外部又直接调用了一次 queue.addPassenger(p),乘客被重复入队,输出中出现同一乘客的两次 IN。这是责任归属不清晰导致的 bug,两处代码都认为自己负责入队。修复后只在 assignPassenger() 内部入队,外部不再直接操作队列。

多线程程序的调试方法

调试多线程程序的核心挑战是:问题不可复现。同样的输入在不同的线程调度下可能产生不同的输出,在 IntelliJ 调试器里打断点会改变线程调度,bug 反而消失——这就是所谓的 Heisenbug。

我采用的主要调试方法是输出分析:逐行读取带时间戳的程序输出,从症状倒推根因。例如,看到 IN-98-F2-2出现两次,立刻定位到队列重复入队的问题;看到程序不终止但最后一行是 CLOSE,说明某个线程卡在 wait() 里没有收到 notifyAll()

辅助手段是在关键路径上插入 System.err.println 输出调试信息,比如在 estimateCost() 里打印每个井道的计算值,直接暴露"为什么 1 号井道没有被选中"的原因。这比用断点更稳定,因为不影响线程调度。

四、线程安全与层次化设计

线程安全的理解

经过三次作业,我对线程安全的理解增加,得知锁只是工具,关键是对共享状态的边界和所有权想清楚。

线程安全不只是加锁,而是要保证:

  1. 共享状态的修改是原子的,

  2. 线程的等待条件是完整的,

  3. 线程的退出条件不能漏掉任何一种仍在运行的情况。

三次作业里出现的大多数 bug,根源都是第 2 或第 3 点出了问题,而不是锁本身用错了。

HW7 还让我体会到 wait()notifyAll() 的细节:wait() 会释放锁,所以在 synchronized 方法内调用 wait() 不会造成死锁;但如果用 synchronized 包裹一个忙等循环(spin loop)代替 wait(),就会永久持有锁,真正导致死锁。我们在 RequestQueue.getPassenger() 最初的实现里差点犯这个错误。

层次化设计的体会

三次作业的线程层次很清晰:

Main
  └── InputThread        (读输入,生产请求)
  └── Dispatcher         (调度决策,分配请求)
       └── ElevatorShaft × 6
             └── MainCab   (主轿厢线程)
             └── SpareCab  (备用轿厢线程,HW7 新增)

每一层只和相邻层通过阻塞队列通信,不跨层直接访问。这个层次结构带来了两个好处:第一,每一层的职责清晰,调试时可以通过输出日志确认问题在哪一层;第二,HW6 到 HW7 的扩展时,只需要在 Dispatcher 和 ElevatorShaft 层增加新逻辑,InputThread 和 Main 几乎不用改动。

五、LLM 的使用

使用的模型

本单元三次作业混合使用 Claude(claude.ai)和 ChatGPT 辅助完成。

分工方式

整体上,我负责理解题目要求、确定整体架构方向、审查生成的代码是否符合规范,Claude 负责根据我的方向生成具体的类结构和实现代码。我会先描述我想要的设计(比如用 ALS 代价函数,参考当前楼层和队列中等待乘客数),Claude 生成对应代码,我再对照题目要求检查边界条件和输出格式。

随着作业从 HW5 到 HW7 逐渐复杂,这种分工也在调整:HW5 时我基本让 Claude 生成整体框架,自己主要做理解和验证;HW7 时因为逻辑更复杂(双轿厢状态机、跨范围调度、多线程退出条件),我开始更主动地指定具体的设计决策,Claude 更多地承担"把我的设计翻译成 Java 代码"的角色。

LLM 的优势与困难

优势方面,Claude 在处理重复性、结构性的代码生成上效率极高。比如 HW6 的维护状态机(NORMAL → REP_ACCEPT → REPAIR → TEST)有大量固定的状态转换逻辑和输出格式,手写容易出现漏写或格式错误,Claude 可以一次性生成完整且格式正确的骨架代码。在调试阶段,将报错信息和相关代码片段一起粘贴给 Claude,它通常能快速定位根因,HW7 里 estimateCost 读取未初始化字段的 bug 就是这样发现的。

困难方面,多线程程序的正确性很难仅凭静态代码分析保证。Claude 生成的代码在逻辑上往往没问题,但线程交互的细节(比如 specialRequestActive 标志的设置时机、Dispatcher 退出条件的完整性)需要我在运行时反复测试才能验证。出错时的调试成本很高,因为复现条件不固定。另外,Claude 有时会生成过于"理想化"的代码——假设某个操作总是按预期顺序执行,而在真实的多线程环境下这个顺序无法保证。这类问题往往在极端测试用例下才会暴露。

我也发现了几处 Claude 给出的代码有误的情况:在实现 ElevatorShaft.canMoveTo() 的早期版本时,Claude 建议用一个 synchronized 静态方法加全局锁保护所有井道的位置检查,这会造成不必要的跨井道竞争,我将其改为实例方法,每个井道只锁自己。还有一次,Claude 生成的重派逻辑在 alightPassengers() 里直接调用了 queue.addPassenger(),没有意识到 assignPassenger() 内部已经做了入队,导致乘客双重入队的 bug。这类细节错误需要对代码有完整的全局理解才能发现。

总体体验

使用 LLM 最大的收获不是"少写了多少代码",而是可以更快地把想法变成可运行的代码去测试
传统流程包括:想法 → 设计 → 写代码 → 测试 → 发现问题 → 修改。
使用 Claude 之后:写代码这一步变得很快,更多的时间花在 测试 → 发现问题 → 分析根因 上,这反而让我对多线程 bug 的成因理解得更深。

六、真实体验与建议

"Happiness is a Process, not a Destination."

第二单元整体是有挑战性但收获丰富的体验。

多线程对我来说是一个陌生的领域,刚开始时光是理解 wait()/notifyAll() 的正确用法就花了不少时间。随着 HW6、HW7 的推进,状态机越来越复杂,线程之间的交互也越来越多,每次看到程序输出不终止或者输出乱序,都需要耐心地逐行分析日志。

让我印象最深的是 HW7 的双轿厢设计。两个轿厢共享同一个井道但不能在同一楼层出现,这个约束在实现上比想象中复杂:不仅要保证它们的位置不冲突,还要保证状态机的转换(UPDATE/RECYCLE)在时间上满足题目的硬限制。把这些约束全部正确实现并通过测试,有一种明显的成就感。

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

304

社区成员

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

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