Java線程學習筆記之線程同步

線程間共享數據

要使多個線程在一個程序中有用,它們必須有某種方法可以互相通信或共享它們的結果。而讓線程共享其結果的最簡單方法是使用共享變量
線程與進程有許多共同點,不同的是線程與同一進程中的其它線程共享相同的進程上下文,包括內存。這非常便利,但也有重大責任。只要訪問共享變量(靜態或實例字段),線程就可以方便地互相交換數據,但線程還必須確保它們以受控的方式訪問共享變量,以免它們互相干擾對方的更改。
爲了確保可以在線程之間以受控方式共享數據,Java 語言提供了兩個關鍵字:synchronizedvolatile
Synchronized (同步)有兩個重要含義:它確保了一次只有一個線程可以執行代碼的受保護部分(互斥,mutual exclusion 或者說 mutex),而且它確保了一個線程更改的數據對於其它線程是可見的(更改的可見性)。
同步讓我們可以定義必須原子地運行的代碼塊,這樣對於其他線程而言,它們要麼都執行,要麼都不執行。
要說明線程同步問題首先要說明Java線程的兩個特性,可見性有序性。多個線程之間是不能直接傳遞數據交互的,它們之間的交互只能通過共享變量來實現。拿上篇博文中的例子來說明,在多個線程之間共享了Count類的一個對象,這個對象是被創建在主內存(堆內存)中,每個線程都有自己的工作內存(線程棧),工作內存存儲了主內存Count對象的一個副本,當線程操作Count對象時,首先從主內存複製Count對象到工作內存中,然後執行代碼count.count(),改變了num值,最後用工作內存Count刷新主內存Count。當一個對象在多個內存中都存在副本時,如果一個內存修改了共享變量,其它線程也應該能夠看到被修改後的值,此爲可見性。多個線程執行時,CPU對線程的調度是隨機的,我們不知道當前程序被執行到哪步就切換到了下一個線程,一個最經典的例子就是銀行匯款問題,一個銀行賬戶存款100,這時一個人從該賬戶取10元,同時另一個人向該賬戶匯10元,那麼餘額應該還是100。那麼此時可能發生這種情況,A線程負責取款,B線程負責匯款,A從主內存讀到100,B從主內存讀到100,A執行減10操作,並將數據刷新到主內存,這時主內存數據100-10=90,而B內存執行加10操作,並將數據刷新到主內存,最後主內存數據100+10=110,顯然這是一個嚴重的問題,我們要保證A線程和B線程有序執行,先取款後匯款或者先匯款後取款,此爲有序性。本文講述了JDK5.0之前傳統線程的同步方式.
下面同樣用代碼來展示一下線程同步問題。
創建兩個線程,執行同一個對象的輸出方法:

public class TraditionalThreadSynchronized {
    public static void main(String[] args) {
        final Outputter output = new Outputter();
        new Thread() {
            public void run() {
                output.output("zhangsan");
            }
        }.start();      
        new Thread() {
            public void run() {
                output.output("lisi");
            }
        }.start();
    }
}
class Outputter {
    public void output(String name) {
        // TODO 爲了保證對name的輸出不是一個原子操作,這裏逐個輸出name的每個字符
        for(int i = 0; i < name.length(); i++) {
            System.out.print(name.charAt(i));
            // Thread.sleep(10);
        }
    }
}

結果:

zhlainsigsan  

顯然輸出的字符串被打亂了,我們期望的輸出結果是zhangsanlisi,這就是線程同步問題,我們希望output方法被一個線程完整的執行完之後再切換到下一個線程,Java中使用synchronized保證一段代碼在多線程執行時是互斥的,有兩種用法:
1. 使用synchronized將需要互斥的代碼包含起來,並上一把鎖。

    {  
        synchronized (this) {  
            for(int i = 0; i < name.length(); i++) {  
                System.out.print(name.charAt(i));  
            }  
        }  
    }  

每次進入output方法都會創建一個新的lock,這個鎖顯然每個線程都會創建,沒有意義。
2. 將synchronized加在需要互斥的方法上。

    public synchronized void output(String name) {  
        // TODO 線程輸出方法  
        for(int i = 0; i < name.length(); i++) {  
            System.out.print(name.charAt(i));  
        }  
    }  

這種方式就相當於用this鎖住整個方法內的代碼塊。
同步可以讓我們確保線程看到一致的內存視圖。
每個鎖對象(JLS中叫monitor)都有兩個隊列,一個是就緒隊列,一個是阻塞隊列,就緒隊列存儲了將要獲得鎖的線程,阻塞隊列存儲了被阻塞的線程,當一個線程被喚醒(notify)後,纔會進入到就緒隊列,等待CPU的調度,反之,當一個線程被wait後,就會進入阻塞隊列,等待下一次被喚醒,這個涉及到線程間的通信,下一篇博文會說明。看我們的例子,當第一個線程執行輸出方法時,獲得同步鎖,執行輸出方法,恰好此時第二個線程也要執行輸出方法,但發現同步鎖沒有被釋放,第二個線程就會進入就緒隊列,等待鎖被釋放。 一個線程執行互斥代碼過程如下:

    1. 獲得同步鎖;

    2. 清空工作內存;

    3. 從主內存拷貝對象副本到工作內存;

    4. 執行代碼(計算或者輸出等);

    5. 刷新主內存數據;

    6. 釋放同步鎖。

Volatile 比同步更簡單,只適合於控制對基本變量(整數、布爾變量等)的單個實例的訪問。當一個變量被聲明成 volatile,任何對該變量的寫操作都會繞過高速緩存,直接寫入主內存,而任何對該變量的讀取也都繞過高速緩存,直接取自主內存。這表示所有線程在任何時候看到的 volatile 變量值都相同。
Volatile 對於確保每個線程看到最新的變量值非常有用,但有時我們需要保護比較大的代碼片段,如涉及更新多個變量的片段。

同步使用監控器(monitor)或鎖的概念,以協調對特定代碼塊的訪問。

每個 Java 對象都有一個相關的鎖。同一時間只能有一個線程持有 Java 鎖。當線程進入 synchronized 代碼塊時,線程會阻塞並等待,直到鎖可用,當它可用時,就會獲得這個鎖,然後執行代碼塊。當控制退出受保護的代碼塊時,即到達了代碼塊末尾或者拋出了沒有在 synchronized 塊中捕獲的異常時,它就會釋放該鎖。

這樣,每次只有一個線程可以執行受給定監控器保護的代碼塊。從其它線程的角度看,該代碼塊可以看作是原子的,它要麼全部執行,要麼根本不執行。

同步的方法

創建 synchronized 塊的最簡單方法是將方法聲明成 synchronized。這表示在進入方法主體之前,調用者必須獲得鎖:

public class Point {
  public synchronized void setXY(int x, int y) {
    this.x = x;
    this.y = y;
  }
}

對於普通的 synchronized方法,這個鎖是一個對象,將針對它調用方法。對於靜態 synchronized 方法,這個鎖是與 Class 對象相關的監控器,在該對象中聲明瞭方法。

僅僅因爲 setXY() 被聲明成 synchronized 並不表示兩個不同的線程不能同時執行 setXY(),只要它們調用不同的 Point 實例的 setXY() 就可同時執行。對於一個 Point 實例,一次只能有一個線程執行 setXY(),或 Point 的任何其它 synchronized 方法。

同步的塊

synchronized 塊的語法比 synchronized 方法稍微複雜一點,因爲還需要顯式地指定鎖要保護哪個塊。Point 的以下版本等價於前一頁中顯示的版本:

public class Point {
  public void setXY(int x, int y) {
    synchronized (this) {
      this.x = x;
      this.y = y;
    }
  }
}

使用 this 引用作爲鎖很常見,但這並不是必需的。這表示該代碼塊將與這個類中的 synchronized 方法使用同一個鎖。
訪問局部(基於堆棧的)變量從來不需要受到保護,因爲它們只能被自己所屬的線程訪問。

大多數類沒有同步

因爲同步會帶來小小的性能損失,大多數通用類,如 java.util 中的 Collection 類,不在內部使用同步。這表示在沒有附加同步的情況下,不能在多個線程中使用諸如 HashMap 這樣的類。 不過,Collections 類提供了一組便利的用於 List、Map 和 Set 接口的封裝器。您可以用 Collections.synchronizedMap 封裝 Map,它將確保所有對該映射的訪問都被正確同步。

什麼時候必須同步

可見性同步的基本規則是在以下情況中必須同步:

讀取上一次可能是由另一個線程寫入的變量
寫入下一次可能由另一個線程讀取的變量

什麼時候不需要同步

在某些情況中,您不必用同步來將數據從一個線程傳遞到另一個,因爲 JVM 已經隱含地爲您執行同步。這些情況包括:

由靜態初始化器(在靜態字段上或 static{} 塊中的初始化器)初始化數據時
訪問 final 字段時
在創建線程之前創建對象時
線程可以看見它將要處理的對象時

死鎖

只要您擁有多個進程,而且它們要爭用對多個鎖的獨佔訪問,那麼就有可能發生死鎖。如果有一組進程或線程,其中每個都在等待一個只有其它進程或線程纔可以執行的操作,那麼就稱它們被死鎖了。

最常見的死鎖形式是當線程 1 持有對象 A 上的鎖,而且正在等待與 B 上的鎖,而線程 2 持有對象 B 上的鎖,卻正在等待對象 A 上的鎖。這兩個線程永遠都不會獲得第二個鎖,或者釋放第一個鎖。它們只會永遠等待下去。

要避免死鎖,應該確保在獲取多個鎖時,在所有的線程中都以相同的順序獲取鎖。

關於性能

機性地優化一個也許最終根本不會成爲性能問題的代碼路徑 ― 以程序正確性爲代價 ― 是一樁賠本的生意。

同步準則

  • 使代碼塊保持簡短。Synchronized 塊應該簡短 ― 在保證相關數據操作的完整性的同時,儘量簡短。把不隨線程變化的預處理和後處理移出 synchronized 塊。
  • 不要阻塞。 不要在 synchronized 塊或方法中調用可能引起阻塞的方法,如 InputStream.read()。
  • 在持有鎖的時候,不要對其它對象調用方法。這聽起來可能有些極端,但它消除了最常見的死鎖源頭。

其他線程API

Object 類定義了 wait()、notify() 和 notifyAll() 方法。要執行這些方法,必須擁有相關對象的鎖。

Wait() 會讓調用線程休眠,直到用 Thread.interrupt() 中斷它、過了指定的時間、或者另一個線程用 notify() 或 notifyAll() 喚醒它。

當對某個對象調用 notify() 時,如果有任何線程正在通過 wait() 等待該對象,那麼就會喚醒其中一個線程。當對某個對象調用 notifyAll() 時,會喚醒所有正在等待該對象的線程。

這些方法是更復雜的鎖定、排隊和併發性代碼的構件。但是,notify() 和 notifyAll() 的使用很複雜。尤其是,使用 notify() 來代替 notifyAll() 是有風險的。除非您確實知道正在做什麼,否則就使用 notifyAll()。

與其使用 wait() 和 notify() 來編寫您自己的調度程序、線程池、隊列和鎖,倒不如使用 util.concurrent 包

本文爲學習筆記,學習資源主要來自以下出處:
Java線程簡介
線程同步synchronized和volatile

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章