OO Unit 2 总结博客

邓宇翔-24373294 2026-04-29 23:03:40

在分析下文内容前,先简述一下我在三次作业中的架构设计。

第一次作业的架构设计是仿照实验课的代码来做的:以两级生产者 - 消费者模型为基础架构,分别为生产者、消费者和它们之间的缓冲区设计类。其中生产者、消费者类中的每一个实例都对应到一个线程,而缓冲区类则作为线程间通信的桥梁。

值得一提的是类对应到线程的实现,我没有使用大部分同学所采用的继承 Thread 类,而是设计为 Runnable 接口的实现,再在程序的入口类 new 一个 Thread 类并将这些 Runnable 接口的实现传入到其构造参数中,一来我们本就不在意这个线程类的实例,没有必要做专门的引用,二来在迭代开发中如果这些生产者、消费者类需要继承,也可以很好地适应 Java 的单继承特性。

(不过这个想法在研讨课上讨论后被认为必要不大,毕竟 “组合优于继承”,除非后面出现五花八门的电梯类型,否则这样的设计实际上也很难起到帮助。)

具体而言,第一次作业我设计了这些类:

  • MainClass - Program:这是程序的入口类,用于启动各个线程,因此我把它们放在一起了;
  • InputHandler:这是用于处理输入的类,其对应线程作为第一级生产者;
  • DispatcherRequestBuffer:这是以输入作为生产者、调度器作为消费者的缓冲区;
  • RequestDispatcher:这是用于分发电梯请求的调度器类,其对应线程作为第一级消费者和第二级生产者;
  • ElevatorRequestBuffer:这是以调度器作为生产者、电梯作为消费者的缓冲区;
  • Elevator:这是电梯类,用于处理电梯的运行逻辑,其对应线程作为第二级消费者。
  • ElevatorStatus:这是 enum 类型的类,表示电梯的运行状态,分为 UPDOWNSTOP 三种。

可以发现,两级生产者 - 消费者模型对应的类形成了一个 “三明治” 结构,层次十分分明。这样设计的好处在下文会提到。

整体的运行逻辑比较明显了,这里不过多赘述。下面我主要来介绍一下我 Elevator 类中的一些设计。

首先介绍一下电梯的运行逻辑:我在设计状态时觉得如果状态机状态数过于多,则需要考虑的情况会显著增加,于是我参考了现实中电梯的状态设计 —— 仪表盘上只会有 “上” “下” 和什么都没有的状态,这恰好对应了 ElevatorStatus 类中的三种状态。这三种状态表示的是电梯接下来的运行逻辑 —— 电梯运行时,使用一个 while (true) 循环轮询,先检查电梯的状态,根据不同的状态调用不同的 Strategy 方法:如果是 UP,那么电梯先上一层楼,输出相关信息后,根据是否有相关请求选择是否开门,然后令适量乘客进入,再关门(开门和关门的逻辑比较复杂,为简化设计我使用了一个 open 变量表示是否开门,调用相关方法时先检查变量的值再决定是否输出相关信息),最后再根据各楼层的请求,决定是继续 UP(优先级最高),还是选择 DOWN(优先级其次),还是没有任何请求选择 STOP(优先级最低);如果是 DOWN,和 UP 同理;如果是 STOPwait 直至有相关请求到来从而避免轮询,再考虑 UP 还是 DOWN

在上述运行逻辑中,有一个很重要的细节:我是如何得知哪些楼层有哪些请求的?这里我的设计不是简单地从缓冲区获取请求,而是设计了一个二级缓冲:在 Elevator 类中设计三个容器 upRequestdownRequestrequestTo,分别代表还未上电梯的上、下请求和已上电梯的到达请求。在电梯运行的 Strategy 方法中,我会在合适的地方调用 AddRequest 方法,一口气把缓冲区所有请求全部拉进二级缓冲中,这样虽然效率有一些低下,毕竟需要遍历全部楼层的全部请求,但是这个方法不会反复地在 Strategy 方法中访问缓冲区内容,Strategy 方法中内容全部由当前类中的资源组成,对于编写代码来说会很有帮助。

(实际上,即使这样我仍然觉得这个方法是过度设计,直到后来迭代过程中的一个想法,让这个设计成为了正规军,这里埋一个伏笔。)

研讨课上我发现很多同学的运行逻辑都进行了更深层次的解耦,说实话我是很期望能够写出这样的代码的,但是下文我会提到有一些主观原因,导致我最终选择了上述运行逻辑。


第二次作业是一次惨烈的滑铁卢,我由于各种原因把作业拖到了最后一天来写,导致最后没能找出所有的 bug,遗憾离场。

第二次作业中我分为了两个独立的部分对项目进行迭代,一是加入调度策略,二是加入维修逻辑。

先来介绍一下维修的逻辑,我在原有基础上加入了一个 REPAIRING 状态和对应的 Strategy 方法,然后在出现相关请求的时候给它最高的优先权让它能够第一时间被送达到相应电梯中。电梯收到请求后转换为 REPAIRING 状态,直接在当前楼层开门踢掉所有乘客(这些逻辑被加在上述已有的 Strategy 方法后),然后调用 Strategy 方法,火急火燎前往 F1 接到工人,再按要求完成后续流程。而这其中踢掉乘客的逻辑就会导致乘客有可能在又有可能不在目标楼层下电梯,与第一次我的逻辑中必定会在目标楼层下电梯不同,为此我设计了 Out 方法根据是否到达目标楼层选择不同的行为,如果没有到达目标楼层,则输出 OUT-F,同时将相关请求调回 DispatcherRequestBuffer 类,令其再次分发(该类被设计为单例模式,可供全局访问)。

调度策略我最初构想了一个看似还不错的版本:先获取所有电梯的状态快照,根据这些状态选择合适的电梯 —— 优先选择顺路的,然后再考虑选择已接受请求尽量少的(加入了一定的随机扰动)。为了这个策略,我将原来的 ElevatorStatus 类重命名为 ElevatorMovementStatus 类,而 ElevatorStatus 类用于描述电梯的各个状态量,包括楼层和状态。

但是在这其中我做了一些很愚蠢的设计:首先为了精确获取所有电梯的快照,我选择用一把大锁同时锁住六个电梯的 ElevatorStatus,这个做法极度不优美,既极大降低了效率,还导致了后续的死锁等一系列 bug;其次在调度策略中,我选择在所有未处于维修的电梯中分配请求,如果所有电梯都处于维修状态则 wait —— 这也是我设计大锁的原因,但这个调度策略在后来和同学聊天时发现可以通过维修五个电梯再把所有请求堆到剩下的电梯上来 hack。总之整个设计就是一个大失败。

我其实写代码的时候已经能够感觉到这个设计有够石山的,但是还是硬着头皮写下去了,只期望能进个互测,之后再重构。没进互测的时候属于是已经没招了,不过也好,不用费心思 hack 别人,而是用更多的时间来重构也未尝不可。我在那一周剩下来的时间里面没有花过多的时间改代码,也没有冥思苦想去找出我的 bug,我在想的是怎么能让我的代码在改动尽量少的情况下能够优雅地完成任务。

在 ddl 截止的前一个小时,我当时发现我叫停线程的逻辑有很奇怪的问题,可能由于偶发性的问题导致最终无法叫停,于是我构思了另外一套逻辑 —— 设计一个计数器(其作为全局资源也被放在了 DispatcherRequestBuffer 类中),每次从 InputHandler 中获得请求就令其加 $1$,每次真正意义上结束请求就令其减 $1$,这样当 InputHandler 罢工并且计数器也为 $0$ 的时候,就能够叫停线程了,整个逻辑非常优雅,我觉得需要保留下来。

后来我觉得实际上就是分发策略的大锁设计导致了各种各样的问题,于是我改为了更加简单的策略 —— 直接分配给已接受请求尽量少的电梯即可(也加入了一定的随机扰动),也不需要统一加锁,直接把各个电梯的快照保存下来即可,最后的误差也不会特别大。但是这里还有一个问题 —— 根据上文的 hack 策略,应该把正在维修的电梯也考虑进来,但是这些电梯既然在维修过程中,那就不可能让它们输出 RECEIVE 的,这应该要怎么办呢?我突然想到了我此前的二级缓冲(回收伏笔) —— 我只要无脑把请求塞给 ElevatorRequestBuffer 就可以了,至于 RECEIVE 请求,既然调用 AddRequest 方法时电梯一定处于非维修状态,那就再在这个时候输出就好了!我当时顿时觉得当初这个靠直觉设计出的看似石山的逻辑优雅至极,马上动手进行了一定的重构。在后来的迭代过程中,我沿用了这个设计,其提供了非常大的帮助。再后来我重新审视了一下代码,把一些当时由于过于急躁导致的一些不太符合原则的设计改掉之后,代码就直接顺利通过了。

这次 bug 修复让我树立起了一定的信心,自己只是由于时间紧迫做出了昏庸的决定,而不是设计能力不足,自己还是能做出很能令自己满意的优雅的设计的,只是之后一定不要再做 ddl 战神了。

(然后第三次作业依旧拖到最后一天来做了。。。)


第三次作业是一次伟大的反击!虽然我由于参加某个数模比赛 + 补觉拖到了最后一个下午来做,但我还是成功调整心态进入了互测!

(虽然最后也出了不少 bug。)

第三次作业虽然宏观上看只有一个新增功能,但是这个功能涉及到的内容还是非常复杂的,双桥厢电梯的启用和回收算是最好实现的了,比较复杂的是对双桥厢电梯的调度,需要同时考虑两个电梯的运行,最困难的则是两部电梯在 F2 的互斥,以及其中衍生出的各种细节问题,都需要一一考虑。

由于时间较短,我当时尽快确定了一个框架,就在框架的基础上写,尽量避免重构,尽量通过最容易实现的逻辑来完成功能。

首先副桥厢电梯最好的实现方法我认为是同主桥厢电梯一样,也通过一个 Runnable 接口的实现对应到一个线程上,在单桥厢模式下保持 wait,直到双桥厢模式再通过 notify 启用。其内部的部分状态和方法都直接沿袭主桥厢,只在涉及到两桥厢的区别的部分,通过软编码的方式修改相关参数的值。主桥厢和副桥厢之间会有一定通信,其一是通过已有的 ElevatorRequestBuffer,其二是通过新增的 ElevatorController,用于通知双桥厢模式的启用和关停,同时也会作为锁在下文的运行逻辑中出现。

分发策略不做任何更改,把两部桥厢看作一个整体,根据整体的已接受请求数分配。这样虽然特定数据的性能可能大打折扣,但至少可以保证极限数据不会崩盘,对于我而言这就够了。不过,当一个请求被分发到缓冲区时,如果双桥厢模式已启用,其被分发到二级缓冲需要进行一定的讨论,根据起始楼层和运行方向,以及请求的类型(维修请求、更新请求、回收请求等等),分发到对应的电梯中。

对于新增的更新请求,其将被分发到主桥厢中,类似 REPAIRING,我同样为它添加一套完整的状态、Strategy 方法和相应的切换逻辑,并且也类似维修请求的方法完成相应的流程,具体而言不再赘述。在此之后,我们通知 ElevatorController,令其启动双桥厢模式并通知到副桥厢,副桥厢被唤醒后类似主桥厢执行 while (true) 轮询,双桥厢模式中的两部电梯就此开始同步运行。

对于新增的回收请求,其将被分发到副桥厢中,相关流程具体不再赘述,结束后其调用 wait 等待下一次双桥厢模式的开启,此时主桥厢也收到通知,切换回单桥厢的运行逻辑。

以上部分基本搭建出了双桥厢模式的运行框架,相对较为简单,接下来介绍相对困难的双桥厢模式运行逻辑。

实际上上文已经提到过,副桥厢的运行逻辑和主桥厢是几乎一致的,都是通过 while (true) 轮询,在不同状态中切换。一个小细节是,有的乘客的行动范围真包含了 F2 层,因此只靠一个桥厢是无法完成其请求的,我的逻辑是把这些人统一在 F2 踢掉,然后同上文 OUT-F 之辈的逻辑一般调回 DispatcherRequestBuffer,让其它电梯自由竞争,毕竟 F2 是兵家必争之地,很难出现另外一部分桥厢的电梯不来接人的情况,在保证了极限数据时间足够的情况下能够给予一定的自由度。

在这次作业中我对运行逻辑再度进行了一次解耦,把所有的移动都用 Move 方法来概括,这是基于我接下来的一个设计。上文提到,本次作业最难处理的可以说是 F2 层的互斥,而我的逻辑是:只要有电梯将要前往 F2 层,就抢占 ElevatorController 锁,这样另外一个桥厢必须阻塞,而在此期间,这部电梯将完成前往 F2 再退回一层的整个流程,最后释放锁供另外一个桥厢进入 F2。这个设计并不能称得上好,因为牺牲了一部分性能,两部电梯不能够同时分别进行让位和上位的流程,但是对于功能的实现而言是大有裨益的,因为这个逻辑几乎不需要考虑任何的复杂情况,纯靠锁的设置和简单的运行逻辑就能够实现功能。更有意思的是,这次作业存在一个很隐晦的逻辑 —— 让位逻辑如果是在无 RECEIVE 的情况下发生的,那么其必须在 RECYCLE 输出前输出 ARRIVE,这个逻辑本来应该需要一个很精细的判断,但是在上述设计下,我们可以在输出 RECYCLE 前调用 ElevatorController 中的 CancelDouble 方法,这样的话,如果电梯正处在上述逻辑中,那么其必然会占用 ElevatorController 的锁,导致 CancelDouble 方法被阻塞,也就是说,整个运行逻辑一定会保证 ARRIVE 输出在 CancleDouble 方法前,而 RECYCLE 又一定输出在 CancelDouble 方法之后!这样我们就轻而易举地解决了这个问题。

当时我用时一个下午设计出以上逻辑,实现并成功通过测试的时候,我整个人都快要疯了,激动地在朋友圈发了一段类似黑子说话的文字。不过想想,到头来实际上质疑声最大的还是我心里的胆怯和对自己的不自信。这次经历成功让我重拾了原本打碎一地的积极情绪。

不过还不能高兴地太早,hack 环节我虽然通过各种极限数据 hack 了别人 29 发,却在快要结束前的 1h 被 hack 了 1 发。不过最后发现实际上强测也有好几个没过的点。究其原因,上文提到两部桥厢的 AddRequest 都有一定的选择性,然而 notifyAll 是只要有请求就会同时唤醒两部正在 WAITING 状态中的桥厢,对于没有被分配到的那个桥厢,其会真正意义上的轮询,导致 CPU 超时。

这个 bug 看似还挺简单,实际改起来出了特别多的坑。一个很简单的方法是在 WAITING 状态下,每次被 notify 时先 AddRequest,如果真的出现了当前电梯的需求时再考虑切换到别的状态,但是这里就会有一个问题,如果副桥厢在这期间收到了回收请求并关停了双桥厢模式,那么主桥厢中的电梯需要重新考虑是否出现了当前电梯的需求(因为条件有所变化),也就需要在 CancelDoublenotify 一下这里。然而!上文的设计中,controller 锁的同步块中会调用 buffersynchronized 方法,而此处我们如果在 buffer 锁的同步块中调用 controller 中的 synchronized 方法,就很有可能产生死锁!如果换一下顺序,把 controller 调到外层,那这个锁本身由于不是 wait 对应的锁,其就会一直阻塞,严重影响性能!

解决方法是在 buffer 中同样设置双桥厢模式的相关变量和方法,在 controller 变化时也通知它们变化,而主桥厢电梯类 Elevator 在判断是否为双桥厢模式时仅询问 buffer,这样锁的调用顺序就固定为了 controller - buffer,就能够相对优雅地实现功能了。

提交上去之后,测试点终于是全绿了。我没有感到多么如释重负,而是仔细回想起了迭代的过程:由于我的时间安排不当,三次迭代作业几乎都是在极限的时间中草率地设计出一个可行的架构,再加上后期不断地缝缝补补中完成的,其中不乏有很多灵巧的设计,但都是一拍脑袋想出来的缺乏深思熟虑的经验主义产物。不过也好,这奇特的经历和这略显诡异的架构能督促我反思,鞭策我继续进步。

接下来针对博客中的一些要求,具体分析其中的一些内容。


总结分析三次作业中同步块的设置和锁的选择,并分析锁与同步块中处理语句之间的关系

上文提到了形如 “三明治” 的结构,生产者与消费者之间夹杂着缓冲区,而其中缓冲区的类实例作为锁,其中的方法作为同步块。这样设计的好处是在线程对应类的编写中我们几乎不需要考虑过多的线程安全问题,它们访问锁对象时自然而然会发生互斥。

这些缓冲区类内部集成了线程之间所有可能的共享资源,因此将其集成为一个类并将所有方法设置为同步的思路是十分自然且有利于后续维护的。同时为了避免死锁,这些类几乎不会发生互相的调用,它们方法的调用者几乎全部来源于线程本身,即使有被互相调用的情况,其也会根据规定的顺序进行。

同步块除了能够保证锁的资源,还有一个功能是基于 wait - notify 的通信机制。在生产者 - 消费者模式中,消费者需要等待来自生产者的资源才能够进行接下来的操作,而轮询会导致 CPU 资源被严重占用,因此消费者必须要进入阻塞态,等待有资源产生时将其唤醒,而唤醒的这个桥梁就是二者之间的缓冲区,其作为锁调用 waitnotify 方法,从而建立起二者之间的通信机制。


总结分析三次作业中的调度器设计,并分析调度器如何与程序中的线程进行交互;总结分析三次作业中的调度策略,并分析自己的调度策略是如何适应时间、电量等多个性能指标的

我的调度器作为输入线程和调度线程之间的缓冲,其基本形式是在这二者中进行通信。不过我把其设置为了单例模式,从而全局都可以利用这个资源,这样当出现 OUT-F 的情况时,就可以将相关请求重新加入到其中,同时其还承担了计数器的功能,用于叫停线程。

我最终的调度策略很简单,就是尽量选择已接受请求数量最少的电梯(包括双桥厢,此时将它们看作一个整体),通过一定的随机因子和限度增加自由度,这样虽然在特定数据下性能不一定能排得上号,但是能够通过极限情况下的数据,这就够了。


分析自己程序出现过的 bug 以及自己面对多线程程序的 debug 方法

各个线程之间本质上都会通过 while (true) 进行一定的轮询,实际上是状态的切换,在切换状态时输出相关调试信息,可以详细了解到线程的运行逻辑,对于可复现的 bug 来说很有用。

然而真正困住我的 bug 分别是作业中的死锁与轮询。死锁实际上是设计问题,根据上文所说,保持一定的设计原则可以几乎杜绝死锁问题;然而轮询问题由于我的架构比较特殊,相对而言确实不好解决。

摘自上文,具体原因是两部桥厢的 AddRequest 都有一定的选择性,然而 notifyAll 是只要有请求就会同时唤醒两部正在 WAITING 状态中的桥厢,对于没有被分配到的那个桥厢,其会真正意义上的轮询,导致 CPU 超时。

解决方法是是在 WAITING 状态下,每次被 notify 时先 AddRequest,如果真的出现了当前电梯的需求时再考虑切换到别的状态,从而避免轮询;同时,在 buffer 中同样设置双桥厢模式的相关变量和方法,在 controller 变化时也通知它们变化,而主桥厢电梯类 Elevator 在判断是否为双桥厢模式时仅询问 buffer,这样锁的调用顺序就固定为了 controller - buffer,从而避免死锁。


结合三次作业谈谈从线程安全和层次化设计的理解

上文提到,线程之间会有部分需要共享的资源,为了保证这些资源读写的原子性,需要通过锁来互斥,从而保证线程安全。

说实话,线程安全听起来就是合理地加锁来保证互斥,但实际上回归到架构设计和实现性能地考虑上,这就不是一个随便的问题了。上文提出了一种设计的原则,就是将线程需要共享的资源集成到一个类中,并且将这个类本身作为锁,从而实现互斥 —— 这是一种十分简洁明了的实现方式,但并不代表其就能够满足所有的需求。不同线程之间贡献的资源有所不同,需要设计不同的类和锁来尽可能地保证并行;有的线程抢夺锁之后可能会长时间占有,严重影响性能,等等。需要考虑的问题可不仅仅是加锁那么简单,线程本身是用来通过提高并行度从而提高程序运行效率的,然而滥用锁则容易导致并行度下降性能大打折扣,与我们的目的背道而驰。

在我的设计中,由于 “三明治” 结构的设计,其尽可能的将并行程度拉到了最大,但也有很多地方为了简单粗暴的实现功能而导致并行度让步的(例如 F2 互斥部分)。这其中需要改进的地方还有很多,或许并不存在完美的设计,但对完美保持追求总是好的。

既然提到了 “三明治”,就不得不再讨论一下层次化的设计了。上文提到架构的基础是两级生产者 - 消费者模型,再后来的迭代中又可以说再加入了一级(副桥厢电梯),这样的架构整体看来是十分清晰的,对于一个请求,我们很轻松地就能够得知其去向,并且也能够很方便地根据这个来做迭代开发。层次化的设计把整个流程拆分为层次,像流水线一样处理请求,在满足高内聚低耦合的同时保证了相当的可读性和可迭代性。


具体谈谈大模型的使用心得

我第一次作业的博客中谈及过自己将会保持 no vibe,但是会尝试使用大模型 debug 和搭评测机。但实际上,把 debug 的活完全交给大模型几乎是不可行的,其上下文几乎不能保证在读懂题意的情况下还能理解代码的逻辑,在这个方向上我踩了数不尽的坑。

我第二次作业依赖大模型 debug 导致巨量的问题没有被查出来,第三次作业我就吸取了教训,尝试从根源上尽可能杜绝部分 bug。对于一些设计思路,我会直接和大模型分享,或者询问它的意见,从而在动手实现之前先了解到这些思路最后可能的结果。事实证明这样的交互模式是有一定效果的,我能够比较顺利地实现出全部功能,并且过程中规避了很多的坑。

研讨课上我记得有同学分享,让大模型 debug 一定需要给它一定的引导,自己需要对自己的代码有一定的感觉,哪一块实现的比较粗糙可能存在 bug,就可以让大模型帮忙看看,同时可以构建提示词工程,让大模型系统性地检查代码中的问题。这个思路可以借鉴一下。

我还是认为 no vibe 是对的,但是在其它的工作上,适当且合理地使用大模型也是一门学问,还需要细细打磨。


谈谈自己二单元的真实体验和感受,并提出建议

如果说第一单元的最大阻碍是还不够熟悉的 Java 语法和首次尝试实现大项目的代码编写与迭代,那么第二单元的最大阻碍就在于对多线程概念的理解以及如何优雅地实现互斥。

依稀记得当时对这种新的概念十分难以理解,在网上找了无数的视频和教程观看,终于在实验课和清明假出去游玩的闲暇之时弄懂了一些皮毛。即使如此,想要实现出真正的多线程还是需要一点点探索,好在实验课的代码给了很大的启发,让我能够顺利的完成大致的调度框架,然后再专门考虑电梯的运行逻辑细节。

不过这次我的代码实现还是有老毛病,部分方法的耦合度过于高了。我觉得可能是我在设计架构的时候,会有一个阈值,对于复杂度低于这个阈值的部分,我基本上就会停止思考,然后直接开始实现,而这个阈值本身由于我写代码的经验相对较为丰富并不低。说实话我觉得这点想要改变需要经年累月的实践,不过既然实现和 debug 都在自己能力范围内的话,这点目前而言还是能够接受的。

这次最应该反思的是自己的时间安排,几乎三次作业都是拖到最后来写的,虽然在绝境中领悟了一些东西,也有成功实现出来的架构,但是这种设计性的东西还是需要更为长久的思考和实践,否则到最后或许还是自己经验主义的自娱自乐。

体验和感受就这些了。建议的话,我记得第一单元博客我曾觉得实验设计有待改进,而这次实验对我的启发很大,因此我觉得实验环节保留还是 OK 的,只不过我还是建议能够取消 OI 赛制,或者至少能够在实验环节给出一个输出的 validator(作业也建议如此)。


END

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

304

社区成员

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

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