OO 第二单元总结博客

王奕-24371483 2026-04-24 11:50:24

 

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

1.1 整体架构

三次作业自始至终采用一套统一的架构:生产者-消费者模式(OOLens 公众号推送的那种类似三明治的架构,3 个层次,2 个中介容器),核心是 Queue-Thread 的组合。InputThread 作为生产者将请求写入 ScheduleQueue,DispatchThread 从 ScheduleQueue 取出请求分派给各 ProcessQueue,各 ElevatorThread 作为消费者从自己的 ProcessQueue 中取任务执行。这一架构在三次作业中没有发生结构性变化,只是随着功能增加而扩展。

最终算上 Main 共 9 个类,类的架构如下:

 

 

1.2 锁的选择与同步块设计

所有 Queue 类(ScheduleQueue、ProcessQueue)的方法均使用 synchronized 修饰,保证对内部集合的读写操作原子性。具体来说:

  • ScheduleQueue:offer、poll、setEnd、waitForNewRequest 等方法均 synchronized,poll 内部使用 wait/notifyAll 实现阻塞等待,避免忙等。
  • ProcessQueue:offer、remove、getRequests、waitUntilNotEmpty、setEnd 等方法均 synchronized,waitUntilNotEmpty 用于阻塞 ElevatorThread 直到有新请求或队列结束。
  • maintLock:额外引入一个共享的 Object 作为锁,专门用于 DispatchThread 在所有电梯均不可用时的等待(maintLock.wait()),以及电梯完成检修/改造/回收后的通知(maintLock.notifyAll())。

锁与同步块中语句的关系遵循一个原则:同步块内只做最必要的操作,不包含耗时的 sleep 或 IO。例如 poll 方法只在 synchronized 块内执行 wait 和队列操作,移动电梯、输出日志等耗时操作都在锁外(ElevatorThread 里)进行,最大限度减少锁的持有时间,降低线程间的争用。

1.3 三次作业的变化

第一次作业由乘客自行指定电梯,ScheduleQueue 按电梯 id 直接分发,ProcessQueue 逻辑简单,只需基本的 synchronized 保护。第二次作业引入 DispatchThread 进行调度,并新增 MaintRequest,需要 maintLock 协调检修期间的线程等待。第三次作业新增 UpdateRequest 和 RecycleRequest,update/recycle 标志位的读写也需要在适当位置通知等待中的 DispatchThread,整体锁结构在第二次作业基础上自然扩展,但没有引入新的锁类型。整体来看,三次作业只是新增了 Request 类型,在多线程安全角度我认为并没有新增迭代任务。

二、调度器设计与调度策略

2.1 调度器设计

调度器由 DispatchThread 单独承担,作为一个独立线程运行。它从 ScheduleQueue 中循环取出请求,根据请求类型分别处理:

  • PersonRequest:调用 dispatchPersonTo 方法选出最优电梯,输出 RECEIVE,将请求放入对应 ProcessQueue。
  • MaintRequest:直接将目标电梯的 maintenance 标志置为 true,并将请求投入对应 ProcessQueue,由 ElevatorThread 自行进入检修流程。
  • UpdateRequest / RecycleRequest(第三次作业):将目标电梯的 update 或 recycle 标志置为 true,投入对应 ProcessQueue。

DispatchThread 与其他线程的交互依赖两个机制:一是 ScheduleQueue 的 wait/notifyAll 实现请求的生产-消费同步;二是 maintLock 实现当所有电梯均不可用时 DispatchThread 的阻塞等待,以及电梯恢复可用后的唤醒通知。

2.2 调度策略

针对 PersonRequest,采用基于优先级和分数的贪心策略,为每部可用电梯计算一个(优先级,分数)二元组,取最优者:

  • 优先级1:电梯方向与乘客方向相同,且乘客在途中——顺风车,最优。分数为当前楼层到乘客起点的距离。
  • 优先级2:电梯处于 STOP 状态——空闲电梯,直接去接。分数为当前楼层到起点的距离。
  • 优先级3:电梯方向与乘客方向相反——需要掉头,次优。分数为最远楼层到起点的距离。
  • 优先级4:电梯同向但乘客不在途中——需要先到达最远点再折回。分数为最远楼层到起点的距离。
  • 优先级5(第三次作业):乘客目的地超出电梯范围,需要换乘——优先级最低,仅作为托底。
  • 优先级6:电梯在维护/升级/回收,或电梯完全不能帮助乘客移动(例如乘客从 F2 到 F5,但电梯最高运行到 F2 层)——不分配给这部电梯,等待 notifyAll 唤醒 wait 方法。

此策略主要优化时间性能:优先利用顺路的电梯,减少空跑。对于电量指标本次未做专项优化,电量消耗随时间优化间接降低。在双轿厢模式下,通过奇偶井道的静态范围划分(奇数井道主轿厢 F2-F7、备用 B4-F1;偶数井道主轿厢 F3-F7、备用 B4-F2)规避碰撞(这省去了很多时间),并通过换乘(OUT-F 轰出后重新入队)实现跨范围乘客的运送。

三、Bug分析与Debug方法

3.1 出现过的Bug

第一次作业未出现 bug,逻辑相对简单。

第二次作业出现了一个线程安全问题:people 列表(存储当前电梯内乘客)最初使用普通 ArrayList,在 ElevatorThread 写入和 getElevatorInfo 读取并发时出现数据竞争,导致偶发的 ConcurrentModificationException。修复方式是将其替换为 CopyOnWriteArrayList,读操作无需加锁,写操作(上车/下车)在 ElevatorThread 单线程内进行,问题消除。

第三次作业调试过程中遇到以下问题:

  • mvup/mvdn 边界检查时机错误:先移动后检查,导致电梯越过 maxFloor/minFloor 一层。修复为移动前先检查边界。
  • IllegalMonitorStateException:在 synchronized(maintLock) 块内误写了裸 notifyAll(),实际唤醒的是持有 this 锁的等待者而非 maintLock 的等待者。修复为 maintLock.notifyAll()。

3.2 Debug方法

面对多线程程序,主要采用以下方法:

  • 构造极端测试数据:针对高并发(大量请求同时到达)、卡时间(请求恰好在某个状态切换边界到来)、多部电梯同时维护/改造/回收等场景构造专项数据。
  • 自动化数据生成:编写数据构造脚本,批量生成测试用例,观察程序是否抛出异常或卡死,且运行时间在 180s 以内。
  • 时间戳分析:利用 TimableOutput 的时间戳,手动核对相邻输出的时间间隔是否符合移动速度、开关门时间等约束,定位违规输出(仅限于测试数据量少的情况)。
  • 二分定位:对于偶发 bug,逐步缩小触发条件,找到最小复现用例。

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

4.1 线程安全

线程安全的核心在于识别共享可变状态,并为其选择合适的保护手段。本次作业中共享可变状态主要有三类:

  • 共享队列(ScheduleQueue、ProcessQueue):用 synchronized 方法保护所有读写,配合 wait/notifyAll 实现阻塞语义。
  • ElevatorThread 内部状态(currentFloor、direction、people 等):这些字段只由 ElevatorThread 自身写入,DispatchThread 通过 getElevatorInfo 读取。通过将 getElevatorInfo 返回不可变的 ElevatorInfo 快照对象,避免了读写并发问题。
  • 标志位(maintenance、update、recycle):由 DispatchThread 写、ElevatorThread 读,由于 Java 对 boolean 的写操作天然原子,且 ElevatorThread 不会在同一时刻被多个线程写入,实际上没有竞态,故是安全的。

一个重要教训是:线程安全不只是加锁,还要注意可见性。people 列表的 ConcurrentModificationException 就是忽略了并发读写的结果,替换为 CopyOnWriteArrayList 后通过结构保证线程安全,而非依赖锁。因为此处 people 并不是一个我自定义的线程安全的容器,所以要特殊考虑。

4.2 层次化设计

层次化设计体现在职责分离上。InputThread 只负责读入,不做任何判断;DispatchThread 只做调度决策,不执行电梯动作;ElevatorThread 只关注自己的状态机,不关心其他电梯。各层之间通过 Queue 传递数据,通过标志位和锁传递控制信号,耦合度低。

第三次作业新增双轿厢功能时,这一层次化架构体现出良好的扩展性:只需在 ElevatorThread 内新增 UPDT/RCYC 状态,在 DispatchThread 内新增对 UpdateRequest/RecycleRequest 的处理分支,以及在 updt() 内动态 start 备用轿厢线程,不需要修改 InputThread 或 Queue 的接口。处理方法类似于处理 MaintRequest,只需要加两个新状态代表 update/recycle 即可。

五、大模型使用心得

5.1 模型与分工

本次作业全程使用 Claude,主要用于架构设计讨论、代码 review、bug 分析三个方面。分工上,我负责整体思路、代码实现和测试,Claude 负责在我思路不清晰时给我提示,在我描述问题后给出分析和建议,或在我提供代码后指出潜在问题。

5.2 大模型的优势

在多线程电梯这类复杂任务中,大模型的优势体现在以下几点:

  • 快速定位问题:当我描述异常现象(如 IllegalMonitorStateException)时,大模型能迅速定位到 synchronized 块内 notifyAll 调用对象错误这一根因,并给出正确写法,比自己盯着代码找要快得多。而且有时候一些不常见的异常种类自己不知道是什么意思,问大模型很方便。
  • 架构方向讨论:在双轿厢电梯设计之初,就「在 ElevatorThread 里加状态」还是「新增一个线程」两种方案进行了讨论,大模型给出了较为清晰的优劣分析,帮助快速确定方向。
  • 边界条件提示:大模型在 review 代码时能指出容易忽略的边界,比如 mvup/mvdn 边界检查时机、mtnc 与 update 期间能否接 RECEIVE 等,节省了大量测试时间。
  • 指导书理解:由于指导书很长,时常容易忘记限制条件,大模型可以在我遇到题目要求方面的问题时告诉我指导书的细节,避免了很多不必要的 bug。

5.3 遇到的困难

大模型也有明显局限:

  • 上下文丢失:在长对话中,大模型有时会忘记前面讨论过的细节,给出与之前矛盾的建议,需要反复补充背景。
  • 代码幻觉:当没有提供完整代码而只描述逻辑时,大模型生成的代码有时会包含错误(如初版 updt 里多余的裸 notifyAll),需要仔细核对,不能直接使用。
  • 无法运行验证:大模型无法真正运行代码,对于程序卡死或偶发异常这类需要实际执行才能复现的 bug,它只能给出推测,最终还是需要自己去跑测试数据验证。
  • 细节问题处理:大模型很难考虑到边界情况/极端情况的 bug,这些需要自己构造测试点自己测。

5.4 总体感受

使用大模型的感受是「提效但不替代思考」。它更像一个随时可以讨论的搭档,快速过滤明显的错误,给出方向性建议,但最终的设计决策和验证,以及更复杂的 debug 还是需要自己来。对于多线程这种「魔法时机」才能触发 bug 的领域,大模型的作用更多是在设计阶段提前规避风险,而不是在出了 bug 之后神奇地找到根因。

六、二单元真实体验与建议

6.1 真实体验

二单元整体难度曲线比较陡峭,第一次作业相对平缓,第二、三次作业引入了大量新的状态和边界条件,是整个单元中最耗时、最难的部分,尤其是第二次作业,因为这是首次出现电梯需要轰出未到终点的乘客的作业。多线程调试的痛点在于 bug 难以稳定复现,往往需要构造非常精确的时序数据才能触发,调试效率较低。

相比逻辑 bug,线程安全问题更难察觉——people 列表的并发问题在大多数测试中都不会触发,只有在高并发场景下才偶发,这让人容易对代码的正确性产生虚假的自信。

6.2 对课程的建议

指导书篇幅偏长,且包含了大量在前两次作业中已经出现过的通用性、显然性的约束(如「移动时不许上人」、「移动时必须关门」、「有需求或有人才能动」等)。这些约束在第一次作业就已内化,反复出现在后续指导书中反而会造成信息噪声,让人在阅读时难以快速定位真正新增的约束点。

建议将不变的通用约束单独整理成一份参考文档,各次作业的指导书只描述新增和变更的内容,这样既能缩短阅读时间,也能让同学更清晰地把握每次作业的增量变化,避免指导书越来越长、越来越难读的问题。

 

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

309

社区成员

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

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