面向对象第二单元总结

面向对象第二单元总结

fishlife

写在前面

​ 终于结束了。。。

​ 面向对象第二单元,算是比较平安地完成了三次作业,没出大锅。因为有了第一单元的锻炼,对面向对象有了更深一层的理解,虽然总的代码量比第一单元多,但是这次的代码编写和迭代相对而言比上一单元轻松了一些。当然这也得益于设计模式。但是由于多线程运行的不确定性,bug难以复现,所以debug难度加大,时间大大加长,整体过程比上个单元折磨太多了。

​ 同时这单元作业也发生了许多有趣的事,客观上还起到了调剂心情的作用。

本单元关键词:多线程,Heisenbug,等待戈多

​ 本单元为我打开了多线程的大门,多线程自然应该作为关键词之一。通过多线程,我们能让互相独立的程序的执行路径并行执行,从而大大提高程序运行效率。如同本单元的电梯作业,如果将其改造为单线程的话那么运行时间将成倍地增加,而多线程也符合电梯各自运行,互不干扰的事实。

​ Heisenbug来自著名量子物理学家Heisenberg的名字。由于他发现了不确定性原理,而多线程运行也具有不确定性,故将多线程的bug称为Heisenbug。本单元作业中,出现过一些奇特而又概率极低的Heisenbug,下文细讲。

​ 等待戈多是爱尔兰现代主义剧作家塞缪尔·贝克特的两幕悲喜剧,主要剧情就是两人两个人在等一个叫“戈多”的人(虽然观众到最后都不知道为什么要等)最后也没等到。而本单元作业中,我主要使用的是waitnotifyAll来控制线程,所以死锁一般不会出现,反倒是活锁成为了最大的问题,而活锁某种意义上比死锁更难debug,因为jconsole等工具只能检测死锁。因为活锁的发生是因为几个线程互相wait然后谁也不叫谁,和等待戈多场景相似,因此将其作为活锁的一个代名词。(当然原剧想表达的远不止这些)

​ 当然,上述一些关键词带有的一些自以为的幽默与戏谑的味道也为单调的编程增添了一点乐趣。

分次作业分析

一些小tips

​ 下面的部分类图可能会让人觉得排版过于松散,这是因为我是先做好了hw7的类图,然后在一步步“删回”到hw6和hw5的类图。不打算再次对删好的类图进行再次排版的原因是我认为这样更能够体现出类图之间发生的变化(因为各部分相对位置没有改变),也更能体现出我迭代的思路以及设计模式的威力。时序图同理。

​ 因为我的架构至始至终没有变过,我自己也认为这是第五次作业开始时架构写得比较好带来的结果。所以可能第五次作业占据篇幅要大一些,后两次作业的篇幅可能稍小。

​ 时序图第一次画,画得不好请见谅(

第五次作业

作业内容分析

​ 本次作业的电梯参数固定,方向只有纵向,请求也只有同座纵向。总体而言比较简单,但却是三次作业中的具有奠基作用的一次作业。所以这次作业,代码花的功夫不一定最大,但花的头脑功夫应该是最大的。也正是这次作业开始,我尝试着应用设计模式。

类图分析

​ 本次作业的类图如下

​ 可以看到整个类图即使将依赖关系画上去也能让做到“线线分明”,说明各模块之间的耦合度比较低。事实上确实如此,这也为我之后的迭代提供了相当多的便利。

​ 前文提到了设计模式,从类图可以明显地看出这里我应用了四种设计模式:单例模式,工厂模式,状态模式和策略模式,除了这四种以外,还有多线程中经典的生产者-消费者模式。

设计模式分析:单例模式

​ 类图中的Tunnel使用的就是单例模式。

​ 单例模式通过仅生成一个对象并暴露一个外界获取该对象的方法,很好地减少了内存的消耗,且保证了无论是哪个类访问,访问到的都是同一个对象。非常适合用在共享对象上。

设计模式分析:工厂模式

​ 类图中Elevator, NormalElevator, ElevatorFactory, NormalElevatorFactory四个类及其之间的关系构成了工厂模式的类图。

​ 工厂模式最明显的一个好处就是可以不用显式地用new去创建对象,而是调用工厂的方法。创建对象的过程对用户而言是透明的,并且我们可以在创建对象前进行一些预处理(本单元作业中并没有体现),而这部分对用户而言也是透明的。

​ 工厂模式的另一个好处就是可以方便地通过继承的方式扩展工厂及实体类,符合开闭原则,这也为之后的迭代带来了很大的便利。

​ 不过比较可惜的一点是,这三次作业中我对工厂模式的应用仅仅局限于将其当成一个和new差不多的创造对象的工具,感觉并没有把工厂模式真正的实力发挥出来。

设计模式分析:状态模式

​ 类图中Elevator, State, CloseState, OpenState, RunningState, StopState五个类及其之间的关系构成了状态模式的类图。

​ 状态模式将每个状态分别写成了一个状态类,只对外暴露一个状态接口,电梯只需要调用接口函数便知道当前它应该做什么。而不是将这些状态写成一大串的条件判断。状态模式的实现简洁,且符合开闭原则,需要扩展状态时直接多写一个状态类实现状态接口即可,原代码不需要更改,而条件判断的形式则需要在原本冗长的条件判断中再增加一条,这无疑增加了复杂度。

设计模式分析:策略模式

​ 类图中Elevator, Strategy, NormalStrategy三个类及其之间的关系共同构成了策略模式的类图。

​ 策略模式和状态模式有点像,也是对外暴露一个策略接口,电梯做决策时也只需要调用接口函数即可。同样符合开闭原则,理由类似上面,不再过多赘述。

​ 策略模式还有一个好处就是比较哪个策略好时不用再到处注释代码了,直接将电梯策略类整个换掉即可。

​ (这部分状态模式和策略模式长得实在太像了不知道是不是我写的代码的问题~)

设计模式分析:生产者-消费者模式

​ 在我的第五次作业中,有两对生产者-消费者。他们的身份和共享的对象如下表所示。

生产者 消费者 共享对象
InputThread Scheduler Tunnel
Scheduler ElevatorThread Elevator

​ 对于第一对生产者-消费者,InputThread将请求加入Tunnel中,Scheduler从Tunnel中取出请求。

​ 对于第二对生产者-消费者,Scheduler将从Tunnel中取到的请求通过调度算法加入某部电梯的等待队列WaitingMap中,而ElevatorThread将等待队列中的请求按照策略完成。

​ 生产者与消费者对共享对象的操作如下图所示,其中W代表写,R代表读,下同。

​ 通过这张图实际上也就自然而然地引出了下面的话题。

时序图

​ 本次作业的时序图如下所示。其中线程之间的协作关系已在上面的设计模式分析:生产者-消费者模式中说明,这里不再赘述,下同。

同步块与锁的选择

​ 由上上张图可以看到,此时所有线程对共享对象的操作均为写,自然就没有必要使用读写锁了。我的选择是对Elevator类操作同步使用ReentrantLock而对tunnel的操作同步使用synchronized

​ 这里先讲synchronized,查阅资料可知synchronized作为一个关键字,在语法层面上就提供了同步,写起来也比较方便,且能够在同步块内出现异常时自动释放锁,程序员不用操心锁的释放的问题,但是不能被中断,不过这里我并没有用到这个特性,所以影响不大。

​ 而ReentrantLock则是作为一个类来提供锁的功能(java任意一个对象均可作为锁),并且需要手动上锁和释放锁,上下锁操作还需要用try-finally括起来防止死锁,好处之一是显式地将锁以及锁的范围表示了出来。

​ 那为什么不在Elevator类里一起使用synchronized呢?当时我的真实想法是学到了新东西总得用一下,但是写到后面涉及到电梯策略优化时就需要用到ReentrantLock提供的一个强大的内部类来实现——条件变量Condition,这里先按下不表,后面有专门一节对其进行解析。

调度器设计

​ 这边先放一段调度器源码

@Override
public void run() {
    //System.out.println("scheduler thread start");
    Tunnel tunnel = Tunnel.getTunnel();
    PersonRequest request;
    while (!tunnel.isEmptyInput()) {
        request = tunnel.get();
        if (request != null) {
            Elevator elevator = elevators.get(request.getFromBuilding() - 'A');
            elevator.getLock().lock();
            try {
                elevator.getWaitingMap().get(request.getFromFloor()).add(request);
                elevator.getWaiting().signalAll();
                if (request.getFromFloor() == elevator.getNowFloor()
                        && request.getToFloor() > request.getFromFloor() == elevator.isUp()) {
                    elevator.getInterrupting().signalAll();
                }
                //System.out.println("Scheduler -->
                // Add Elevator" + elevator.getLocation() +"'s request: done");
            } finally {
                elevator.getLock().unlock();
            }
        } else {
            break;
        }
    }
    for (Elevator elevator:elevators) {
        elevator.getLock().lock();
        try {
            elevator.setShutdownable(true);
            //System.out.println("Scheduler -->
            // set Elevator" + elevator.getId() + "'s shutDown: true done");
            elevator.getWaiting().signalAll();
        } finally {
            elevator.getLock().unlock();
        }
    }
}

​ 不难发现,这里调度器采用的是“取一推一”的方式完成请求的传递。之前我有想过也实践过一次将接到的所有请求推给一部电梯的做法,但很不幸翻车了。后来我仔细一想,发现一次取一堆推给电梯可能并不是一个很好的做法,这边给出我的观点(这里的观点基于我之前的实践,可能比较片面,有其他想法欢迎批评指正)。

​ 取一堆推一堆的模式有一个显著的问题就是调度器需要花费更多的时间来将请求推送给电梯,而推送请求时至少要将电梯的等待队列锁起来,这样电梯就无法在这个时候上人了。还有一点,这种一次推一堆的模式易导致一部分早到的请求却要在比较晚的时候才会被服务(饿死了但又没完全饿死)。

​ 举个例子,此时同时在ABCDE座各有6个人要乘梯,输入线程只推了ABCD四个座的请求,此时调度器一轮扫描下来,按整组的方式将请求塞给电梯,但是E座的电梯没有请求(因为输入线程还没将E的请求给tunnel)。然后这时ABCD又各有6个人要乘梯,输入线程将剩余所有请求全给了tunnel,此时调度器一轮扫描下来还是按ABCDE的顺序将请求传给电梯。可以发现,原本第一轮到的6个E座的人却等着调度器塞了两轮ABCD(48个)请求才轮到他们,显然这是不公平的。而取一推一的策略天然地需要维护一个队列,自然就保证了先来先得的原则。

​ 当然,取一推一一个缺点就是电梯可能接到一个请求就跑了,以及无法集中处理请求,效率可能比较低。这也会在后文提到的策略分析中得以解决,这里也先留个悬念。

​ 调度器与其他线程的互动模式已在上面的设计模式分析:生产者-消费者模式中说明,这里不再赘述,下同。

第六次作业

作业内容分析

就有没有一种可能,第二次作业其实就只是电梯数量变多了 ——助教 林星涵

​ 相较于第五次作业,本次作业多了横向电梯和横向请求。由电梯的横竖二象性可知,这两者可以做到统一,即实际上他们是一种电梯。(这里再次感谢林星涵助教水群里的一句话点开了我的思路)。

​ 横竖二象性,简单来说就是原来的纵向电梯是[A-E]座[1-10]层,而新加的横向电梯可以看作是[1-10]座[A-E]层,只不过横向电梯可以做循环运行。再将一些二者特有的行为写成抽象方法由他们的子类去实现,这样便将两种电梯统一了起来,原有的电梯运行逻辑也就不再需要改动,所做的最多就是将原有的操作封装成方法而已。具体内容可以移步讨论区帖子http://oo.buaa.edu.cn/assignment/335/discussion/1167

类图分析

​ 本次作业类图如下所示。

​ 相较于上次作业,本次作业多了建造者线程Builder,电梯多了横向电梯TransverseElevator,当然也多了横向对应的策略和工厂,原有的纵向电梯被重命名为VerticalElevator。同时Elevator类里多了一批抽象方法,新增共享对象ElevatorGroup

​ 可以看到,有了设计模式,这次迭代我只是把新增的东西用一个类表示后直接实现接口即可,并不需要去动原代码。很好地符合了开闭原则。并且整套体系的耦合度依旧很低。

设计模式分析:单例模式

​ 由于多了一个共享对象ElevatorGroup,所以对其也应用了单例模式。

设计模式分析:工厂模式,策略模式

​ 直接将横向电梯对应的电梯类继承原电梯抽象类,横向电梯的策略实现策略接口。这样就完成了迭代。

设计模式分析:状态模式

​ 得益于前面对电梯类型的统一,这部分代码完全不需要改变。

设计模式分析:生产者,消费者模式

​ 本次作业中的生产者消费者关系如下表所示。

生产者 消费者 共享对象
InputThread Scheduler,Builder Tunnel
Scheduler ElevatorThread Elevator
Builder Scheduler ElevatorGroup

​ 对于第一对生产者-消费者,InputThread将请求加入Tunnel中,Scheduler从Tunnel中取出人的请求,Builder从Tunnel中取出加电梯请求。

​ 对于第二对生产者-消费者,同第五次作业。

​ 对于第三对生产者-消费者。Builder将建好的电梯加入ElevatorGroup中,而Scheduler在每次调度时则需要从ElevatorGroup中选择一部电梯,将请求传给他。所以Scheduler在这里并不是严格意义上的消费者,二者的关系更像是读者和写者。

​ 生产者与消费者对共享对象的操作如下图所示。

时序图

​ 本次作业的时序图如下所示。

同步块与锁的选择

​ 由上上张图可以看到,此时除了Scheduler对ElevatorGroup为读操作外,其他所有线程对共享对象的操作均为写,依旧没有必要使用读写锁。我对新加的ElevatorGroup的操作同步使用synchronized,理由同第五次作业时对Tunnel使用synchronized的理由。

调度器设计

​ 老规矩,放一段调度器源码。

@Override
public void run() {
    //SafeOutput.println("scheduler thread start");
    PersonRequest request;
    while (!tunnel.isEmptyInput()) {
        request = tunnel.getPersonRequest();
        if (request != null) {
            Elevator elevator = group.chooseElevator(request);
            elevator.getLock().lock();
            //SafeOutput.println("scheduler get elevator " + elevator.getId() + "'s lock");
            try {
                elevator.addWaitingRequest(request);
                elevator.getWaiting().signalAll();
                if (elevator.interruptable(request)) {
                    elevator.getInterrupting().signalAll();
                }
                //SafeOutput.println("Scheduler --> Add Elevator"
                // + elevator.getId() +"'s request: done");
            } finally {
                //SafeOutput.println("scheduler lose elevator " + elevator.getId() + "'s lock");
                elevator.getLock().unlock();
            }
        } else {
            break;
        }
    }
    group.shutdownAll();
}

​ 可以看到,调度器依旧只负责将人的请求取一推一,设计思路并没有变,但由于我将选择电梯部分以及结束时关闭电梯的部分封装为了ElevatorGroup里的两个方法。相较于上次作业,调度器的顶层逻辑显得更加简洁了。

第七次作业

作业内容分析

​ 本次作业增加的内容在我这被分为了三个部分,迭代时我也是按照三个部分分别思考和迭代来完成的。

  • 电梯参数定制
  • 请求路线规划
  • 节点请求回传

​ 第一部分,电梯参数定制实际上很简单,将请求参数传给电梯类的构造方法即可,这里不多做赘述。

​ 第二部分,请求路线规划难点实际上不在实现,而是在优化上。我的实现是单独封装一个Myrequest类,里面装有一个PersonRequest以及设定的一个参数switchFloor。这里我的想法是换乘次数越少越好,能一不二,能二不三,最多三次。这也与实际中的电梯换乘相同,毕竟人人都乐意一步到达目的地。而且换乘次数多实际上意味着一种不确定性,程序的输出一般而言会更不稳定。

​ 参数switchFloor的含义如下表所示,可以看出这里该参数也充当了状态标志的作用。

意义
1-10 需要换乘,且换乘层为switchFloor
0 正在换乘层等待或仅需横向电梯即可完成
-1 已经换乘完毕或仅需纵向电梯即可完成

​ 根据人下电梯时其所处位置与终点位置是否相同以及switchFloor即可判断出回塞的请求应该是什么。

​ 第三部分实际上需要和第二部分配合,这里我的做法是直接让电梯也作为一个请求发起者,其发起的请求就是其回塞的请求。这样只需要在电梯里多写一个方法即可,容易迭代。

​ 综上,三个部分处理完毕。

类图分析

​ 本次作业的类图如下所示。

​ 从类图中可以看出,本次作业新增了MyRequest类,PersonGroup共享对象,Splitter拆分器线程以及选择换乘楼层的策略SwitchStrategy和选择电梯的策略ChooseStrategy

​ 其他类在上面已有说明,这里重点说一下SplitterPersonGroup的作用。

Splitter主要作用是接受PersonGrouprequestQueue中的请求,将其拆分后为switchFloor赋值,并将拆分后的第一步请求加到Tunnel里,供Scheduler读取。而输入线程在这则是负责将原始请求封装成myRequest并将其加入requestQueue中。

​ 虽然类增多了,但实际上每个类还是各司其职,类图中每条线也能清楚地辨明,整体耦合度和复杂度较低。

设计模式分析:单例模式

​ 由于多了一个共享对象PersonGroup,所以对其也应用了单例模式。

设计模式分析:工厂模式

​ 由于本次对电梯有个性化请求,因此针对工厂的接口进行了调整,虽然对接口调整不是件好事,但是整体调整幅度并不大,而且调整后的接口能适应更加广泛的个性化请求。

设计模式分析:状态模式

​ 这部分主要是在OpenState里加上了backScheduler方法来回传请求。(也许叫backTunnel好点?)

设计模式分析:策略模式

​ 这部分主要是针对选择换乘楼层和选择电梯分别加上了新的策略接口及实现类。这为我抉择策略时的调试提供了很大的便利。

设计模式分析:生产者-消费者模式

​ 本次作业中的生产者消费者关系如下表所示。

生产者 消费者 共享对象
InputThread Splitter PersonGroup
Splitter,InputThread,ElevatorThread Scheduler,Builder Tunnel
Scheduler ElevatorThread Elevator
Builder Scheduler ElevatorGroup

​ 对于第一对生产者-消费者,互动关系已在上面说明

​ 对于第二对生产者-消费者,此时的InputThread只负责将加电梯请求塞给Tunnel(人请求需要经过Splitter处理),Splitter则只负责将人请求加给Tunnel。而Scheduler也只取人请求,Builder也只取加电梯请求。

​ 对于第三对生产者-消费者,同第五次作业。

​ 对于第四对生产者-消费者,同第六次作业。

​ 生产者与消费者对共享对象的操作如下图所示。

时序图

​ 本次作业的时序图如下所示。

同步块与锁的选择

​ 由上上张图可以看到,此时除了Scheduler对ElevatorGroup为读操作外,其他所有线程对共享对象的操作均为写,依旧没有必要使用读写锁。我对新加的PersonGroup的操作同步使用synchronized,理由同第五次作业时对Tunnel使用synchronized的理由。

调度器设计

​ 上源码!

@Override
public void run() {
    //SafeOutput.println("scheduler thread start");
    PersonRequest request;
    while (!PersonGroup.getGroup().isFinished()) {
        request = tunnel.getPersonRequest();
        //SafeOutput.println("Scheduler get a request from tunnel: " + request);
        if (request != null) {
            Elevator elevator = egroup.chooseElevator(request);
            //SafeOutput.println("Scheduler choose elevator " + elevator.getId()
            // + " for request: " + request);
            elevator.getLock().lock();
            //SafeOutput.println("scheduler get elevator " + elevator.getId() + "'s lock");
            try {
                elevator.addWaitingRequest(request);
                elevator.getWaiting().signalAll();
                if (elevator.interruptable(request)) {
                    elevator.getInterrupting().signalAll();
                }
                //SafeOutput.println("Scheduler --> Add Elevator "
                // + elevator.getId() +"'s request: done");
            } finally {
                //SafeOutput.println("scheduler lose elevator " + elevator.getId() + "'s lock");
                elevator.getLock().unlock();
            }
        } else {
            break;
        }
    }
    egroup.shutdownAll();
    //SafeOutput.println("Scheduler shutdown!");
}

​ 可以发现,虽然这次作业复杂度的跨度远胜前一次作业,但是调度器的代码几乎没有发生任何改变!

​ 唯一改变的是调度器线程跳出循环的条件,此时由于电梯回塞请求的存在,调度器不能像之前一样随着输入的停止而停止,而是需要在所有请求都被处理完后才能停止下来,代码中表现为PersonGroup.isFinished()true时跳出循环。

分次bug与debug分析

自己的bug

​ 在第五次作业和第七次作业中,我的公测和互测均未被发现bug,但在第六次作业的互测中,我被一个非常简单的互测样例hack到了。bug描述如下:

​ 当有请求时,无论是电梯请求还是人请求输入线程input均会在将请求塞入队列后唤醒建造者builder和调度器scheduler。在运行环境比较特殊的情况下,可能导致在有电梯请求时唤醒调度器,然后调度器发现人请求队列仍为空继续wait,然后又切换回输入线程,输入线程继续将电梯请求塞入队列后唤醒两个线程,此时下一个轮到的线程又正好是调度器,调度器于是继续wait。循环往复导致在真正人请求到来时builder可能还没被轮到,并未建造电梯,最终报错。若这种情况发生在将要结束时则可能因电梯线程关不干净而超时。

​ 当然,修正的方法也很简单。当Scheduler找不到对应电梯时先wait等待builder建造完电梯后唤醒Scheduler,并在最后关闭所有电梯的条件中加上所有建造电梯请求已处理完毕即可。

while (verticalElevators.get(request.getFromBuilding() - 'A').isEmpty()) {
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}//等待当前需要的电梯建造完毕

while (!buildDone) {
    try {
        wait();
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
} //等待所有电梯建造完毕

​ 由于有刚建的电梯1s后才能有对应请求的限制存在,实际上这个bug发生的概率相当低,从hack的数据点出发,它需要在1.1s内不给Builder分发哪怕一个时间片或者分发时间片时其恰好抢不到锁。这个事件发生的概率几乎为0。只考虑前者时的具体计算过程如下。

​ 我算出这个结果后的表情是这样的。

​ 当然,这也从侧面凸显出评测机的实力和压力有多大,不要想着暗度陈仓(

别人的bug:hack思路

​ 因为思路都是一样的,所以就统一讲了。

​ 我的测试策略是先黑盒后白盒。首先先使用一些能发现共性问题的测试点进行测试,然后再针对代码中线程安全问题分别构造测试点。

​ 黑盒测试中提到的发现共性问题的测试点,懂得都懂,就是高压数据,在一个时间点内涌入大量的请求,我都是在限定时间的最末尾一次性加入最大数量的请求,可以同时hack高压带来的线程安全的问题以及TLE。而在第六次作业及之后,我还构造了一种非常好用的测试点(1-70测试点):

[1.0]乘客ID-FROM-起点座-起点层-TO-终点座-终点层
[70.0]ADD-type-电梯ID-楼座ID-容纳人数V1-运行速度V2(-可开关门信息M)//最后一次作业互测时时间戳为50.0

​ 即在第1s加入一条乘客请求,在最后时间加入一个加电梯请求。这种测试点很容易hack出无法正常停止线程的那部分程序

p.s. 其实原本是想只加电梯请求最后需要在没有任何输出的情况下退出程序,指导书没说不允许然而评测机不允许,所以在前面加了条乘客请求。

​ 白盒测试其实就是下载别人的代码看,和上面一样画出共享对象的操作图来辅助分析可能存在的线程安全问题。然后针对性地构造样例。但是白盒测试其实在这三次作业中并没有起到太大的作用。

​ 因为黑盒测试基本上解决了绝大部分的问题,高压数据点基本没掉过刀,1-70测试点也是一打一个准。而这两个点基本上能够把绝大多数线程安全问题检测出来,从hack的角度来看,基本上已经达成了目的。(不知道是不是我运气好)当然从debug的角度,还是需要自己去捋一遍线程运行逻辑的。

与第一单元测试的区别

​ 显然,第二单元与第一单元最大的区别就是第一单元是单线程程序,而第二单元是多线程程序。

单线程程序运行的结果是确定的。且可以用IDE自带的debugger打断点进行调试,bug容易复现。而多线程程序的运行每次的结果一般是不一样的,具有不确定性,有可能存在一个bug跑几次出现一下的情况,并且debugger失效,因为你无法知道这个断点之前其他线程都做了些什么,bug极难复现和调试。自然也需要转变一下调试的思路。

​ 第二单元调试中我主要采用的是输出信息的形式,在特定位置(比如上锁解锁,等待和唤醒的地方)输出一些日志来判定这部分线程的执行程序,在上述Schdeuler的源码中被注释掉的部分就是输出日志信息。

​ 我也使用jconsole工具来对线程运行进行跟踪与debug。而jconsole自带检查死锁功能也能让我迅速地检查出问题到底是不是死锁。不过这三次作业中并没有出现死锁问题。反倒是活锁问题困扰了我半天,于是就会出现下面的世界名画。

戈戈和狄狄终究没等到戈多,甚至没等到那个小孩对他们说:“戈多今晚不来。”

​ 我还自己写了个小工具,用于将输出信息按照每部电梯分开,并且在每段信息开头输出该部电梯的信息,实现效果如下所示。

Elevator15
Type: floor
create at 25.2
initial location: A
initial floor 6
maxPeople: 6
speed: 0.2
arrive: C D E
[  36.9900]ARRIVE-B-6-15
[  37.1920]ARRIVE-C-6-15
[  37.1920]OPEN-C-6-15
[  37.3950]IN-59-C-6-15
[  37.3950]IN-86-C-6-15
[  37.3950]IN-65-C-6-15
[  37.6000]CLOSE-C-6-15
[  37.8030]ARRIVE-D-6-15
[  37.8030]OPEN-D-6-15
[  37.8030]OUT-59-D-6-15
[  37.8050]OUT-86-D-6-15
[  37.8050]OUT-65-D-6-15
[  38.0080]IN-85-D-6-15
[  38.2110]CLOSE-D-6-15
[  38.4160]ARRIVE-C-6-15
[  38.4160]OPEN-C-6-15
[  38.4160]OUT-85-C-6-15
[  38.8200]CLOSE-C-6-15
[  40.4460]ARRIVE-D-6-15
[  40.4470]OPEN-D-6-15
[  40.6500]IN-88-D-6-15
[  40.6500]IN-76-D-6-15
[  40.8530]CLOSE-D-6-15
[  41.0580]ARRIVE-C-6-15
[  41.0580]OPEN-C-6-15
[  41.0580]OUT-88-C-6-15
[  41.0580]OUT-76-C-6-15
[  41.4630]CLOSE-C-6-15
[  46.9430]ARRIVE-D-6-15
[  46.9430]OPEN-D-6-15
[  47.1470]IN-102-D-6-15
[  47.3510]CLOSE-D-6-15
[  47.5550]ARRIVE-C-6-15
[  47.5550]OPEN-C-6-15
[  47.5550]OUT-102-C-6-15
[  47.9620]CLOSE-C-6-15
[  51.2570]ARRIVE-D-6-15
[  51.2570]OPEN-D-6-15
[  51.4600]IN-113-D-6-15
[  51.6630]CLOSE-D-6-15
[  51.8790]ARRIVE-C-6-15
[  51.8790]OPEN-C-6-15
[  51.8790]OUT-113-C-6-15
[  52.2880]CLOSE-C-6-15
[  56.2820]OPEN-C-6-15
[  56.4860]IN-98-C-6-15
[  56.6920]CLOSE-C-6-15
[  56.8940]ARRIVE-D-6-15
[  56.8940]OPEN-D-6-15
[  56.8940]OUT-98-D-6-15
[  57.3040]CLOSE-D-6-15
[  66.2540]OPEN-D-6-15
[  66.4570]IN-129-D-6-15
[  66.6610]CLOSE-D-6-15
[  66.8630]ARRIVE-C-6-15
[  66.8630]OPEN-C-6-15
[  66.8630]OUT-129-C-6-15
[  67.0650]IN-135-C-6-15
[  67.2690]CLOSE-C-6-15
[  67.4710]ARRIVE-D-6-15
[  67.4710]OPEN-D-6-15
[  67.4710]OUT-135-D-6-15
[  67.8750]CLOSE-D-6-15

​ 这可以让我在人眼debug时方便一些,实际上可以基于此将评测机写出来,但是其中涉及太多条件判断了,因此作罢。最后这个工具也帮我de出了很多bug。一个最经典的bug就是第七次作业我没有考虑可达性的提交居然能过中测,没这个工具的话可能就不会发现这个问题了。

宏观架构分析

设计模式的应用

​ 本次作业相较于上次作业,我开始有意地去使用一些设计模式。可以确定的是,设计模式是真的有用。本身设计模式的出现就是为了重用代码、让代码更容易被他人理解、保证代码可靠性。 而从整个作业的迭代过程中也很好地体现出了应用设计模式能够平滑地进行迭代,并且天然地符合开闭原则。

​ 之前我对类图的排版的作用就体现出来了,从第五次作业到第七次作业的类图中可以看出,整个迭代过程我对原有架构几乎没有做任何改动,而是直接通过继承与实现等方式向上添加新的类与接口。符合开闭原则:软件中的对象(类,模块,函数等等)应该对于扩展是开放的,但是对于修改是封闭的。同时,上述工厂模式,策略模式与状态模式实际上也符合依赖倒置原则:程序要依赖于抽象接口,不要依赖于具体实现。高层不应该依赖低层模块。抽象不应该依赖细节,细节应该依赖抽象。

​ 所以说,设计模式的应用对整个程序的迭代和维护的确有很大的帮助。

电梯调度策略与小技巧

​ 电梯调度策略使用的是LOOK策略,我知道的大部分同学使用的都是LOOK策略,故不再赘述。

​ 小技巧部分就要来解决前面提到的ReentrantLock的内部类Condition的强大之处了。

​ 电梯开关门的0.4s以及运行的时间一般是使用sleepwait来模拟,用sleep的一个不好的地方就是电梯在跑的时候也占用着电梯的锁,但实际上这部分时间完全可以不浪费,电梯可以用wait来模拟这段时间,将锁交出去给调度器,让调度器往里面塞请求。因为电梯大部分时间都在跑(开关门,运行),所以锁大部分时间都在调度器手上,这样也一起解决了之前调度器取一推一的低效率问题。

​ 但是用wait带来的另一个问题是,notifyAll是没有选择性的,这样会把所有wait的线程唤醒,这便会导致电梯跑到一半被唤醒,然后执行下一步操作,导致“开关门间隔太短”等时间戳错误。所以我们需要一个具有选择性的等待唤醒机制来解决这个问题。

Condition作为条件变量,具有awaitsignalAll方法,功能等同于waitnotifyAll但是signalAll只唤醒这个Conditionawait的线程,并且这些Condition用的都是一把锁,即他们对应的ReentrantLock。这相当于一把锁内有多个等待队列,每次唤醒时我可以指定只唤醒哪个队列里的线程。这样便有了解决上述问题的思路。

private ReentrantLock lock;
private Condition waiting;//只能被唤醒的等待队列
private Condition running;//只能超时唤醒的等待队列
private Condition interrupting;//可被中途唤醒的超时唤醒队列

​ 上述代码是Elevator类的锁相关属性。其中waiting为电梯没有请求而等待时的条件变量,running为开门,无法再塞人时电梯关门以及电梯运行的条件变量,而interrupting为当人没满时关门或运行的等待队列,此时await到中途可能有当前所在位置的符合要求的新请求加入,需要再次上人或开门,这部分涉及到量子电梯的小trick,具体实现就是在关门时间结束前程序中的门一直开着,关门时间结束的一瞬间程序中的门关门。运行一层时间结束前电梯一直处在当前层,结束后电梯瞬移到下一位置。量子电梯的名字及实现后半部分我是借鉴的同组同学的想法,故不在这多做介绍。

​ 关于最后一次作业的电梯选择策略和换乘策略。。。

人这东西还真是能力有限啊,我从短暂的人生中学到的就是,人越是玩弄计谋,计谋就越可能因意料之外的情况而失败

​ ——迪奥 布兰度

​ 我不做策略啦,OO!

​ 我直接交了一个简单的式子上去作为抉择换乘层及电梯的策略依据,只是简单地想把请求按照能力比较均匀地分到每部电梯上。因为我认为整个电梯组的运行时间还是遵循木桶效应的,所以要尽量将请求摊匀,让每部电梯都尽量同时完成他自己的所有请求最好。

​ 事实证明,效果还可以。

心得与体会

​ 本单元是我第一次接触多线程,虽然平安地度过了这个单元,但是回看自己的程序,还是有许多处理得比较粗糙的地方。其中有一点是对一些共享对象(比如Tunnel)的粒度分得太粗了。一个原因是原本我的架构设计的时候Tunnel里面就只有一个队列,所以就直接将整个对象锁起来了,但是后面在Tunnel里的队列越来越多,再将整个对象锁起来显然就不太明智。虽然将整个对象锁起来能够回避掉绝大部分线程安全问题,且前文提到的电梯极长的wait时间能够让这种粗粒度的锁造成的时间浪费忽略不计,但是在之后可能的多线程程序设计中,要面对的并发量肯定不止是评测机给的这一点,胡乱加这种对象锁的话极易导致程序卡顿甚至宕机,显然我需要注意这一点。

​ 这次让我觉得进步比较大的一点就是设计模式的应用,相较于上次作业凌乱的类图,这次的类图就清晰了很多。这让我整个程序的可读性变得比较好,也很好地能够进行版本的迭代。但是可能是由于我使用设计模式不够熟练,在这次作业中暴露了另一个问题——类爆炸。我周边的同学中,我的类数(31个类/接口)和代码行数(1528/1822)是数一数二的,并且和我预想的不同,这次作业比我第一次作业的代码量还大(但是复杂度却不算高,远低于第一次作业)。我之前一直认为这是开闭原则的对增加开放带来的一个必然结果,但是后面我了解到利用开闭原则其实也能写出短小精悍的代码,可见虽然这次的层次化设计和架构设计我做得有进步,但是却没有做好代码量上的优化,可能存在一些冗余代码,这也算是一个要注意的点。

​ 传说中的第二单元的电梯结束了,这次作业为我打开了多线程的大门,让我对多线程有了一个初步的认识。之后的JML和UML没有什么历史可考资料,但是电梯都过去了,后面应该问题不大(吧)。

虽然老师说是很简单,但是老师自己都说”程序员是不可信任的“,所以最好留个心眼。

posted @ 2022-04-27 10:46  fishlife  阅读(62)  评论(0编辑  收藏  举报