2026面向对象第二单元总结

季鼎凯-24371376 2026-04-29 21:50:54

2026面向对象第二单元总结

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

在电梯多线程调度的三次迭代中,随着需求的增加(单电梯 -> 多电梯调度 -> 动态双轿厢及重置),多线程的并发冲突点不断增多。整个系统的核心难点在于保证共享资源(请求队列、电梯状态、共享井道)的线程安全,同时避免死锁和 CPU 轮询空转

1. 锁的选择策略

整个系统中,我主要采用了两种锁机制来应对不同的并发场景:

  • Java 内置的 Monitor 锁 (synchronized): 这是系统中最常用的锁,主要用于保护具有明确归属的共享对象(如 RequestQueue 队列对象、全局 elevators 列表)。选择它的原因是实现简单且符合面向对象思维——谁的数据谁负责同步。
  • 自定义互斥锁 (F2Lock): 专门为双轿厢模式下的 2 楼(换乘层)防撞设计的锁。由于 2 楼是物理意义上的共享资源,且涉及到两部独立运行的电梯线程的互斥访问,使用独立的 F2Lock 对象进行明确的 acquire()release() 能够更精确地控制物理碰撞临界区。

2. 同步块的设置及与内部语句的关系

系统中的同步块设置严格遵循了“最小化临界区”和“读写一致性”原则。以下是核心同步块的场景分析:

  • 场景一:生产者-消费者模型中的请求队列调度
    • 同步对象: RequestQueue 实例(即 this)。
    • 代码位置: RequestQueue.java 中的大部分方法(如 addRequest, getOneRequest, waitForNewRequest)。
    • 锁与语句的关系: 同步块内的语句执行了对底层 ArrayList 的直接增删改查。当 InputThread(生产者)调用 addRequest 时,同步块保护了 requests.add() 操作不被打断,紧接着的 this.notifyAll() 语句负责唤醒正在等待的电梯。相反,消费者获取请求或发现队列为空时,利用 wait() 挂起,这完美配合了同步块,避免了 CPU 轮询造成的资源浪费。
  • 场景二:调度器分配策略中的快照读取
    • 同步对象: 全局电梯列表 elevators
    • 代码位置: DispatcherThread.java 中的 dealPassenger 方法。
    • 锁与语句的关系: 同步块 synchronized (elevators) 内部包含了遍历所有 12 部电梯、读取当前运行状态和计算分配分数的语句。这里的锁并不是为了防止乘客被重复分配,而是为了保证在读取状态(尤其是处理 UPDATE 状态动态生成备用轿厢时)列表的拓扑结构是一致的,防止遍历时遇到并发修改异常或读取到空指针。
  • 场景三:换乘层(F2)的物理防撞
    • 同步对象: F2Lock
    • 代码位置: ElevatorThread.java 中的 moveFloormoveInDoubleMode 方法。
    • 锁与语句的关系:nextFloor == 2 时执行 f2Lock.acquire(),离开 2 楼后执行 f2Lock.release()。被这两个操作包裹的中间语句是 Thread.sleep(400)currentFloor = nextFloor。这段语句在物理上模拟了电梯占用 2 楼的过程。这种锁与语句的绑定极其直接,临界区严格限制在“电梯处于 2 楼的生命周期”内,确保同一时刻同一井道只有一部电梯能执行这段语句。
  • 场景四:电梯重置与队列回退的原子操作
    • 同步对象: 电梯专属等候队列 myQue
    • 代码位置: ElevatorThread.java 中的重置处理方法(如 dealcaserepAc 等)。
    • 锁与语句的关系: 同步块 synchronized (myQue) 内部包含了打印 MAINT1-BEGIN 以及调用 returnQueuedPassengers() 将未上车乘客退回全局队列的语句。把这些语句打包进一个同步块,保证了“电梯正式进入维修状态”与“拒载并退回乘客”这两个动作在宏观上的原子性,防止调度器在电梯即将进入维修的瞬间继续向其专属队列中塞入新乘客。

3. 架构演进总结

随着三次作业的迭代,同步块的作用范围其实是在不断缩小的。一开始可能是粗粒度地锁住整个系统,但为了性能,最终演变成了目前的数据分离与细粒度锁:全局调度器只锁分配逻辑,电梯内部只锁自身队列,特定楼层互斥使用专属锁。这种解耦不仅提高了并发度,也大大降低了死锁的风险。


二、调度器设计调度策略

在三次电梯作业的迭代中,系统的调度核心从最初简单的单电梯“接单”,演变成了复杂的“多电梯动态协同调度”。为了平衡各个电梯的负载并追求更优的性能表现,我采用了宏观与微观相结合的调度体系。

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

我的调度器(DispatcherThread)在系统中扮演着“交通枢纽”的角色,完美契合了生产者-消费者模型,并在线程间起到了关键的解耦作用。

  • 架构定位: 调度器作为一个独立的线程运行,它本身不处理任何具体的电梯移动逻辑,而是专门负责“分发”任务。
  • 线程交互过程:
    1. 与输入线程的交互: InputThread 是第一层生产者,它将解析到的请求放入全局共享的 waitQueue。调度器作为第一层消费者,不断从 waitQueue 中通过 getOneRequest() 获取请求。如果队列为空且未输入结束,调度器会因 wait() 而阻塞,不浪费系统资源。
    2. 与电梯线程的交互: 拿到请求后,调度器化身为第二层生产者。对于乘客请求,它会遍历全局 elevators 列表获取状态快照,计算最佳电梯,并将乘客放入目标电梯的专属队列(elevatorQueue)。对于特殊的工人请求(如检修、改造、回收),则根据指令中指定的 ID 直接精确投递。电梯线程作为第二层消费者,从自己的专属队列中取客运行。
  • 交互中的边界处理: 调度器的精妙之处在于处理异常流。当电梯因双轿厢限制或进入重置状态无法接客时,调度器会拒绝分配,或者电梯中途将乘客“抛回”全局队列时,调度器能够通过重新参与循环,再次将这些乘客分配给其他合适的电梯,形成了一个健壮的闭环

2. 调度策略演进与多性能指标(时间、电量)适配

三次作业的性能评价指标从单纯的“运行时间”,逐渐过渡到“运行时间 + 耗电量”的综合考量。为此,我的调度策略分为两层:宏观分配(Dispatcher)与微观运行(Strategy)。

微观层面:局部 LOOK 算法(Strategy.java 单部电梯的运行采用了标准的 LOOK(ALS)算法变种。电梯不会盲目走到顶或底,而是通过 hasReqAhead() 判断当前方向前方是否还有需求(包括车厢内的目的地和车厢外的呼叫地)。

  • 适配指标: 这种策略能够最大程度减少电梯的“空跑”和“无意义掉头”,在保证乘客不会饿死(优化等待时间)的同时,极大地减少了电梯移动层数(大幅降低耗电量)。

宏观层面:权重评分动态分配机制(calculateScore 这是调度器分配乘客的核心算法,通过给每部电梯打分来决定归属。满分为基准分,按以下几个维度进行扣分或加分,巧妙地平衡了时间与电量:

  • 工作量惩罚(平衡时间): score -= elevator.getWorkload() * 250;
    • 分析: 权重最高的一项。工作量(车厢内人数 + 专属队列人数)直接反映了电梯的繁忙程度。高额的扣分强制调度器将乘客分配给相对空闲的电梯,实现了负载均衡。这有效防止了某一部电梯大排长龙而其他电梯闲置的情况,极大地优化了所有乘客的平均等待时间系统总运行时间
  • 距离惩罚(综合优化): score -= distance * 10;
    • 分析: 电梯当前楼层与乘客起点的绝对距离。优先派距离近的电梯去接客,不仅能减少乘客的初始等待时间,还能减少电梯接客过程中的空载移动层数,从而节省电量
  • 顺路奖励(优化电量与时间): score += 150score -= 150
    • 分析: 如果电梯当前的运行方向与乘客同向,且电梯“还没开过”乘客所在的楼层(即完全顺路),则给予加分奖励;反之,如果需要电梯跑完当前行程再掉头回来接,则给予重罚。这一策略尽量避免了为了接一个相反方向的乘客而打断当前电梯的平顺运行,减少了起停和转向的电量消耗
  • 双轿厢物理限制过滤:
    • 对于双轿厢模式(主轿厢和备用轿厢),在评分阶段直接通过极端的负分(-10000)剔除了它们无法到达的楼层请求,保证了分配的有效性,避免电梯接到不可能完成的任务而产生逻辑死锁。

总结: 总体而言,这套调度策略没有采用死板的静态分配(如对 ID 取模),而是基于电梯实时状态的动态评分。它通过惩罚高负载来争取“时间得分”,通过奖励顺路和惩罚远距离来争取“电量得分”,是一种在复杂并发场景下鲁棒性极强、且能兼顾多维度性能指标的实用启发式调度算法。


三、线程安全与层次化设计体会

经历了三次电梯作业的迭代,我从最初对多线程的一无所知,到后来能够熟练运用各种锁机制,最大的体会是:多线程编程不仅是加锁的技术,更是一种对数据流动和对象生命周期严格把控的艺术。而层次化设计,则是这门艺术的基石。

1. 对线程安全的理解:从“盲目加锁”到“精准控制”

在单线程时代,我们只需要关心逻辑是否正确;但在多线程的电梯系统中,时间成为了一个不可控的维度。任何一个共享变量的非原子操作,都可能引发灾难性的并发 Bug(如把同一名乘客分配给两部电梯,或者系统无法正常终止)。

结合三次作业的迭代,我对线程安全的理解经历了以下几个层次:

  • 第一层:认清共享资源,利用 Monitor 模式(synchronized)保平安。 在架构中,RequestQueue 是最核心的共享资源(被输入线程、调度器线程和电梯线程共同读写)。我深刻体会到了将共享资源封装为线程安全类的必要性。通过在 RequestQueue 的方法上加 synchronized 关键字,将内部的 ArrayList 操作变为原子操作,使得外部调用者(如电梯和调度器)不需要关心底层的并发冲突,实现了“高内聚”。
  • 第二层:拒绝 CPU 轮询,掌握 Wait-Notify 机制。 线程安全不仅要求数据不错乱,还要求不浪费系统资源。早期可能会犯用 while(true) 死等队列的错误,导致 CPU 占用率飙升(CTLE)。通过熟练掌握 wait()notifyAll(),我的系统实现了优雅的事件驱动:当队列为空时,调度器或电梯主动让出 CPU 进入等待池;当新乘客到来或系统输入结束时,再由队列精准唤醒。这种“按需唤醒”是多线程高效运行的关键。
  • 第三层:针对特定场景的细粒度锁与无锁设计。 随着第三次作业双轿厢的引入,单一的 synchronized 已经无法满足需求。
    • 细粒度锁: 针对 2 楼的防撞机制,我并没有锁住整个井道或整个系统,而是引入了极小粒度的 F2Lock。电梯仅在跨越 2 楼的生命周期内持有该锁,最大程度地保留了双轿厢的并发性能。
    • 无锁设计: 对于全局请求总数的统计,我使用了 AtomicInteger totalRequests。利用底层的 CAS(Compare-And-Swap)机制实现了线程安全的计数,避免了因为频繁加锁解锁全局计数器带来的性能损耗,优雅地解决了系统安全退出的判定问题。

2. 对层次化设计的理解:各司其职,拥抱变化

如果在第一次作业就把调度逻辑、电梯运行逻辑、输入逻辑揉在一个类里,那到了第三次作业加入“重置”和“双轿厢”时,代码必定会面临重构甚至重写的命运。我的架构之所以能平稳迭代,得益于严格的层次化设计与单一职责原则(SRP)。

我的系统主要分为三个清晰的层次:

  • 顶层分配层(宏观):DispatcherThread + InputThread 这一层只负责“揽客”和“分发”。调度器不关心电梯内部是怎么运行的,它只看电梯暴露出的一些宏观状态(当前楼层、工作量、是否维修中)。这种设计使得新增调度策略(比如从随机分配改为按分数分配)时,完全不需要修改电梯的内部代码。
  • 中间执行层(中观):ElevatorThread 电梯线程是纯粹的“打工人”,它内部维护了一个状态机(NORMAL, REPAIR, UPDATE, DOUBLE 等)。当收到改造指令时,电梯只需改变自身状态,将乘客退回,并自行孵化出备用电梯。电梯的重置与分裂逻辑被完美封装在这一层,没有让调度器去承担这些繁琐的物理限制逻辑。
  • 底层策略层(微观):Strategy 我将电梯“下一步该往哪走”、“该不该开门”的逻辑完全抽离到了一个纯静态的策略类中。ElevatorThread 只需要把自己的当前状态(当前楼层、方向、内外队列情况)传给 Strategy,然后无脑执行返回的 Advice(UP, DOWN, OPEN, WAIT)。这使得微观的 LOOK 算法被彻底解耦,即便未来需要换成更复杂的扫描算法,也只需替换这个工具类。

总结来说,层次化设计的本质就是“画地为牢”: 让数据只在规定的通道流动,让每一个类只做自己分内的事。这样在面对三次作业极其复杂的业务堆叠时,才能做到牵一发而不动全身。


四、Bug 分析与多线程 Debug 方法论

1. 那些年我踩过的坑:典型 Bug 分析

在三次作业的迭代中,我遇到过几个非常典型的并发 Bug,它们在最终的代码中都得到了彻底的修复:

  • Bug 1:CPU 轮询导致的 CTLE (CPU Time Limit Exceeded)
    • 现象: 在早期的测试中,程序虽然能算出正确结果,但 CPU 占用率极高,导致评测机报出超时。
    • 原因分析: 在消费者(电梯或调度器)发现队列为空时,我最初使用了粗暴的 while(isEmpty()) { continue; } 死循环等待。这导致线程疯狂占用 CPU 资源空转。
    • 修复方案: 严格落实了 wait()notifyAll() 机制。如最终代码中 RequestQueuegetOneRequest() 所示,当队列为空且未结束时,当前线程调用 this.wait() 进入阻塞池交出 CPU 控制权;直到 InputThread 放入新请求调用 notifyAll(),线程才会被重新唤醒。
  • Bug 2:遍历时的 ConcurrentModificationException (CME)
    • 现象: 电梯在到达某一层开门放人时,程序突然抛出 CME 异常并崩溃。
    • 原因分析: 最初我使用了 for-each 循环或者常规 for 循环遍历 passengersInside 列表,并在循环内部直接调用 passengersInside.remove(p) 来让乘客下电梯。由于 ArrayList 不是并发安全的,且在迭代过程中直接修改结构,破坏了内部的 modCount,从而抛出异常。
    • 修复方案: 最终采用了 Iterator(迭代器)进行安全删除。在 ElevatorThreaddropArrivedPassengerskickAllPassengersOut 方法中,我都规范地使用了 Iterator<Passenger> it = passengersInside.iterator(); it.remove();,完美规避了这个问题。
  • Bug 3:双轿厢换乘层的“死锁”与“幽灵碰撞”
    • 现象: 第三次作业引入双轿厢后,主副轿厢有时会同时卡在 2 楼不动(死锁),或者输出日志显示两部轿厢同时处于 2 楼(碰撞)。
    • 原因分析: 碰撞是因为早期对 2 楼的物理互斥控制不够严格;死锁则是因为主副轿厢在抢占 2 楼互斥资源时,又同时试图去获取其他共享队列的锁,导致了经典的“交叉锁”问题。
    • 修复方案: 引入了专门的防撞锁 F2Lock,并且极度缩小了该锁的临界区。在 moveInDoubleMode() 方法中,仅仅在 nextFloor == 2 到离开 2 楼的这一小段物理移动时间内上锁。不让 F2Lock 与其他任何同步块(如 synchronized(myQue))发生嵌套,从根本上打破了死锁的环路等待条件。

2. 驯服多线程:我的 Debug 方法论

多线程的 Bug 往往“测不准”——平时运行好好的,一交上去就挂,加了断点它又不复现了。经过这三次作业的折磨,我总结出了一套行之有效的 Debug 组合拳:

  • 方法一:日志大法(Print Debugging)为主,结合 Python 脚本验证
    • 由于打断点会改变多线程的执行时序,隐藏原本的 Bug,因此基于官方 TimableOutput 的日志打印是多线程 Debug 的第一生产力
    • 我会在关键的状态流转处(如电梯收到维修指令、抛出未上车乘客、双轿厢 F2 争夺锁时)加上带有时间戳、电梯 ID 和线程状态的自定义输出。
    • 进阶: 结合 Python 编写简单的自动化评测和死锁检测脚本。由于肉眼看几千行日志不现实,通过脚本正则匹配 INOUTARRIVE 等关键字,可以瞬间校验“是否有人没下电梯”、“是否有电梯瞬移”、“双轿厢是否相撞”。
  • 方法二:活用 JConsole / VisualVM 进行死锁排查
    • 当程序死跑不结束,或者提交后发现 TLE 时,大概率是发生了死锁或者某处 wait() 没有被唤醒。
    • 遇到这种情况,我会通过 JDK 自带的 JConsole 工具连接正在运行的 Java 进程,直接查看 “线程 (Threads)” 面板。JConsole 会直接标红死锁的线程,并清晰地展示出是谁持有了什么锁、又在等待什么锁。这对于排查 synchronized 嵌套导致的死锁堪称神器。
  • 方法三:IDE 断点的“降维打击”(Thread Suspend)
    • 虽然全盘打断点不可取,但在 IDEA 中可以右键断点,将 Suspend 策略从默认的 All(挂起所有线程)改为 Thread(仅挂起当前触发断点的线程)。
    • 这使得我可以在不干扰其他电梯正常运行和调度器分发的前提下,单独“冻结”某一部出现异常的电梯,查看其内部的乘客队列快照和状态机,实现了精准定向爆破。
  • 方法四:极端并发数据的针对性构造
    • 多线程 Bug 通常在高并发下才会暴露。我会针对性地构造“压力测试”数据:
      • 同时并发型: 在同一时间戳 [0.0] 投入 50 个前往不同楼层的乘客,测试分配器的瞬间抗压能力和线程创建开销。
      • 状态冲突型: 在电梯刚满载准备关门的瞬间,强制投入 UPDATEREPAIR 指令,刻意刁难电梯的状态切换逻辑,检验退客机制(kickAllPassengersOutreturnQueuedPassengers)是否会丢失乘客。

五、大模型的使用

相比于第一单元的内容,第二单元堆大模型的依赖程度较为减弱,大部分情况下采用的都是自行尝试理解任务需求,并在大模型的更正下进行初次的独立尝试,大模型进行评判之后指导我进行优化;在debug方面还是比较依赖大模型,特别是代码体量变大的情况下,亟需大模型的辅助。主要用gemini 3.1pro作为辅助工具。

个人感受:

感觉多线程任务难度较大,在debug阶段非常痛苦,不太容易发现具体的错误发生点,希望将来能够以更合适,更方便学习的方式进行多线程任务的学习与挑战。

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

309

社区成员

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

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