淺談Java線程安全
- - 2019-04-25 17:37:28
線程安全
Java中的線程安全
按照線程安全的安全程序由強至弱來排序,我們可以將Java語言中各種操作共享的數據分為以下五類。
1.1 不可變
在Java語言里面,不可變(Immutable)的對象一定是線程安全的,無論是對象的方法實現還是方法的調用者,都不需要再進行任何的線程安全保障措施。
如果共享數據是一個基本數據類型,那么只要在定義時使用final關鍵字修飾它就可以保證它是不可變的。還java.lang.String類的對象。
1.2 絕對線程安全
絕對的線程安全完全滿足Brian Goetz給出的線程安全的定義,這個定義其實是很嚴格的,一個類要達到“不管運行時環境如何,調用者都不需要任何額外的同步措施”通常需要付出很大的,甚至是不切實際的代價。
在Java API中標注自己是線程安全的類,大多數都不是絕對的線程安全。
1.3 相對線程安全
相對的線程安全就是我們通常意義上所講的線程安全,它需要保證對這個對象單獨的操作是線程安全的,我們在調用的時候不需要做額外的保障措施,但是對於一些特定順序的連續調用,就可能需要在調用端使用額外的同步手段來保證調用的正確性。
1.4 線程兼容
線程兼容是指對象本身並不是線程安全的,但是可能通過在調用端正確地使用同步手段來保證對象在並發環境中安全地使用,我們平常說一個類不是線程安全的,絕大多數指的都是這種情況。
1.5 線程對立
線程對立是指不管調用端是否采取了同步措施,都無法在多線程環境中並發使用的代碼。由於Java語言天生就具備多線程特性,線程對立這種排斥多線程的代碼是很少出現的,而且通常都是有害的,應當盡量避免。
線程安全的實現方法
2.1 不可變
不可變(Immutable)的對象一定是線程安全的,不需要再采取任何的線程安全保障措施。只要一個不可變的對象被正確地構建出來,永遠也不會看到它在多個線程之中處於不一致的狀態。多線程環境下,應當盡量使對象成為不可變,來滿足線程安全。
不可變的類型:
1) final 關鍵字修飾的基本數據類型
2) String
3) 枚舉類型
4) Number 部分子類,如 Long 和 Double 等數值包裝類型,BigInteger 和 BigDecimal 等大數據類型。但同為 Number 的原子類 AtomicInteger 和 AtomicLong 則是可變的。
對於集合類型,可以使用 Collections.unmodifiableXXX() 方法來獲取一個不可變的集合。
| publicclass ImmutableExample { publicstaticvoid main(String[] args) { Map<String,Integer> map = new HashMap<>(); Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map); unmodifiableMap.put("Mr_Zhangxd", 1); } } |
| Exception in thread "main" java.lang.UnsupportedOperationException at java.util.Collections$UnmodifiableMap.put(Unknown Source) at org.zxd.com.ImmutableExample.main(ImmutableExample.java:11) |
Collections.unmodifiableXXX() 先對原始的集合進行拷貝,需要對集合進行修改的方法都直接拋出異常。
| public V put(K key,V value) { thrownew UnsupportedOperationException(); } |
2.2 互斥同步
| 互斥同步(Mutual Exclusion & Synchroniztion)是最常見的一種並發正確性保障手段。 |
| 同步 - 指在多個線程並發訪問共享數據時,保證共享數據在同一個時刻只被一條(或者是一些,使用信號量的時候)線程使用。 |
| 互斥 - 是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和信號量(Semaphore)都是主要的互斥實現方式。 |
| 互斥是因,同步是果,互斥是方法,同步是目的。 |
在Java里面,最基本的互斥同步手段就是synchronized關鍵字,synchronized關鍵字經過編譯之后,會在同步塊的前后分別形成monitorenter和monitorexit這兩個字節碼指令,這兩個字節碼都需要一個reference類型的參數來指明要鎖定和解鎖的對象。如果Java程序中的synchronized明確指定了對象參數,那就是這個對象的reference;如果沒有明確指定,那就根據synchronized修飾的是實例方法還是類方法,卻取對應的對象實例或Class對象來作為鎖對象。
根據虛擬機規范的要求,在執行monitorenter指令時,首先要去嘗試獲取對象的鎖。如果這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應的,在執行monitorexit指令時會將鎖計數器減1,當計數器為0時,鎖就被釋放了。
如果獲取對象鎖失敗了,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。
在虛擬機規范對monitorenter和monitoreexit的行為描述中,有兩點是需要特別注意的。
-
- synchronized同步塊對同一條線程來說是可重入的,不會出現自己把自己鎖死的問題。
- 同步塊在已進入的線程執行完之前,會阻塞后面其他線程的進入。
除了synchronized之外,我們還可以使用java.util.concurrent名中的重入鎖(ReentrantLock)來實現同步,在基本用法上,ReentrantLock與synchronized很相似,他們都具備一樣的線程重入特性,只是代碼寫法上有點區別。不過ReentrantLock比synchronized增加了一些高級功能,主要有以下三項:
-
- 等待可中斷 - 當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
- 公平鎖 - 多個線程在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的線程都有機會獲得鎖。synchronized中的鎖是非公平的,ReentrantLock默認情況下也是非公平的,但可以通過帶布爾值的構造函數要求使用公平鎖。
- 鎖綁定多個條件 - 一個ReentrantLock對象可以同時綁定多個Condition對象,而在synchronized中,鎖對象的wait()和notify()或notifyAll()方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外地添加一個鎖,而ReentrantLock則無須這樣做,只需要多次調用newCondition()方法即可。
單核處理器下兩種鎖的吞量對比圖

JDK1.5、雙Xeon處理器下兩種鎖的吞吐量對比

從上面兩個圖可以看出,多線程環境下synchronized的吞吐量下降得非常嚴重,而ReentrantLock則能基本保持在同一個比較穩定的水平上。
總結 - 互斥同步最主要的問題就是進行線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱為阻塞同步(Blocking Synchronization)。它屬於一種悲觀的並發策略,總是認為只要不去做正確的同步措施(加鎖),那就肯定會出現問題,無論共享數據是否真的會出現競爭,它都要進行加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等操作。
2.3 非阻塞同步
互斥同步最主要的問題就是線程阻塞和喚醒所帶來的性能問題,因此這種同步也稱為阻塞同步。
互斥同步屬於一種悲觀的並發策略,總是認為只要不去做正確的同步措施,那就肯定會出現問題。無論共享數據是否真的會出現競爭,它都要進行加鎖(這里討論的是概念模型,實際上虛擬機會優化掉很大一部分不必要的加鎖)、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要喚醒等操作。
1、CAS
隨着硬件指令集的發展,我們可以使用基於沖突檢測的樂觀並發策略:先進行操作,如果沒有其它線程爭用共享數據,那操作就成功了,否則采取補償措施(不斷地重試,直到成功為止)。這種樂觀的並發策略的許多實現都不需要將線程阻塞,因此這種同步操作稱為非阻塞同步。
樂觀鎖需要操作和沖突檢測這兩個步驟具備原子性,這里就不能再使用互斥同步來保證了,只能靠硬件來完成。硬件支持的原子性操作最典型的是:比較並交換(Compare-and-Swap,CAS)。CAS 指令需要有 3 個操作數,分別是內存地址 V、舊的預期值 A 和新值 B。當執行操作時,只有當 V 的值等於 A,才將 V 的值更新為 B。
2、AtomicInteger
J.U.C 包里面的整數原子類 AtomicInteger 的方法調用了 Unsafe 類的 CAS 操作。
以下代碼使用了 AtomicInteger 執行了自增的操作。
| private AtomicInteger cnt = new AtomicInteger(); publicvoid add() { cnt.incrementAndGet(); } |
以下代碼是 incrementAndGet() 的源碼,它調用了 Unsafe 的 getAndAddInt() 。
| publicfinalint incrementAndGet() { return unsafe.getAndAddInt(this,valueOffset,1)+1; } |
以下代碼是 getAndAddInt() 源碼,var1 指示對象內存地址,var2 指示該字段相對對象內存地址的偏移,var4 指示操作需要加的數值,這里為 1。通過 getIntVolatile(var1, var2) 得到舊的預期值,通過調用 compareAndSwapInt() 來進行 CAS 比較,如果該字段內存地址中的值等於 var5,那么就更新內存地址為 var1+var2 的變量為 var5+var4。
可以看到 getAndAddInt() 在一個循環中進行,發生沖突的做法是不斷的進行重試。
| publicfinalint getAndAddInt(Object var1,longvar2,intvar4) { intvar5; do { var5 = this.getIntVolatile(var1, var2); }while(!this.compareAndSwapInt(var1,var2,var5,var5+var4)) { return var5; } } |
3、ABA
如果一個變量初次讀取的時候是 A 值,它的值被改成了 B,后來又被改回為 A,那 CAS 操作就會誤認為它從來沒有被改變過。
J.U.C 包提供了一個帶有標記的原子引用類 AtomicStampedReference 來解決這個問題,它可以通過控制變量值的版本來保證 CAS 的正確性。大部分情況下 ABA 問題不會影響程序並發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。
2.4 無同步方案
-
- 棧封閉
多個線程訪問同一個方法的局部變量時,不會出現線程安全問題,因為局部變量存儲在虛擬機棧中,屬於線程私有的。
| publicclass StackClosedExample { publicvoid Add() { intcnt = 0; for(inti = 0;i < 100;i++) { cnt ++; } System.out.println(cnt); } } |
| publicstaticvoid main(String[] args) { // TODO Auto-generated method stub StackClosedExample example = new StackClosedExample(); ExecutorService executorService = Executors.newCachedThreadPool(); executorService.execute(() -> example.Add()); executorService.execute(() -> example.Add()); executorService.shutdown(); } |
| 運行結果:100 100 |
2、線程本地存儲(Thread Local Storage)
如果一段代碼中所需要的數據必須與其他代碼共享,那就看看這些共享數據的代碼是否能保證在同一個線程中執行。如果能保證,我們就可以把共享數據的可見范圍限制在同一個線程之內,這樣,無須同步也能保證線程之間不出現數據爭用的問題。
符合這種特點的應用並不少見,大部分使用消費隊列的架構模式(如“生產者-消費者”模式)都會將產品的消費過程盡量在一個線程中消費完。其中最重要的一個應用實例就是經典 Web 交互模型中的“一個請求對應一個服務器線程”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用線程本地存儲來解決線程安全問題。
可以使用 java.lang.ThreadLocal 類來實現線程本地存儲功能。
對於以下代碼,thread1 中設置 threadLocal 為 1,而 thread2 設置 threadLocal 為 2。過了一段時間之后,thread1 讀取 threadLocal 依然是 1,不受 thread2 的影響。
| publicclass ThreadLocalExample { publicstaticvoid main(String[] args) { // TODO Auto-generated method stub ThreadLocal threadlocal = new ThreadLocal(); Thread thread1 = new Thread(()->{ threadlocal.set(1); try { Thread.sleep(1000); }catch(InterruptedException e) { e.printStackTrace(); } System.out.println(threadlocal.get()); threadlocal.remove(); }); Thread thread2 = new Thread(() ->{ threadlocal.set(2); threadlocal.remove(); }); thread1.start(); thread2.start(); } } |
| 運行結果:1 |
為了理解 ThreadLocal,先看以下代碼:
| public static void main(String[] args) { // TODO Auto-generated method stub ThreadLocal threadlocal1 = new ThreadLocal(); ThreadLocal threadlocal2 = new ThreadLocal(); Thread thread1 = new Thread(()-> { threadlocal1.set(1); threadlocal2.set(1); }); Thread thread2 = new Thread(()->{ threadlocal1.set(2); threadlocal2.set(2); }); thread1.start(); thread2.start(); } } |
它所對應的底層結構圖為:

每個 Thread 都有一個 ThreadLocal.ThreadLocalMap 對象。
| ThreadLocal.ThreadLocalMapthreadlocals = null; |
當調用一個 ThreadLocal 的 set(T value) 方法時,先得到當前線程的 ThreadLocalMap 對象,然后將 ThreadLocal->value 鍵值對插入到該 Map 中。
| publicvoid set(X value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if(map != null) { map.set(this,value); }else { createMap(t,value); } } |
get() 方法類似。
| public X get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if(map != null) { ThreadLocalMap.Entry e = map.getEntry(this); if(e != null) { X result = (X)e.value; return result; } } return setInitialValue(); } |
ThreadLocal 從理論上講並不是用來解決多線程並發問題的,因為根本不存在多線程競爭。
在一些場景 (尤其是使用線程池) 下,由於 ThreadLocal.ThreadLocalMap 的底層數據結構導致 ThreadLocal 有內存泄漏的情況,應該盡可能在每次使用 ThreadLocal 后手動調用 remove(),以避免出現 ThreadLocal 經典的內存泄漏甚至是造成自身業務混亂的風險。
3、可重入代碼(Reentrant Code)
這種代碼也叫做純代碼(Pure Code),可以在代碼執行的任何時刻中斷它,轉而去執行另外一段代碼(包括遞歸調用它本身),而在控制權返回后,原來的程序不會出現任何錯誤。
可重入代碼有一些共同的特征,例如不依賴存儲在堆上的數據和公用的系統資源、用到的狀態量都由參數中傳入、不調用非可重入的方法等。
