熱點推薦:
您现在的位置: 電腦知識網 >> 編程 >> Java編程 >> Java高級技術 >> 正文

編寫多線程Java應用程序常見問題

2022-06-13   來源: Java高級技術 
幾乎所有使用 AWT 或 Swing 編寫的畫圖程序都需要多線程但多線程程序會造成許多困難剛開始編程的開發者常常會發現他們被一些問題所折磨例如不正確的程序行為或死鎖
  
  在本文中我們將探討使用多線程時遇到的問題並提出那些常見陷阱的解決方案
  
  線程是什麼?
  
  一個程序或進程能夠包含多個線程這些線程可以根據程序的代碼執行相應的指令多線程看上去似乎在並行執行它們各自的工作就像在一台計算機上運行著多個處理機一樣在多處理機計算機上實現多線程時它們確實可以並行工作和進程不同的是線程共享地址空間也就是說多個線程能夠讀寫相同的變量或數據結構
  
  編寫多線程程序時你必須注意每個線程是否干擾了其他線程的工作可以將程序看作一個辦公室如果不需要共享辦公室資源或與其他人交流所有職員就會獨立並行地工作某個職員若要和其他人交談當且僅當該職員在且他們兩說同樣的語言此外只有在復印機空閒且處於可用狀態(沒有僅完成一半的復印工作沒有紙張阻塞等問題)時職員才能夠使用它在這篇文章中你將看到在 Java 程序中互相協作的線程就好像是在一個組織良好的機構中工作的職員
  
  在多線程程序中線程可以從准備就緒隊列中得到並在可獲得的系統 CPU 上運行操作系統可以將線程從處理器移到准備就緒隊列或阻塞隊列中這種情況可以認為是處理器掛起了該線程同樣Java 虛擬機 (JVM) 也可以控制線程的移動在協作或搶先模型中從准備就緒隊列中將進程移到處理器中於是該線程就可以開始執行它的程序代碼
  
  協作式線程模型允許線程自己決定什麼時候放棄處理器來等待其他的線程程序開發員可以精確地決定某個線程何時會被其他線程掛起允許它們與對方有效地合作缺點在於某些惡意或是寫得不好的線程會消耗所有可獲得的 CPU 時間導致其他線程饑餓
  
  在搶占式線程模型中操作系統可以在任何時候打斷線程通常會在它運行了一段時間(就是所謂的一個時間片)後才打斷它這樣的結果自然是沒有線程能夠不公平地長時間霸占處理器然而隨時可能打斷線程就會給程序開發員帶來其他麻煩同樣使用辦公室的例子假設某個職員搶在另一人前使用復印機但打印工作在未完成的時候離開了另一人接著使用復印機時該復印機上可能就還有先前那名職員留下來的資料搶占式線程模型要求線程正確共享資源協作式模型卻要求線程共享執行時間由於 JVM 規范並沒有特別規定線程模型Java 開發員必須編寫可在兩種模型上正確運行的程序在了解線程以及線程間通訊的一些方面之後我們可以看到如何為這兩種模型設計程序
  
  線程和 Java 語言
  
  為了使用 Java 語言創建線程你可以生成一個 Thread 類(或其子類)的對象並給這個對象發送 start() 消息(程序可以向任何一個派生自 Runnable 接口的類對象發送 start() 消息)每個線程動作的定義包含在該線程對象的 run() 方法中run 方法就相當於傳統程序中的 main() 方法線程會持續運行直到 run() 返回為止此時該線程便死了
  
  上鎖
  
  大多數應用程序要求線程互相通信來同步它們的動作在 Java 程序中最簡單實現同步的方法就是上鎖為了防止同時訪問共享資源線程在使用資源的前後可以給該資源上鎖和開鎖假想給復印機上鎖任一時刻只有一個職員擁有鑰匙若沒有鑰匙就不能使用復印機給共享變量上鎖就使得 Java 線程能夠快速方便地通信和同步某個線程若給一個對象上了鎖就可以知道沒有其他線程能夠訪問該對象即使在搶占式模型中其他線程也不能夠訪問此對象直到上鎖的線程被喚醒完成工作並開鎖那些試圖訪問一個上鎖對象的線程通常會進入睡眠狀態直到上鎖的線程開鎖一旦鎖被打開這些睡眠進程就會被喚醒並移到准備就緒隊列中
  
  在 Java 編程中所有的對象都有鎖線程可以使用 synchronized 關鍵字來獲得鎖在任一時刻對於給定的類的實例方法或同步的代碼塊只能被一個線程執行這是因為代碼在執行之前要求獲得對象的鎖繼續我們關於復印機的比喻為了避免復印沖突我們可以簡單地對復印資源實行同步如同下列的代碼例子任一時刻只允許一位職員使用復印資源通過使用方法(在 Copier 對象中)來修改復印機狀態這個方法就是同步方法只有一個線程能夠執行一個 Copier 對象中同步代碼因此那些需要使用 Copier 對象的職員就必須排隊等候
  
  class CopyMachine {
  
  public synchronized void makeCopies(Document d int nCopies) {
  
  // only one thread executes this at a time
  
  }
  
  public void loadPaper() {
  
  // multiple threads could access this at once!
  
  synchronized(this) {
  
  // only one thread accesses this at a time
  
  // feel free to use shared resources overwrite members etc
  
  }
  
  }
  
  }
  
  Finegrain 鎖
  
  在對象級使用鎖通常是一種比較粗糙的方法為什麼要將整個對象都上鎖而不允許其他線程短暫地使用對象中其他同步方法來訪問共享資源?如果一個對象擁有多個資源就不需要只為了讓一個線程使用其中一部分資源就將所有線程都鎖在外面由於每個對象都有鎖可以如下所示使用虛擬對象來上鎖
  
  class FineGrainLock {
  
  MyMemberClass x y;
  
  Object xlock = new Object() ylock = new Object()
  
  public void foo() {
  
  synchronized(xlock) {
  
  // access x here
  
  }
  
  // do something here but dont use shared resources
  
  synchronized(ylock) {
  
  // access y here
  
  }
  
  }
  
  public void bar() {
  
  synchronized(this) {
  
  // access both x and y here
  
  }
  
  // do something here but dont use shared resources
  
  }
  
  }
  
  若為了在方法級上同步不能將整個方法聲明為 synchronized 關鍵字它們使用的是成員鎖而不是 synchronized 方法能夠獲得的對象級鎖
  
  信號量
  
  通常情況下可能有多個線程需要訪問數目很少的資源假想在服務器上運行著若干個回答客戶端請求的線程這些線程需要連接到同一數據庫但任一時刻只能獲得一定數目的數據庫連接你要怎樣才能夠有效地將這些固定數目的數據庫連接分配給大量的線程?一種控制訪問一組資源的方法(除了簡單地上鎖之外)就是使用眾所周知的信號量計數 (counting semaphore)信號量計數將一組可獲得資源的管理封裝起來信號量是在簡單上鎖的基礎上實現的相當於能令線程安全執行並初始化為可用資源個數的計數器例如我們可以將一個信號量初始化為可獲得的數據庫連接個數一旦某個線程獲得了信號量可獲得的數據庫連接數減一線程消耗完資源並釋放該資源時計數器就會加一當信號量控制的所有資源都已被占用時若有線程試圖訪問此信號量則會進入阻塞狀態直到有可用資源被釋放


  
  信號量最常見的用法是解決消費者生產者問題當一個線程進行工作時若另外一個線程訪問同一共享變量就可能產生此問題消費者線程只能在生產者線程完成生產後才能夠訪問數據使用信號量來解決這個問題就需要創建一個初始化為零的信號量從而讓消費者線程訪問此信號量時發生阻塞每當完成單位工作時生產者線程就會向該信號量發信號(釋放資源)每當消費者線程消費了單位生產結果並需要新的數據單元時它就會試圖再次獲取信號量因此信號量的值就總是等於生產完畢可供消費的數據單元數這種方法比采用消費者線程不停檢查是否有可用數據單元的方法要高效得多因為消費者線程醒來後倘若沒有找到可用的數據單元就會再度進入睡眠狀態這樣的操作系統開銷是非常昂貴的
  
  盡管信號量並未直接被 Java 語言所支持卻很容易在給對象上鎖的基礎上實現一個簡單的實現方法如下所示
  
  class Semaphore {
  
  private int count;
  
  public Semaphore(int n) {
  
  unt = n;
  
  }
  
  public synchronized void acquire() {
  
  while(count == ) {
  
  try {
  
  wait()
  
  } catch (InterruptedException e) {
  
  // keep trying
  
  }
  
  }
  
  count;
  
  }
  
  public synchronized void release() {
  
  count++;
  
  notify() // alert a thread thats blocking on this semaphore
  
  }
  
  }
  
  常見的上鎖問題
  
  不幸的是使用上鎖會帶來其他問題讓我們來看一些常見問題以及相應的解決方法
  
  死鎖死鎖是一個經典的多線程問題因為不同的線程都在等待那些根本不可能被釋放的鎖從而導致所有的工作都無法完成假設有兩個線程分別代表兩個饑餓的人他們必須共享刀叉並輪流吃飯他們都需要獲得兩個鎖共享刀和共享叉的鎖假如線程 A 獲得了刀而線程 B 獲得了叉線程 A 就會進入阻塞狀態來等待獲得叉而線程 B 則阻塞來等待 A 所擁有的刀這只是人為設計的例子但盡管在運行時很難探測到這類情況卻時常發生雖然要探測或推敲各種情況是非常困難的但只要按照下面幾條規則去設計系統就能夠避免死鎖問題
  
  讓所有的線程按照同樣的順序獲得一組鎖這種方法消除了 X 和 Y 的擁有者分別等待對方的資源的問題
  
  將多個鎖組成一組並放到同一個鎖下前面死鎖的例子中可以創建一個銀器對象的鎖於是在獲得刀或叉之前都必須獲得這個銀器的鎖
  
  將那些不會阻塞的可獲得資源用變量標志出來當某個線程獲得銀器對象的鎖時就可以通過檢查變量來判斷是否整個銀器集合中的對象鎖都可獲得如果是它就可以獲得相關的鎖否則就要釋放掉銀器這個鎖並稍後再嘗試
  
  最重要的是在編寫代碼前認真仔細地設計整個系統多線程是困難的在開始編程之前詳細設計系統能夠幫助你避免難以發現死鎖的問題
  
  Volatile 變量 volatile 關鍵字是 Java 語言為優化編譯器設計的以下面的代碼為例
  
  class VolatileTest {
  
  public void foo() {
  
  boolean flag = false;
  
  if(flag) {
  
  // this could happen
  
  }
  
  }
  
  }
  
  一個優化的編譯器可能會判斷出 if 部分的語句永遠不會被執行就根本不會編譯這部分的代碼如果這個類被多線程訪問flag 被前面某個線程設置之後在它被 if 語句測試之前可以被其他線程重新設置用 volatile 關鍵字來聲明變量就可以告訴編譯器在編譯的時候不需要通過預測變量值來優化這部分的代碼
  
  無法訪問的線程 有時候雖然獲取對象鎖沒有問題線程依然有可能進入阻塞狀態在 Java 編程中 IO 就是這類問題最好的例子當線程因為對象內的 IO 調用而阻塞時此對象應當仍能被其他線程訪問該對象通常有責任取消這個阻塞的 IO 操作造成阻塞調用的線程常常會令同步任務失敗如果該對象的其他方法也是同步的當線程被阻塞時此對象也就相當於被冷凍住了其他的線程由於不能獲得對象的鎖就不能給此對象發消息(例如取消 IO 操作)必須確保不在同步代碼中包含那些阻塞調用或確認在一個用同步阻塞代碼的對象中存在非同步方法盡管這種方法需要花費一些注意力來保證結果代碼安全運行但它允許在擁有對象的線程發生阻塞後該對象仍能夠響應其他線程
  
  為不同的線程模型進行設計
  
  判斷是搶占式還是協作式的線程模型取決於虛擬機的實現者並根據各種實現而不同因此Java 開發員必須編寫那些能夠在兩種模型上工作的程序
  
  正如前面所提到的在搶占式模型中線程可以在代碼的任何一個部分的中間被打斷除非那是一個原子操作代碼塊原子操作代碼塊中的代碼段一旦開始執行就要在該線程被換出處理器之前執行完畢在 Java 編程中分配一個小於 位的變量空間是一種原子操作而此外象 double 和 long 這兩個 位數據類型的分配就不是原子的使用鎖來正確同步共享資源的訪問就足以保證一個多線程程序在搶占式模型下正確工作
  
  而在協作式模型中是否能保證線程正常放棄處理器不掠奪其他線程的執行時間則完全取決於程序員調用 yield() 方法能夠將當前的線程從處理器中移出到准備就緒隊列中另一個方法則是調用 sleep() 方法使線程放棄處理器並且在 sleep 方法中指定的時間間隔內睡眠
  
  正如你所想的那樣將這些方法隨意放在代碼的某個地方並不能夠保證正常工作如果線程正擁有一個鎖(因為它在一個同步方法或代碼塊中)則當它調用 yield() 時不能夠釋放這個鎖這就意味著即使這個線程已經被掛起等待這個鎖釋放的其他線程依然不能繼續運行為了緩解這個問題最好不在同步方法中調用 yield 方法將那些需要同步的代碼包在一個同步塊中裡面不含有非同步的方法並且在這些同步代碼塊之外才調用 yield
  
  另外一個解決方法則是調用 wait() 方法使處理器放棄它當前擁有的對象的鎖如果對象在方法級別上使同步的這種方法能夠很好的工作因為它僅僅使用了一個鎖如果它使用 finegrained 鎖則 wait() 將無法放棄這些鎖此外一個因為調用 wait() 方法而阻塞的線程只有當其他線程調用 notifyAll() 時才會被喚醒
  
  線程和AWT/Swing
  
  在那些使用 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 提供了兩個方法來解決這個問題invokeLater() 和 invokeAndWait()為了修改 Swing 狀態只要簡單地調用其中一個方法讓 Runnable 的對象來做這些工作因為 Runnable 對象通常就是它們自身的線程你可能會認為這些對象會作為線程來執行但那樣做其實也是不安全的事實上Swing 會將這些對象放到隊列中並在將來某個時刻執行它的 run 方法這樣才能夠安全修改 Swing 狀態
  
  總結
  
  Java 語言的設計使得多線程對幾乎所有的 Applet 都是必要的特別是IO 和 GUI 編程都需要多線程來為用戶提供完美的體驗如果依照本文所提到的若干基本規則並在開始編程前仔細設計系統包括它對共享資源的訪問等你就可以避免許多常見和難以發覺的線程陷阱


From:http://tw.wingwit.com/Article/program/Java/gj/201311/27408.html
    推薦文章
    Copyright © 2005-2022 電腦知識網 Computer Knowledge   All rights reserved.