Java編程的邏輯 (66) - 理解synchronized

本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營連接http://item.jd.com/12299018.htmlhtml


上節咱們提到了多線程共享內存的兩個問題,一個是競態條件,另外一個是內存可見性,咱們提到,解決這兩個問題的一個方案是使用synchronized關鍵字,本節就來討論這個關鍵字。java

用法git

synchronized能夠用於修飾類的實例方法、靜態方法和代碼塊,咱們分別來看下。github

實例方法編程

上節咱們介紹了一個計數的例子,當多個線程併發執行counter++的時候,因爲該語句不是原子操做,出現了意料以外的結果,這個問題能夠用synchronized解決。swift

咱們來看代碼:緩存

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會有以下報告:

同步容器及其注意事項

同步容器

咱們在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。

(與其餘章節同樣,本節全部代碼位於 https://github.com/swiftma/program-logic)

----------------

未完待續,查看最新文章,敬請關注微信公衆號「老馬說編程」(掃描下方二維碼),從入門到高級,深刻淺出,老馬和你一塊兒探索Java編程及計算機技術的本質。用心原創,保留全部版權。

相關文章
相關標籤/搜索