計算機程式的思維邏輯 (66) - 理解synchronized

swiftma發表於2017-02-16

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (66) - 理解synchronized

上節我們提到了多執行緒共享記憶體的兩個問題,一個是競態條件,另一個是記憶體可見性,我們提到,解決這兩個問題的一個方案是使用synchronized關鍵字,本節就來討論這個關鍵字。

用法

synchronized可以用於修飾類的例項方法、靜態方法和程式碼塊,我們分別來看下。

例項方法

上節我們介紹了一個計數的例子,當多個執行緒併發執行counter++的時候,由於該語句不是原子操作,出現了意料之外的結果,這個問題可以用synchronized解決。

我們來看程式碼:

public class Counter {
    private int count;

    public synchronized void incr(){
        count ++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}
複製程式碼

Counter是一個簡單的計數器類,incr方法和getCount方法都加了synchronized修飾。加了synchronized後,方法內的程式碼就變成了原子操作,當多個執行緒併發更新同一個Counter物件的時候,也不會出現問題,我們看使用的程式碼:

public class CounterThread extends Thread {
    Counter counter;

    public CounterThread(Counter counter) {
        this.counter = counter;
    }

    @Override
    public void run() {
        try {
            Thread.sleep((int) (Math.random() * 10));
        } catch (InterruptedException e) {
        }
        counter.incr();
    }

    public static void main(String[] args) throws InterruptedException {
        int num = 100;
        Counter counter = new Counter();
        Thread[] threads = new Thread[num];
        for (int i = 0; i < num; i++) {
            threads[i] = new CounterThread(counter);
            threads[i].start();
        }
        for (int i = 0; i < num; i++) {
            threads[i].join();
        }
        System.out.println(counter.getCount());
    }
}
複製程式碼

與上節類似,我們建立了100個執行緒,傳遞了相同的counter物件,每個執行緒主要就是呼叫Counter的incr方法,main執行緒等待子執行緒結束後輸出counter的值,這次,不論執行多少次,結果都是正確的100。

這裡,synchronized到底做了什麼呢?看上去,synchronized使得同時只能有一個執行緒執行例項方法,但這個理解是不確切的。多個執行緒是可以同時執行同一個synchronized例項方法的,只要它們訪問的物件是不同的,比如說:

Counter counter1 = new Counter();
Counter counter2 = new Counter();
Thread t1 = new CounterThread(counter1);
Thread t2 = new CounterThread(counter2);
t1.start();
t2.start();
複製程式碼

這裡,t1和t2兩個執行緒是可以同時執行Counter的incr方法的,因為它們訪問的是不同的Counter物件,一個是counter1,另一個是counter2。

所以,synchronized例項方法實際保護的是同一個物件的方法呼叫,確保同時只能有一個執行緒執行。再具體來說,synchronized例項方法保護的是當前例項物件,即this,this物件有一個鎖和一個等待佇列,鎖只能被一個執行緒持有,其他試圖獲得同樣鎖的執行緒需要等待,執行synchronized例項方法的過程大概如下:

  1. 嘗試獲得鎖,如果能夠獲得鎖,繼續下一步,否則加入等待佇列,阻塞並等待喚醒
  2. 執行例項方法體程式碼
  3. 釋放鎖,如果等待佇列上有等待的執行緒,從中取一個並喚醒,如果有多個等待的執行緒,喚醒哪一個是不一定的,不保證公平性

synchronized的實際執行過程比這要複雜的多,而且Java虛擬機器採用了多種優化方式以提高效能,但從概念上,我們可以這麼簡單理解。

當前執行緒不能獲得鎖的時候,它會加入等待佇列等待,執行緒的狀態會變為BLOCKED。

我們再強調下,synchronized保護的是物件而非程式碼,只要訪問的是同一個物件的synchronized方法,即使是不同的程式碼,也會被同步順序訪問,比如,對於Counter中的兩個例項方法getCount和incr,對同一個Counter物件,一個執行緒執行getCount,另一個執行incr,它們是不能同時執行的,會被synchronized同步順序執行。

此外,需要說明的,synchronized方法不能防止非synchronized方法被同時執行,比如,如果給Counter類增加一個非synchronized方法:

public void decr(){
    count --;
}
複製程式碼

則該方法可以和synchronized的incr方法同時執行,這通常會出現非期望的結果,所以,一般在保護變數時,需要在所有訪問該變數的方法上加上synchronized。

靜態方法

synchronized同樣可以用於靜態方法,比如:

public class StaticCounter {
    private static int count = 0;

    public static synchronized void incr() {
        count++;
    }

    public static synchronized int getCount() {
        return count;
    }
}
複製程式碼

前面我們說,synchronized保護的是物件,對例項方法,保護的是當前例項物件this,對靜態方法,保護的是哪個物件呢?是類物件,這裡是StaticCounter.class,實際上,每個物件都有一個鎖和一個等待佇列,類物件也不例外。

synchronized靜態方法和synchronized例項方法保護的是不同的物件,不同的兩個執行緒,可以同時,一個執行synchronized靜態方法,另一個執行synchronized例項方法。

程式碼塊

除了用於修飾方法外,synchronized還可以用於包裝程式碼塊,比如對於前面的Counter類,等價的程式碼可以為:

public class Counter {
    private int count;

    public void incr(){
        synchronized(this){
            count ++;    
        }
    }
    
    public int getCount() {
        synchronized(this){
            return count;
        }
    }
}
複製程式碼

synchronized括號裡面的就是保護的物件,對於例項方法,就是this,{}裡面是同步執行的程式碼。

對於前面的StaticCounter類,等價的程式碼為:

public class StaticCounter {
    private static int count = 0;

    public static void incr() {
        synchronized(StaticCounter.class){
            count++;    
        }    
    }

    public static int getCount() {
        synchronized(StaticCounter.class){
            return count;    
        }
    }
}
複製程式碼

synchronized同步的物件可以是任意物件,任意物件都有一個鎖和等待佇列,或者說,任何物件都可以作為鎖物件。比如說,Counter的等價程式碼還可以為:

public class Counter {
    private int count;
    private Object lock = new Object();
    
    public void incr(){
        synchronized(lock){
            count ++;    
        }
    }
    
    public int getCount() {
        synchronized(lock){
            return count;
        }
    }
} 
複製程式碼

理解synchronized

介紹了synchronized的基本用法和原理,我們再從下面幾個角度來進一步理解一下synchronized:

  • 可重入性
  • 記憶體可見性
  • 死鎖

可重入性

synchronized有一個重要的特徵,它是可重入的,也就是說,對同一個執行執行緒,它在獲得了鎖之後,在呼叫其他需要同樣鎖的程式碼時,可以直接呼叫,比如說,在一個synchronized例項方法內,可以直接呼叫其他synchronized例項方法。可重入是一個非常自然的屬性,應該是很容易理解的,之所以強調,是因為並不是所有鎖都是可重入的(後續章節介紹)。

可重入是通過記錄鎖的持有執行緒和持有數量來實現的,當呼叫被synchronized保護的程式碼時,檢查物件是否已被鎖,如果是,再檢查是否被當前執行緒鎖定,如果是,增加持有數量,如果不是被當前執行緒鎖定,才加入等待佇列,當釋放鎖時,減少持有數量,當數量變為0時才釋放整個鎖。

記憶體可見性

對於複雜一些的操作,synchronized可以實現原子操作,避免出現競態條件,但對於明顯的本來就是原子的操作方法,也需要加synchronized嗎?比如說,對於下面的開關類Switcher,它只有一個boolean變數on和對應的setter/getter方法:

public class Switcher {
    private boolean on;

    public boolean isOn() {
        return on;
    }

    public void setOn(boolean on) {
        this.on = on;
    }
}
複製程式碼

當多執行緒同時訪問同一個Switcher物件時,會有問題嗎?沒有競態條件問題,但正如上節所說,有記憶體可見性問題,而加上synchronized可以解決這個問題。

synchronized除了保證原子操作外,它還有一個重要的作用,就是保證記憶體可見性,在釋放鎖時,所有寫入都會寫回記憶體,而獲得鎖後,都會從記憶體中讀最新資料。

不過,如果只是為了保證記憶體可見性,使用synchronzied的成本有點高,有一個更輕量級的方式,那就是給變數加修飾符volatile,如下所示:

public class Switcher {
    private volatile boolean on;

    public boolean isOn() {
        return on;
    }

    public void setOn(boolean on) {
        this.on = on;
    }
}
複製程式碼

加了volatile之後,Java會在操作對應變數時插入特殊的指令,保證讀寫到記憶體最新值,而非快取的值。

死鎖

使用synchronized或者其他鎖,要注意死鎖,所謂死鎖就是類似這種現象,比如, 有a, b兩個執行緒,a持有鎖A,在等待鎖B,而b持有鎖B,在等待鎖A,a,b陷入了互相等待,最後誰都執行不下去。示例程式碼如下所示:

public class DeadLockDemo {
    private static Object lockA = new Object();
    private static Object lockB = new Object();

    private static void startThreadA() {
        Thread aThread = new Thread() {

            @Override
            public void run() {
                synchronized (lockA) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                    }
                    synchronized (lockB) {
                    }
                }
            }
        };
        aThread.start();
    }

    private static void startThreadB() {
        Thread bThread = new Thread() {
            @Override
            public void run() {
                synchronized (lockB) {
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                    }
                    synchronized (lockA) {
                    }
                }
            }
        };
        bThread.start();
    }

    public static void main(String[] args) {
        startThreadA();
        startThreadB();
    }
}
複製程式碼

執行後aThread和bThread陷入了相互等待。怎麼解決呢?首先,應該儘量避免在持有一個鎖的同時去申請另一個鎖,如果確實需要多個鎖,所有程式碼都應該按照相同的順序去申請鎖,比如,對於上面的例子,可以約定都先申請lockA,再申請lockB。

不過,在複雜的專案程式碼中,這種約定可能難以做到。還有一種方法是使用後續章節介紹的顯式鎖介面Lock,它支援嘗試獲取鎖(tryLock)和帶時間限制的獲取鎖方法,使用這些方法可以在獲取不到鎖的時候釋放已經持有的鎖,然後再次嘗試獲取鎖或乾脆放棄,以避免死鎖。

如果還是出現了死鎖,怎麼辦呢?Java不會主動處理,不過,藉助一些工具,我們可以發現執行中的死鎖,比如,Java自帶的jstack命令會報告發現的死鎖,對於上面的程式,在我的電腦上,jstack會有如下報告:

計算機程式的思維邏輯 (66) - 理解synchronized

同步容器及其注意事項

同步容器

我們在54節介紹過Collection的一些方法,它們可以返回執行緒安全的同步容器,比如:

public static <T> Collection<T> synchronizedCollection(Collection<T> c)
public static <T> List<T> synchronizedList(List<T> list)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
複製程式碼

它們是給所有容器方法都加上synchronized來實現安全的,比如SynchronizedCollection,其部分程式碼如下所示:

static class SynchronizedCollection<E> implements Collection<E> {
    final Collection<E> c;  // Backing Collection
    final Object mutex;     // Object on which to synchronize

    SynchronizedCollection(Collection<E> c) {
        if (c==null)
            throw new NullPointerException();
        this.c = c;
        mutex = this;
    }
    public int size() {
        synchronized (mutex) {return c.size();}
    }
    public boolean add(E e) {
        synchronized (mutex) {return c.add(e);}
    }
    public boolean remove(Object o) {
        synchronized (mutex) {return c.remove(o);}
    }
    //....
}
複製程式碼

這裡執行緒安全針對的是容器物件,指的是當多個執行緒併發訪問同一個容器物件時,不需要額外的同步操作,也不會出現錯誤的結果。

加了synchronized,所有方法呼叫變成了原子操作,客戶端在呼叫時,是不是就絕對安全了呢?不是的,至少有以下情況需要注意:

  • 複合操作,比如先檢查再更新
  • 偽同步
  • 迭代

複合操作

先來看複合操作,我們看段程式碼:

public class EnhancedMap <K, V> {
    Map<K, V> map;
    
    public EnhancedMap(Map<K,V> map){
        this.map = Collections.synchronizedMap(map);
    }
    
    public V putIfAbsent(K key, V value){
         V old = map.get(key);
         if(old!=null){
             return old;
         }
         map.put(key, value);
         return null;
     }
    
    public void put(K key, V value){
        map.put(key, value);
    }
    
    //... 其他程式碼
}
複製程式碼

EnhancedMap是一個裝飾類,接受一個Map物件,呼叫synchronizedMap轉換為了同步容器物件map,增加了一個方法putIfAbsent,該方法只有在原Map中沒有對應鍵的時候才新增。

map的每個方法都是安全的,但這個複合方法putIfAbsent是安全的嗎?顯然是否定的,這是一個檢查然後再更新的複合操作,在多執行緒的情況下,可能有多個執行緒都執行完了檢查這一步,都發現Map中沒有對應的鍵,然後就會都呼叫put,而這就破壞了putIfAbsent方法期望保持的語義。

偽同步

那給該方法加上synchronized就能實現安全嗎?如下所示:

public synchronized V putIfAbsent(K key, V value){
    V old = map.get(key);
    if(old!=null){
        return old;
    }
    map.put(key, value);
    return null;
}
複製程式碼

答案是否定的!為什麼呢?同步錯物件了。putIfAbsent同步使用的是EnhancedMap物件,而其他方法(如程式碼中的put方法)使用的是Collections.synchronizedMap返回的物件map,兩者是不同的物件。要解決這個問題,所有方法必須使用相同的鎖,可以使用EnhancedMap的物件鎖,也可以使用map。使用EnhancedMap物件作為鎖,則EnhancedMap中的所有方法都需要加上synchronized。使用map作為鎖,putIfAbsent方法可以改為:

public V putIfAbsent(K key, V value){
    synchronized(map){
        V old = map.get(key);
         if(old!=null){
             return old;
         }
         map.put(key, value);
         return null;    
    }
}
複製程式碼

迭代

對於同步容器物件,雖然單個操作是安全的,但迭代並不是。我們看個例子,建立一個同步List物件,一個執行緒修改List,另一個遍歷,看看會發生什麼,程式碼為:

private static void startModifyThread(final List<String> list) {
    Thread modifyThread = new Thread(new Runnable() {
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                list.add("item " + i);
                try {
                    Thread.sleep((int) (Math.random() * 10));
                } catch (InterruptedException e) {
                }
            }
        }
    });
    modifyThread.start();
}

private static void startIteratorThread(final List<String> list) {
    Thread iteratorThread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                for (String str : list) {
                }
            }
        }
    });
    iteratorThread.start();
}

public static void main(String[] args) {
    final List<String> list = Collections
            .synchronizedList(new ArrayList<String>());
    startIteratorThread(list);
    startModifyThread(list);
}
複製程式碼

執行該程式,程式丟擲併發修改異常:

Exception in thread "Thread-0" java.util.ConcurrentModificationException
    at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:859)
    at java.util.ArrayList$Itr.next(ArrayList.java:831)
複製程式碼

我們之前介紹過這個異常,如果在遍歷的同時容器發生了結構性變化,就會丟擲該異常,同步容器並沒有解決這個問題,如果要避免這個異常,需要在遍歷的時候給整個容器物件加鎖,比如,上面的程式碼,startIteratorThread可以改為:

private static void startIteratorThread(final List<String> list) {
    Thread iteratorThread = new Thread(new Runnable() {
        @Override
        public void run() {
            while (true) {
                synchronized(list){
                    for (String str : list) {
                    }    
                }
            }
        }
    });
    iteratorThread.start();
}
複製程式碼

併發容器

除了以上這些注意事項,同步容器的效能也是比較低的,當併發訪問量比較大的時候效能很差。所幸的是,Java中還有很多專為併發設計的容器類,比如:

  • CopyOnWriteArrayList
  • ConcurrentHashMap
  • ConcurrentLinkedQueue
  • ConcurrentSkipListSet

這些容器類都是執行緒安全的,但都沒有使用synchronized、沒有迭代問題、直接支援一些複合操作、效能也高得多,它們能解決什麼問題?怎麼使用?實現原理是什麼?我們留待後續章節介紹。

小結

本節詳細介紹了synchronized的用法和實現原理,為進一步理解synchronized,介紹了可重入性、記憶體可見性、死鎖等,最後,介紹了同步容器及其注意事項如複合操作、偽同步、迭代異常、併發容器等。

多執行緒之間除了競爭訪問同一個資源外,也經常需要相互協作,怎麼協作呢?下節介紹協作的基本機制wait/notify。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (66) - 理解synchronized

相關文章