1. 程式人生 > >執行緒安全與鎖優化

執行緒安全與鎖優化

摘自《深入理解 Java 虛擬機器:JVM 高階特性與最佳實踐》(第二版)

        併發處理的廣泛應用是使得 Amdahl 定律代替摩爾定律成為計算機效能發展源動力的根本原因,也是人類 “壓榨” 計算機運算能力的最有力武器。

概述

        在軟體業發展的初期,程式編寫都是以演算法為核心的,程式設計師會把資料和過程分別作為獨立的部分來考慮,資料代表問題空間中的客體,程式程式碼則用於處理這些資料,這種思維方式直接站在計算機的角度去抽象問題和解決問題,稱為面向過程的程式設計思想。與此相對的是,面向物件的程式設計思想是站在現實世界的角度去抽象和解決問題,它把資料和行為都看做是物件的一部分

,這樣可以讓程式設計師能以符合現實世界的思維方式來編寫和組織程式。

        面向過程的程式設計思想極大地提升了現代軟體開發的生產效率和軟體可以達到的規模,但是顯示世界與計算機世界之間不可避免地存在一些差異。例如,人們很難想象現實中的物件在一項工作進行期間,會被不停地中斷和切換,物件的屬性(資料)可能會在中斷期間被修改和變 “髒”,而這些事件在計算機世界中則是很正常的事情。有時候,良好的設計原則不得不向現實做出一些讓步,我們必須讓程式在計算機正確無誤地執行,然後再考慮如何將程式碼組織得更好,讓程式執行得更快。對於這部分的主題 “高效併發” 來將,首先需要保證併發的正確性,然後在此基礎上實現高效。本章先從如何保證併發的正確性和如何實現執行緒安全講起。

執行緒安全

        “執行緒安全” 這個名稱,相信稍有經驗的程式設計師都會聽說過,甚至在程式碼編寫和走查的時候可能還會經常掛在嘴邊,但是如何找到一個不太拗口的概念來定義執行緒安全卻不是一件容易的事情,筆者嘗試在 Google 中搜索它的概念,找到的是類似於 “如果一個物件可以安全地被多個執行緒同時使用,那它就是執行緒安全的” 這樣的定義——並不能說它不正確,但是人們無法從中獲取到任何有用的資訊

        筆者認為《Java Concurrency In Practice》的作者 Brian Goetz 對 “執行緒安全” 有一個比較恰當的定義:“當多個執行緒訪問一個物件時,如果不用考慮這些執行緒在執行時環境下的排程和交替執行,也不需要進行額外的同步,或者在呼叫方進行任何其他的協調操作,呼叫這個物件的行為都可以獲得正確的結果,那這個物件是執行緒安全的

”。

        這個定義比較嚴謹,它要求執行緒安全的程式碼都必須具備一個特徵:程式碼本身封裝了所有必要的正確性保障手段(如互斥同步等),令呼叫者無須關心多執行緒的問題,更無須自己採取任何措施來保證多執行緒的正確呼叫。這點聽起來簡單,但其實並不容易做到,在大多數場景中,我們都會將這個定義弱化一些,如果把 “呼叫這個物件的行為” 限定為 “單次呼叫” 這個定義的其他描述也能夠成立的話,我們就可以稱它是執行緒安全了,為什麼要弱化這個定義,現在暫且放下,稍後再詳細探討。

Java 語言中的執行緒安全

        我們已經有了執行緒安全的一個抽象定義,那接下來就討論一下在 Java 語言中,執行緒安全具體是如何體現的?有哪些操作是執行緒安全的?我們這裡討論的執行緒安全,就限定於多個執行緒之間存在共享資料訪問這個前提,因為如果一段程式碼根本不會與其他執行緒共享資料,那麼從執行緒安全的角度來看,程式是序列執行還是多執行緒執行對它來說是完全沒有區別的。

        為了更加深入地理解執行緒安全,在這裡我們可以不把執行緒安全當做一個非真即假的二元排他選項來看待,按照執行緒安全的 “安全程度” 由強至弱來排序,我們(注:這種劃分方法也是 Brian Goetz 在 IBM developWorkers 上發表的一篇論文中提出的,這裡寫 “我們” 純粹是筆者下筆行文中的語言用法)可以將 Java 語言中各個操作共享的資料分為以下 5 類:不可變絕對執行緒安全相對執行緒安全執行緒相容執行緒對立

1.不可變

        在 Java 語言中(特指 JDK 1.5 以後,即 Java 記憶體模型被修正之後的 Java 語言),不可變(Immutable)的物件一定是執行緒安全的,無論是物件的方法實現還是方法的呼叫者,都不需要採取任何的執行緒安全保障措施,在前面我們談到 final 關鍵字帶來的可見性時曾經提到過這一點,只要一個不可變的物件被正確地構建出來(沒有發生 this 引用逃逸的情況),那其外部的可見狀態永遠也不會改變,永遠也不會看到它在多個執行緒之中處於不一致的狀態。“不可變” 帶來的安全性是最簡單和最純粹的

        Java 語言中,如果共享資料是一個基本資料型別,那麼只要在定義時使用 final 關鍵字修飾它就可以保證它是不可變的。如果共享資料是一個物件,那就需要保證物件的行為不會對其狀態產生任何影響才行,如果讀者還沒有想明白這句話,不妨想一想 java.lang.String 類的物件,它是一個典型的不可變物件,我們呼叫它的 substring()、replace() 和 concat() 這些方法都不會影響它原來的值,只會返回一個新的構造的字串物件。

        保證物件行為不影響自己狀態的途徑有很多種,其中最簡單的就是把物件中帶有狀態的變數都宣告為 final,這樣在建構函式結束之後,它就是不可變的,例如程式碼清單 13-1 中 java.lang.Integer 建構函式所示的,它通過將內部狀態變數 value 定義為 final 來保障狀態不變。

程式碼清單 13-1  JDK 中 Integer 類的建構函式

  /**
     * The value of the {@code Integer}.
     *
     * @serial
     */
    private final int value;

    /**
     * Constructs a newly allocated {@code Integer} object that
     * represents the specified {@code int} value.
     *
     * @param   value   the value to be represented by the
     *                  {@code Integer} object.
     */
    public Integer(int value) {
        this.value = value;
    }

        在 Java API 中符合不可變要求的型別,除了上面提到的 String 之外,常用的還有列舉型別,以及 java.lang.Number 的部分子類,如 Long 和 Double 等數值包裝型別,BigInteger 和 BigDecimal 等大資料型別;但同為 Number 的子型別的原子類 AtomicInteger 和 AtomicLong 則並非不可變的,讀者不妨看看這兩個原子類的原始碼,想一想為什麼。

2.  絕對執行緒安全

        絕對的執行緒安全完全滿足 Brian Goetz 給出的執行緒安全的定義,這個定義其實是很嚴格的,一個類要達到 “不管執行是環境如何,呼叫者都不需要任何額外的同步措施” 通常需要付出很大的,甚至有時候是不切實際的代價。在 Java API 中標註自己是執行緒安全的類,大多數都不是絕對的執行緒安全。我們可以通過 Java API 中一個不是 “絕對執行緒安全” 的執行緒安全類來看看這裡的 “絕對” 是什麼意思。

        如果說 java.util.Vector 是一個執行緒安全的容器,相信所有的 Java 程式設計師對此都不會有異議,因為它的 add()、get() 和 size() 這類方法都是被 synchronized 修飾的,儘管這樣效率很低,但確實是安全的。但是,即時它所有的方法都被修飾成同步,也不意味著呼叫它的時候永遠都不需要同步手段了,請看一下程式碼清單 13-2 中的測試程式碼。

程式碼清單 13-2  對 Vector 執行緒安全的測試

private static Vector<Integer> vector = new Vector<Integer>();

public static void main(String[] args) {
	while (true) {
		for (int i = 0; i < 10; i++) {
			vector.add(i);
		}
		
		Thread removeThread = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < vector.size(); i++) {
					vector.remove(i);
				}
			}
		});
		
		Thread printThread = new Thread(new Runnable() {
			@Override
			public void run() {
				for (int i = 0; i < vector.size(); i++) {
					System.out.println(vector.get(i));
				}
			}
		});
		
		removeThread.start();
		printThread.start();
		
		// 不要同時產生過多的執行緒,否則會導致作業系統假死
		while (Thread.activeCount() > 20);
	}
}

        執行結果如下:

Exception in thread "Thread-10865" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 11
	at java.util.Vector.get(Vector.java:744)
	at cn.mk.day0810.MyTest$2.run(MyTest.java:30)
	at java.lang.Thread.run(Thread.java:745)

        很明顯,儘管這裡使用到的 Vector 的 get()、remove() 和 size() 方法都是同步的,但是在多執行緒的環境中,如果不在方法呼叫端做額外的同步措施的話,使用這段程式碼仍然是不安全的,因為如果另一個執行緒恰好在錯誤的時間裡刪除了一個元素,導致序號 i 已經不再可用的話,再用 i 訪問陣列就會丟擲一個 ArrayIndexOutOfBoundsException。如果要保證這段程式碼能正確執行下去,我們不得不把 removeThread 和 printThread 的定義改成如程式碼清單 13-3 所示的樣子。

程式碼清單 13-3  必須加入同步以保證 Vector 訪問的執行緒安全性

	Thread removeThread = new Thread(new Runnable() {
		@Override
		public void run() {
			synchronized(vector) {
				for (int i = 0; i < vector.size(); i++) {
					vector.remove(i);
				}
			}
		}
	});
	
	Thread printThread = new Thread(new Runnable() {
		@Override
		public void run() {
			synchronized(vector) {
				for (int i = 0; i < vector.size(); i++) {
					System.out.println(vector.get(i));
				}
			}
		}
	});

3. 相對執行緒安全

        相對的執行緒安全就是我們通常意義上所講的執行緒安全,它需要保證對這個物件單獨的操作是執行緒安全,我們在呼叫的時候不需要做額外的保障措施,但是對於一些特定順序的連續呼叫,就可能需要在呼叫端使用額外的同步手段來保證呼叫的正確性。上面程式碼清單 13-2 和程式碼清單 13-3 就是相對執行緒安全的明顯的案例。

        在 Java 語言中,大部分的執行緒安全類都屬於這種型別,例如 Vector、HashTable、Collections 的 synchronizedCollection() 方法包裝的集合等。

4. 執行緒相容

        執行緒相容是指物件本身並不是執行緒安全的,但是可以通過在呼叫端正確地使用同步手段來保證物件在併發環境中可以安全地使用,我們平常說一個類不是執行緒安全的,絕大多數時候指的是這一種情況。Java API 中大部分的類都是屬於執行緒相容的,如與前面的 Vector 和 HashTable 相對應的集合類 ArrayList 和 HashMap 等。

5. 執行緒對立

        執行緒對立是指無論呼叫端是否採取了同步措施,都無法在多執行緒環境中併發使用的程式碼。由於 Java 語言天生就具備多執行緒特性,執行緒對立這種排斥多執行緒的程式碼是很少出現的,而且通常都是有害的,應當儘量避免。

        一個執行緒對立的例子是 Thread 類的 suspend() 和 resume() 方法,如果有兩個執行緒同時持有一個執行緒物件,一個嘗試去中斷執行緒,另一個嘗試去恢復執行緒,如果併發進行的話,無論呼叫時是否進行了同步,目標執行緒都是存在死鎖風險的,如果 suspend() 中斷的執行緒就是即將要執行 resume() 的那個執行緒,那就肯定要產生死鎖了。也正是由於這個原因,suspend() 和 resume() 方法已經被 JDK 宣告廢棄(@Deprecated)了。常見的執行緒對立的操作還有 System.setIn()、System.setOut() 和 System.runFinalizerosOnExit() 等。

執行緒安全的實現方法

        瞭解了什麼是執行緒安全之後,緊接著的一個問題就是我們應該如何實現執行緒安全,這聽起來似乎是一件由程式碼如何編寫來決定的事情,確實,如何實現執行緒安全與程式碼編寫有很大的關係,但虛擬機器提供的同步和鎖機制也起到了非常重要的作用。本節中,程式碼編寫如何實現執行緒安全和虛擬機器如何實現同步與鎖這兩者都會有所涉及,相對而言更偏重後者一些,只要讀者瞭解了虛擬機器執行緒安全手段的運作過程,自己去思考程式碼如何編寫並不是一件困難的事情。

1. 互斥同步

        互斥同步(Mutual Exclusion & Synchronization)是常見的一種併發正確性保障手段。同步是指在多個執行緒併發訪問共享資料時,保證共享資料在同一個時刻只被一個(或者是一些,使用訊號量的時候)執行緒使用。而互斥是實現同步的一種手段,臨界區(Critical Section)、互斥量(Mutex)和訊號量(Semaphore)都是主要的互斥實現方式。因此,在這 4 個字裡面,互斥是,同步是;互斥是方法,同步是目的

        在 Java 中,最基本的互斥同步手段就是 synchronized 關鍵字,synchronized 關鍵字經過編譯之後,會在同步塊的前後分別形成 monitorenter 和 monitorexit 這兩個位元組碼指令,這兩個位元組碼都需要一個 reference 型別的引數來指明要鎖定和解鎖的物件。如果 Java 程式中的 synchronized 明確指定了物件引數,那就是這個物件的 reference;如果沒有明確指定,那就根據 synchronized 修飾的是例項方法還是類方法,去取對應的物件例項或 Class 物件來作為鎖物件。

        根據虛擬機器規範的要求,在執行 monitorenter 指令時,首先要嘗試獲取物件的鎖。如果這個物件沒被鎖定,或者當前執行緒已經擁有了那個物件的鎖,把鎖的計數器加 1,相應的,在執行 monitorexit 指令時將鎖計數器減 1,當計數器為 0 時,鎖就被釋放。如果獲取物件鎖失敗,那當前執行緒就要阻塞等待,知道物件鎖被另外一個執行緒釋放為止

        在虛擬機器規範對 monitorenter 和 monitorexit 的行為描述中,有兩點是需要特別注意的首先,synchronized 同步塊對同一條執行緒來說是可重入的,不會出現自己把自己鎖死的問題其次,同步塊在已進入的執行緒執行完之前,會阻塞後面其他執行緒的進入。在前面講過,Java 的執行緒是對映到作業系統的原生執行緒之上的,如果要阻塞或喚醒一個執行緒,都需要作業系統來幫忙完成,這就需要從使用者態轉換到核心態中,因此狀態轉換需要耗費很多的處理器時間。對於程式碼簡單的同步塊(如被 synchronized 修飾的 getter() 或 setter() 方法),狀態轉換消耗的時間有可能比使用者程式碼執行的時間還要長。所以 synchronized 是 Java 語言中一個重要級(Heavyweight)的操作,有經驗的程式設計師都會在確實必要的情況下才使用這種操作。而虛擬機器本身也會進行一些優化,譬如在通知作業系統阻塞執行緒之前加入一段自旋等待過程,避免頻繁地切入到核心態之中。

        除了 synchronized 之外,我們還可以使用 java.util.concurrent(下文成 J.U.C)包中的重入鎖(ReentrantLock)來實現同步,在基本用法上,ReentrantLock 與 synchronized 很相似,他們都具備一樣的執行緒重入特性,只是程式碼寫法上有點區別,一個表現為 API 層面的互斥鎖(lock() 和 unlock() 方法配合 try/finally 語句塊來完成),另一個表現為原生語法層面的互斥鎖。不過,相比 synchronized,ReentrantLock 增加了一些高階功能,主要有以下 3 項:等待可中斷可實現公平鎖,以及鎖可以繫結多個條件

  • 等待可中斷是指當持有鎖的執行緒長期不釋放鎖的時候,正在等待的執行緒可以選擇放棄等待,改為處理其他事情,可中斷特性對處理執行時間非常長的同步塊很有幫助。
  • 公平鎖是指多個執行緒在等待同一個鎖時,必須按照申請鎖的時間順序來依次獲得鎖;而非公平鎖則不保證這一點,在鎖被釋放時,任何一個等待鎖的執行緒都有機會獲得鎖。synchronized 中的鎖是非公平的,ReentrantLock 預設情況下也是非公平的,但可以通過帶布林值的建構函式要求使用公平鎖。
  • 鎖繫結多個條件是指一個 ReentrantLock 物件可以同時繫結多個 Condition 物件,而在 synchronized 中,鎖物件的 wait() 和 notify() 或 notifyAll() 方法可以實現一個隱含的條件,如果要和多於一個的條件關聯的時候,就不得不額外新增一個鎖,而 ReentrantLock 則無須這樣做,只需要多次呼叫 newCondition() 方法即可。

        如果需要使用上述功能,選用 ReentrantLock 是一個很好的選擇,那如果是基於效能考慮呢?關於 synchronized 和 ReentrantLock 的效能問題,Brian Goetz 對這兩種鎖在 JDK 1.5 與單核處理器,以及 JDK 1.5 與雙 Xeon 處理器環境下做了一組吞吐量對比的實現,實現結果如圖 13-1 和圖 13-2 所示。

圖 13-1  JDK 1.5、單核處理器下兩種鎖的吞吐量對比

圖 13-2  JDK 1.5、雙 Xeon 處理器下的兩種鎖的吞吐量對比

        從圖 13-1 和圖 13-2 可以看出,多執行緒環境下 synchronized 的吞吐量下降得非常嚴重,而 ReentrantLock 則能基本保持在同一個比較穩定的水平上。與其說 ReentrantLock 效能好,還不如說 synchronized 還有非常大的優化餘地。後續的技術發展也證明了這一地單,JDK 1.6 中加入了很多針對鎖的優化措施,JDK 1.6 釋出之後,人們就發現 synchronized 與 ReentrantLock 的效能基本上是完全持平了。因此,如果讀者的程式是使用 JDK 1.6 或以上部署的話,效能因素就不再是選擇 ReentrantLock 的理由了,虛擬機器在未來的效能改進中肯定也會更加偏向於原生的 synchronized,所以還是提倡在 synchronized 能實現需求的情況下,優先考慮使用 synchronized 來進行同步

4. 非阻塞同步

        互斥同步最主要的問題就是進行執行緒阻塞喚醒所帶來的效能問題,因此這種同步也稱為阻塞同步(Blocking Synchronization)。從處理問題的方式上來說,互斥同步屬於一種悲觀的併發策略,總是認為只要不去做正確的同步措施(例如加鎖),那就肯定會出現問題,無論共享資料是否真的會出現競爭,它都要進行加鎖(這裡討論的是概念模型,實際上虛擬機器會優化掉很大一部分不必要的加鎖)、使用者 態核心態轉換、維護鎖計數器和檢查是否有被阻塞的執行緒需要喚醒等操作。隨著硬體指令集的發展,我們有了另外一個選擇:基於衝突檢測的樂觀併發策略,通俗地說,就是先進行操作,如果沒有其他執行緒爭用共享資料,那操作就成功了;如果共享資料有爭用,產生了衝突,那就再採取其他的補償措施(最常見的補償措施就是不斷地重試,知道成功為止),這種樂觀的併發策略的許多實現都不需要把執行緒掛起,因此這種同步操作稱為非阻塞同步(Non-Blocking Synchronization)。

        為什麼筆者說使用樂觀併發策略需要 “硬體指令集的發展” 才能進行呢?因為我們需要操作和衝突檢測這兩個步驟具備原子性,靠什麼來保證呢?如果這裡再使用互斥同步來保證就失去意義了,所以我們只能靠硬體來完成這件事情,硬體保證一個從語法上看起來需要多次操作的行為只通過一條處理器指令就能完成,這類指令常用的有:

  • 測試並設定(Test-and-Set)。
  • 獲取並增加(Fetch-and-Increment)。
  • 交換(Swap)。
  • 比較並交換(Compare-and Swap,下文成 CAS)。
  • 載入連線 / 條件儲存(Load-Linked / Store-Conditional,下文稱 LL/SC)。

        其中,前面的 3 條是 20 世紀就已經存在於大多數指令集之中的處理器指令,後面的兩條是現代處理器新增的,而且這兩條指令的目的和功能是類似的。在 IA64、x86 指令集中有 cmpxchg 指令完成 CAS 功能,在 sparc-TSO 也有 casa 指令實現,而在 ARM 和 PowerPC 架構下,則需要使用一對 Idrex/strex 指令來完成 LL/SC 的功能。

        CAS 指令需要有 3 個運算元,分別是記憶體位置(在 Java 中可以簡單理解為變數的記憶體地址,用 V 表示)、舊的預期值(用 A 表示)和新值(用 B 表示)。CAS 指令執行時,當且僅當 V 符合舊值預期值 A 時,處理器用新值 B 更新 V 的值,否則它就不執行更新,但是無論是否更新了 V 的值,都會返回 V 的舊值,上述的處理過程是一個原子操作。

        在 JDK 1.5 之後,Java 程式中才可以使用 CAS 操作,該操作由 sun.misc.Unsafe 類裡面的 compareAndSwapInt() 和 compareAndSwapLong() 等幾個方法包裝提供,虛擬機器在內部對這些方法做了特殊處理,即時編譯出來的結果就是一條平臺相關的處理器 CAS 指令,沒有方法呼叫的過程,或者可以認為是無條件內聯進去了。(注:這種被虛擬機器特殊處理的方法稱為固有函式(intrinsics),類似的固有函式還有 Math.sin() 等

        由於 Unsafe 類不是提供給使用者程式呼叫的類(Unsafe.getUnsafe() 的程式碼中限制了只有啟動類載入器(Bootstrap ClassLoader)載入的 Class 才能訪問它),因此,如果不採用反射手段,我們只能通過其他的 Java API 來間接使用它,如 J.U.C 包裡面的整數原子類,其中的 compareAndSet() 和 getAndIncrement() 等方法都使用了 Unsafe 類的 CAS 操作。

        我們不妨拿一段在第 12 章中沒有解決的問題程式碼來看看如何使用 CAS 操作來避免阻塞同步,程式碼如程式碼清單 12-1 所示。我們曾經通過這段 20 個執行緒自增 10000 次的程式碼來證明 volatile 變數不具備原子性,那麼如何才能讓它具備原子性呢?把 “race++” 操作或 increase() 方法用同步塊包裹起來當然是一個辦法,但是如果改成程式碼清單 13-4 所示的程式碼,那效率將會提高許多(實測效率更低~~)。

程式碼清單 13-4  Atomic 的原子自增運算

/**
 * Atomic 變數自增運算測試
 * 
 * @author mk
 */
public class AtomicTest {
	
	public static AtomicInteger race = new AtomicInteger(0);
	
	public static void increase() {
		race.incrementAndGet();
	}
	
	private static final int THREADS_COUNT = 20;
	
	public static void main(String[] args) {
		Thread[] threads = new Thread[THREADS_COUNT];
		for (int i = 0; i < THREADS_COUNT; i ++) {
			threads[i] = new Thread(new Runnable() {
				@Override
				public void run() {
					for (int i = 0; i < 10000; i++) {
						increase();
					}
				}
			});
			threads[i].start();
		}
		
		// 等待所有累加執行緒都結束
		while (Thread.activeCount() > 1) 
			Thread.yield();
		
		System.out.println(race);
	}
}

        執行結果如下:
200000

        使用 AtomicInteger 代替 int 後,程式輸出了正確的結果,一切都要歸功於 incrementAndGet() 方法的原子性。它的實現其實非常簡單,如程式碼清單 13-5 所示。

程式碼清單 13-5  incrementAndGet() 方法的 JDK 原始碼

 /**
     * Atomically increments by one the current value.
     *
     * @return the updated value
     */
    public final int incrementAndGet() {
        for (;;) {
            int current = get();
            int next = current + 1;
            if (compareAndSet(current, next))
                return next;
        }
    }

        incrementAndGet() 方法在一個無限迴圈中,不斷嘗試將一個比當前值大 1 的新值賦給自己。如果失敗了,那說明在執行 “獲取-設定” 操作的時候值已經有了修改,於是再次迴圈進行下一次操作,直到設定成功為止。

        儘管 CAS 看起來很美,但顯然這種操作無法涵蓋互斥同步的所有使用場景,並且 CAS 從語義上來說並不是完美的,存在這樣的一個邏輯漏洞:如果一個變數 V 初次讀取的時候是 A 值,並且在準備賦值的時候檢查到它仍然為 A 值,那我們就能說它沒有被其他執行緒改變過了嗎?如果在這段期間它的值曾經被改成了 B,後來又被改回 A,那 CAS 操作就會誤認為它從來沒有被改變過。這個漏洞稱為 CAS 操作的 “ABA” 問題。J.U.C 包為了解決這個問題,提供了一個帶有標記的原子引用類 “AtomicStampedReference”,它可以通過控制變數值的版本來保證 CAS 的正確性。不過目前來說這個類比較 “雞肋”,大部分情況下 ABA 問題不會影響程式併發的正確性,如果需要解決 ABA 問題,改用傳統的互斥同步可能會比原子類更高效。

3. 無同步方案

        要保證執行緒安全,並不是一定就要進行同步,兩者沒有因果關係。同步只是保證共享資料爭用時的正確性的手段,如果一個方法本來就不涉及共享資料,那它自然就無須任何同步措施去保證正確性,因此會有一些程式碼天生就是執行緒安全的,筆者簡單地介紹其中的兩類。

        可重入程式碼(Reentrant Code):這種程式碼也叫做純程式碼(Pure Code),可以在程式碼執行的任何時刻中斷它,轉而去執行另外一段程式碼(包括遞迴呼叫它本身),而在控制權返回後,原來的程式不會出現任何錯誤。相對執行緒安全來說,可重入性是更基本的特性,它可以保證執行緒安全,即所有的可重入的程式碼都是執行緒安全的,但是並非所有的執行緒安全的程式碼都是可重入的。

        可重入程式碼有一些共同的特徵,例如不依賴儲存在堆上的資料和公用的系統資源、用到的狀態量都由引數中傳入、不呼叫非可重入的方法等。我們可以通過一個簡單的原則來判斷程式碼是否具備可重入性如果一個方法,它的返回結果是可以預測的,只要輸入了相同的資料,就都能返回相同的結果,那它就滿足可重入性的要求,當然也就是執行緒安全的

        執行緒本地儲存(Thread Local Storage):如果一段程式碼中所需要的資料必須與其他程式碼共享,那就看看這些共享資料的程式碼是否能保證在同一個執行緒中執行?如果能保證,我們就可以把共享資料的可見範圍限制在同一個執行緒之內,這樣,無須同步也能保證執行緒之間不出現資料爭用的問題。

        符合這種特點的應用並不少見,大部分使用消費佇列的架構模式(如 “生產者 - 消費者” 模式)都會將產品的消費過程儘量在一個執行緒中消費完,其中最重要的一個應用例項就是經典 Web 互動模型中的 “一個請求對應一個伺服器執行緒”(Thread-per-Request)的處理方式,這種處理方式的廣泛應用使得很多 Web 服務端應用都可以使用執行緒本地儲存來解決執行緒安全問題。

        Java 語言中,如果一個變數要被多執行緒訪問,可以使用 volatile 關鍵字宣告它為 “易變的”;如果一個變數要被某個執行緒獨享,Java 中就沒有類似 C++ 中的 __declspec(thread) (注:在 Visual C++ 是 “__declspec(thread)” 關鍵字,而在 GCC 中是 “__thread”)這樣的關鍵字,不過還是可以通過 java.lang.ThreadLocal 類來實現執行緒本地儲存的功能。每一個執行緒的 Thread 物件中都有一個 ThreadLocalMap 物件,這個物件儲存了一組以 ThreadLocal.threadLocalHashCode 為鍵,以本地執行緒變數為值的 K-V 值對,ThreadLocal 物件就是當前執行緒的 ThreadLocalMap 的訪問入口,每一個 ThreadLocal 物件都包含了一個獨一無二的 threadLocalHashCode 值,使用這個值就可以線上程 K-V 值對中找回對應的本地執行緒變數。

鎖優化

        高效併發是從 JDK 1.5 到 JDK 1.6 的一個重要改進,HotSpot 虛擬機器開發團隊在這個版本上花費了大量的精力去實現各種鎖優化技術,如適應性自旋(Adaptive Spinning)、鎖消除(Lock Elimination)、鎖粗化(Lock Coarsening)、輕量級鎖(Lightweight Locking)和偏向鎖(Biased Locking)等。這些技術都是為了線上程之間更高效地共享資料,以及解決競爭問題,從而提高程式的執行效率。

自旋鎖與自適應自旋

        前面我們討論互斥同步的時候,提到了互斥同步對效能最大的營銷阻塞的實現,掛起執行緒和恢復執行緒的操作都需要轉入核心態完成,這些操作給系統的併發效能帶來了很大的壓力。同時,虛擬機器的開發團隊也注意到在許多應用上,共享資料的鎖定狀態只會持續很短的一段時間,為了這段時間去掛起和恢復執行緒並不值得。如果物理機器有一個以上的處理器,能讓兩個或以上的執行緒同時並行執行,我們就可以讓後面請求鎖的那個執行緒 “稍等一下”,但不放棄處理器的執行時間,看看持有鎖的執行緒是否很快就會釋放鎖。為了讓執行緒等待,我們只需讓執行緒執行一個忙迴圈(自旋),這項技術就是所謂的自旋鎖

        自旋鎖在 JDK 1.4.2 中就已經引入,只不過預設是關閉的,可以使用 -XX:+UseSpinning 引數來開啟,在 JDK 1.6 就已經改為預設開啟了。自旋等待不能代替阻塞,且先不說對處理器數量的要求,自旋等待本身雖然避免了執行緒切換的開銷,但它是要佔用處理器時間的,因此,如果鎖被佔用的時間很短,自旋等待的效果就會非常好,反之,如果鎖被佔用的時候很長,那麼自旋的執行緒只會白白消耗處理器資源,而不會做任何有用的工作,反而會帶來效能上的浪費。因此,自旋等待的時間必須要有一定的限度,如果自旋超過了限定的次數仍然沒有成功獲得鎖,就應當使用傳統的方式去掛起執行緒了。自旋次數的預設值是 10 次,使用者可以使用引數 -XX:PreBlockSpin 來更改

        在 JDK 1.6 中引入了自適應的自旋鎖。自適應意味著自旋的時間不再固定了,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。如果在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行中,那麼虛擬機器就會認為這次自旋也很有可能再次成功,進而它將允許自旋等待持續相對更長的時間,比如 100 個迴圈。另外,如果對於某個鎖,自旋很少成功獲得過,那在以後要獲取這個鎖時將可能省略掉自旋過程,以避免浪費處理器資源。有了自適應自旋,隨著程式執行和效能監控資訊的不斷完善,虛擬機器對程式鎖的狀況預測就會越來越準確,虛擬機器就會變得越來越 “聰明” 了。

鎖消除

        鎖消除是指虛擬機器即時編譯器在執行時,對一些程式碼上要求同步,但是被檢測到不可能存在共享資料競爭的鎖進行消除。鎖消除的主要判定依據來源於逃逸分析的資料支援,如果判定在一段程式碼中,堆上的所有資料都不會逃逸出去從而被其他執行緒訪問到,那就可以把他們當做棧上資料對待,認為它們是執行緒私有的,同步加鎖自然就無須進行。

        也許讀者會有疑問,變數是否逃逸,對於虛擬機器來說需要使用資料流分析來確定,但是程式自己應該是很清楚的,怎麼會在明知道不存在資料爭用的情況下要求同步呢?答案是有許多同步措施並不是程式設計師自己加入的。同步的程式碼在 Java 程式中的普遍程度也許超過了大部分讀者的想象。我們來看看程式碼清單 13-6 中的例子,這段非常簡單的程式碼僅僅是輸出 3 個字串相加的結果,無論是原始碼字面上還是程式語義上都沒有同步。

程式碼清單 13-6  一段看起來沒有同步的程式碼

public static String concatString(String s1, String s2, String s3) {
	return s1 + s2 + s3;
}

        我們也知道,由於 String 是一個不可變的類,對字串的連線操作總是通過生成新的 String 物件來進行的,因此 Javac 編譯器會對 String 連線做自動優化。在 JDK 1.5 之前,會轉化為 StringBuffer 物件的連續 append() 操作,在 JDK 1.5 及以後的版本中,會轉化為 StringBuilder 物件的連續 append() 操作,即程式碼清單 13-6 中的程式碼可能會程式設計程式碼清單 13-7 的樣子(注:客觀地說,既然談到鎖消除與逃逸分析,那虛擬機器就不可能是 JDK 1.5 之前的版本,實際上會轉化為非執行緒安全的 StringBuilder 來完成字串拼接,並不會加鎖,但這也不影響筆者用這個例子證明 Java 物件中同步的普遍性)。

程式碼清單 13-7  Javac 轉化後的字串連線操作

public static String concatString(String s1, String s2, String s3) {
	StringBuffer sb = new StringBuffer();
	sb.append(s1);
	sb.append(s2);
	sb.append(s3);
	return sb.toString();
}

        現在大家還認為這段程式碼沒有涉及同步嗎?每個 StringBuffer.append() 方法中都有一個同步塊,鎖就是 sb 物件。虛擬機器觀察變數 sb,很快就會發現它的動態作用域被限制在 concatString() 方法內部。也就是說,sb 的所有引用永遠不會 “逃逸” 道 concatString() 方法之外,其他執行緒無法訪問到它,因此,雖然這裡有鎖,但是可以被安全地消除掉,在即時編譯之後,這段程式碼就會忽略掉所有的同步而直接執行了。

鎖粗化

        原則上,我們在編寫程式碼的時候,總是推薦將同步塊的作用範圍限制得儘量小——只在共享資料的實際作用域中才進行同步,這樣是為了使得需要同步的運算元量儘可能變小,如果存在鎖競爭,那等待鎖的執行緒也能儘快拿到鎖。

        大部分情況下,上面的原則都是正確的,但是如果一系列的連續操作都對同一個物件反覆加鎖和解鎖,甚至加鎖操作是出現在迴圈體中,那即使沒有執行緒競爭,頻繁地進行互斥同步操作也會導致不必要的效能損耗。

        程式碼清單 13-7 中連續的 append() 方法就屬於這類情況。如果虛擬機器探測到由這樣的一串零碎的操作都對同一個物件加鎖,將會把加鎖同步的範圍擴充套件(粗化)到整個操作序列的外部,以程式碼清單 13-7 為例,就是擴充套件到第一個 append() 操作之前直至最後一個 append() 操作之後,這樣只需要加鎖一次就可以了。

輕量級鎖

        輕量級鎖是 JDK 1.6 之中加入的新型鎖機制,它名字中的 “輕量級” 是相對於使用作業系統互斥量來實現的傳統鎖而言的,因此傳統的鎖機制就稱為 “重量級” 鎖。首先需要強調一點的是,輕量級鎖並不是用來代替重要級鎖的,它的本意是在沒有多執行緒競爭的前提下,減少傳統的重量級鎖使用作業系統互斥量產生的效能消耗。

        要理解輕量級鎖,以及後面會講到的偏向鎖的原理和運作過程,必須從 HotSpot 虛擬機器的物件(物件頭部分)的記憶體佈局開始介紹。HotSpot 虛擬機器的物件頭(Object Header)分為兩部分資訊,第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC 分代年齡(Generational GC Age)等,這部分資料是長度在 32 位和 64 位的虛擬機器中分別為 32 bit 和 64 bit,官方稱它為 “Mark Word”,它是實現輕量級鎖和偏向鎖的關鍵。另外一部分用於儲存指向方法區物件型別資料的指標,如果是陣列物件的話,還會有一個額外的部分用於儲存陣列長度。

        物件頭資訊是與物件自身定義的資料無關的額外儲存成本,考慮到虛擬機器的空間效率,Mark Work 被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。例如,在 32 位的 HotSpot 虛擬機器中物件未被鎖定的狀態下,Mark Word 的 32bit 空間中的 25bit 用於儲存物件雜湊碼(HashCode),4bit 用於儲存物件分代年齡,2bit 用於儲存鎖標誌位,1bit 固定為 0,在其他狀態(輕量級鎖定、重量級鎖定、GC 標記、可偏向)下物件的儲存內容見表 13-1。

        簡單地介紹了物件的記憶體佈局後,我們把話題返回到輕量級鎖的執行過程上。在程式碼進入同步塊的時候,如果此同步物件沒有被鎖定(鎖標誌位為 “01” 狀態)虛擬機器首先將在當前執行緒的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖物件目前的 Mark Word 的拷貝(官方把這份拷貝加上了一個 Displaced 字首,即 Displaced Mark Word),這時候執行緒堆疊與物件頭的狀態如圖 13-3 所示。

        然後,虛擬機器將使用 CAS 操作嘗試將物件的 Mark Word 更新為指向 Lock Record 的指標。如果這個更新動作成功了,那麼這個執行緒就擁有了該物件的鎖,並且物件 Mark Word 的鎖標誌位 (Mark Word 的最後 2bit)將轉變為 “00”,即表示此物件處於輕量級鎖定狀態,這時候執行緒堆疊與物件頭的狀態如圖 12-4 所示。

        如果這個更新操作失敗了,虛擬機器首先會檢查物件的 Mark Word 是否指向當前執行緒的棧幀,如果只說明當前執行緒已經擁有了這個物件的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖物件以及被其他執行緒執行緒搶佔了。如果有兩條以上的執行緒爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,所標誌的狀態變為 “10”,Mark Word 中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的執行緒也要進入阻塞狀態。

        上面描述的是輕量級鎖的加鎖過程,它的解鎖過程也是通過 CAS 操作來進行的,如果物件的 Mark Word 仍然指向著執行緒的鎖記錄,那就用 CAS 操作把物件當前的 Mark Word 和執行緒中複製的 Displaced Mark Word 替換回來,如果替換成功,整個同步過程就完成了。如果替換失敗,說明有其他執行緒嘗試過獲取該鎖,那就要釋放鎖的同時,喚醒被掛起的執行緒。

        輕量級鎖能提升程式同步效能的依據是 “對於絕大部分的鎖,在整個同步週期內都是不存在競爭的”,這是一個經驗資料。如果沒有競爭,輕量級鎖使用 CAS 操作避免了使用互斥量的開銷,但如果存在鎖競爭,除了互斥量的開銷外,還額外發生了 CAS 操作,因此在有競爭的情況下,輕量級鎖會比傳統的重量級鎖更慢。

偏向鎖

        偏向鎖也是 JDK 1.6 中引入的一項鎖優化,它的目的是消除資料在無競爭情況下的同步原語,進一步提高程式的執行效能。如果說輕量級鎖是在無競爭的情況下使用 CAS 操作去消除同步使用的互斥量,那偏向鎖就是在無競爭的情況下把整個同步都消除掉,連 CAS 操作都不做了

        偏向鎖的 “偏”,就是偏心的 “偏”、偏袒的 “偏”,它的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

        如果讀者讀懂了前面輕量級鎖中關於物件頭 Mark Word 與執行緒之間的操作過程,那偏向鎖的原理理解起來就會很簡單。假設當前虛擬機器啟用了偏向鎖(啟用引數 -XX:+UseBiasedLocking,這是 JDK 1.6 的預設值),那麼,當鎖物件第一次被執行緒獲取的時候,虛擬機器將會把物件頭中的標誌位設為 “01”,即偏向模式。同時使用 CAS 操作把獲取到這個鎖的執行緒 ID 記錄在物件的 Mark Word 之中,如果 CAS 操作成功,持有偏向鎖的執行緒以後每次進入這個鎖相關的同步塊時,虛擬機器都可以不再進行如何同步操作(例如 Locking、Unlocking 及對 Mark Word 的 Update 等)。

        當有另外一個執行緒去嘗試獲取這個鎖時,偏向模式就宣告結束。根據鎖物件目前是否處於被鎖定的狀態,撤銷偏向(Revoke Bias)後恢復到未鎖定(標誌位為 “01”)或輕量級鎖定(標誌位為 “00”)的狀態,後續的同步操作就如上面介紹的輕量級鎖那樣執行。偏向鎖、輕量級鎖的狀態轉換及物件 Mark Word 的關係如圖 13-5 所示。

        偏向鎖可以提高帶有同步但無競爭的程式效能。它同樣是一個帶有效益權衡(Trade Off)性質的優化,也就是說,它並不一定總是對程式執行有利,如果程式中大多數的鎖總是被多個不同的執行緒訪問,那偏向模式就是多餘的。在具體問題具體分析的前提下,有時候使用引數 -XX:-UseBiasedLocking 來禁止偏向鎖優化反而可以提升效能。