BUAA_OO_Unit2阶段总结

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


OO第二单元总结

前言

对于第二单元作业,个人认为就难度上并未比上一单元难,具体体现在这一单元对“算法”方面的要求比较低。只要算法设计合理,通过强测并不是什么很难的事。而且多线程程序的执行结果本身具有比较大的不确定性,即使程序存在Bug,也很有可能顺利通过强测以及互测。

但是第二单元主要就恶心在debug方面。笔者曾经用评测机去测试我的程序,大概高强度测六千次才能出现一次,而且这些Bug还经常无法复现。这种种因素的叠加使得第二单元的bug显得十分“玄学”。我只能用最原始的"print调试法"打印程序运行过程中的各个阶段信息来debug,这为我debug带来了极大的阻碍。

再加之我个人架构极其特殊,难以参考他人经验,使我在写代码的过程中举步维艰。

但我终究还是通过“屎上叠屎”的方式,比较满意地通过了第二单元,感悟颇丰,自己面向对象的功底进一步加深,对多线程程序的理解也得到了深入。

在做了充足的心理准备后(指足足两天),我才下定决心,回顾我那屎一样的代码。

程序概况

类图

由于我在hw7中由于超过了500行的CheckStyle限制,所以我将大量Elevator类中的方法迁移到了静态类Strategy中,这导致我由IDEA导出的类图混乱不堪~~(当然我的程序是屎山,这也是部分原因)~~,因此我决定自己绘制类图,类图如下所示。
HW7类图

UML协作图

HW7协作图

类的概况

Main:在一定程度上等同于其它同学的输入流线程,它负责从elevator.input()方法中获取输入、创建ElevatorManger类和CustomerManager类,以及将乘客请求传递给CustomerManger类、将重置请求传递给ElevatorManage类;

ElevatorMangerCustomerManager:分别对ElevatorCustomer类进行统一管理;

Elevator:继承自Thread类,负责模拟电梯的运行,并输出ARRIVE, ACCEPT, CLOSE, OPEN, RESET等信息;

DoubleCarElevator:继承自Elevator类,并对其中部分方法进行了重写,使其能够符合双轿厢电梯的行为;

CustomerCounter:计数器静态类,负责记录未完成请求的乘客数目,用于在适当的时候“告知”电梯线程其可以退出线程;

Customer:负责管理乘客的行为,输出OUT, IN等信息,并对CustomerCounter进行调整;

ElevatorShaft:负责对同一个电梯井内的两个双轿厢电梯进行管理;

Strategy:负责可能在迭代过程中进行优化的策略,包括调度策略,分配策略等;

ElevatorComparator:在TreeSet中作为参数传入,用于作为进行乘客分配的依据。

总体概况

数据量

复杂度

总体而言,我认为Unit2作业的整体复杂度比起Unit1有了很好的控制。其中表现较差的几个方法也在心理预期内,其中表现较差的几个方法大多是许多不可避免的分支判断造成的。但是Elevator类在码代码的过程中超过五百行限制是我Unit2的一个痛点(虽然但是,我至今都认为我本来的,将所有与电梯状态相关的方法放在电梯类中,是一个可维护性更好,可读性更高的方法)。

程序架构

本人的程序架构极其特殊,最突出的体现有以下几个方面:

同步块的设置

本人对于代码中的锁经历了三个阶段的认识:

第一阶段没必要加就不加,尽量少加。因为这个认识导致我在hw5中出现了较多的因多线程同步不当出现的Bug,因此我在第二个阶段走向了另一个极端;

第二阶段凡是涉及到共享数据的,都加上锁。事实上,在写hw6的时候我就是这么做的,但是在hw6中我因为加锁不当出现了大量的死锁bug,后来我自己总结出了一种避免死锁的方法,具体查看下文“心得体会”部分;

第三阶段有必要加才加,不然不加。我成功运用了我总结出的方法,将许多不必要的锁进行了去除,同时由于hw7双轿厢电梯要求电梯间许多数据的交互,因此我在许多已经上锁的方法中添加了许多上锁的同步代码块,这使我的代码变的臃肿,极不美观~~(不过反正最后一次了,管它呢)~~。

数据读入方面

不同于绝大多数的同学,本人的程序并不存在单独的输入流线程,乘客分配到电梯的等待队列这一过程直接在乘客类的构造函数中进行。

    public Customer(Integer customerId, Integer fromFloor, Integer finFloor) {
        this.customerId = customerId;
        this.fromFloor = fromFloor;
        this.toFloor = finFloor;    // 在当此电梯运行中乘客需要到达的楼层
        this.finTarget = toFloor;   // 最终乘客需要到达的楼层
        synchronized (ElevatorManger.class) {
            // 调用策略类的静态方法,获取乘客需要被分配到的电梯编号
            this.elevatorId = Strategy.decideWhichElevatorToTake(this);    
            Elevator elevator = ElevatorManger.getElevator(elevatorId);
            // 判断乘客的目标楼层是否超过电梯的运行范围
            if (CustomerManager.customerTargetExceed(elevator, this)) {
                this.toFloor = ((DoubleCarElevator) elevator).getTransferFloor();
            }
            // 将乘客分配到对应电梯的等待队列中,并输出相应信息
            come();
        }
    }

当发生电梯重置,电梯换乘,新乘客到来等事件时,我便会在相应代码处new一个新的乘客,这样就可以顺利将乘客分配到对应电梯。

这样做的优点在于,在第一次作业的基础上不需要进行更改,没有输入流在一定程度上也降低了程序的整体复杂度,同时大大减少了码代码所需的时间。~~(懒~

但这样做的缺点也是很明显的。如果存在作为单独线程的输入流,那么只需要处理输入流和电梯线程之间的数据交互就可以了。如果因死锁出现了bug,这样更容易定位bug的位置,也更容易进行修改。但我的架构不存在输入流,因此每两电梯之间都有可能存在数据交互,极易出现死锁Bug,de起来难度也比较大(这也是我为什么在上述代码需要用Elevator. class进行上锁的原因) 。

现在我进行回顾,由于惨痛的debug经历,我可能会更倾向于使用输入流线程进行数据读入

调度策略

由于我在完成代码前并未听说过“影子电梯”“量子电梯”策略(说实话,这种极其反生活常识的策略真的是人能想出来的吗?),也刻意不去参考往年博客,因此我的调度策略也比较“与众不同”。

调度器内置到Strategy的一个方法中,在乘客被创建时调用这个方法,从而决定这个乘客所需要乘坐的电梯。

我的调度策略所使用的指标是“从乘客发出请求到乘客到达终点,该乘客乘坐的某电梯运行的楼层数乘以运行时间”(为了降低复杂度,如果选择的电梯是双轿厢电梯,则按照“该电梯能够到达所有楼层”进行计算)。并且如果“该电梯到达该乘客所在楼层时容量已满”,则顺延至下一电梯。如果“所有电梯均将在到达该乘客所在楼层时满员”,则“将乘客分配至等待人数最少的电梯”,代码选段如下:

Integer minWaitingCustomerNum = Integer.MAX_VALUE;
Integer elevatorIdOfMinWaitingCustomerNum = 1; // 等待人数最少的电梯编号
for (Elevator elevator : elevators) {
    // 如果该电梯在到达乘客所在楼层时未满员
    if (willNotFull(elevator, customer)) {
        return elevator.getElevatorId();
    }
    // 更新电梯编号
    if (elevator.getCurrentWaitingCustomerNum() < minWaitingCustomerNum) {
        minWaitingCustomerNum = elevator.getCurrentWaitingCustomerNum();
        elevatorIdOfMinWaitingCustomerNum = elevator.getElevatorId();
    }
}
// 所有电梯均已满员,分配至最少等待人数的电梯
return elevatorIdOfMinWaitingCustomerNum;

其中elevators是一个TreeSet类型的引用,这个容器中的elevator成员按照“从乘客发出请求到乘客到达终点,该乘客乘坐的某电梯运行的楼层数乘以运行时间”,从小到大排序。在这个指标相同的时候按照“双轿厢电梯优先,ID小电梯优先”的方式排序。

    @Override
    public int compare(Elevator o1, Elevator o2) {
        Integer distance1 = getDistance(o1, customer);
        Integer distance2 = getDistance(o2, customer);
        if (distance1.equals(distance2)) {
            // 非双轿箱电梯优先
            if (o1 instanceof DoubleCarElevator && !(o2 instanceof DoubleCarElevator)) {
                return 1;
            } else if (!(o1 instanceof DoubleCarElevator) && o2 instanceof DoubleCarElevator) {
                return -1;
            } else {
                // id较小的电梯优先
                return o1.getElevatorId().compareTo(o2.getElevatorId());
            }
        }
        return distance1.compareTo(distance2);
    }

我自认为这样调度策略比较好,它在hw6中的性能不输影子电梯,虽然可能运输乘客所需时间上可能不如影子电梯,但是耗电量具有优势。而且直观,易于理解,符合第一直觉,没有因为计算过于复杂而CPU运行时间超标的担忧,而且线程较少,不需要对电梯进行拷贝,数据交互较少,比较不容易出现死锁Bug,易于debug。

电梯运行方面

这里我自认为是一个失败的设计,具体如下

public void run() {
        while (true) {
            // 处理reset
            if (toReset) {
                if (resetType) {
                    reset4DoubleCar();
                    return;
                } else {
                    reset();
                }
            }
            // 开关门以及乘客进出
            customersInAndOut();
            synchronized (lock) {
                // 决定电梯的下一步运行方向,有UP,DOWN,和STOP
                Strategy.decideTarget(this);
                if (getCurrentState() == ElevatorState.STOP && !toReset) {
                    // 如果输入处理完毕且所有乘客运输完毕
                    if (ElevatorManger.isNoLeftCustomerWillCome() && CustomerCounter.isZero()) {
                        // 结束线程
                        return;
                    } else {
                        try {
                            lock.wait();
                            continue;
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }
                }
            }
            // 进行移动
            move();
        }
    }

现在回顾这段代码,我认为就如同指导书中推荐的那样,将所有的诸如“开关门”“乘客进出”“重置”“停止”等操作抽象为一个Action类,通过外界Strategy类进行分配会比较好,这样程序比较稳定,而且针对reset请求可以自由决定将要继续运行的楼层数。

乘客换乘方面

对于Reset请求和双轿厢电梯所带来的乘客换乘请求,我修改了Customer.getOut()部分内容,具体如下

public void getOut(Elevator elevator) {
        elevator.removeCustomer(this);
    
        //…… 输出打印信息
    
        // 判断是否到达乘客的最终楼层
        if (elevator.getCurrentFloor() == finTarget) {
            CustomerCounter.sub();
        } else {
            new Customer(customerId, elevator.getCurrentFloor(), finTarget);
        }
    }

也就是当“乘客下了电梯,但是没有到目标楼层”的情况下,我会创建一个新的乘客对象,并在这个新的乘客对象中为其分配到某个电梯的等待队列中。以此实现“电梯换乘”的效果

双轿厢电梯的特殊设计

为了实现双轿厢电梯运行中两个双轿厢不相撞,我重写了DoubleCarElevator类中的goUp()goDown()方法,添加了判断是否相撞以及解决相撞问题的方法。

    elevatorShaft.checkEncounterAndDeal(this);
    super.CurrentFloorPlus(-1);
    TimableOutput.println(String.format("ARRIVE-%d-%d-%c",
            getCurrentFloor(), realId, mark));

在判断电梯即将相撞时,让电梯陷入wait状态,同时发出信号给另一个电梯,禁止其在换乘楼层陷入wait状态,使其脱离换乘楼层。当双轿厢电梯中的另一个电梯脱离换乘楼层后,唤醒陷入wait状态的电梯。

三次作业的架构迭代

总体而言,我的架构在Unit2中顺利进行了三次迭代,并且电梯的调度策略,乘客的分配策略等内容一脉相承。不过由于我的项目中“不存在输入流线程”的特殊设计,导致我线程间的数据交互越来越复杂:

在第一次作业中,需要进行交互的数据仅有主线程和电梯线程。所以此阶段我只设计了主类电梯类乘客类,以及策略类

在第二次作业中,由于Reset请求,电梯间需要进行数据交互,但是数据交互还仅限于电梯的等待队列和电梯内部的乘客队列。因此我新增了用于统计乘客请求数目的计数器类以及用于为乘客乘坐的电梯优先级进行排序的电梯排序类

在第三次作业中,由于双轿厢电梯的出现,各个电梯的楼层信息与电梯中的乘客信息也成为了电梯间需要进行交互的数据。 因此我新增了电梯井类用于协调双轿厢电梯的行为。

这样的直接结果就是锁的调用越来越复杂,在电梯线程运行的过程中甚至可能出现三层锁的嵌套使用。不过也得益于本人“丰富的debug经历“带来的避免死锁的经验,因此并未给我带来太多的困扰。

在这三次迭代中除了”乘客不在指定电梯“这一要求使我必须重写乘客分配逻辑,其它的迭代内容基本都属于增量式迭代,基本未对之前写过的代码进行修改。其中普通电梯的运行逻辑在第一次作业中就已经固定,乘客的分配逻辑在第二次作业中得到了固定。易变的内容无非是在电梯线程的run()方法中新增一个分支判断去处理新增的重置请求,这也是由于我并未把电梯的各种行为抽象成一个Action类导致的后遗症。

未来拓展能力分析

说实话,在不明确具体要求的前提下,我也很难总结出自己的架构在未来的拓展能力如何。但是结合往届的一些题目要求来看,我认为我的架构可以适应那些要求。我以往年的横向电梯为例。

为了满足横向电梯的需要,我只需要在电梯线程的run()方法中新增一个分支判断,判断在当前楼层是否具有横向电梯,并依据判断结果对电梯执行wait操作。当横向电梯到达某个楼层后对处于该楼层的电梯进行唤醒。再新建一个”横向电梯类“,对横向电梯特有的行为逻辑进行管理。只需这样,我便可以完成”横向电梯“的要求。

程序bug分析

由于本人与其它同学合作开发了“我自认为比较好用”的评测机,并且使用该评测机对我的程序进行了高强度评测,因此我在强测中并未出现Bug,但是由于调度策略方面的失误,导致我本人在hw6和hw7的互测中均出现了性能问题。

hw6的性能问题

在hw6中,当所有电梯全都满员之后,我会将新的乘客分配至编号最小的电梯。那么当大量乘客在短时间内一起到来后,就会有大量的乘客都被分配到同一电梯的等待队列中,进而导致出现性能上的bug,超出题目允许的时间范围内。

hw7的性能问题

在hw7中出现的性能问题就纯纯是我自己笔误~~(手贱)~~了。在码代码的时候“一不小心”把Elevator类写地太大了,超过了CheckStyle的500行限制,因此在完成代码工作后进行了比较多的代码迁移到了Stragety静态类中。在迁移过程中出现了一个笔误,将up写成了down,因此可能会在“大量乘客同时上行”的情况下将大量乘客分配至同一电梯中,进而导致性能问题。

Debug心得

说起debug,那我可是行家啊~~(因为我有丰富的debug经验)~~。对于可复现的bug那很简单,只需逐步减少测试样例,精确定位引发bug的样例,结合IDEA的”存储线程“功能,可以比较轻松地找出bug所在;

重点在于难以复现的bug,我的方法是先观察测试样例,猜测bug最有可能出现的代码部分,然后进行”瞪眼法“;当然绝大多数时候这样并不可行,这时我们可以在程序中添加一些对程序运行关键参数的输出语句:如果你是死锁问题,那么就每有线程获取锁后进行打印输出;如果你是线程无法正常终止问题,那么就打印决定线程终止的关键信息。然后在评测机中测它个昏天黑地,直到再次出现bug为止。之后我们就可以结合输出的打印信息进行bug定位。(俗称:print大法)。

心得体会

线程安全

本人在hw6中与死锁Bug鏖战了一整天,最后积累了一些个人的避免死锁Bug的心得体会,我成功地使用了这些经验,使我在hw7中没有出现死锁Bug。

这个方法,简单来说,就是将各个锁按照调用关系绘制有向图,其中A->B表示在含有A锁的代码块中使用了包含B锁的代码块或者方法。如果图中出现了有向回路,那么就有可能出现死锁。即

该有向图中不存在有向回路是程序不存在死锁问题的充分不必要条件

不存在死锁的情况

在上图所示的这种情况下不会出现死锁问题。

可能存在死锁的情况

而在上图所示的这种情况可能存在死锁问题。

在具体实现过程中,我就会刻意保证不会再图中出现有向回路,因此我在hw7中也没有再出现死锁Bug。

层次化设计

我自认为本人的项目层次化设计较好,每个类各司其职,功能之间相对独立。但是最后因为CheckStyle的一个类代码不超过500行代码的限制,导致我不得不将Elevator类中的方法大量迁移至静态类Strategy中,反而使得我层次化水平有所降低……。

为了实现层次化设计,首先我们应当要思考”这个方法所代表的动作的直接发起者是谁?”并将这个方法放置在对于的发起者所在的类中。我自认为做到了这一点就可以达成比较好的层次化设计。

后记

😭回来吧第一单元😭
🌟我最骄傲的信仰🌟
⚡️历历在目的式子⚡️
😭眼泪莫名在流淌😭
💥依稀记得表达式💥
👍还有给力数据点👍
⚡️顽固Bug给打退⚡️
✨通宵熬夜都不累✨

我现在收回我对第一单元的全部诋毁。有一说一,不能复现,万里挑一的bug,de起来是真难受(多线程,很神奇吧~~)。不过这也让我进一步认识到了评测机的作用,只有有效的评测机才能支持我不断地跑数据以复现那些“万里挑一”的bug。

顺带吐个槽,在hw7的互测中,房间内全部的人我都用我的评测机找出了bug,但是这些bug本身可能需要跑一百次,甚至一千次才可能复现一次。正因如此,我用这些数据根本无法hack到他们,导致我一度“拔剑四顾心茫然”。诚然,我明白保证程序正确性的重要性,但是看着那些人即使不de那些bug,也仍然可以在强测和互测中取得较好的成绩,这还是让我感到了极大的心理不平衡。

因此,我建议之后课程组在设计第二单元的测试时,可以尝试每个数据点对每个学生测试十次甚至一百次。这样能够降低“运气”因素对第二单元测试的影响,增加公平性。

最后,还是很庆幸自己无伤通过第二单元强测,并在最后取得了还不错的分数(我一直以为我会被影子电梯暴杀来着……),我也很期待(???)下一次OO作业的挑战。

  • 18
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值