編寫多線程的 Java 應用程序如何避免當前編程中最常見的問題 ![]() |
![]() |
![]() |
級別: 初級 Alex Roetter (aroetter@CS.Stanford.edu), Teton Data Systems 的軟件工程師 2001 年 2 月 01 日 Java Thread API 允許程序員編寫具有多處理機制優點的應用程序,在后臺處理任務的同時保持用戶所需的交互感。Alex Roetter 介紹了 Java Thread API,并概述多線程可能引起的問題以及常見問題的解決方案。 幾乎所有使用 AWT 或 Swing 編寫的畫圖程序都需要多線程。但多線程程序會造成許多困難,剛開始編程的開發者常常會發現他們被一些問題所折磨,例如不正確的程序行為或死鎖。 在本文中,我們將探討使用多線程時遇到的問題,并提出那些常見陷阱的解決方案。 一個程序或進程能夠包含多個線程,這些線程可以根據程序的代碼執行相應的指令。多線程看上去似乎在并行執行它們各自的工作,就像在一臺計算機上運行著多個處理機一樣。在多處理機計算機上實現多線程時,它們確實 可以并行工作。和進程不同的是,線程共享地址空間。也就是說,多個線程能夠讀寫相同的變量或數據結構。 編寫多線程程序時,你必須注意每個線程是否干擾了其他線程的工作。可以將程序看作一個辦公室,如果不需要共享辦公室資源或與其他人交流,所有職員就會獨立并行地工作。某個職員若要和其他人交談,當且僅當該職員在“聽”且他們兩說同樣的語言。此外,只有在復印機空閑且處于可用狀態(沒有僅完成一半的復印工作,沒有紙張阻塞等問題)時,職員才能夠使用它。在這篇文章中你將看到,在 Java 程序中互相協作的線程就好像是在一個組織良好的機構中工作的職員。 在多線程程序中,線程可以從準備就緒隊列中得到,并在可獲得的系統 CPU 上運行。操作系統可以將線程從處理器移到準備就緒隊列或阻塞隊列中,這種情況可以認為是處理器“掛起”了該線程。同樣,Java 虛擬機 (JVM) 也可以控制線程的移動――在協作或搶先模型中――從準備就緒隊列中將進程移到處理器中,于是該線程就可以開始執行它的程序代碼。 協作式線程 模型允許線程自己決定什么時候放棄處理器來等待其他的線程。程序開發員可以精確地決定某個線程何時會被其他線程掛起,允許它們與對方有效地合作。缺點在于某些惡意或是寫得不好的線程會消耗所有可獲得的 CPU 時間,導致其他線程“饑餓”。 在 搶占式線程 模型中,操作系統可以在任何時候打斷線程。通常會在它運行了一段時間(就是所謂的一個時間片)后才打斷它。這樣的結果自然是沒有線程能夠不公平地長時間霸占處理器。然而,隨時可能打斷線程就會給程序開發員帶來其他麻煩。同樣使用辦公室的例子,假設某個職員搶在另一人前使用復印機,但打印工作在未完成的時候離開了,另一人接著使用復印機時,該復印機上可能就還有先前那名職員留下來的資料。搶占式線程模型要求線程正確共享資源,協作式模型卻要求線程共享執行時間。由于 JVM 規范并沒有特別規定線程模型,Java 開發員必須編寫可在兩種模型上正確運行的程序。在了解線程以及線程間通訊的一些方面之后,我們可以看到如何為這兩種模型設計程序。
為了使用 Java 語言創建線程,你可以生成一個
大多數應用程序要求線程互相通信來同步它們的動作。在 Java 程序中最簡單實現同步的方法就是上鎖。為了防止同時訪問共享資源,線程在使用資源的前后可以給該資源上鎖和開鎖。假想給復印機上鎖,任一時刻只有一個職員擁有鑰匙。若沒有鑰匙就不能使用復印機。給共享變量上鎖就使得 Java 線程能夠快速方便地通信和同步。某個線程若給一個對象上了鎖,就可以知道沒有其他線程能夠訪問該對象。即使在搶占式模型中,其他線程也不能夠訪問此對象,直到上鎖的線程被喚醒、完成工作并開鎖。那些試圖訪問一個上鎖對象的線程通常會進入睡眠狀態,直到上鎖的線程開鎖。一旦鎖被打開,這些睡眠進程就會被喚醒并移到準備就緒隊列中。 在 Java 編程中,所有的對象都有鎖。線程可以使用
Fine-grain 鎖
若為了在方法級上同步,不能將整個方法聲明為
通常情況下,可能有多個線程需要訪問數目很少的資源。假想在服務器上運行著若干個回答客戶端請求的線程。這些線程需要連接到同一數據庫,但任一時刻只能獲得一定數目的數據庫連接。你要怎樣才能夠有效地將這些固定數目的數據庫連接分配給大量的線程?一種控制訪問一組資源的方法(除了簡單地上鎖之外),就是使用眾所周知的信號量計數 (counting semaphore)。 信號量計數將一組可獲得資源的管理封裝起來。信號量是在簡單上鎖的基礎上實現的,相當于能令線程安全執行,并初始化為可用資源個數的計數器。例如我們可以將一個信號量初始化為可獲得的數據庫連接個數。一旦某個線程獲得了信號量,可獲得的數據庫連接數減一。線程消耗完資源并釋放該資源時,計數器就會加一。當信號量控制的所有資源都已被占用時,若有線程試圖訪問此信號量,則會進入阻塞狀態,直到有可用資源被釋放。 信號量最常見的用法是解決“消費者-生產者問題”。當一個線程進行工作時,若另外一個線程訪問同一共享變量,就可能產生此問題。消費者線程只能在生產者線程完成生產后才能夠訪問數據。使用信號量來解決這個問題,就需要創建一個初始化為零的信號量,從而讓消費者線程訪問此信號量時發生阻塞。每當完成單位工作時,生產者線程就會向該信號量發信號(釋放資源)。每當消費者線程消費了單位生產結果并需要新的數據單元時,它就會試圖再次獲取信號量。因此信號量的值就總是等于生產完畢可供消費的數據單元數。這種方法比采用消費者線程不停檢查是否有可用數據單元的方法要高效得多。因為消費者線程醒來后,倘若沒有找到可用的數據單元,就會再度進入睡眠狀態,這樣的操作系統開銷是非常昂貴的。 盡管信號量并未直接被 Java 語言所支持,卻很容易在給對象上鎖的基礎上實現。一個簡單的實現方法如下所示:
不幸的是,使用上鎖會帶來其他問題。讓我們來看一些常見問題以及相應的解決方法:
判斷是搶占式還是協作式的線程模型,取決于虛擬機的實現者,并根據各種實現而不同。因此,Java 開發員必須編寫那些能夠在兩種模型上工作的程序。 正如前面所提到的,在搶占式模型中線程可以在代碼的任何一個部分的中間被打斷,除非那是一個原子操作代碼塊。原子操作代碼塊中的代碼段一旦開始執行,就要在該線程被換出處理器之前執行完畢。在 Java 編程中,分配一個小于 32 位的變量空間是一種原子操作,而此外象 而在協作式模型中,是否能保證線程正常放棄處理器,不掠奪其他線程的執行時間,則完全取決于程序員。調用 正如你所想的那樣,將這些方法隨意放在代碼的某個地方,并不能夠保證正常工作。如果線程正擁有一個鎖(因為它在一個同步方法或代碼塊中),則當它調用 另外一個解決方法則是調用
在那些使用 Swing 和/或 AWT 包創建 GUI (用戶圖形界面)的 Java 程序中,AWT 事件句柄在它自己的線程中運行。開發員必須注意避免將這些 GUI 線程與較耗時間的計算工作綁在一起,因為這些線程必須負責處理用戶時間并重繪用戶圖形界面。換句話來說,一旦 GUI 線程處于繁忙,整個程序看起來就象無響應狀態。Swing 線程通過調用合適方法,通知那些 Swing callback (例如 Mouse Listener 和 Action Listener )。 這種方法意味著 listener 無論要做多少事情,都應當利用 listener callback 方法產生其他線程來完成此項工作。目的便在于讓 listener callback 更快速返回,從而允許 Swing 線程響應其他事件。 如果一個 Swing 線程不能夠同步運行、響應事件并重繪輸出,那怎么能夠讓其他的線程安全地修改 Swing 的狀態?正如上面提到的,Swing callback 在 Swing 線程中運行。因此他們能修改 Swing 數據并繪到屏幕上。 但是如果不是 Swing callback 產生的變化該怎么辦呢?使用一個非 Swing 線程來修改 Swing 數據是不安全的。Swing 提供了兩個方法來解決這個問題:
Java 語言的設計,使得多線程對幾乎所有的 Applet 都是必要的。特別是,IO 和 GUI 編程都需要多線程來為用戶提供完美的體驗。如果依照本文所提到的若干基本規則,并在開始編程前仔細設計系統――包括它對共享資源的訪問等,你就可以避免許多常見和難以發覺的線程陷阱。
|
如果我是國王:關于解決 Java編程語言線程問題的建議![]() |
![]() |
![]() |
級別: 初級 Allen Holub自由撰稿人 2000 年 10 月 01 日 Allen Holub 指出,Java 編程語言的線程模型可能是此語言中最薄弱的部分。它完全不適合實際復雜程序的要求,而且也完全不是面向對象的。本文建議對 Java 語言進行重大修改和補充,以解決這些問題。 Java 語言的線程模型是此語言的一個最難另人滿意的部分。盡管 Java 語言本身就支持線程編程是件好事,但是它對線程的語法和類包的支持太少,只能適用于極小型的應用環境。 關于 Java 線程編程的大多數書籍都長篇累牘地指出了 Java 線程模型的缺陷,并提供了解決這些問題的急救包(Band-Aid/邦迪創可貼)類庫。我稱這些類為急救包,是因為它們所能解決的問題本應是由 Java 語言本身語法所包含的。從長遠來看,以語法而不是類庫方法,將能產生更高效的代碼。這是因為編譯器和 Java 虛擬器 (JVM) 能一同優化程序代碼,而這些優化對于類庫中的代碼是很難或無法實現的。 在我的《 Taming Java Threads》(請參閱 參考資料 )書中以及本文中,我進一步建議對 Java 編程語言本身進行一些修改,以使得它能夠真正解決這些線程編程的問題。本文和我這本書的主要區別是,我在撰寫本文時進行了更多的思考, 所以對書中的提議加以了提高。這些建議只是嘗試性的 -- 只是我個人對這些問題的想法,而且實現這些想法需要進行大量的工作以及同行們的評價。但這是畢竟是一個開端,我有意為解決這些問題成立一個專門的工作組,如果您感興趣,請發 e-mail 到 threading@holub.com。一旦我真正著手進行,我就會給您發通知。 這里提出的建議是非常大膽的。有些人建議對 Java 語言規范 (JLS)(請參閱 參考資料 )進行細微和少量的修改以解決當前模糊的 JVM 行為,但是我卻想對其進行更為徹底的改進。 在實際草稿中,我的許多建議包括為此語言引入新的關鍵字。雖然通常要求不要突破一個語言的現有代碼是正確的,但是如果該語言的并不是要保持不變以至于過時的話,它就必須能引入新的關鍵字。為了使引入的關鍵字與現有的標識符不產生沖突,經過細心考慮,我將使用一個 ($) 字符,而這個字符在現有的標識符中是非法的。(例如,使用 $task,而不是 task)。此時需要編譯器的命令行開關提供支持,能使用這些關鍵字的變體,而不是忽略這個美元符號。 Java 線程模型的根本問題是它完全不是面向對象的。面向對象 (OO) 設計人員根本不按線程角度考慮問題;他們考慮的是 同步信息 異步 信息(同步信息被立即處理 -- 直到信息處理完成才返回消息句柄;異步信息收到后將在后臺處理一段時間 -- 而早在信息處理結束前就返回消息句柄)。Java 編程語言中的 這是面向對象 (OO) 的處理方法。但是,如前所述,Java 的線程模型是非面向對象的。一個 Java 編程語言線程實際上只是一個 對于此問題,在我的書中深入討論過的一個解決方法是,使用一個 在一個 active 對象上運行的異步信息實際上是同步的,因為它們被一個單一的服務線程按順序從隊列中取出并執行。因此,使用一個 active 對象以一種更為過程化的模型可以消除大多數的同步問題。 在某種意義上,Java 編程語言的整個 Swing/AWT 子系統是一個 active 對象。向一個 Swing 隊列傳送一條訊息的唯一安全的途徑是,調用一個類似 那么我的第一個建議是,向 Java 編程語言中加入一個 task (任務)的概念,從而將active 對象集成到語言中。( task的概念是從 Intel 的 RMX 操作系統和 Ada 編程語言借鑒過來的。大多數實時操作系統都支持類似的概念。) 一個任務有一個內置的 active 對象分發程序,并自動管理那些處理異步信息的全部機制。 定義一個任務和定義一個類基本相同,不同的只是需要在任務的方法前加一個
所有的寫請求都用一個
這種基于類的處理方法,其主要問題是太復雜了 -- 對于一個這樣簡單的操作,代碼太雜了。向 Java 語言引入
注意,異步方法并沒有指定返回值,因為其句柄將被立即返回,而不用等到請求的操作處理完成后。所以,此時沒有合理的返回值。對于派生出的模型,
注意,為確保線程安全,異步方法的參數必須是不變 (immutable) 的。運行時系統應通過相關語義來保證這種不變性(簡單的復制通常是不夠的)。 所有的 task 對象必須支持一些偽信息 (pseudo-message),例如:
除了常用的修飾符( 在《 Taming Java Threads 》的第八章中,我給出了一個服務器端的 socket 處理程序,作為線程池的例子。它是關于使用線程池的任務的一個好例子。其基本思路是產生一個獨立對象,它的任務是監控一個服務器端的 socket。每當一個客戶機連接到服務器時,服務器端的對象會從池中抓取一個預先創建的睡眠線程,并把此線程設置為服務于客戶端連接。socket 服務器會產出一個額外的客戶服務線程,但是當連接關閉時,這些額外的線程將被刪除。實現 socket 服務器的推薦語法如下:
注意,每個傳送到
雖然在多數情況下,
解決這些問題的辦法是:擴展
超時是需要的,但還不足以使代碼強壯。您還需要具備從外部中止請求鎖等待的能力。所以,當向一個等待鎖的線程傳送一個 對 另一個可解決的問題是最常見的死鎖情況,在這種情況下,兩個線程都在等待對方完成某個操作。設想下面的一個例子(假設的):
設想一個線程調用
編譯器(或虛擬機)會重新排列請求鎖的順序,使 但是,這種方法對多線程不一定總成功,所以得提供一些方法來自動打破死鎖。一個簡單的辦法就是在等待第二個鎖時常釋放已獲得的鎖。這就是說,應采取如下的等待方式,而不是永遠等待:
如果等待鎖的每個程序使用不同的超時值,就可打破死鎖而其中一個線程就可運行。我建議用以下的語法來取代前面的代碼:
超時檢測問題可以通過重新定義 基于狀態的條件變量的概念是很重要的。如果此變量被設置成
嵌套監控鎖定問題非常麻煩,我并沒有簡單的解決辦法。嵌套監控鎖定是一種死鎖形式,當某個鎖的占有線程在掛起其自身之前不釋放鎖時,會發生這種嵌套監控封鎖。下面是此問題的一個例子(還是假設的),但是實際的例子是非常多的:
此例中,在 在這個例子中,有很多明顯的辦法來解決問題:例如,對任何的方法都使用同步。但是在真實世界中,解決方法通常不是這么簡單。 一個可行的方法是,在 我也希望能等到下述復雜條件被實現的一天。例如:
其中
同時支持搶占式和協作式線程的能力在某些服務器應用程序中是基本要求,尤其是在想使系統達到最高性能的情況下。我認為 Java 編程語言在簡化線程模型上走得太遠了,并且 Java 編程語言應支持 Posix/Solaris 的“綠色(green)線程”和“輕便(lightweight)進程”概念(在“( Taming Java Threads ”第一章中討論)。 這就是說,有些 Java 虛擬機的實現(例如在 NT 上的 Java 虛擬機)應在其內部仿真協作式進程,其它 Java 虛擬機應仿真搶占式線程。而且向 Java 虛擬機加入這些擴展是很容易的。 一個 Java 的 例如,目前的語法:
能有效地為 把
現有的覆蓋(override)
應在語言中加入更多的功能以支持線程間的相互通信。目前,
讀寫鎖的概念應內置到 Java 編程語言中。讀寫器鎖在“ Taming Java Threads ”(和其它地方)中有詳細討論,概括地說:一個讀寫鎖支持多個線程同時訪問一個對象,但是在同一時刻只有一個線程可以修改此對象,并且在訪問進行時不能修改。讀寫鎖的語法可以借用
對于一個對象,應該只有在 如果讀和寫線程都在等待,缺省情況下,讀線程會首先進行。但是,可以使用
當前情況下,JLS 允許訪問部分創建的對象。例如,在一個構造函數中創建的線程可以訪問正被創建的對象,既使此對象沒有完全被創建。下面代碼的結果無法確定:
設置 對此問題的一個解決方法是,在構造函數沒有返回之前,對于在此構造函數中創建的線程,既使它的優先級比調用 這就是說,在構造函數返回之前, 另外,Java 編程語言應可允許構造函數的同步。換句話說,下面的代碼(在當前情況下是非法的)會象預期的那樣工作:
我認為第一種方法比第二種更簡潔,但實現起來更為困難。
JLS 要求保留對于 volatile 操作的請求。大多數 Java 虛擬機都簡單地忽略了這部分內容,這是不應該的。在多處理器的情況下,許多主機都出現了這種問題,但是它本應由 JLS 加以解決的。如果您對這方面感興趣,馬里蘭大學的 Bill Pugh 正在致力于這項工作(請參閱 參考資料)。
如果缺少良好的訪問控制,會使線程編程非常困難。大多數情況下,如果能保證線程只從同步子系統中調用,不必考慮線程安全(threadsafe)問題。我建議對 Java 編程語言的訪問權限概念做如下限制;
由于對不變對象的訪問不需要同步,所以在多線程條件下,不變的概念(一個對象的值在創建后不可更改)是無價的。Java 編程言語中,對于不變性的實現不夠嚴格,有兩個原因:
第一個問題可以解決,不允許線程在構造函數中開始執行 (或者在構造函數返回之前不能執行開始請求)。 對于第二個問題,通過限定
有了 最后,當使用內部類(inner class)后,在 Java 編譯器中的一個錯誤使它無法可靠地創建不變對象。當一個類有重要的內部類時(我的代碼常有),編譯器經常不正確地顯示下列錯誤信息:
既使空的 final 在每個構造函數中都有初始化,還是會出現這個錯誤信息。自從在 1.1 版本中引入內部類后,編譯器中一直有這個錯誤。在此版本中(三年以后),這個錯誤依然存在。現在,該是改正這個錯誤的時候了。 除了訪問權限外,還有一個問題,即類級(靜態)方法和實例(非靜態)方法都能直接訪問類級(靜態)域。這種訪問是非常危險的,因為實例方法的同步不會獲取類級的鎖,所以一個
由于
或則,編譯器應獲得讀/寫鎖的使用:
另外一種方法是(這也是一種 理想的 方法)-- 編譯器應 自動 使用一個讀/寫鎖來同步訪問非不變 static 域,這樣,程序員就不必擔心這個問題。
當所有的非后臺線程終止后,后臺線程都被突然結束。當后臺線程創建了一些全局資源(例如一個數據庫連接或一個臨時文件),而后臺線程結束時這些資源沒有被關閉或刪除就會導致問題。 對于這個問題,我建議制定規則,使 Java 虛擬機在下列情況下不關閉應用程序:
后臺線程在它執行完
重新引入 stop() 、 suspend() 和 resume() 關鍵字 由于實用原因這也許不可行,但是我希望不要廢除 對于這個問題,可以重新定義 與這種和異常類似的處理方法帶來的實際問題是,你必需在每個 應把
應該能打斷任何被阻斷的操作,而不是只讓它們 還有,程序應支持 I/O 操作的超時。所有可能出現阻斷操作的對象(例如 InputStream 對象)也都應支持這種方法:
這和 Socket 類的
以上是我的建議。就像我在標題中所說的那樣,如果我是國王...(哎)。我希望這些改變(或其它等同的方法)最終能被引入 Java 語言中。我確實認為 Java 語言是一種偉大的編程語言;但是我也認為 Java 的線程模型設計得還不夠完善,這是一件很可惜的事情。但是,Java 編程語言正在演變,所以還有可提高的前景。 Allen 撰寫了八本書籍,最近新出的一本討論了 Java 線程的陷阱和缺陷《 Taming Java Threads 》。他長期從事設計和編制面向對象軟件。從事了 8 年的 C++ 編程工作后,Allen 在 1996 年由 C++ 轉向 Java。他現在視 C++ 為一個噩夢,其可怕的經歷正被逐漸淡忘。他從 1982 年起就自己和為加利弗尼亞大學伯克利分校教授計算機編程(首先是 C,然后是 C++ 和 MFC,現在是面向對象設計和 Java)。 Allen 也提供 Java 和面向對象設計方面的公眾課程和私授 (in-house) 課程。他還提供面向對象設計的咨詢并承包 Java 編程項目。請通過此 Web 站點和 Allen 取得聯系并獲取信息: www.holub.com。
|
地震讓大伙知道:居安思危,才是生存之道。
