戈 俊
現實生活中,體育賽事的售票一般是并發執行,要求多窗口同時進行售票任務。并且要保證售票順利進行,不可以出現錯票現象。如何通過多線程同步技術解決錯票問題,是本研究的主要目的。
根據研究內容和研究目的,查閱了近年來有關多線程技術等方面的專著、期刊、論文和資料,并對資料進行整理分析、篩選、歸納、概括。為寫作提供依據,為后續研究提供了充足的理論支持。
通過Eclipse集成開發軟件,建立JavaSE開源項目,通過創建包、接口、類、配置文件等方法,進行項目開發的基本配置,通過WindowBuilder插件,進行GUI可視化組件開發,使用多線程技術開發體育賽事售票系統,結合錯票問題提出解決方案。
3.1.1 進程與線程的關系分析
進程顧名思義是正在進行中的程序。當我們在執行一個程序時,程序啟動后會在內存中開辟空間,這個被開辟的空間就是進程,進程是一個應用程序對應內存中的一片空間,等待程序運行完畢后,會將此片空間釋放掉,硬盤是持久化存儲,而內存是程序運行時臨時存儲的。線程是任意進程在內存中的執行路徑,當進程開辟多個執行路徑并同時操作多部分代碼時,即開啟多個線程任務。
3.1.2 多線程創建方式分析
創建線程目的是為了開啟一條執行路徑去運行指定的代碼,和其他代碼實現同時運行,而運行的指定代碼就是這個執行路徑的任務[1]。Java中Thread類用于描述線程,線程是需要任務,這個任務就通過Thread類中的run方法來體現,run方法就是封裝自定義線程運行任務的函數[1]。run方法中定義的就是線程要運行的任務代碼,開啟線程是為了運行指定代碼,所以只有繼承Thread類并復寫run方法,將運行的代碼定義在run方法中即可[1]。
Oracle公司在定義Thread類時,先定義了一個私有的Runnable接口引用的全局變量[2]。定義一個帶參構造函數,而構造函數的參數就是Runnable接口。通過參數傳遞,將局部變量的參數傳遞給全局變量。同時在Thread類的run方法中定義了如果全局變量不為空的話。就運行實現Runnable接口子類對象中的run方法[2]。Runnable接口的出現,僅僅是將線程任務進行了對象的封裝。
3.1.3 多線程運行時內存管理分析
只要開啟一條執行路徑,棧內存中就隨即存在了一條單獨的執行路徑,當程序運行時調用到了主方法,主線程即可被創建出來,主線程在順序執行的過程中,會執行到繼承自線程Thread對象的子類對象,通過關鍵字new線程對象時,即創建了新的線程。當主線程讀到start方法時,在棧內存中開啟了新的線程路徑,在主線程中執行的內容是main方法中的內容,而新的線程路徑中執行的則是繼承自Thread線程對象被子類覆蓋的run方法中的內容。每條線程在棧內存中都被分配了獨立的空間。同時,主線程中調用的方法就在主線程中壓棧彈棧,而run方法中調用的方法將在新開辟的路徑中壓棧彈棧,新開啟的線程都是由Thread-加上數字來命名的。也就是說每個run方法中都有自己所屬的棧區,run方法中定義的局部變量也都在各自的棧區run方法內[3]。一旦run方法內的所有任務執行完,該線程的run方法彈棧,該線程所分配的執行空間被釋放。多線程程序運行時,即便主函數先運行完畢彈棧后,該程序的其他正在運行的線程依然存在,保證著程序的正常運行。
3.1.4 多線程執行的狀態分析
當應用程序在執行時,CPU在多個執行線程中做著高速切換,這個切換是隨機的。一旦線程處于運行狀態時,CPU在對其進行處理將分兩種狀態:正在被處理表明該線程具備CPU的執行資格和執行權;在處理隊列中排隊表明該線程具備著CPU的執行資格[4]。當線程運行時執行到sleep方法或wait方法時就進入凍結狀態,這是線程在釋放執行權的同時釋放了執行資格的過程。如果想讓凍結的線程恢復到運行狀態,可以等待設置的休眠時間times up或使用notify喚醒線程。當被凍結的線程被喚醒后將進入兩種狀態(運行狀態/臨時阻塞狀態),處于臨時阻塞狀態的線程具備著執行資格,但是不具備執行權[5]。此時就看CPU有沒有切到該線程上。當一個線程被創建后,通過start方法開啟并運行該線程,如果線程任務結束后那就是消亡狀態,通過stop方法也可以結束線程使線程進入消亡狀態。
3.2.1 建立售票對象時繼承Thread出現嚴重錯票現象
首先創建票的數量作為該類的全局變量,此時把票定義為100張。通過循環語句,可以執行售票方法。票務將進行遞減操作,售完一張在票務的總數上遞減一張。由于門票對象已經繼承了線程對象,那么該門票就是線程對象,可以創建多個售票窗口的同時調用線程類中的run方法執行售票程序。此時系統將出現一個問題,4個線程分別都售出了100張門票,共計售出400張門票,出現了嚴重的錯票現象。主要原因是每個對象創建,堆內存中都有個引用變量門票數ticketNumber,默認初始化為0,顯示初始化為100。但在調用過程中,每個線程中間都有自己的run方法,而執行的時候每個run都有自己的對象所屬。所以引用變量ticketNumber在各自對象中進行操作,于是4個線程操作了4個ticketNumber。
3.2.2 通過實現Runnable接口臨時解決錯票問題
出現錯票現象以后,可以換一種思路,通過實現Runnable接口進行多線程的售票。這樣將符合實現Runnable接口的優勢,將線程任務進行獨立的封裝[2]。同時開啟4個線程將售票任務作為參數傳遞給4個線程,4個線程將共同操作同一個任務,這樣就不會出現錯票現象。
很明顯,4個線程同時在售賣100張票,此時并沒有出現錯票現象。但是這種情況真的不會出現錯票現象嗎?如果4個線程共同售賣同100張門票時,其中有一個線程臨時處于了等待狀態并沒有及時售出門票。當該線程處于等待狀態時,線程任務的執行權將被切換到其他線程身上。再次將執行權切回到本線程身上時,就有可能出現錯票現象。這種現象,很容易出現在最后幾張票的售賣過程中,往往會出現負數票現象。我們可以通過讓線程休眠若干毫秒來模擬實現錯票現象。如圖1所示。

圖1 讓線程短暫休眠后的錯票現象
3.2.3 實現Runnable接口后錯票現象依然存在的原因分析
通過讓線程休眠若干毫秒來模擬線程安全問題時,可以假設當門票已經售賣到最后一張,那么門票數ticketNumber就為1。而4個線程在爭奪執行權的同時都進入了售票循環系統中,判斷條件是ticketNumber只要大于0就可以繼續進入If條件語句的執行體內,ticketNumber目前等于1已經滿足If判斷語句的條件,任何一個線程進入判斷語句的執行體內,由于先執行到之前為了模擬票務系統出現錯誤的休眠語句,讓該線程在if執行語句的執行體內休眠1000毫秒。與此同時,if執行體內的執行語句并未被執行,于是門票數ticketNumber并未得到改變。而當該線程處于休眠狀態以后,釋放了執行權。其他線程獲取了執行權以后,將直接進入if語句的條件判斷,由于ticketNumber仍然是大于0,所以該線程仍然可以進入if的執行體內。以此類推,每個線程進入if判斷語句的執行體內,都會按照我們事先設定好的讓線程休眠1000毫秒,讓線程釋放執行權。而當每個線程依次恢復執行狀態時,都會進行ticketNumber減減的動作,這時就會出現錯票現象。出現負票,因為ticketNumber分別被4個線程從1減到0,從0減到-1,從-1減到-2。
3.2.4 通過同步代碼塊或同步函數解決錯票問題
當一個線程讀到synchronized同步代碼塊時,該線程會判斷并檢查同步代碼塊中的參數鎖對象是否存在[6]。如果存在將攜帶該鎖進入同步內繼續執行;如果不存在即便已獲取執行權的線程也無法進入同步內,因為無法獲取并持有同步鎖對象。持有同步代碼塊參數對象的線程,即使在同步內休眠釋放執行權,其他線程也無法持有對象鎖,將無法進入同步內執行相應代碼,只有當該線程執行完同步內所有代碼,出同步代碼塊時,該線程會釋放同步鎖對象。這樣其他線程才有可能持有該同步鎖進入同步內,這樣就避免了出現線程安全的問題。如圖2所示。

圖2 同步代碼塊將操作共享數據的多條代碼進行封裝解決錯票問題
體育賽事的門票售賣多為并發執行,要求多窗口同時進行售票任務,在技術選型上傾向于多線程技術,由于線程任務執行時會偶發臨時阻塞狀態,待運行狀態恢復時極易觸發錯票事故,主要問題在于一個線程在操作多條作為共享數據的體育賽事門票代碼的同時,其他線程也有可能在爭奪執行權的情況下參與運算。
將作為共享數據的體育賽事門票代碼封裝打包成一個整體并加上鎖,當一個線程拿到鎖進入封裝體內售賣門票時,其他線程無法獲取該封裝體的鎖,于是無法參與同時售賣,此舉有效地避免了錯票事故的發生。只有當售票線程結束售票任務離開封裝體后,將鎖移交其他線程,這樣其他線程才有可能效仿前者執行售票任務。