OO第二单元——多线程总结
本章的任务是建立一个多线程的电梯系统,在保证电梯各自正确运行的基础上,也要尽量使电梯的运行时间和运行的楼层数更少。
多线程是程序的一个设计方法。它可以开启多个不同的线程,使程序”同时“处理多个任务。相比于单线程,它使得程序运行效率大幅提高,在操作系统、网页等众多场合都有着广泛应用。
问题在于,多线程引入了”线程不安全“这个隐患。以往的单线程程序,只有一个进程改变变量的值。无论何时一个语句调用变量的值,这条语句以上的程序已经修改好了变量,所以语句得到的必然是正确的值;但是对于多线程而言,A线程在读取一个变量的值的时候,B线程恰好对这个变量进行修改、又或者A进程正在向一个数列中写入若干新值,B进程恰好在中途遍历该数列。这就导致了值不同步的问题,需要用同步锁或者线程安全容器来设计。
在习惯单线程的设计方式之后,想要设计出线程安全的多线程程序绝非易事,而多线程“从入门到精通”更是需要不少的时间。在摸着石头过河的三周时间里,我先后重构了五次程序,目的就是将学到的多线程的知识融入自己的应用中。”实践出真知“,或许在若干次重构中,成绩并不尽如人意,但是在”固执“的推翻和”精细“的雕琢中,我巩固了新知识,学到了真知识。
第一次作业——设计六部电梯
第一版
新主楼有六座电梯,可以接送1层到11层之间的乘客。
我最初模仿了生产者-消费者模式的设计方式——在一个传送带上,生产者放商品,消费者拿商品。当传送带上没有商品时,消费者就等待(wait),直到生产者放置商品并且通知(notify)生产者可以拿走。
我将电梯Elevator设为消费者线程,将读取顾客的类RequestInput作为生产者线程,将策略类Strategy作为中间的传送带。当RequestInput读取到顾客信息,就找到一个合适的电梯唤醒,并把他放在传送带上;电梯已到达对应楼层后接取并送到目标地点。
在第一次作业中我使用syncronized作为同步锁。在Strategy类中的put和get函数设置同步锁,防止等待队列的线程不同步;同时,Strategy类具备电梯当前楼层、目标楼层、是否到达、是否开门等等信息,以共享对象的身份作为六部电梯的属性,电梯可以通过Strategy内部函数来访问当前信息。也就是说,Strategy衔接两个线程——乘客输入和电梯,负责收、放乘客。
但是这种设计有很大的问题。第一,同步锁设置少了。对于访问与设置属性的方法,也就是getter和setter,都需要设置同步锁,防止同时坐电梯的人较多时,出现两个线程一个读一个写的情况;第二,当前楼层、目标楼层、乘客列等属性都是电梯的属性,而非Strategy策略类的属性,在设计上是说不通的;第三,程序仅仅模仿了生产者-消费者模式的”皮毛“,没有从真正意义上理解这个模式,电梯每次只能接同楼层的一个人,这可能会导致大数据输入下的超时问题,性能就更差了。上述问题不仅导致代码难读懂、难理解,更会导致超时问题,结果强测全部RTLE。亟需重构。
第二版
发现之前的问题后,我进行了第一次作业的重构。
根据训练课的代码,我重新规划了自己的设计:仍然采用生产者-消费者模式,但是这次理解了它的内涵,即生产者线程放入容器中若干元素,并唤醒消费者线程;消费者被唤醒后从线程取出容器中取出若干元素,其中决定哪个消费者取出的过程可以由一个负责调度的线程定夺。
Person类是基本元素,其中含有乘客的起终点、序号、是否进电梯等。PersonQueue是一个容器,可以用来表示等待队列、电梯中乘客队列等等。既然是乘客队列,涉及到增删乘客,它的方法就需要加锁。所以,我对增删乘客的方法、以及访问乘客列状态的方法都用了syncronized锁。于是,电梯线程接人与输入线程放人不可能同时进行,线程安全。与此同时,接送人可以直接增删乘客列中的元素,所以电梯由只能接一个人,变成能接在该楼层等待的至多六人。在这两个线程中间,有一个Schedule线程,负责将乘客送到理论上最快的电梯中,提高性能。三者共同组成了一个全新的生产者-消费者模型。
新建Floor类,涵盖了电梯的起始楼层、目标楼层等信息,作为Elevator和Schedule的共享对象。Elevator根据Floor中的信息决定上下行以及开关门操作,Schedule根据当前楼层决定将哪个乘客放入对应电梯,能使得性能最短。其中的调度策略为:若电梯中有乘客,则目标楼层是乘客中目标楼层离当前楼层最近的那个;若没有乘客,则目标楼层为等待该电梯的乘客中起始楼层距离当前楼层最近的那个;以上为主请求。若有主请求,则可以考虑捎带,也就是若当前等待队列中乘坐电梯的方向和电梯运行方向相同、且起始楼层位于电梯当前楼层和目标楼层之间,则捎带乘客。除此之外,Schedule判断乘客进入哪个电梯,在满足上述条件的情况下,优先选择距离最近的电梯。这样乘客就能够更快速地进入电梯,节省时间;而ALS调度策略也可以节约耗电量。
为了解决CPU TIME LIMIT EXCEED的问题,我在Schedule分配乘客时,若没有等待的乘客,则将线程wait,直到出现等待乘客或者询问等待队列是否结束时,将Schedule唤醒;在电梯中没有乘客、且没有人等待时,电梯也进入等待状态,直到出现等待乘客或者询问等待队列是否结束时,将Elevator唤醒。这样做可以防止轮询,大幅降低CPU使用时间。在debug的时候,可以采用JProfiler辅助分析,当识别到一个正在运行的Java文件时,就利用CPU占比图来观察哪个进程消耗最多,从而定位到底是哪里可能会出现轮询的情况。
类图如下:
协作图如下:
上述的类就是最基本的架构了,是稳定的内容。在之后的迭代中,增加和维护电梯,也就是改变Schedule类中ArrayList<PersonQueue>和ArrayList<Floor>的内容,再在电梯中加入维护过程,非常方便。
虽然我bug成功修复了,然而我并没有理解其中syncronized代码块的作用。我在本次迭代中反复使用syncronized(ArrayList),这就为第二次作业的失败埋下了祸根,更直接导致了第二次作业的重构。
第二次作业——新增/维护电梯
第一版
增加电梯速度、容量属性;可以进行增加、维护电梯的操作。
在我的架构中,用ArrayList<Floor>和ArrayList<PersonQueue>来代表电梯,因为其中有电梯的基本属性。
Input线程需要增加识别增加/维护电梯请求的部分,若增加电梯,则锁住上述两个数组,并建造新的电梯加入其中;若维护电梯,则将该电梯中的floor中的maintain属性置为真,并唤醒该电梯。
Elevator线程增加了自我维修功能,即若检测到自己的floor中的maintain为真,则进入维修环节:若电梯等待队列没有乘客、电梯内部也没有乘客,就直接将自己加入maintainElevator的部分,等待删除;若电梯内部有人,则开门将其中的人放回waitQueue等待调度,并修改他们的起始点,并把电梯等待队列的人放回waitQueue。
Schedule线程新增功能,负责将自我维修后的电梯从电梯列中删除。若自我维修电梯列非空,就逐个删除它们,以防调度乘客时又将他们调回维护电梯中,出现丢乘客的情况。
程序结束的方法是:输入结束、等待队列为空、现有电梯全空则对电梯轮流唤醒并将结束标志置为真,然后结束Schedule线程;电梯被唤醒后,就可以判断是否已被结束,再结束自身。
这次汲取上次教训,将所有读取数列、数列中增删元素的部分全部上锁。但是用的方法是syncronized(ArrayList),这就导致了事实上并没有锁住该ArrayList的任何方法,只是保证两个线程拿到了同一个ArrayList锁不会同时访问该对象的功能。简而言之,虽然起到了锁的作用,但是性能大幅下降,因为不能访问非syncronized函数;另外,因为是间接维护,即将elevator的floor中设置maintain,elevator再从floor中反复确认是否maintain,这就使得写入值和读取值的时间间隔过长,就会导致收到维护指令后,电梯仍然移动超过两层后,才收到maintain信号;最后,Schedule调度器中run的部分过于庞大,因为它不仅起到一个调度乘客的功能,还要起到删除维护电梯的功能,更要判断是否结束。复杂度过高导致在运行时总会出现bug,比如,电梯wait时需要唤醒Schedule判断是否结束,而此时Schedule正在其他功能的很长的代码处,导致唤醒无效,而电梯已经等待;此时Schedule从waitQueue拿取一个乘客,发现waitQueue为空,同样进入wait状态。这导致了程序本该结束,但所有线程都进入了wait状态,没有线程负责结束!
问题过多,性能很差。为了解决问题,我当时选择饮鸩止渴,用了过多while循环来强制赋值、检测。这直接使得中测通过,强测CPU超时很多,还有个别数据出现了电梯在维修后仍然运行三层的问题。亟需重构。
第二版
痛定思痛。在bug修复阶段,我决定阅读一些设计模式,或者比较“实用又美观”的程序写法,方便记忆功能、找到bug;除此之外,学习线程安全相关的知识,抛弃传统老套的ArrayList,用功能更加强大的容器来实现。
以下是我在第二版设计前学习到的内容:
-
设计模式
-
单例模式(懒汉模式):每个类只有一个对象,该对象在初始化的时候就已经创建好,所以使用时直接调用该对象即可。
class Schedule{ private Schedule{ }//构造函数私有,防止构造新实例 private static final Schedule SCHEDULE= new Schedule();//初始化时构造唯一实例 public static Schedule getInstance(){ return SCHEDULE; }//获取实例唯一方法,保证唯一且同步 }
-
-
线程安全
- syncronized代码块不好管理,采用封装写法;
- 守护线程:setdaemon();当其他线程全部结束时,守护线程无论在什么操作,也同时结束;
- ReadWriteLock:读写锁。读锁锁住时,线程可读不可写;写锁锁住时,线程不可读写;
- Condition:判断Lock锁的情况。await(),signal(),signalAll()相当于syncronized锁中的wait(),notify(),notifyAll()函数;
- CopyOnWriteArrayList:线程安全的ArrayList。
-
程序写法
- 将相似的类打包,便于整理;
有了这些高科技,功能实现起来既强大又美观。
- WaitQueue:乘客等待队列,需要进出乘客,进出乘客和判断是否结束的函数都用syncronized锁,进出同步;
- PersonQueue:电梯乘客队列和电梯等待队列,需要进出乘客,进出乘客和判断是否结束的函数都用syncronized锁,进出同步;
- ElevatorQueue:现有电梯列,需要增减电梯;增减电梯部分采用写锁,读取电梯列、电梯状态是采用读锁,增删同步;
- MaintainElevatorQueue:维护电梯列,需要增删维修电梯;方法都采用syncronized锁,增删同步;
调度器进行了极大的简化,与输入线程和电梯线程交互,功能只有把等待队列的乘客一一放入最佳电梯这一个功能,不会出现第一版的bug。调度策略同以前的作业,仍然是在捎带策略的基础上选择距离最近且不满员的电梯。
类图如下:
测试中出现丢乘客的bug,原因是在WaitQueue拿去乘客时从队列中删除该乘客,而判断该乘客是否能够进入电梯时,若电梯爆满,没有把他放回WaitQueue。
UML协作图如下:
作业中以Input,Schedule,Elevator三者间构建出来的生产者-消费者关系是稳定的。而易变的内容为调度方法,需要根据作业具体要求变动。
在未来的迭代中,也就是信号量和可达性的迭代,只需新建类实现即可,不用较多改变类的内部结构和类之间的耦合关系。
第三次作业——信号量与可达性
每层限制开门的电梯数,电梯只能到达部分楼层。
本次迭代量较少。对于信号量部分,新建Permit类,用信号量来控制许可个数,十分方便,省去自己实现的过程。电梯方面只需要增加服务中和只接人的判断,再进行获取、释放许可即可。
可达性部分考验算法。对于一张连通图,求任意两点的最短路径,我使用了Dijkstra算法。建立ElevatorAccess类,内部是关于楼层的邻接矩阵表,根据每个电梯的可达性,将直达的楼层都设为对应楼层间的距离。当一个乘客进入时,根据起点和终点,用算法求出一个最短路径,并取他的第一个楼层和第二个楼层作为他的新起点和新终点。随后,根据新的楼层,找到一个电梯使得它的可达性涵盖该乘客的起点和终点,就可以将乘客送到中转的楼层了,随后再送入waitQueue重复上述操作,直至到达真正的终点。
在新增、维护电梯时,需要更新邻接矩阵表,及时更新数据。
本次迭代的锁、调度器与其他线程的交互与上次作业完全一致;调度策略改为Dijkstra算法,如果出现多个电梯满足上述条件仍然是挑选距离最近的来接。能够保证正确性,但是性能较差。
UML类图如下:
UML协作图如下:
因为初始六部电梯为全达电梯,所以Dijkstra算法总是会优先选择初始电梯作为乘客投放对象,这会导致性能下降,甚至还会有超时的情况发生。我的解决方法是,当初始六部电梯全部维护时采取上述算法,否则采用较为均衡的送客方式,让新增加的电梯将乘客送到尽量远的地方,再由其他电梯继续运送。这样分配更加均衡。
心得体会
线程安全是一个新的程序设计方法。在多线程程序中,线程运行的任意时刻都可能被其他线程中途“插入“,由此带来数值不同或者容器内元素不一致的线程安全问题。为了解决问题,我们需要掌握同步的相关知识,比如中断线程、守护线程,volatile立即回写,众多锁比如syncronized、ReentrantLock、ReadWriteLock等保证线程间数据的同步性,Semaphore控制线程许可个数,Concurrent集合以及Atomic从数据和集合本身保障同步性等等。同时要考虑,线程的某个功能访问的数据是否有被改动的风险?这里就需要仔细琢磨了,否则会偶然出现bug。最后,wait和notify是非常好用的功能,通过让线程暂时等待从而节省了运算时间。
层次化设计需要多加考虑。我就是在几次作业不断迭代重构摸爬滚打中领略了层次化设计的好处。首先,最基层的是生产者-消费者模型,它以Input输入线程为生产者——放置乘客,Elevator电梯线程为消费者——拿取乘客,Schedule调度器线程分配乘客,从而搭建出了最为核心的程序结构。在此基础上,不改变基层架构,逐渐地增加功能,比如增删电梯、可达性等功能,作为上层建筑。这样设计程序就会显得有条不紊,便于迭代和修复。
如何写好Java程序呢?
回看篇幅,5000字的文章道不尽含辛茹苦。多线程确实成为了学习Java路上的一大难关。从第五次作业开始,我提交代码,中测全过,强测全错;第六次遇到相同的情况;直到第七次成绩才较为满意。在那段日子里,我也并没有荒废课业。我不禁想问:书在不停地看,代码也不停在写,bug也不停在修复,为什么还是常常以失败告终呢?
现在回看当时的经历,我受益很多。
“学不可以已。”我可能过于固守曾经的知识,不想走出“舒适区”去尝试一些新的把握不住的新事物。就像这一单元最初的时候,我选择继续使用熟练使用了一个多学期的ArrayList和Hashmap,尽管这些容器在多线程的背景下早就“退环境”了。而那些愿意”横冲直撞“的,愿意用大量的时间磕磕绊绊地去琢磨、消化新知识的同学,往往结果都并不差。所以,永远怀着谦卑的心态学习,从广泛的资源中获取有用的信息。站在巨人的肩膀上看待问题,就显得轻松很多,更容易接近正确的结果。
“三思而后行。”难题不是一出现,就心急火燎地开始,莽莽撞撞地进行就做得好的。机会留给有准备的人,两手空空地做事情,不如先在脑袋里想好做事情的步骤——怎么做才能保证多个线程的运行万无一失?怎么设计才能改动最小?如何调度能让性能更好?将这些前期珍贵的细碎的思考整理下来,从而在实际编写的时候,将这些思维的火花具化成巧妙的设计,在讨论课上用思维的碰撞去修改、更正、优化,我们一定会更加趋于完美。
”乐而不淫,哀而不伤。“意思是:快乐并非没有节制,悲哀的时候切莫太过悲伤。即使本单元的作业做的不好,但是在反思中我的确鞭策了自己一把——学习新知识,然后重构。我并非不想在原代码上雕琢,只是我想让自己在实践中重新真正领会这一知识,而不是无意义地堆砌然后拿一个亮眼的成绩。事实证明,这样做是有效的,这些知识就慢慢烙印在了脑海里,我真的能够熟练运用。我想说的是,当处于挫折的时候,确实需要达到”哀而不伤“的境界,反倒是借此意难平的空当多加查缺补漏,屡败屡战,就像”作茧自缚“的虫子,每一层看起来像是”束缚自我“的茧都暗示着它最终的”破茧成蝶“。
所以,如何学好Java程序呢?
以谦卑的品质学习新知,以开放的思维合作共赢,以顽强的品质攻坚克难,以悲观的心态迎接未来。