「BUAA OO Unit 2 HW8」第二单元总结

Posted by saltyfishyjk on 2022-05-02
Words 7.1k and Reading Time 24 Minutes
Viewed Times

「BUAA OO Unit 2 HW8」第二单元总结

Part 0 前言

第二单元多线程已经告一段落,本单元中我收获颇丰,在这里再次总结记录。

本篇博客将分为以下几个部分,读者可自取所需:

  • Part 1 第五次作业
  • Part 2 第六次作业
  • Part 3 第七次作业
  • Part 4 多线程心得体会
  • Part 5 Tricks
  • Part 6 展望

其中,为了阅读顺畅,我将对象头、锁和同步以及线程安全——封装安全输出类两个模块放在Part 1 第五次作业的后半部分介绍,但这两块内容贯穿始终,请读者留意。


Part 1 第五次作业

1.1 作业要求

模拟实现支持五座楼,每座十层,且每座有一台电梯可以在本座所有楼层间运行的多线程实时电梯系统。

1.2 架构设计

第五次作业类图如上图示。

InputThread负责输入,并将对应的Passenger委派给对应的楼座的WaitTable,对应楼座的电梯Elevator通过对应的WaitTable获取请求。这里,我还额外将存储Passenger的容器封装为一个PassengerQueue类,这将有利于未来迭代时更换实际上的容器而不更改PassengerQueue的接口。

这次作业中我采用了生产者-消费者模型,具体如下:

  • 生产者:输入线程InputThread
  • 托盘:候乘表WaitTable
  • 消费者:电梯线程Elevator

运行流程:生产者输入线程获取输入,按照乘客类别不同,分别投放到对应的楼座的托盘候乘表WaitTable,消费者电梯线程自己运行,同时扫描其所在的楼座的WaitTable,按照其自身调度队候乘表中乘客采取接客getPassenger方法。

结束标识:输入得到ctrl + D结束信号时,调用所有WaitTablesetEnd()方法,首先结束掉同时满足对应楼座候乘表没有未运送乘客且电梯内部没有乘客的电梯线程;对于此时还在运行或不能被停止的电梯线程,WaitTable提供方法isEnd(),电梯可以通过该方法知道输入信号截止,并最终在对应楼座候乘表没有未运送乘客且电梯内没有乘客的时刻停止自身线程。

1.3 协作图

1.4 调度分析

在第一单元中,任务需求较为简单。考虑到单元训练的重点是多线程设计以及为未来迭代做好准备,我秉持less is more的想法,在比较多种调度策略后最终选择了LOOK算法,其在满足较高的性能的同时实现复杂度较低,这意味着更低的出bug概率以及更好的可扩展性。考虑到网上资料中并没有详细描述LOOK具体原理和实现的文章,我这里简单谈谈我的个人认知,可做参考。

  1. 当电梯有乘客时,以乘客中目标楼层距离当前楼层最远的请求为主请求确定目标楼层

  2. 当电梯没有乘客时,首先按照原方向,寻找距离当前楼层最远的有请求的楼层,找到则确定为目标楼层,这次寻找最终结果有可能会是当前层。如果寻找无果(沿方向的所有层包括本层都没有请求)则改变方向,继续寻找。若最后没有找到,则可以返回一个标志结束的值表示电梯进入空闲。

1.5 bug分析

自己bug

本次作业在中测、强测和互测中没有出现bug。最终得分为93.7485,可以看到性能分得分表现欠佳。尽管得分不高,但是本次作业遵循了鲁棒性和可扩展性的要求,这为后续迭代开发和维护带来了便利。

但是在中测的前几次提交,分别出现了一些bug,以下分别介绍:

使用了run而不是start方法启动线程

这是一个非常低级的错误,产生的原因是第一次写多线程没有经验。值得注意的是,run方法是一个方法,其可以被调用这是自然的,但是多线程中应当采用start来启动线程。

别人bug

在互测中,我主要通过评测机用大范围随机数据进行检验,没有检查出错误,但是有同学被其他屋内同学hack到,这意味着随机数据的局限性。

1.6 对象头、锁和同步

荣文戈老师上课介绍了对象头的相关知识,查阅相关资料后简要整理如下:

对象实例结构

Java的实例对象储存在Heap中,其结构如上图示。

锁和同步

如上图示,对象头部分储存的信息包含最近持有该对象锁的线程ID。而我们使用的synchronized包含三种情况,分别是:

  1. 修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得当前对象实例的锁

    1
    2
    3
    synchronized void method() {
    // CODE
    }
  2. 修饰静态方法:给当前类加锁,会作用于类的所有对象实例,进入同步代码前要获得当前 class 的锁

    class信息存储在method area中。

    1
    2
    3
    synchronized void staic method() {
    // TODO
    }
  3. 修饰代码块:指定加锁对象,对给定对象/类加锁。synchronized(this|object) 表示进入同步代码库前要获得给定对象的锁synchronized(类.class) 表示进入同步代码前要获得 当前 class 的锁

    1
    2
    3
    synchronized(this) {
    // TODO
    }

通过以上资料,我们可以更加清楚加锁是如何进行和记录的。

参考资料

  1. synchronized详解
  2. Java new一个Object对象占用多少内存?

对象锁

包括方法锁(默认锁对象为this,当前实例对象)和同步代码块锁(自己指定锁对象)。

方法锁形式

synchronized修饰普通实例方法,锁对象默认为this

1
2
3
public synchronized void method() {
// CODE
}
代码块形式

手动指定锁定对象,可以是this,也可以是其他对象

1
2
3
synchronized (obj) { // obj可以为this或其他对象
// CODE
}

类锁

synchronized修饰静态的方法或指定锁对象为Class对象。这种情况下,所有对象共用一把锁。

静态方法形式
1
2
3
public static synchronized void method() {
// TODO
}
代码块形式
1
2
3
synchronized (classA.class) {
// TODO
}

参考资料

关键字: synchronized详解

1.7 线程安全——封装安全输出类

指导书中提示到,官方提出的输出包线程不安全,即,获得时间戳和输出不是原子操作,可能出现时间戳和输出内容不匹配的情况。经过lxh助教的提示,我们有以下两种思路处理这个问题。

每次调用加锁

第一种思路,我们可以在每次调用的时候都加锁,这样可以解决上述问题。得益于我们本次作业的输出较为简单,所以这样也不会显得很麻烦,但是当我们要包装为“原子操作”的类方法很多时,或者,祖传代码很长而我们只知道方法的接口的时候,可能会比较麻烦,以下介绍第二种方法。

封装输出类

单例模式

关于单例模式介绍,可以参考菜鸟教程-单例模式

封装

利用单例模式,我们可以设计一个安全输出类,这个类只有一个对象,提供的方法的参数同我们想要使线程安全的类的方法的参数完全相同,并使方法为synchronized的,这样就可以保证每次调用同一个全局对象的方法,且是线程安全的。

CODE:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import com.oocourse.TimableOutput;

public class OutputThread {

private static OutputThread outputThread = new OutputThread();

private OutputThread(){}

public static OutputThread getInstance() {
return outputThread;
}

public synchronized void println(String msg) {
TimableOutput.println(msg);
}
}

Tip:构建上述OutputThread类后,我们仍需要在MainClass开头调用TimableOutput.initStartTimestamp();

化简

实际上,为了使方法更具有普适性(适应要封装为线程安全类的类有较多方法)以及应用完整的单例模式,上述代码在本例中略显冗余,可以如下化简:

1
2
3
4
5
6
7
import com.oocourse.TimableOutput;

public class OutputThread {
public synchronized void println(String msg) {
TimableOutput.println(msg);
}
}

Part 2 第六次作业

2.1 作业要求

模拟实现支持五座楼,每座十层,每座有一台基础电梯可以在本座所有楼层间运行,支持加装可在所有不同楼座同一层间运行的电梯,支持加装纵向电梯的多线程实时电梯系统。

与第五次作业相比,本次作业的迭代要求为:

  • 支持加装可在所有楼座运行的横向电梯
  • 支持加装可在所有楼层运行的纵向电梯
  • 支持同一层不同楼座的乘客需求

2.2 架构设计

第六次作业UML类图如上图示。

本次作业基于第五次作业迭代而来,没有进行大规模重构,因此主要介绍设计思路和迭代变化

新需求分析

第二次作业的变化主要包括:增加横向电梯种类并支持在同楼层不同座之间的乘客需求和支持动态增加(纵向和横向)电梯数量。

对于第一个需求,我们秉持开闭原则,增设Elevator父类,纵向电梯ElevatorCol和横向电梯ElevatorRow分别继承它,只需在子类重写run方法即可。

对于第二个需求,我们采用工厂模式,通过ElevatorFactory生产具有我们需要的功能的电梯。

请求群

对于同楼座(层),我们首先定义请求群的概念,一个请求群内的所有请求对该群内的所有电梯均可视(即电梯都有接到该请求的能力),群内所有电梯都可接待所有请求。

类虚拟空间

同时,为了简化调度负担及结构复杂度,我们借用OS中虚拟空间的思路。

对于群内的电梯,其可视所有请求,并且不认为有其他电梯,这样即可不更改第一次电梯中的LOOK策略,并便于应用自由竞争的调度策略,这将在下一部分简述。同时,通过类物理空间的理念,我们通过请求是否被真实满足来避免产生冲突,保证了架构的安全性。

设计的核心理念

正确性、易维护性和可迭代性是我们架构设计的核心思想,秉持less is more的思路,我们尽可能在逻辑上简化调度策略,在实现难度和迭代维护难度与性能方面做取舍平衡,最终选择LOOK和自由竞争的策略。

读写锁

在上述介绍中,电梯们对于所属请求群内的候乘表读相比写更频繁,可以采用读写锁来进一步优化提升性能。

但是在实际验证中,我们发现读写锁对于性能提升非常有限,甚至在数据波动时反而会劣于synchronized。为了便于迭代和维护,最终我们仍选择全部使用synchronized实现,这是一种在架构鲁棒性和相对高性能之间的取舍。

2.3 协作图

如上图示为本次作业协作图。

可以注意到,本次作业和第五次作业相比,设计和迭代是线性的,没有改变架构和思路,这体现了我们架构较为优越的可扩展性和鲁棒性。

2.4 调度分析

横向LOOK

纵向LOOK在Part1 第五次作业 1.4调度分析中已经介绍过,这里主要介绍横向LOOK:

  1. 当电梯有乘客时,依然希望寻找其中目标楼座距离当前楼座最远的请求为主请求,但是这里的距离不应该是简单的加减法,可以加上模运算(注意若出现负数直接取模不符合我们的要求):

    1
    dis = (target - nowBuilding + 5) % 5;
  2. 当电梯没有乘客时,原则上仍然按照第一次的方法寻找,只是如果第一次向上的尽头是10层,这一次顺时针尽头应该变为 (now + 2) % 5;向下的尽头是1层,这一次逆时针尽头应该变为(now - 2 + 5) % 5

LOOK的性能在随机大数据中有较好的表现,同时易于实现。

自由竞争

上文中,我们介绍了请求群和虚拟空间的想法,在这基础上,我们介绍自由竞争的具体实现。

对于每台电梯(无论横纵),其均按照自己的LOOK进行调度运行;当其在访问本请求群内的请求时,不会考虑别的电梯是否正在前往其中某个请求,而只考虑是否满足自己的调度来运行。这样,我们只需要在电梯arrive每层的时候访问请求群内的请求即可,若其接到了请求,则在请求群内移除;若某格请求被其他电梯接走,而其事实上是使本电梯运行的动力,那么至多付出一层的代价,本电梯就会重新访问请求群并基于LOOK获得新的运行方向。

上述设计较为精简,在面对大数据时表现均衡;同时,省去了中央调度的设计,类分布式的思路使得电梯自行运动而无需被指派任务;并且对电梯数量没有任何限制,覆盖了作业要求的15部要求。

2.5 bug分析

自己bug

本次作业在中测、强测和互测中没有出现bug。最终得分为96.2015,可以看到性能分得分优于上次,我认为这是自由竞争策略的优势。尽管得分不高,但是本次作业遵循了鲁棒性和可扩展性的要求,这为后续迭代开发和维护带来了便利。

但是在中测的前几次提交以及本地评测姬随机测试中出现了一些bug,以下分别介绍:

空开关门

空开关门指的是,在自由竞争的模式下,多个电梯奔向同一个请求,在极小的时间内开门,但是人只进了一个电梯,而没得到人的电梯便会再关门,此即空开关门。事实上,这并不算错误,但是会严重影响性能和观感

这个问题算是自由竞争模式下的典型问题。解决办法是:将判断有人可以进和真正将人放入封装为原子操作。具体来讲,在判断当前楼层是否需要开门时,一旦判定有人可以接待,便直接将其从waitTable取走,在open后再真正将人in进来。得益于这样的设计以及锁的特性,不会存在多部电梯同时访问到一个请求从而判断自己可以开门而造成错误。

别人bug

本次作业共成功hack7次,分别是三位同学的bug。评测方法主要是评测机随机测试。

忘记关门

有一位同学忘记关门,应该是迭代时只改了纵向没改横向。

RTLE

有两位同学的电梯会在某些数据下在楼层间反复横跳,最终RTLE。有趣的是,其中一位同学的这个bug并不能稳定复现,该数据点交上去复测若干次才最终hack到。

可以看出,随机数据测试具有一定可靠性,在读代码找bug困难时不失为一种好的选择。

Part 3 第七次作业

3.1 作业要求

模拟实现支持五座楼,每座十层,每座有一台基础纵向电梯可以在本座所有楼层间运行,一层有一台可达全部楼座的中转横向电梯,支持加装可在同一层间运行的横向电梯,支持加装纵向电梯,支持电梯包括速度、容量在内的自定义属性,支持横向电梯可达楼座的自定义属性,支持起点终点间楼座不同楼层不同请求的换成需求的多线程实时电梯系统。

与第六次作业相比,本次作业的迭代要求为:

  • 支持所有电梯自定义速度和容量属性
  • 支持横线电梯自定义可达楼座属性
  • 一层加装可达全部楼座的中转横向电梯

3.2 架构设计与新需求分析

本次作业架构设计如上图示。可以看出,整体结构基本没有发生变化,只为部分类增加部分内容。

支持自定义属性

只需要在电梯类内部新增属性即可。

支持自定义可达楼座

通过公式((M >> (P -'A')) & 1) + ((M >> (Q -'A')) & 1) == 2判断是否可达即可。

值得注意的是,在支持自定义可达楼座后,同一层的横向电梯应当所属不同的WaitTable

举例而言,一个可达A和E的五楼横向电梯与一个可达A和B的五楼横向电梯对于需求为A-5 -> E->5的请求的处理应当是不同的,不满足起点终点楼座均可达的请求不应当被电梯“看到”,这也是笔者的一个重要bug,将在后面介绍。

支持换乘

需要对请求进行划分,对于一般的请求而言,需要“三段式”,即From Floor From Building -> From Building Middle Floor ; From Building Middle Floor -> To Building Middle Floor ; To Building Middle Floor -> To Building To Floor。

具体到策略,有静态分配和动态分配两种。静态分配即请求从输入线程获得时就已经划分好;动态分配即每次规划一段路,规划下一段时要重新计算。

从可扩展性、和性能方面看,动态分配优于静态分配,但综合实现难度、优化程度以及鲁棒性,我最终选择较为容易实现的静态分配方法。

3.3 协作图

如上图示为本次作业协作图。

可以注意到,本次作业和第六次作业相比,设计和迭代是线性的,没有改变架构和思路,这体现了我们架构较为优越的可扩展性和鲁棒性。

我们主要扩展了分段请求在没有真正完成时要再次加入WaitTable重新等待电梯运送。

3.4 调度分析

本次作业中沿用了第六次作业的纵向与横向LOOK以及自由竞争算法,整体思路没有变化。

值得一提的是,对于横向电梯,其在计算候乘表中请求时需要先额外考虑是否起点和终点均可达,若不满足,则忽视该请求。

3.5 bug分析

自己bug

本次作业在中测、强测和互测中没有出现bug。最终得分为93.7454,可以看到性能分不高,我认为这是朴素调度的劣势。尽管得分不高,但是本次作业遵循了鲁棒性和可扩展性的要求,这为后续迭代开发和维护带来了便利。

但是在中测的前几次提交以及本地评测姬随机测试中出现了一些bug,以下分别介绍:

CTLE

前置背景:setEnd()是输入线程结束时给所有WaitTable()设置end布尔值为真的方法;isEnd()WaitTable提供的查询end布尔值是否为真的方法。

在本次作业中,出现了较为严重的CTLEbug。具体而言,是多部电梯的判断wait的条件中的isEnd()等方法中有notifyAll(),这意味着如果目前有两台电梯,但是没有请求,第一部电梯判断条件满足进入wait,但是其判断过程中唤醒了正在wait的第二部电梯,这样彼此唤醒最终导致CTLE。

解决办法:只设置和保留必要的notifyAll()

究其本源,我们在前两次作业的setEnd()方法中设置notifyAll()是为了唤醒并结束候乘表空、电梯空且电梯wait的线程;isEnd()方法是为了保证电梯在运行的循环节开头每次可以判断是否输入已经结束,如果输入结束、候乘表空且电梯内人员运送完毕,即可结束线程。

因此,我们发现,其实isEnd()方法并不需要notifyAll(),经过这样的分析论证,我们删去了许多赘余的notifyAll(),最终强测所有数据点的CPU时间均在2.5s以下。

线程结束

在本次作业中,线程结束是一个易出锅的地方:尽管输入结束、当前候乘表空、当前电梯空,但这并不意味可以结束该电梯线程,因为可能其他电梯会将未处理完的请求重新置入本候乘表。

解决办法:设置Counter类,作为单例模式对已输入请求和完全处理结束请求计数,取代输入线程结束的setEnd()方法和信号。

别人bug

本次作业共成功hack9次,分别是两位同学的bug。评测方法主要是评测机随机测试。

RTLE

和第六次作业类似的bug,面对较为有压力的数据会超时。

另外有一位同学的bug最终没有能够复现,他实现了模拟电梯运行过程,自行计算时间来缩短IO的影响,但是会在某些情况产生开关门时间不足等情况,不过课程组评测姬没有复现也就无所谓了。

Part 4 多线程心得体会

多线程基本原理

本单元中,最重要的收获是迈出了多线程从0到1的这一步。在本单元学习之前,我仅仅从概念上对多线程有一些朴素的认识,但经过本单元的理论课、实验课研讨课以及作业的短平快训练,我已经能够初步认识、设计和实现简单的多线程逻辑。

多线程debug

多线程会有一些单线程不会出现的问题,比如轮询、死锁等,我在作业中也出现过轮询等问题,在老师、助教和同学们的无私帮助下一步一步解决了在这些问题并有所长进。

多线程思维

本单元的电梯问题是一个经典的多线程问题,并且其没有最优调度的特点让我们能够更好地离开固有的单纯计算和查找最优解的思路,我们在真实工程需求中可能往往只需要一个局部最优解和一个“看起来性能达标”的策略即可以不差的性能满足设计要求。而在这之上,我们更多地需要考虑架构和设计层面的问题,不能为了性能上的“蝇头小利”开架构的倒车,放弃了对可迭代性、可维护性和鲁棒性的要求。

当然,以上只是个人浅见,事实上算法等领域依然有非常值得探索和研究的课题,不过对于工程来说,或许有时需要做一些取舍。

线程安全封装

本单元作业中有一个有趣的点:课程组提供的输出类是不安全的。从这个角度我窥得大型工程开发的一角:绝大多数时候我们不可能从头开发或者直接重构一个已经安全运行多年的大型项目,而这时候我们会面临在这基础上迭代开发的需求。SOLID原则告诉我们,对于已经久经考验、安全运行的代码最好不要做任何改动,而一些祖传的代码或许并不支持多线程,因此我们需要将其封装为线程安全的。

这一部分荣文戈老师在理论课上曾有介绍,即,将不安全的部分Wrapper一下,就变得安全了。而在课下作业中,通过请教林星涵助教,我具体明白了荣老师上课所说的Wrapper的含义,相关内容汇总为一篇帖子发在讨论区中并得到加精,这也让我非常开心。

Part 5 Tricks

在本单元作业实现中,有一些不怎么高级的小技巧但很有用,在这里记录和分享。

数据投喂包使用批处理代替手动输入cmd命令

使用数据投喂包测试时每次在命令行输入datainput_student_win64.exe | java -jar code.jar较为繁琐,在Windows中可以通过写bat文件后每次双击来代替重复输入命令的操作。

run.bat

1
2
datainput_student_win64.exe | java -jar code.jar
pause

直觉上比较类似sh脚本文件。第二行pause的作用是运行完之后不退出命令行界面。

用记事本等编辑器写好上述文件后,双击run.bat即可运行。

脚本最后一行若再加上cmd \k能让命令行界面可以继续使用。

随机数据评测机

在多线程单元,随机数据评测机依旧发挥稳定,虽然不太能构造出极端样例卡RTLE等,但是对于广域压力测试可靠性效果显著,这也帮助我在第六、七次作业成功hack了多位同学。

plantUML绘图

在本次作业中需要绘制协作图,processOn和starUML固然是很好的选择,但是plantUML为我们提供了利用代码生成虽然不那么个性化和精细化但是很达标的快速绘制协作图的方法,可以参考这两篇文章PlantUML画图软件简介PlantUML简述

Part 6 回顾与展望

第二单元以实时交互电梯问题为载体,通过短平快的三次迭代作业让曾是多线程小白的我快速上手多线程,完成从0到1的改变,在此再次感叹课程组设计的精妙并表达由衷的感谢。

本单元作业较往年主要新增了横向电梯的需求,尽管乍一看来非常吓人,但只要多思考多讨论,便也不是不可跨越的难关。

不同于第一单元,本单元中我始终没有重构。尽管重构是改善代码质量的重要方法,但是基于已经成熟的,经过考验的架构迭代开发显然可以避免相当多无谓的bug,这也是一种取舍和平衡。

尽管在第一单元的展望中我希望不要拖ddl,但是第七次作业还是直到中测截止前2h才最终定稿,依旧非常惊心动魄,希望未来可以尽可能调度好个人时间和安排,尽量避免这种情况。

在本单元作业中,老师、助教和同学们给予了我非常大的帮助,可以说没有大家的帮助,我不可能完成这一单元的任务,再一次向大家表示由衷的感谢。另外,我也更深层地体会到了合作、讨论和有效沟通的重要性。在一年级时,我很少会和大家讨论和分析代码相关任务,一般单打独斗就走下来了,但是二年级的计组和OOOS让我更深一步体会合作的重要性,希望在未来可以继续和大家一起讨论,一起进步。


This is copyright.