2026面向对象设计与构造 第二单元总结反思

董济源-24371513 2026-04-25 19:51:28

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

RequestQueue类中所有公开方法均用synchronized修饰,锁对象统一为队列实例自身,形成了一致且内聚的保护边界。addRequest等修改方法在变更共享状态后立即调用notifyAll(),而takeOne方法则用while循环反复检查条件,条件不满足时调用wait()并释放锁。这避免了 CPU 空转,同时保证每次被唤醒后都能在持有锁的前提下重新检查状态。

在第三次作业中,主轿厢与备用轿厢共享同一把ReentrantLock,锁仅包裹“进入2楼”到“离开2楼”的临界区,而非锁住整个电梯。在进入 F2 时加锁,在离开 F2 且当前线程持有锁时才解锁。这种设计把互斥范围压缩到最小,只阻止另一部联动电梯同时跨越 F2,却毫不影响它们在其余楼层的并发运行。相比粗暴地在电梯整体上加synchronized,细粒度锁在大幅提升并发度的同时,依然牢固保障了物理逻辑的安全。

此外,volatile关键字与同步块的配合有效降低了锁开销。ElevatorCoremaintStateminFloormaxFloorisSilent 等被频繁读取但较少整组修改的字段,均声明为volatile。调度线程在分配请求时无需获取电梯对象锁,即可读到这些变量的最新值,从而快速做出初步筛选。只有真正需要原子性确认并写入的分配环节,才进入synchronized (targetElevator)块完成最终操作。这种“volatile保证可见性 + 同步块保原子性”的分层策略,在正确性与性能间取得了良好平衡。

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

第一次作业

本次作业中为每个乘客指定了电梯,分配器只需要将乘客加入对应电梯的请求队列,输出对应电梯 RECEIVE 的信息即可。每部电梯的调度策略则是最基本的 LOOK:如果有乘客需要上下电梯,即当前楼层是电梯中乘客的目标楼层,或当前楼层有乘客请求上电梯且当前电梯运行方向与乘客相同、电梯载重可以容纳,则在当前楼层开门上下客;否则如果当前电梯中仍有乘客,或前方有乘客请求上电梯,则沿当前方向继续移动;否则如果相反方向有乘客请求上电梯,则折返;如果所有请求完全结束(取决于输入信息),则结束电梯进程,否则在当前楼层等待下一个请求。

第二次作业

每个乘客不再指定电梯,需要对乘客进行分派,为此需要设计分派算法;同时新增检修操作,需要新增检修相关的调度策略。我首先排除了当前处于检修状态的电梯(如果所有电梯都处于检修状态则等待,直到有电梯恢复正常运行),然后以当前电梯位置与乘客位置相隔的楼层数为基础分(尽量减少乘客等待时间),如果电梯处于静止状态则加 1 分(优先选择正在运行中的顺路电梯,增加调度灵活性),如果电梯向相反方向移动则加 10 分(等到电梯绕一圈回来,大约要消耗 10 个楼层的时间,拍脑袋想出来的),选取分数最低的电梯,将乘客加入该电梯的请求队列。另外,在收到检修请求后,电梯返回 F1,途经乘客目标楼层时可以让乘客下电梯,同时还可以捎带顺路乘客(同样为了减少乘客等待时间),最后在 F1 清客。我还尝试让电梯在折返前先将反方向的乘客请出电梯,但时间仓促,最终没有采用这一策略。我在完成作业时首要考虑程序的正确性,要使代码尽量简洁,并没有在性能优化上投入太多精力。

第三次作业

本次作业引入双轿厢模式,这时分派电梯的条件就比较复杂了,不仅要考虑电梯状态,而且还要考虑电梯的运行范围,只有满足条件的电梯才能进入分数计算。分数计算逻辑与第二次作业相似,没有大幅改动。此外根据题目要求,双轿厢电梯(处于DOUBLE,REC_ACCEPT,RECYCLE状态)可以不受 RECEIVE 约束从换乘层 F2 移动一层以离开换乘层,调度策略也增加了对双轿厢状态的判断,如果处于双轿厢状态,在 F2 上下客后要撤退一层避开 F2。

三、bug 分析

第一次作业

Strategy中的逻辑与 ElevatorThread.pickUp() 中的开门判断逻辑不一致,导致电梯在同一楼层反复开关门但无法上下乘客的死循环。

// ElevatorThread.java
    private void pickUp() { // 先下后上,在 pickUp 之前已经应下尽下
        synchronized (waitQueue) {
            ArrayList<Person> allInQueue = waitQueue.getAll();
            for (Person p : allInQueue) {
                if (p.getFromFloor() == currentFloor) {
                    int personWeight = p.getWeight();
                    if (totalWeight + personWeight <= 400) { // 如果电梯总重量加上请求加入的乘客重量小于 400kg,才可以进入电梯
                        // 如果满足条件,则让乘客进入电梯
                        // ......
                    }
                }
            }
        }
    }
// Strategy.java
    public Advice getAdvice(ElevatorThread elevator, RequestQueue waitQueue) {
        // ......
        synchronized (waitQueue) {
            // ......
            if (totalWeight <= 350) { // 当前电梯重量小于 350kg 才有可能让乘客进入电梯,但如果出现类似于 350 + 70 的情况,乘客无法上电梯,但 Strategy 依然建议开门
                for (Person p : waitQueue.getAll()) {
                    if (p.getFromFloor() == currentFloor) {
                        if (passengers.isEmpty() ||
                                (p.getToFloor() > currentFloor && direction == 1) ||
                                (p.getToFloor() < currentFloor && direction == -1)) {
                            return Advice.OPEN; // 开门
                        }
                    }
                }
            }
            // ......
        }
    }

第二、三次作业

第二、三次作业在强测的随机数据中表现不错,但在互测中被发现两个 bug:

首先是第二次作业时在收到检修请求后返回 F1 的途中开关门次数过多导致总体时间超过 7s,违反题目要求。我修复这个 bug 的方式简单粗暴,直接取消收到检修请求后的中途停靠,不再上下乘客,“火花带闪电”回到 F1,所有乘客重新在 F1 等待重新分配。但这种方式会导致运行效率降低,其实更优雅的方式是事先计算中途可以停靠的次数,在返回 F1 的途中最大限度满足顺路请求。

其次是如果在 50s 附近同时发出大量请求,而且此时只有一部电梯正常运行、其它电梯都在检修,就会导致一部电梯 RECEIVE 所有乘客,旱的旱死、涝的涝死,总运行时长超过 180s(第三次作业懒得改分配策略了,还是这个 bug)。很多同学都出现了这个问题,哪怕按照随机数分配,同一时间戳的乘客依然会被分配到同一部电梯。我一开始用的是投机取巧的方式修复这个 bug,按照乘客id % 6分配电梯,但这不是长久之计,依然会被轻易 hack;正经的修复方式是一部电梯的请求队列中乘客过多就暂不接收新请求,不急于 RECEIVE,待其他电梯恢复服务后再分配,这种方式也更加自然。

debug 方法

写完程序后可以用 Python 脚本生成大量随机请求,用管道接入 Java 程序进行测试,并统计输出结果中的逻辑错误,这样可以解决大部分问题。但对于高并发的极端情况,还需要自己构造特殊数据进行测试(比如所有乘客请求集中在同一时间、多部电梯同时检修/升级/回收),这两种方法结合有效发现程序中的 bug,再通过 debug 提升程序的鲁棒性。

四、对线程安全和层次化设计的理解

线程安全的本质是管理好对“共享可变状态”的访问。我的体会是,封装是保障安全的第一步。如果一个状态(如电梯的位置)被分散在多个类中被公开修改,那锁的设计必然混乱。本单元的练习让我深刻领悟到,应尽量将状态收敛至对象内部,并提供线程安全的有限接口。

层次化设计则是应对复杂性的利器。在本单元作业中,我从第一次作业开始就采用了“输入层-调度层-物理层”三层架构,输入层(InputThread)只负责解析,将请求扔进总队列;调度层(DispatchThread)负责路径计算、乘客分配,不直接操作电梯运行;物理层(ElevatorThread)负责电梯的具体行为,只关心队列内取任务、上下行、开关门。此外还有一个单独的策略类(Strategy)从ElevatorThread获得当前状态和请求队列,并基于 LOOK 策略向·ElevatorThread`返回要执行的动作。这种高内聚、低耦合的架构直接保障了多线程环境下逻辑的正确性,因为每个层次的锁都只保护自己层的数据,而且迭代时只需要根据新的功能要求在相应的层次中增量开发,不需要大幅调整架构。

五、大模型使用心得

本单元作业中,我主要使用了 Gemini 3.1 Pro。我负责顶层架构设计、线程交互关系和锁策略的决策,在此过程中也与大模型交流意见;大模型同时还用于辅助搭建程序架构、实现特定设计模式与算法,帮助学习 Java 语法知识,作为 reviewer 查找代码中的死锁或其它不符合题意的 bug;最重要的是,大模型能够用于编写数据生成脚本、搭建多线程评测机(虽然约束条件比较复杂,需要多轮对话才能搭建出基本符合题意的评测机),这对程序调试的帮助非常大,有时只盯着代码看很难看出问题,经过大量测试才能暴露出问题。

对于“生产者-消费者”、“状态模式”、“策略模式”等常用设计模式,AI 实现准确率极高,可以节省很多时间;大模型在检查代码时能够发现一些问题,确实比我个人的能力要强;大模型还能够快速理解逻辑约束,并生成具有针对性的“刁钻”测试样例。但受上下文窗口、提示词水平、“降智”等因素影响,大模型仍然存在幻觉,如果只追求利用 vibe coding 快速通过中测,而不进行进一步的代码审查、压力测试,那么程序可能还有不少漏洞。所以,借助大模型编程的时候,我们依然要对自己的程序上心,要清楚大模型做了什么,在这个过程中自己也要学习。此外,大模型可以为我们提供启发,但我们不能被大模型牵着鼻子走,而是要独立思考、举一反三,比如性能优化算法,大模型可以提供思路,但具体参数要靠自己调整;再比如互测的 hack 数据,大模型的思路是在 1s 同时发出大量乘客请求和检修请求可能触发轮询 CPU_TIME_LIMIT_EXCEED,但我自己就没有想到在 50s 发出大量请求更容易触发 REAL_TIME_LIMIT_EXCEED。

六、第二单元真实体验与感受

通过第二单元的学习与实践,我积累了多线程项目开发和 bug 调试的经验,体验了酣畅淋漓的互测,确实有很大收获。当然也有一些遗憾,就是我写的程序还不完美,调度器的分配策略太简陋了,性能方面还可以优化;ElevatorThread超过 500 行后也没有彻底拆分,架构不够美观。

传说中的电梯月终于落下帷幕,有 debug 的折磨,也有成功的喜悦,总体感受还算舒适,未来继续加油吧。

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

305

社区成员

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

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