并發系列的文章都是根據閱讀《Java 并發編程的藝術》這本書總結而來,想更深入學習的同學可以自行購買此書進行學習。
一 鎖的內存語義
眾所周知,鎖可以讓臨界區互斥執行。但鎖的另一個同樣重要的功能卻常常被大家忽略:鎖的內存語義。
1. 鎖的釋放 - 獲取建立的 happens-before 關系
鎖是 Java 并發編程中最重要的同步機制。鎖除了可以讓臨界區互斥外,還可以讓釋放鎖的線程向獲取同一個鎖的線程發送消息。
來看一個鎖釋放 — 獲取的簡單示例代碼:
class MonitorExample{
int a = 0;
public synchronized void writer(){
a++;
}
public synchronized void reader(){
int i = a;
......
}
}
假設線程 A 執行 writer() 方法,隨后線程 B 執行 reader() 方法。根據 happens-before 規則,這個過程包含的 happens-before 關系可以分為 3 類:
- 根據程序次序規則:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens-before 6。
- 根據監視器鎖的規則,3 happens-before 4。
- 根據 happens-before 的傳遞性,2 happens-before 5
因此,線程 A 在釋放鎖之前所有可見的共享變量,在線程 B 獲取到同一個鎖之后,將立刻變得對 B 線程可見。
2. 鎖的釋放和獲取的內存語義
當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。以上面代碼為例。
當線程獲取鎖時,JMM 會把該線程對應的本地內存置為無效。從而使得被監視器保護的臨界區代碼必須從主內存中讀取共享變量。
對比鎖釋放—獲取的內存語義與 volatile 寫—讀的內存語義可以看出:鎖釋放與 volatile 寫有相同內存語義;鎖獲取與 volatile 讀有相同的內存語義。
3. 鎖內存語義的實現
我們通過 ReentrantLock 的源代碼,來分析鎖的內存語義的具體實現機制:
class ReentrantLockExample{
int a = 0;
ReentrantLock lock = new ReentrantLock();
public void writer(){
lock.lock; //獲取鎖
try{
a++;
}finally{
lock.unlock(); //釋放鎖
}
}
public void reader(){
lock.lock(); //獲取鎖
try{
int i = a;
......
}finally{
lock.unlock(); //釋放鎖
}
}
}
ReentrantLock 中,通過 lock() 方法獲取鎖,調用 unlock() 方法釋放鎖。這個類的實現依賴于 Java 同步器框架 AbstractQueuedSynchronizer (AQS)。AQS 使用一個整型的 volatile 變量 state 來維護同步狀態,這個變量時 ReentrantLock 內存語義實現的關鍵。類圖如下:
ReentrantLock 分為公平鎖和非公平鎖,首先看下公平鎖,使用公平鎖時,加鎖方法 lock() 調用軌跡如下:
- ReentrantLock : lock()。
- FairSync : lcok()。
- AQS : acquire(int arg)。
- ReentrantLock : tryAcquire(int acquires)。
第四步時候真正開始加鎖,下面是 tryAcquire(int acquires) 方法的源代碼
protected final boolean tryAcquire(int acquires){
final Thread current = Thread.currentThread();
int c = getState(); //獲取鎖的開始,首先讀 volatile 變量 state
if (c == 0){
if (isFirst(current) &&
compareAndSetState(0, acquires)){
setExclusiveOwnerThread(current);
return true;
}
}else if (current == getExclsiveOwnerThread()){
int nextc = c + acquires;
if (nextc < 0){
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
return false;
}
從上面源代碼可以看出,加鎖方法首先讀 volatile 變量 state。
公平鎖解鎖方法 unlock()的調用軌跡如下:
- ReentrantLock : unlock()
- AQS : release(int arg)
- Sync : tryRelease(int release)
從第三步開始釋放鎖,下面是源代碼:
protected final boolean tryRelease(int releases){
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread()){
throw new IllegalMonitorStateException();
}
boolean free = false;
if (c == 0){
free = true;
setExclusiveOwnerThread(null);
}
setState(c); //釋放鎖的最后,寫 volatile 變量 state
return free;
}
可以看出,在釋放鎖的最后寫 volatile 變量 state。根據 volatile 的 happens-before 規則,釋放鎖的線程在寫 volatile 變量之前可見的共享變量,在獲取鎖的線程讀取同一個 volatile 變量后將立即變得對獲取鎖的線程可見。
非公平鎖的釋放和公平鎖一樣,所以僅僅分析非公平鎖的獲取,加鎖方法 lock() 調用軌跡如下:
- ReentrantLock : lock()
- NonfairSync : lock()
- AQS : compareAndSetState(int expect , int update)
第三步開始真正加鎖,源代碼:
protected final boolean compareAndSetState(int expect, int update) {
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
改方法以原子操作的方式更新 state 變量,CAS 操作具有 volatile 讀和寫的內存語義。
為了同時實現 volatile 讀和 volatile 寫的內存語義,編譯器不能對 CAS 前后的任意內存操作重排序。
在 inter X86 處理器中,CAS 是如何同時具有 volatile 讀寫的內存語義的呢?下面是 sun.misc.Unsafe 類的 compareAndSwapInt() 方法的源代碼:
public final native boolean compareAndSwapInt(Object o ,long offset,
int expected,
int x);
這是一個本地方法調用。對應于 X86 處理器的源代碼如下:
inline jint Atomic::cmpxchg (jint exchange_value, volatile jint* dest, jint compare_value){
//alternative for InterlockedCompareExchange
int mp = os::is_MP();
_asm{
mov edx, dest
mov ecx, exchange_value
mov eax, compare_value
Lock_IF_MP(mp)
cmpxchg dword ptr [edx], ecx
}
}
程序會根據當前處理器類型來決定是否為cmpxchg
指令添加lock
前綴。如果程序運行在多處理器上,則加上lock
前綴,單處理器上則省略,單處理器自身會維護處理器內的順序一致性,不需要lock
前綴提供的內存屏障效果。
inter 手冊對 lock 前綴的說明如下:
- 確保對內存的讀-改-寫操作原子執行。在 Pentium 以及之前的處理器中,lock 前綴指令將鎖住總線,開銷很大。之后的處理器中,Inter 使用緩存鎖定來保證指令執行的原子性。
- 禁止該指令,與之前和之后的讀寫指令重排序。
- 把寫緩沖區中的所有數據刷新到內存中。
上面第二點和第三點具有內存屏障效果,足以同時實現 volatile 讀和寫的內存語義。
通過上面對 ReentrantLock 的分析可以看出,鎖釋放—獲取的內存語義的實現至少有下面兩種方式:
- 利用 volatile 變量的寫—讀所具有的內存語義。
- 利用 CAS 鎖附帶的 volatile 讀和寫的內存語義。
4. concurrent 包的實現
volatile 變量的讀寫和 CAS 可以實現線程之間的通信,它們形成了整個 concurrent 包得以實現的基石。如果仔細分析 concurrent 包的源代碼實現,會發現一個通用化的實現模式:
- 首先,聲明共享變量為 volatile
- 然后,使用 CAS 的原子條件更新來實現線程之間的同步
- 同時,配合以 volatile 的讀/寫和 CAS 所具有的 volatile 讀寫內存語義來實現線程之間的通信。
final 域的內存語義
對于 final 域,編譯器和處理器要遵守兩個重排序規則:
- 在構造函數內對一個 final 域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作不能重排序。
- 初次讀一個包含 final 域的對象的引用,與隨后初次讀這個 final 域,這兩個操作之間不能重排序。
1. 寫 final 域的重排序規則
寫 final 域的重排序規則禁止把 final 域的寫重排序到構造函數之外。這個規則實現包含兩個方面:
- JMM 禁止編譯器把 final 域的寫重排序到構造函數之外。
- 編譯器會在 final 域的寫之后,構造函數 return 之前,插入一個 StoreStore 屏障。這個屏障禁止處理器把 final 域的寫重排序到構造函數之外。
2. 讀 final 域的重排序規則
在一個線程中,初次讀對象引用與初次讀該對象包含的 final 域,JMM 禁止處理器重排序這兩個操作,這個規則僅針對處理器。編譯器會在讀 final 域操作的前面插入一個 LoadLoad 屏障。
讀 final 域的重排序規則可以確保:在讀一個對象的 final 域之前,一定會先讀包含這個 final 域的對象的引用。如果先讀的引用不為 null,那么引用對象的 final 域一定已經被讀取該引用的線程初始化過了。
3. final 域為引用類型
對于引用類型,寫 final 域的重排序規則對編譯器和處理器增加了如下約束:在構造函數內對一個 final 引用的對象的成員域的寫入,與隨后在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。
但在多線程情況下構造函數之外對 final 引用對象成員域的寫入與讀取存在數據競爭,JMM 不保證這些線程的寫入對其他線程可見,如果想要確保這種原子性,還是需要使用同步原語(lock 和 volatile)確保內存可見性。
4. 為什么 final 引用不能從構造函數內「溢出」
寫 final 域的重排序規則可以確保:在引用變量為任意線程可見之前,該引用變量指向的對象的 final 域已經在構造函數中被正確初始化過了。其實,要得到這個效果還需要一個保證:在構造函數內部,不能讓這個被構造對象的引用為其他線程所見,也就是對象引用不能在構造函數中「逸出」。來看一個可能造成逸出的代碼:
public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj;
public FinalReferenceEscapeExample(){
i = 1; //1. 寫 final 域
obj = this;//2. this 引用在此「逸出」
}
public static void writer(){
new FinalReferenceEscapeExample();
}
public static void reader(){
if (obj != null){ //3
int temp = obj.i;//4
}
}
}
假設一個線程 A 執行 writer() 方法,另一個線程 B 執行 reader() 方法,這里的操作 2 使得對象還未完成構造前就為線程 B 可見。即使這里的操作 2 是構造函數的最后一步,且在程序中操作 2 排在 操作 1 的后面,執行 reader() 方法的線程仍然可能無法看到 final 域被初始化后的值,因為這里操作 1 和操作 2 之間可能被重排序。實際的執行時序可能如下:
從上圖可以看出:在構造函數返回前,被構造對象的引用不能為其他線程所見,因為此時的 final 域可能還沒有初始化。構造函數返回后,任意線程都將保證能看到 final 域正確初始化之后的值。
5. final 語義在處理器中的實現
我們上面提到過,寫 final 域的重排序規則會要求編譯器在 final 域寫之后,構造函數 return 之前插入一個 StoreStore 屏障。讀 final 域的重排序規則要求編譯器在讀 final 域的操作前面插入一個 LoadLoad 屏障。
由于 X86 處理器不會對寫—寫操作做重排序,所以在 X86 處理器中,寫 final 域需要的 StoreStore 屏障會被省略掉。同樣,由于 X86 處理器不會對存在間接依賴關系的操作做重排序,所以在 X86 處理器中,讀 final 域需要的 LoadLoad 屏障也會被省略掉。也就是說在 X86 處理器中,final 域的讀/寫不會插入任何屏障。
6. JSR-133 為什么要增強 final 的語義
在舊的內存模型中,一個最嚴重的缺陷就是線程可能看到 final 域的值會改變。最常見的例子就是在舊的 Java 內存模型中,String 的值可能會改變。
為了修補這個漏洞,JSR-133 專家組增強了 final 的語義。通過為 final 域增加寫和讀重排序規則,可以為 Java 程序員提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造函數中沒有「逸出」),那么不需要使用同步(指 lock 和 volatile 的使用)就可以保證任意線程都能看到這個 final 域在構造函數中被初始化之后的值。
happens-before
happens-before 是 JMM 最核心的概念。對應 Java 程序員來說,理解 happens-before 是理解 JMM 的關鍵。
1. JMM 的設計
從 JMM 設計者的角度,在設計 JMM 時,需要考慮兩個關鍵因素。
- 程序員對內存模型的使用。程序員希望內存模型易于理解、易于編程。程序員希望基于一個強內存模型來編寫代碼。
- 編譯器和處理器對內存模型的實現。編譯器和處理器希望內存模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高性能。編譯器和處理器希望實現一個弱內存模型。
由于這兩個因素互相矛盾,所以 JSR-133 專家組在設計 JMM 時的核心目標就是找到一個好的平衡點:一方面,要為程序員提供足夠強的內存可見性保證;另一方面,對編譯器和處理器的限制要盡可能放松。下面來看一段代碼來研究下 JSR-133 是如何實現這一目標的。
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面計算圓的面積的示例代碼存在 3 個 happens-before 關系,如下:
- A happens-before B
- B happens-before C
- A happens-before C
在 3 個 happens-before 關系中,2 和 3 是必須的,但 1 是不必要的。因此 JMM 把 happens-before 要求禁止的重排序分為了下面兩類:
- 會改變程序執行結果的重排序
- 不會改變程序結果的重排序
JMM 對兩種不同性質的重排序,采取了不同的策略:
- 對于會改變程序執行結果的重排序,JMM 要求編譯器和處理器必須禁止這種重排序。
- 對于不會改變程序執行結果的重排序,JMM 對編譯器和處理器不做要求(JMM 允許這種重排序)
下圖是 JMM 的設計示意圖:
從上圖可以看出兩點:
- JMM 向程序員提供的 happens-before 規則能滿足程序員的需求。JMM 的 happens-before 規則不但簡單易懂,而且也向程序員提供了足夠強的內存可見性(有些內存可見性保證其實不一定真實存在,步入上面的 A happens-before B)。
- JMM 對編譯器和處理器的束縛已經盡可能少。從上面的分析可以看出,JMM 其實是在遵循一個基本原則:只要不改變程序的執行結果(指的是單線程程序和正確同步的多線程程序),編譯器和處理器怎么優化都行。例如,如果編譯器經過細致分析后認定一個鎖只會被單個線程訪問,那么這個鎖可以被消除。再如,如果編譯器經過細致分析后,認定一個 volatile 變量只會被單個線程訪問,那么編譯器可以把這個 volatile 變量當做一個普通變量來對待。這些優化既不會改變程序的執行結果,又能提高程序的執行效率。
2. happens-before 的定義
JSR-133 使用 happens-before 的概念來指定兩個操作之間的執行順序。由于這兩個操作可以在一個線程內,也可以在不同線程之間。因此 JMM 可以通過 happens-before 關系向程序員提供跨線程的內存可見性保證(如果 A 線程的寫操作 a 與 B 線程的讀操作 b 之間存在 happens-before 關系,盡管 a 和 b 在不同的線程中執行,但 JMM 向程序員保證 a 操作將對 b 操作可見)。
JSR-133 對 happens-before 關系的定義如下:
- 如果一個操作 happens-before 另一個操作,那么第一個操作的執行結果將對第二個操作可見,且第一個操作的執行順序排在第二個操作之前。
- 兩個操作之間存在 happens-before 關系,并不意味著 Java 平臺的具體實現必須要按照 happens-before 關系指定的順序來執行。如果重排序之后的執行結果,與按 happens-before 關系來執行的結果一致,那么這種重排序并不非法,JMM 允許這種重排序。
上面第一條是JMM 對程序員的承諾。從程序員角度來說,可以這樣理解 happens-before 關系:如果 A happens-before B,那么 Java 內存模型將向程序員保證——A 操作的結果將對 B 可見,且 A 的執行順序在 B 之前。注意,這只是 Java 內存模型向程序員做出的保證。
上面第二條是JMM 對編譯器和處理器重排序的約束原則。JMM 其實是在遵循一個基本原則:只要不改變程序的執行結果,編譯器和處理器怎么優化都可以。JMM 這么做的原因是:程序員對這兩個操作是否真的被重排序并不關心,程序員關心的是程序執行時的語義不能被改變。因此,happens-before 關系本質上和 as-if-serial 語義是一回事。
as-if-serial 和 happens-before 這么做的目的,都是為了在不改變程序執行結果的前提下,盡可能地提高程序執行的并行度。
3. happens-before 規則
JSR-133 定義了如下 happens-before 規則:
- 程序順序規則:一個線程中的每個操作,happens-before 于該線程中的任意后續操作。
- 監視器鎖規則:對一個鎖的解鎖,happens-before 于隨后對這個鎖的加鎖。
- volatile 變量規則:對一個 volatile 域的寫,happens-before 于任意后續對這個 volatile 域的讀
- 傳遞性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
- start() 規則:如果線程 A 執行操作 ThreadB.start()(啟動線程B),那么線程 A 的 ThreadB.start() 操作 happens-before 于線程 B 中的任意操作。
- join() 規則:如果線程 A 執行操作 ThreadB.join() 并成功返回,那么線程 B 中的任意操作 happens-before 于線程 A 從 ThreadB.join 操作成功返回。
雙重檢查鎖定與延遲初始化
在 Java 多線程程序中,有時候需要采用延遲初始化來降低初始化類和創建對象的開銷。雙重檢查鎖定是常見的延遲初始化技術,但它是一個錯誤的用法。
1. 雙重檢查鎖的由來
Java 程序中,有時候可能需要推遲一些高開銷的對象初始化操作,在使用這些對象的時候才進行初始化,此時,一般都會蠶蛹延遲初始化,但要正確的實現線程安全的延遲初始化需要一定技巧,否則很容易出現問題。下面是一個非線程安全的延遲初始化對象的示例代碼:
public class UnsafeLazyInitialization{
private static UnsafeLazyInitialization instance;
private UnsafeLazyInitialization(){
}
public static UnsafeLazyInitialization getInstance(){
if (instance == null){ //1: A 線程執行
instance = new UnsafeLazyInitialization(); //2: B 線程執行
}
return instance;
}
}
A 線程執行代碼 1 的同時,B 線程執行代碼 2 ,此時,線程 A 可能會看到 instance 引用的對象還沒有完全初始化。對于以上類,我們可以對 getInstance() 方法做同步處理來實現線程安全的延遲初始化:
public class SafeLazyInitialization{
private static Instance instance;
public synchronized static Instance getInstance(){
if(instance == null){
instance = new Instance();
}
return instance;
}
}
由于對 getInstance() 方法做了同步處理,synchronized 將導致性能下降,當該方法被多個線程頻繁調用,將導致程序執行性能的下降。
早期的 JVM 中,synchronized 存在巨大的性能開銷。因此,人們想出了一個「聰明」的技巧:雙重檢查鎖定,人們想通過雙重鎖定來降低同步的開銷。下面是雙重鎖定的代碼:
public class DoubleCheckedLocking{
private static Instance instance;
public static Instance getInstance(){
if(instance == null){
synchronized (DoubleCheckedLocking.class){
if(instance == null){
instance = new Instance();
}
}
return instance;
}
}
}
雙重鎖定可以大幅降低 synchronized 帶來的性能開銷,看起來似乎很完美,但這是一個錯誤的優化!線程執行到第四行的時候,代碼讀取到 instance 不為 null 時,instance 引用的對象可能還沒有完成初始化。
5. 問題的根源
出現問題的根源就在于代碼的第七行(instance = new Singleton();)創建了一個對象,這行代碼可以分解為三行偽代碼:
memory = allocate(); //1:分配對象的內存空間
ctorInstance(memory); //2:初始化對象
instance = memory; //3:設置 instance 指向剛分配的內存地址
上面代碼中 2 和 3 之間可能會被重排序,那么在將 instance 指向分配的內存地址的時候,對象還沒有被初始化。這種重排序沒有改變單線程程序執行的結果,可以提高程序的執行性能。
但這種重排序就有可能導致后面訪問這段代碼的線程在訪問 instance 所引用的對象的時候,這個對象還沒有被初始化過。在知曉了問題的根源后,我們可以想出兩個辦法來實現線程安全的延遲初始化:
- 不允許 2 和 3重排序。
- 允許 2 和 3 重排序,但不允許其他線程「看到」這個重排序。
3. 基于 volatile 的解決方案
我們可以把上面的DoubleCheckedLocking
示例代碼中的 instance 聲明為 volatile 型,就可以實現線程安全的延遲初始化。當聲明對象的引用為 volatile 后,偽代碼中的 2 和 3 之間的重排序在多線程環境中將會被禁止。
4. 基于類初始化的解決方案
JVM 在類的初始化階段(即在 Class 被加載后,且被線程使用之前),會執行類的初始化。在執行類的初始化期間,JVM 會去獲取一個鎖。這個鎖可以同步多個線程對同一個類的初始化。
基于這個特性,可以實現另一種線程安全的延遲初始化方案,這個方案經常被用來作為實現單例模式。
public class InstanceFactory{
private static class InstanceHolder{
public static Instance instance = new Instance();
}
public static Instance getInstance(){
return InstanceHolder.instance; //這里將導致InstanceHolder 類被初始化
}
}
假設兩個線程并發執行 getInstance() 方法,下面是執行的示意圖:
這個方案的實質是:允許偽代碼中的 2 和 3 重排序,但不允許非構造線程「看到」這個重排序。
初始化一個雷,包括執行這個類的靜態初始化和初始化在這個類中聲明的靜態字段。根據 Java 語言規范,在首次發生下列任意一種情況時,一個類或接口類型 T 將被立即初始化。
- T 是一個類,而且一個 T 類型的實例被創建。
- T 是一個類,而且 T 中聲明的一個靜態方法被調用。
- T 中聲明的一個靜態字段被賦值。
- T 中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
- T 是一個頂級類,而且一個斷言語句嵌套在 T 內部被執行。
在InstanceFactory
示例代碼中,首次執行 getInstance() 方法的線程將導致 InstanceHolder 類被初始化,這符合上面的情況 4 。
Java 語言規范規定,對于每個類或接口 C ,都有一個唯一的初始化鎖 LC 與之對應。從 C 到 LC 的映射,由 JVM 的具體實現去自由實現。JVM 在類初始化期間會獲取這個初始化鎖,并且每個線程至少獲取一次鎖來確保這個類已經被初始化過。
為了便于理解,書中的作者把類初始化的處理過程分為 5 個階段。
第一階段:通過在 Class 對象上同步,獲取 Class 對象的初始化鎖,來控制類或接口的初始化。這個獲取鎖的線程會一直等待,直到當前線程能夠獲取到這個初始化鎖。
假設現在有 A 和 B 兩個線程在爭奪初始化鎖,然后 A 爭奪到了鎖,發現 Class 對象還沒有被初始化,初始化狀態 state = noInitialization。此時 A 將會設置 state = initializing。
第二階段:線程 A 執行類的初始化,同時線程 B 在初始化鎖對應的 condition 上等待。
第三階段:線程 A 設置 state = initialized,然后喚醒在 condition 中等待的所有線程。
第四階段:線程 B 結束類的初始化處理
線程 A 在第二階段的 A1 執行類的初始化,并在第 3 階段的 A4 釋放初始化鎖;線程 B 在第 4 階段的 B1 獲取同一個初始化鎖,并在第 4 階段的 B4 之后才開始訪問這個類。根據 JMM 規范的鎖規則,存在如下 happens-before 關系:
線程 A 執行類的初始化時的寫入操作(執行類的靜態初始化和初始化類中聲明的靜態字段),線程 B 一定能看到。
第五階段:線程 C 執行類的初始化的處理
在第三階段之后,類已經完成了初始化。因此線程 C 在第五階段的類初始化處理過程相對簡單一點。只需要經理一次鎖的獲取—釋放,其他線程的類初始化過程都經歷了兩次。
線程 A 執行類的初始化時的寫入操作,線程 C 一定能看到。
通過對比基于 volatile 的雙重鎖定檢查方案和基于類初始化的方案,我們發現后者的代碼更加簡潔。但前者有一個額外的優勢:除了對靜態字段可以實現延遲初始化外,還可以對實例字段實現延遲初始化。
字段延遲初始化降低了初始化類或創建實例的開銷,但增加了訪問延遲初始化的字段的開銷。大多數情況,正常的初始化要優于延遲初始化。如果要對實例字段進行線程安全的延遲初始化,請使用 volatile,如果確實需要對靜態字段進行線程安全的延遲初始化,請用基于類初始化的方案。
Java 內存模型綜述
1. 處理器的內存模型
順序一致性是一個理論參考模型,JMM 和處理器內存模型設計的時候通常會以順序一致性模型作為參考。但 JMM 和處理器都會對這一模型進行一定程度的放松。如果完全按照順序一致性模型來處理,那么很多處理器和編譯器優化都會被禁止,執行性能將會受到很大影響。
由于常見的處理器內存模型比 JMM 要弱,所以 Java 編譯器在生成字節碼時,會在執行指令序列的適當位置插入內存屏障來限制處理器重排序。同時由于處理器內存模型的強弱不同,為了在不同處理器平臺向程序員展示一個一致的內存模型,JMM 在不同的處理器中需要插入的內存屏障的數量和種類也不相同。JMM 屏蔽了不同處理器內存模型的差異,它在不同的處理器平臺之上為 Java 程序員呈現了一個一致的內存模型。
2. 各種內存模型之間的關系
JMM 是一個語言級的內存模型,處理器內存模型是硬件級的內存模型,順序一致性是一個理論參考模型。常見的處理器內存模型一般比常用的語言內存模型要弱,處理器和語言內存模型都比順序一致性內存模型弱。通處理器內存模型一樣,越追求性能的語言,內存模型設計得會越弱,但易編程性就會越差。
3. JMM 的內存可見性保證
按程序類型,Java 程序的內存可見性保證可以分為下列 3 類:
- 單線程程序。單線程程序不會出現內存可見性問題。執行結果與順序一致性執行結果相同。
- 正確同步的多線程程序。正確同步的多線程程序的執行將具有順序一致性。這是 JMM 關注的重點,JMM 通過限制編譯器和處理器的重排序來為程序員提供內存可見性保證。
- 未同步/未正確同步的多線程程序。JMM 為它們提供了最小安全性保障:線程執行時讀取到的值,要么是之前某個線程寫入的值,要么是默認值(0,null,false)。
最小安全性保證線程讀到的值不會無中生有的冒出來,但并不保證線程讀取到的值一定是正確的。最小安全性保證與 64 位數據的非原子性寫并不矛盾。
4. JSR-133 對舊內存模型的修補
JSR-133 對 JDK 5 之前的舊內存模型的修補主要有兩個:
- 增強 volatile 的內存語義。舊內存模型允許 volatile 變量與普通變量的重排序。JSR-133 嚴格限制 volatile 變量與普通變量的重排序。
- 增強 final 的內存語義。在舊內存模型中,多次讀取同一個 final 變量的值可能會不相同。為此 JSR-133 為 final 增加了兩個重排序規則。在保證 final 引用不會從構造函數內逸出的情況下,final 具有了初始化安全性。