OO_Unit2_多线程电梯
Elevator 1
本次作业,需要完成的任务为单部多线程傻瓜调度(FAFS)电梯的模拟。
第一次电梯作业,可以简单的抽象成一个生产者-消费者模型,且只有唯一的生产者——乘客请求(InputThread),和唯一的消费者——单部电梯(Elevator)。有了生产者和消费者,我们还需要一个“共享托盘”——调度器(Scheduler,本质上是一个请求队列)来盛放生产者生产出来的“货物”。
如此一来,傻瓜电梯的雏形就有了:
InputThread是输入线程,负责将输入的请求放入调度器的请求队列里;Scheduler是调度器,负责管理输入线程输入的请求;Elevator是电梯线程,负责电梯的正常运行。
再进一步细化、完善各自的功能,这个傻瓜电梯大功告成:
其实,在本次作业中,调度器的存在可有可无。由于没有调度算法的需求,完全可以直接将请求队列放在电梯里,把输入的乘客请求直接给电梯。但是,若考虑程序可扩展性,调度器必须存在。(谁知道下次作业会有什么变态的要求呢?)为了降低下一次重构的工作量,同时使结构更加完整,我选择先实现一个假的调度器。
有了框架之后,如何实现线程之间的交互呢?由于程序的并发执行,不可避免地产生了多个线程对同一个共享资源访问,造成了资源的争夺。两个或多个线程对同一共享数据同时进行访问,最后的结果是不可预测的,它取决与各个线程对共享资源访问的相对次序。为了避免两个同时访问临界资源,造成不可预料的后果,我们需要使用synchronized
关键字对共享资源(Scheduler)加锁,进行线程的同步与互斥。而对于电梯,当调度器中没有请求的时候,应该阻塞等待新的请求(wait
),对于乘客请求,当新的请求到来时,应该唤醒阻塞中的电梯(notifyAll
)。可见,通过wait/notifyAll
,我们可以实现进程间的通信。
synchronized
synchronized使用的是JVM内置的锁机制,称为物理锁。JVM 可以确保被 synchronized 修饰的代码块每次只能被一个线程访问执行,内部通过锁住一个对象或类来实现。
synchronized 可以在被锁定的资源或未被锁定的资源情况下工作。 但是,在任何线程执行 synchronized 代码块之前,它需要首先获取这个对象的锁才可以执行。而线程执行非 synchronized 代码块不需要获取对象的锁。当然,线程在代码块执行完毕后,需要释放该锁。这样其它处于等待状态的线程可以获取对象的锁以执行。
锁对象与锁类的区别:
synchronized(X.class) 使用 class类 所为锁定类, 那是因为只有一个 class 类在 JVM 中被 classLoader 加载。 如果一个线程要执行该代码块,必须拥有 class 类的锁。 此时只能有一个线程在执行该代码块。
synchronized(this) 使用类的实例为锁定对象。 如果一个线程要执行该代码块,只需拥有实例的锁即可。 此时可以有多个线程在执行该代码块。
wait
该方法用来将当前线程置入休眠状态,直到接到通知或被中断为止。在调用 wait()之前,线程必须要获得该对象的对象监视器锁,即只能在同步方法或同步块中调用 wait()方法。调用wait()方法之后,当前线程会释放锁。如果调用wait()方法时,线程并未获取到锁的话,则会抛出IllegalMonitorStateException异常,这是以个RuntimeException。如果再次获取到锁的话,当前线程才能从wait()方法处成功返回。
notify
该方法也要在同步方法或同步块中调用,即在调用前,线程也必须要获得该对象的对象级别锁,如果调用 notify()时没有持有适当的锁,也会抛出 IllegalMonitorStateException。 该方法任意从WAITTING状态的线程中挑选一个进行通知,使得调用wait()方法的线程从等待队列移入到同步队列中,等待有机会再一次获取到锁,从而使得调用wait()方法的线程能够从wait()方法处退出。调用notify后,当前线程不会马上释放该对象锁,要等到程序退出同步块后,当前线程才会释放锁。
notifyAll
该方法与 notify ()方法的工作方式相同,重要的一点差异是: notifyAll 使所有原来在该对象上 wait 的线程统统退出WAITTING状态,使得他们全部从等待队列中移入到同步队列中去,等待下一次能够有机会获取到对象监视器锁。
作为多线程的入门,本次作业旨在让我们学习和掌握线程的创建、线程的同步以及线程的通信的相关知识。学会运用这些基础知识,本次作业便迎刃而解了。
Elevator 2
本次作业,需要完成的任务为单部多线程可捎带调度(ALS)电梯的模拟。
第二次电梯作业,可以说是基于第一次电梯作业的架构上的算法改进和性能优化。这一次,我们的电梯变聪明了,学会给自己减负了。
可见,第二次电梯作业的整体架构与第一次基本相同。
第二次电梯作业的难点主要在于电梯捎带算法。我选择的算法与指导书上的算法基本类似,在电梯运行过程中,每移动一层,都通过调度器check一下是否有可捎带的请求,若有,则更新电梯中的请求列表。同时,当电梯每移动一层,根据电梯中的请求列表,判断该层门口是否有等待电梯的乘客,或者电梯里的是否有到达该层的乘客,若有,执行开关门操作。
ALS(可捎带电梯)规则介绍
- 可捎带电梯调度器将会新增主请求和被捎带请求两个概念
- 主请求选择规则:
- 如果电梯中没有乘客,将请求队列中到达时间最早的请求作为主请求
- 如果电梯中有乘客,将其中到达时间最早的乘客请求作为主请求
- 被捎带请求选择规则:
- 电梯的主请求存在,即主请求到该请求进入电梯时尚未完成
- 该请求到达请求队列的时间小于等于电梯到达该请求出发楼层关门的截止时间
- 电梯的运行方向和该请求的目标方向一致。即电梯主请求的目标楼层和被捎带请求的目标楼层,两者在当前楼层的同一侧。
强测分数证明,该算法还是存在很多可以优化的空间。同时,通过检查代码,我发现我的代码中还存在synchronized
关键字滥用的情况,synchronized
没有用在正确地方,这在某种程度上也应该了程序的性能。
Elevator 3
本次作业,需要完成的任务为多部多线程智能(SS)调度电梯的模拟。
第三次电梯作业,电梯大boss来了!不仅电梯从单部变成了三部,而且多部电梯的可停靠楼层,运行时间,最大载客量都不相同。
首先,对于三部参数不同的电梯,我选择了继承的方式。由于三部电梯的行为是一样的,所以只需在子类的构造器中分别传入各自的参数即可。
由于时间和精力有限,每部电梯还是沿用了第二次电梯作业中的捎带算法。特别的是,本次作业中,有些楼层只有特定的电梯能够到达,例如神奇的三楼,只有C电梯能够抵达。这意味着如果一个乘客想从2层到3层,就必须先从2层坐B电梯到1层,再从一层坐C电梯到3层。这便衍生出了一类新的请求——不可直达请求,其余的请求皆为可直达请求。
- 对于可直达请求,乘客等待可直达电梯即可。(大多数情况下,乘客都会优先选择直达电梯)
- 对于不可直达请求,我们可以将它拆解多个请求,通过电梯间的配合,由多个的电梯共同完成请求。但是基于人的惰性,乘客都会优先选择换乘次数少的方案,而且在该题中,对于任意两个楼层的请求,一次换乘都可以完成,所以只需将不可直达请求拆成两个可直达请求即可。请求拆解算法只需从所有A楼层到B楼层的路径中选出移动楼层数最少的即可(或许选择运行时间最短的会更好)。
将不可直达请求拆解成两个可直达请求之后,该请求就被分成了两段。调度器先将第一段请求放入自己的请求队列中,而第二段请求则保存在一个数组里;当第一段请求被执行完之后,电梯将第二段请求抛出,放入调度器的请求队列中,等待其他电梯响应请求。该算法严格保证了这两段请求的逻辑顺序,避免出现第二段请求先执行的情况。
显然,这样的调度算法肯定不是最优的。当一个电梯在执行第一段请求的时候,第二个电梯里没有乘客的话,那么第二个电梯可以提前运行到中转楼层进行等待。但是,对于本次作业来说(40条随机数据),该方案增加程序复杂度的同时,也未必会带来更好的性能,甚至可能使性能更差,所以并未采用。
关于CPU_TIME_LIMIT_EXCEED和RUNTIME_ERROE
在本次作业的中测中,第一次提交作业时,全部测试点通过,但是第二次重新提交新的版本之后,最后一个测试点出现了RUNTIME_ERROR(以下简称RE);第三次提交时,最后一个点报出CPU_TIME_LIMIT_EXCEED(以下简称CTLE)。以下简单介绍一下debug的技巧。
对于RE,bug很可能出现在结束程序的时候。当输入ctrl-D
的时候,输入线程通知调度器,等到调度器和电梯中的所有请求执行完毕后,方可退出程序。当有一个电梯还在工作时,其他电梯必须阻塞等待。对于此类bug,可以在结束程序的代码块里利用System.out
输出一些相关信息,比如可以在与之相关wait/notifyAll
的前后输出当前阻塞/唤醒线程的名称,确保所有阻塞的线程在程序结束时都被唤醒,正常退出程序。
对于CTLE,本质上是由于CPU空跑导致的,所谓CPU空跑,大多情况下是由于某个线程进入一个没有意义的空循环,大量占用CPU资源而导致的,这无疑是对CPU资源的浪费。该类bug的原因可能是notifyAll
的时候将一些不该唤醒的线程唤醒了,使它们进入了一个没有意义的循环中。所以要定位该类bug,不仅要在相应的wait/notify
的前后输出相关信息,判断线程阻塞和唤醒的情况,同时还需要在程序中的某些循环语句中加入相应的标识,以便准确定位bug出现的地方。
以上两类bug都不会影响程序的正确性,所以不易察觉。可以利用JProfiler等软件分析CPU的运行情况,协助寻找bug。同时,当提交测试程序的时候,应当留意一下反馈结果中的CPU实际运行时间,正常情况下,在中测的测试点中应该不超过1秒,若其数值过大,就应该考虑是否出现了空跑的情况。(如果你发现跑程序的时候CPU风扇嗡嗡嗡转个不停,也要小心了!)
UML协作图
SOLID原则
S.O.L.I.D是面向对象设计和编程(OOD&OOP)中几个重要编码原则(Programming Priciple)的首字母缩写。
SRP | The Single Responsibility Principle | 单一责任原则 |
OCP | The Open Closed Principle | 开放封闭原则 |
LSP | The Liskov Substitution Principle | 里氏替换原则 |
ISP | The Interface Segregation Principle | 接口分离原则 |
DIP | The Dependency Inversion Principle | 依赖倒置原则 |
SRP:当需要修改某个类的时候原因有且只有一个(THERE SHOULD NEVER BE MORE THAN ONE REASON FOR A CLASS TO CHANGE)。换句话说就是让一个类只做一种类型责任,当这个类需要承当其他类型的责任的时候,就需要分解这个类。每个类或方法只实现自己的功能,基本符合该原则。
OCP:软件实体应该是可扩展,而不可修改的。也就是说,对扩展是开放的,而对修改是封闭的。当扩展电梯系统支持ALS调度时,可通过扩展类实现。
LSP:当一个子类的实例应该能够替换任何其超类的实例时,它们之间才具有is-A关系。Elevator子类除了构造参数不同,无其余扩展,符合。
ISP:高层模块不应该依赖于低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。本单元作业中未使用接口设计。
DIP:不能强迫用户去依赖那些他们不使用的接口。换句话说,使用多个专门的接口比使用单一的总接口总要好。本单元作业中未使用接口设计。
总结
多线程在给我们带来更高的资源利用率的同时,也给我们带来了程序设计复杂度的提升。线程之间的交互往往非常复杂。不正确的线程同步产生的错误非常难以被发现,并且重现以修复。这就需要我们更好地掌握多线程的运行机制来维护线程安全。在这三次多线程作业中,我学习了多线程的基础知识,掌握了多线程的基本方法,收获颇丰,收益匪浅。但是想学好多线程,还有需多学习,多思考,多实践。
以上是OO电梯系列作业的一些心得体会,若有笔误,欢迎纠正。同时也欢迎各位读者在评论区留言,一起探讨和学习。共勉!