本系列文章經補充和完善,已修訂整理成書《Java編程的邏輯》,由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買,京東自營鏈接:http://item.jd.com/12299018.html
上節我們提到了多線程共享內存的兩個問題,一個是競態條件,另一個是內存可見性,我們提到,解決這兩個問題的一個方案是使用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實例方法的過程大概如下:
- 嘗試獲得鎖,如果能夠獲得鎖,繼續下一步,否則加入等待隊列,阻塞並等待喚醒
- 執行實例方法體代碼
- 釋放鎖,如果等待隊列上有等待的線程,從中取一個並喚醒,如果有多個等待的線程,喚醒哪一個是不一定的,不保證公平性
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編程及計算機技術的本質。用心原創,保留所有版權。