1 同步
如何同步多個線程對共享資源的訪問是多線程編程中最基本的問題之一。當多個線程並發訪問共享數據時會出現數據處於計算中間狀態或者不一致的問題,從而影響到程序的正確運行。我們通常把這種情況叫做競爭條件(race condition),把並發訪問共享數據的代碼叫做關鍵區域(critical section)。同步就是使得多個線程順序進入關鍵區域從而避免競爭條件的發生。
1.1 Synchronized關鍵字
Synchronized是Java多線程編程中最常用的關鍵字。所有的Java 對象都有自己唯一的隱式同步鎖。該鎖只能同時被一個線程獲得,其他試圖獲得該鎖的線程都會被阻塞在對象的等待隊列中直到獲得該鎖的線程釋放鎖才能繼續工作。Synchronized關鍵字通常有兩種用法。當Synchronized關鍵字用於類方法定義中時,表示所有調用該方法的線程都必須獲得當前對象的鎖。這種方式比較簡單,但是同步的粒度比較大,當一個線程要執行某個對象的同步方法的時候,必須同時沒有任何其他線程在執行該對象的任一同步方法。此外,同步方法中的所有代碼均在同步塊中,獲得鎖的線程必須在執行完所有的代碼離開該方法後才會釋放鎖,這些代碼中可能只有一部分涉及到對共享資源(例如成員變量)的訪問需要同步,其余則不需要,那麼這樣粗粒度的同步顯然增加了其他線程的等待時間。Synchronized的另一種 用法允許作用在某個對象上,並且只同步一段代碼而不是整個方法。
synchronized (object) {
// 需要同步的代碼
}
這裡synchronized所作用的對象可以是類的某個成員變量,也可以是這個類對象(用this表示)。這種用法使得程序員可以根據需要同步不同的成員變量,而不總是當前類對象,提高了靈活性。
值得一提的是,並不是只有對象才有鎖,類本身也有自己的鎖,這使得static方法同樣可以用synchronized來修飾。訪問同步static方法的線程需要獲得類的同步鎖才能繼續執行。
1.2 Volatile關鍵字
在Java內存模型中每個線程擁有自己的本地存儲(例如寄存器),並且允許線程擁有變量值的拷貝。這使得本來不需要同步的一些原子操作,例如boolean成員變量存儲和讀取也變得不安全。設想我們有個叫做done的boolean成員變量和一個當done為true時才會停止的循環,該循環由後台線程執行,另一個UI線程等待用戶輸入,用戶按下某個按鈕以後會把done設成true從而終止循環。由於UI線程自己本地擁有done的拷貝,用戶在按下按鈕時只是把自己本地的done設成了true而沒有及時更新主內存中的done,所以後台線程由於看不到done的改變而不會終止。即使主內存中的done變化了,後台線程也會因為自己本地的變量值沒有及時更新而沒有察覺到done的變化。解決這一問題的方法之一是為done提供synchronized的setter和getter方法,這是因為獲得同步鎖會迫使所有變量的值從臨時存儲(寄存器)寫會主內存。除此之外,Java提供了一個解決這個問題更為優雅的方法:Volatile關鍵字。每次使用volatile變量,JVM都會保證從主內存中讀取它的值;同樣每次修改volatile變量,JVM都會把值寫回到主內存中。
Volatile適用的場景比較嚴格,必須很清楚地看到volatile只是告訴JVM對於該變量的讀寫必須每次都在主內存中進行而禁止使用臨時的拷貝來優化,它只是出於JVM特殊的內存模型的需要,並沒有同步的功能。因此只有對volatile變量進行的原子操作(讀取和賦值)才是線程安全的,像自增++自減--這樣包含多個命令的操作仍然需要其它的同步措施。
另一個需要注意的的地方是當用volatile修飾數組的時候,它只是說數組的引用是volatile的,而數組中的元素還是和普通變量一樣,可能被JVM優化,我們無法為數組中的元素加上volatile修飾。解決上述問題的方法是使用Atomic變量。作為使用volatile修飾數組的一個例子,可以參考java.util.concurrent.CopyOnWriteArrayList。它的add操作是通過復制原來的數組並把新元素添加到新數組末尾然後再把內部數組引用變量指向新數組來實現的,因此數組變量經常會被修改,需要使用volatile。
1.3 顯式鎖Lock
盡管synchronized關鍵字可以解決大多數同步問題,J2SE5.0還是引入了Lock接口。相比使用synchronized關鍵字獲取對象隱式的同步鎖,我們稱Lock為顯式鎖。使用顯式鎖的一個顯而易見的好處是它不再屬於某個對象,從而可以在多個對象之間共享它。Lock接口有lock()和unlock()兩個方法,使用它們和使用synchronized關鍵字類似,在進入需要同步的代碼之前調用lock,在離開同步代碼塊時調用unlock。通常unlock會被放在finally中以保證即使同步代碼塊中有異常發生,鎖仍然可以被釋放。
和使用synchronized關鍵字和lock()方法總是把未能獲得鎖的線程阻塞不同,Lock接口還提供了非阻塞的tryLock()方法。調用tryLock方法的線程如果未能獲得鎖會立刻返回false,線程可以繼續執行其他代碼而避免等待,這為程序員提供了更多自由。
Lock接口還提供了一個newCondition () 方法,它返回一個Condition對象。Condition對象的作用和Object用於線程通知的wait-notify機制相同。
1.4 信號量Semaphore
有時候我們有多個相同的共享資源可以同時被多個線程使用。我們希望在鎖的基礎上加上一個計數器,根據資源的個數來初始化這個計數器,每次成功的lock操作都會使計數器的值減去1,只要計數器的值不為零就表示還有資源可以使用,lock操作就能成功。每次unlock操作都會給這個計數器加1。只有當計數器的值為0的時候lock操作才會阻塞當前線程。這就是Java中的信號量Semaphore。
Semaphore類提供的方法和Lock接口非常類似,當把信號量的資源個數設置成1時,信號量就退化為普通的鎖。
1.5 讀寫鎖ReadWriteLock
對共享資源的訪問通常可以分為讀取和寫入。在有些應用場景中讀取可能需要花費較長時間,我們需要使用互斥鎖來阻止並發的寫入操作以保證數據的一致性。但是對於並發的讀取線程其實並不需要使用同步。事實上只有使數據發生變化的操作才需要同步,我們希望有一種方法可以把讀取和寫入區分開來,讀取和寫入的操作之間是互斥的,但是多個讀取操作可以同時進行,這樣可以有效提高讀取密集型程序的性能。J2SE5.0提供了ReadWriteLock接口並提供了實現該接口的ReentrantReadWriteLock類:
public interface ReadWriteLock {
Lock readLock();
Lock writeLock();
}
從接口方法中不難看出讀寫鎖中包含讀鎖和寫鎖。實現類ReentrantReadWriteLock為我們提供了更多便捷的方法來使用讀寫鎖,例如isWriteLocked可以用來檢測是否被寫鎖定。
2 線程通知
除了同步鎖,Java Object還有兩個可用於線程間通知的同步方法wait和notify。調用對象wait方法的線程會被阻塞在該對象的等待隊列中直到其他線程調用notify方法來喚醒它。每次notify調用只能喚醒一個在等待隊列中的線程,notifyAll方法可以喚醒所有在該對象等待隊列中的線程。