OO第二單元總結博客


前言


相較於第一單元作業,由於對面向對象語言和層次化設計有了比較充分的認識,第二單元相對輕松(但還是很痛苦)。第二單元作業相較於第一單元,輸入輸出接口課程組已經提供,沒有第一單元非常瑣碎的化簡等細節問題,困難點分布比較集中,攻克起來更加容易,主要是多線程編程的程序安全問題。第二單元作業第一次作業花了很多時間入門多線程與熟悉電梯調度算法,消耗了很多腦細胞,面向對象已經拋到一邊了;有了第一次作業的基礎,第二三次作業相對容易入手,通過重構讓自己的代碼更加面向對象,提高可擴展性,並進行了比較充足的調度優化。多線程編程的確很酷,線程之間的交互與現實世界非常貼切,每個線程做好自己的事情,並且由我們確定好線程交互規則,線程就開始運作,仿佛活了起來,非常奇妙!總之,本單元還是有非常大的收獲。

總結分析三次作業中同步塊的設置和鎖的選擇,並分析鎖與同步塊中處理語句直接的關系


三次作業都是使用的物理鎖(synchronized+代碼塊)進行的同步控制,三次作業主要理解了三個問題:如何確定用多少個鎖?鎖住的代碼塊的范圍應當如何確定?如何盡可能減少鎖的嵌套?

第一次作業

第一次作業比較簡單,輸入線程與電梯線程之間只有一個共享對象share,包括共享隊列與輸入結束信號兩部分,所以在兩個線程中對share有讀寫操作的代碼塊我都使用了synchronized(share)進行同步控制。

第二次作業

第二次作業也第一次作業類型,只不過數據共享是分層次的,但都是相似的。我抽象出Queue的類,存儲共享隊列與共享信號,輸入線程與總調度器共享一個Queue的對象,總調度器與各個電梯各共享一個Queue的對象。兩兩之間存在共享,不存在三者的共享。對Queue類對象操作的代碼使用synchronized(xxx.queue){}進行同步。

第三次作業

第三次作業在第二次作業的基礎上增加了count對象,用於統計當前時刻剩余未完成的請求數,由輸入、總調度器、電梯共享,輸入線程與電梯線程會對count進行寫操作,總調度器和電梯線程會對count進行寫操作,相應的代碼塊使用synchronized(count){}進行同步控制。對Queue進行了擴展,存儲兩個共享信號:輸入結束信號inputEnd、程序結束信號finalEnd,對兩者的操作代碼也使用synchronized(xxx.queue){}進行同步。

鎖與數據共享是緊密相關的,如何確定數據共享的數量與結構很大程度上決定了鎖的使用。對於初學者,鎖的使用中要盡量減少嵌套關系,不然很容易出現死鎖的bug,這個將會在下文的bug分析中仔細闡述,我在本單元作業中,除了count對象為三者共享,其余的所有共享都是保證在兩者的,所以對count的加鎖也是讓我思考了許多時間,確保不會產生死鎖。在保證正確的情況下,應當進一步減小鎖的顆粒度,與monitor對象無關的代碼應當移除鎖外。在第三次作業中,Dispatcher類中判斷輸入是否結束,如結束則notify各個電梯線程的代碼被放在了count鎖住的代碼塊中,其實是沒有必要的。

本次作業由於對鎖不是很熟練,於是都使用了比較嚴謹但是也用起來比較繁瑣的物理鎖,即synchronized(monitor){}結構。對於進一步的學習,可以嘗試着使用更加靈活的邏輯鎖Lock來使代碼更加清晰。

總結分析三次作業中的調度器設計,並分析調度器如何與程序中的線程進行交互


第一次作業

第一次作業並未抽象出調度器這個模塊,但是定義了共享對象Share這個類,主要完成電梯線程與輸入線程的數據共享,電梯的運行策略通過書寫狀態機狀態轉移邏輯,完成了ALS算法,針對不同的模式完成了不同的方法。本次作業中電梯線程與輸入線程通過Share類交互,完成乘客請求和輸入線程結束信號的傳遞。Share類包含共享隊列與輸入結束信號,輸入線程將乘客請求放入共享隊列,電梯從共享隊列中獲得請求並執行,輸入線程結束時將輸入結束信號置True,電梯讀取這個信號並在請求全部完成時結束線程。

第二次作業

第二次作業重構了架構,使用分級調度管理。由總調度器線程dispatcher將輸入得到請求分配給各電梯,每台電梯內置一個“調度器”完成單電梯運行的調度(電梯內置的“調度器”並不是一個線程,我更願意稱之為策略類,而不是調度器)。

總調度器dispatcher與輸入線程共享待調度隊列與輸入結束信號,輸入線程將乘客請求加入到待調度隊列,總調度器dispatcher內置了多種調度算法,根據需要選擇合適的算法將請求分配給各個電梯。每個電梯內置一個策略類strategy,根據輸入模式選擇合適的策略完成單部電梯運行,總調度器和每個電梯之間共享一個電梯等待隊列和輸入結束信號,總調度器需要將請求從待調度隊列拿出並放入目標電梯的等待隊列,相應電梯從自己的等待隊列中獲取請求並執行。輸入線程結束將結束信號傳遞給總調度器,再由總調度器傳遞給各個電梯,各個線程完成自己的工作並輸入結束后會結束線程。

第三次作業

第三次作業基本沿用了第二次作業的調度架構,這次作業調度工作不僅需要完成請求的分配,還需要完成請求的分割(將請求分割成單部電梯一次運輸可以完成的原子請求),同時本次作業的進程結束判斷比較復雜。

對於請求的分割,目前的調度器負擔已經比較重,我將這一部分功能轉移到其他部分。於是我建立了一個換乘表類transferTable,通過動態規划獲得一個換乘表,通過逐步查表法將請求分割成一個個原子請求。調度器dispatcher層面只需要調度一個個原子請求即可。

本次進程結束不能通過輸入結束進行判斷,而是需要判斷全部請求是否完成。但為了morning策略能夠正確執行,我保留了輸入結束信號的傳遞。在輸入線程、總調度器線程dispatcher、電梯線程之間添加了共享計數器count用來統計剩余未完成請求數,輸入線程得到新的輸入時count++,電梯將請求運送到最終目的地時count--,總調度器線程與電梯線程結束的判斷邏輯中,需要讀取count的值並判斷是否為0。

從功能設計與性能設計的平衡方面,分析和總結自己第三次作業架構設計的可擴展性


第一次作業

  • UML類圖

  • UML協作圖

第二次作業

  • UML類圖

  • UML協作圖

第三次作業

  • UML類圖

  • UML協作圖

morning模式的設計難度更高,在此展示morning模式下的UML協作圖。

  • 擴展性分析

針對電梯的擴展性較好,針對調度算法的擴展性較好,但是針對策略的擴展性差。

電梯使用工廠模式,不同類型的電梯只需要傳遞不同的參數即可創建不同的電梯。模擬電梯繼承電梯類,模擬電梯可以獲取當前電梯狀態並進行模擬,所以增加新的電梯種類時完全不需要修改模擬電梯的內容,就可以支持新的功能。乘客換乘只需要查transferTable,電梯是否可以經停只需要查mapTable,增加新的種類電梯只需要修改這兩個類就可以,mapTable使用獨熱碼標記相應樓層可以停留的電梯,相應種類的電梯通過掩碼查詢是否可以經停,增加新的電梯種類只需要修改獨熱碼並添加新的掩碼即可,transferTable中只需要再增加一個可達性矩陣即可。

調度器中將調度算法抽象為一個個方法,更換新的調度算法只需要再調度器中更改方法調用即可完成,更進一步來看,調度器還可以支持根據外部狀態隨時調整調度算法的功能。

針對策略的擴展性較差,不同的策略(morning、random、night)在狀態轉移中有諸多不同,策略不同使得策略類高度相似而在代碼其余部分出現不同,擴展性非常差。分析原因,應當是我最初沒有回答好一個問題:“策略類需要完成什么工作?”,從我的架構設計來進行反思,我的不同策略類分別表示了不同的狀態轉移方式,於是應當這樣改進:將狀態轉移的公共操作如查詢電梯狀態等放入strategy抽象類,在不同模式狀態轉移路徑出現差異的部分定義不同策略的方法,使得狀態類的狀態轉移代碼都是進行的比較抽象層面的操作。這樣可以使得代碼再策略方面有更好的擴展性。

分析自己程序的bug


課下進行了周密的形式驗證與數據測試,三次作業的強測與互測均未出現bug。下面主要談一談自己的公測與自測中遇到的bug。

第一次作業

第一次作業主要主要是電梯的狀態轉移不夠周全,出現bug。主要原因是把所有的狀態方法寫到Elevator中,狀態轉移邏輯代碼switch語句塊和狀態方法中均有,非常零散。為了實現捎帶功能,對電梯使用了兩個方向不同的讀入緩沖區buffUp buffDown,沒有必要,直接從等待隊列中獲取並判斷好方向就可以,只要注意好不要超載。

第二次作業

第二次作業比較順利,使用狀態模式解決電梯狀態轉移問題,用LOOK算法取代了ALS算法,代碼更加容易理解,並且性能分得到很大提高,沒有遇到bug。

第三次作業

第三次作業由於粘貼了一部分代碼出現了兩處代碼修改不同步導致出現了bug,可見自己的抽象能力仍然有待提高,在初步的設計架構中,三種策略random、morning、night只需要重寫不同的部分即可,結果在寫代碼的過程中,需要修改的地方竟然不止在三個策略類,在別的地方仍然需要修改,例如我的Rest狀態類,於是我的策略類代碼只好復制粘貼,導致bug。本質上看,這個bug的出現是因為我對於策略類的抽象不夠深入,仍然需要將策略類進一步內聚,使得其余部分代碼與策略徹底無關。

第三次作業的自測中,我出現了死鎖,原因是我另外創建了一個共享對象標記輸入線程結束,inputEnd起初並沒有放在Queue類中,結果發生了死鎖。以下通過代碼展示一下這個bug,均與morning模式有關:

/* InputThread 輸入線程 */
 if (request == null) {
     synchronized (dispatchingQueue) {
         synchronized(inputEnd) {
             inputEnd.setInputEnd(true);
             dispatchingQueue.notifyAll();
         }
         dispatchingQueue.setFinalEnd(true);
         dispatchingQueue.notifyAll();
     }
     break;
 }
/* Dispatcher 總調度器線程 */
synchronized(inputEnd) {
	if (inputEnd.isInputEnd()) {
    	for (Elevator elevator : ele2waitingQueue.keySet()) {
        	synchronized (ele2waitingQueue.get(elevator)) {
            	ele2waitingQueue.get(elevator).notifyAll();
        	}
    	}
	}
}
/* Rest 電梯的Rest狀態類 */
synchronized(elevator.getWaitingQueue()){
	..............;
	elevator.getWaitingQueue().wait();
	while (true) {
    	synchronized(elevator.getInputEnd()) {
        	if (!((elevator.getWaitingQueue().getQueue().size()< elevator.getPersonNumMax())
              	&& (!inputEnd.isInputEnd()))) {
            	break;
        	}
        	elevator.getWaitingQueue().wait();
    	}
	}
}

在這段代碼中出現在morning模式中,電梯在輸入結束前,需要等人滿之后才會運送乘客,在輸入結束后,不再等待人滿。空電梯在waitingQueue對象上等待,當調度器給電梯分配一個請求時會喚醒電梯,電梯進入while循環等待人滿,人不滿並且輸入未結束繼續在waitingQueue對象上等待。輸入線程如果結束會將inputEnd置True,調度器線程檢測inputEnd如果為True,喚醒電梯。

看似比較合理,但是在A鎖套B鎖的結構下,線程釋放A鎖等待而不是釋放B鎖等待本身就讓人感到十分奇怪。while循環中,電梯釋放了waitingQueue的鎖等待,此時還拿着inputEnd的鎖,輸入線程結束時,無法拿到inputEnd的鎖所以被阻塞無法喚醒調度器,調度器更無法喚醒電梯,然而電梯只有被調度器喚醒才能夠繼續執行從而釋放出inputEnd的鎖,這就出現了矛盾,從而死鎖。

解決辦法是將沿用老辦法inputEnd也放到Queue類中,將所有的信號和隊列放到一個類來共享,然后只鎖這一個對象,可以解決死鎖的問題。

分析自己發現別人程序bug所采用的策略


  • 列出自己所采取的測試策略及有效性

分析自己程序bug主要通過形式驗證與數據測試,互測中主要通過數據測試。

前兩次作業中,通過梳理狀態轉移圖,將電梯各個運行狀態之間的狀態轉移邏輯進行了周密的檢查,所以我的電梯沒有成為過“∞電梯”、“震盪電梯”、“吃人電梯”、“造人電梯”、“量子電梯”。另外對電梯的鎖的結構進行了梳理,繪制了一張鎖的並列嵌套關系表,通過改變數據共享方式,例如將聯系緊密的數據打包成一個共享對象減少鎖的嵌套,並針對這張表盡可能構造極端的各進程取得鎖的序列,檢查是否會出現死鎖。最后,對實驗中的難點——如何讓進程正確的結束進行了檢查,重點檢查了每個進程的結束判斷邏輯是否周密,后期我將判斷部分改成冗余法判斷使得進程在進入waiting狀態前后都進行是否結束的判斷,從而使得程序一定能夠正確結束,並在不同的時刻輸入^D檢查是否能夠正確結束。

數據測試主要通過構造極端數據的方式進行,針對不同的輸入模式、加不同的電梯等情況構造測試用例。例如在請求的樓層跨度上我考慮了大跨度樓層分布,小范圍樓層分布,相鄰樓層分布,針對第三次作業,我分別對於ABC電梯的特點,設置了分別與不同類型電梯適應的樓層分布,對於時間,我考慮同時大量請求進入,時間稀疏的請求進入,針對換乘,設置了許多不順路的情形,第三次作業中單部電梯就能夠完成但是比較慢的情形。特別地,對morning模式這個易錯的模式,進行了許多與輸入結束有關的測試,主要檢測是否能夠完成所有請求,不會有遺漏。

我的測試策略幫助我安全地度過了強測與互測,在互測中也能夠取得一定的戰績。多線程的線程安全類bug存在並不一定能夠復現,在第一次作業互測中,我成功hack了兩次,均為“電梯吃人”的bug。第二次作業互測中,發現有一名同學只用一步電梯完成所有請求,於是我針對這個情況構造了許多邊界數據進行壓力測試,可惜互測210s的限制過於寬松,無法使TA超時,第二次作業互測無功而返。第三次作業中,在本地的自動測評中發現某個同學在遍歷ArrayList中出現內容項的修改觸發了異常,可能是將線程對象放入ArrayList,但是線程運行具有不確定性,所以有時候會觸發異常有時候不會;另外我還發現一個同學morning模式下會出現電梯的震盪不止的情況,在本地測試也會發生一次復現bug,多次沒有復現的情況,可能是狀態轉移不周密導致的,我連續提交了多次測試用例,在平台測評機中卻一次也沒有成功hack,比較可惜。

  • 分析自己采用了什么策略來發現線程安全相關的問題

(1)通過數據測試發現自己出現了死鎖,使用IDEA的快照功能能夠很清楚的看出來哪個線程得到了鎖,哪個線程在waiting,並且可以查看鎖的ID,結合自己的代碼邏輯便可以分析出是那兩個線程之間發生了死鎖。

(2)另外如上所述,通過檢查鎖的層次結構,枚舉邊界數據的訪問序列,進行人腦模擬多線程測試

  • 分析本單元的測試策略與第一單元測試策略的差異之處

第一單元是靜態的測試,主要考慮測試數據本身的極端性,而且寫對拍機簡單,使用Python的sympy直接寫就可以,如果發現了bug則刀一次中一次。本單元的測試數據不光要考慮數據本身的極端性,還要考慮序列的時間特征,本次寫測評機要滿足的數據約束比較多,正確性檢查比較麻煩,由於多線程的不確定性,即使發現了bug也難以一次就復現,需要同樣的數據多次測試。

心得體會


  • 線程安全層面

在我看來,線程安全是本單元最難的部分,如果不關注電梯的性能,可以說線程安全是本次作業唯一的難點了。課堂上講解的幾種經典情形在本次作業中得到了充足的展現。第一次作業中一遍遍閱讀代碼才發現一個潛在的線程安全問題,但是對於這類問題見得多了也就更敏感了,在第三次作業中,如果寫到可能出現線程不安全的地方,便會下意識地提醒自己這里要多做些工作。

線程安全與生活中許多場景是非常相似的,以生活場景作類比再來回過頭來理解這個問題,其實里面也不過是非常朴素的道理,更多地是開發者要有線程安全的意識,然后再談用什么樣的技術手段維護線程的安全。java中有許多線程安全的類,其目的就是服務於多線程編程,既然說“不要重復造輪子”,那么接下來的學習中還應該學習使用這些東西,讓自己變得更加專業。

  • 層次化設計

層次化設計方面老生常談了,從第一單元就開始了,本單元提高明顯,但是仍舊在這方面有許多不足之處。第一次作業基本采用面向過程,主要用來熟悉多線程與電梯調取算法了。第二次作業才着手重構,“輸入-調度器-電梯”的層次架構基本明確,這次作業采取狀態模式建模,在run方法中只需依次執行狀態的操作,電梯進行了參數化,創建不同類型的電梯只需要傳遞不同的參數。第二次作業使用模擬電梯來進行優化,只需要繼承電梯類並重寫狀態的操作即可,修改起來非常容易,這些都實現了較好的可擴展性。但是電梯的狀態轉移與電梯策略仍然高度耦合,層次化做的不夠好,使得實現morning策略的過程中有較大工作量。

雖然在設計架構的時候已經進行了充足的設計,但是在實際寫代碼的過程仍然與最初設計在實現上發生抵觸,這是不可避免的,初學者難以一遍成功,還需要實際開發的過程中不斷迭代調整架構,還需要更多的訓練才能夠讓自己對設計有更高的敏感度,對實現過程的可能遇到的問題要有一定的預見性。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM