![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIn5GcuEzMyMDN0MzNz0CM3kzN0MDM3ETMwEDMyIDMy0SM4UDOzITMvwVMwIjMwIzLcFDO1gzMyEzLcd2bsJ2Lc12bj5ycn9Gbi52YuAjMwIzZtl2Lc9CX6MHc0RHaiojIsJye.png)
Java記憶體模型說明了某個線程的記憶體操作在那些情況下對于其他線程是可見的。其中包括確定這些操作按照一個種Happens-Before的偏虛關系進行排序。
第十六章:Java記憶體模型
本文我們将重點放在Java記憶體模型(JMM)的一些高層設計問題,以及JMM的底層需求和所提供的保證,還有一些高層設計原則背後的原理。
例如安全釋出,同步政策的規範以及一緻性等。他們的安全性都來自于JMM,并且當你了解了這些機制的工作原理後,就能更容易的使用他們。
1、什麼是記憶體模型,為什麼要使用它
假設一個線程為變量aVar指派:
a = 3;
記憶體模型要解決的問題是:“在什麼條件下,讀取a的線程可以看到這個值為3?”。這聽起來似乎是一個愚蠢的問題,但如果缺少同步,那麼會有很多因素導緻無法立即、甚至永遠看不到一個線程的操作結果。這包括很多因素,如果沒有使用正确的同步,例如:
- 編譯器中生成的指令順序與源代碼中的順序不同;
- 編譯器将變量儲存在寄存器而不是記憶體中;
- 處理器可以亂序或者并行執行指令;
- 緩存可能會改變将寫入變量送出到主記憶體的次序;
- 處理器中也有本地緩存,對其他處理器不可見;
在單線程中,我們無法看到所有這些底層技術,他們除了提高成勳的執行速度,不會産生其他影響。Java語言規範要求JVM線上程中維護一種類似串行的語義:隻要程式的最終結果與在嚴格環境中的執行結果相同,那麼上述操作都是允許的。
這确實是一件好事情,因為在近幾年中,計算性能的提升在很大的程度上要歸功于:
- 重新排序措施;
- 時鐘頻率的提升;
- 不斷提升的并行性;
- 采用流水線的超标量執行單元,動态指令調整, 猜測執行以及完備的多級緩存等;
随着處理器越來越強大,編譯器也在不斷的改進,通過指令重排序實作優化執行,以及使用成熟的全局寄存器配置設定算法。由于時鐘頻率越來越難以提高,是以許多處理器生産商都開始轉而生産多核處理器,因為能夠提高的隻有硬體的并行性。
在多線程環境中,維護程式的串行性将導緻很大的性能開銷,并發程式中的線程,大多數時間裡都執行各自的任務,是以線程之間協調操作隻會降低應用程式的運作速度,不會帶來任何好處。隻有當多個線程要共享資料時,才必須協調他們之間的操作,并且JVM依賴程式通過同步操作找出這些協調操作将何時發生。
JMM規定了JVM必須遵循一組最小的保證,這組保證規定了對變量的寫入操作在何時将對其他線程可見。
JMM在設計時就在可預測性與易于開發性之間進行了權衡,進而在各種主流的處理器體系架構上能實作高性能的JVM。如果你不了解在現代處理器和編譯器中使用的程式性能提升措施,那麼在剛剛接觸JMM的某些方面時會感到困惑。
1.1 平台的記憶體模型
在共享記憶體的多處理器體系架構中,每個處理器擁有自己的緩存,并且定期的與主記憶體進行協調。在不同的處理器架構中提供了不同級别的緩存一緻性(cache coherence)。其中一部分隻提供最小的保證,即允許不同的處理器在任意時刻從同一個存儲位置上看到不同的值。作業系統、編譯器以及runtime運作時(有時甚至包括應用程式)需要彌補這種硬體能力與線程安全需求之間的差異。
要確定每個處理器在任意時刻都知道其他處理器在進行的工作,這将開銷巨大。多數情況下,這完全沒必要,可随意放寬存儲一緻性,換取性能的提升。
在架構定義的記憶體模型中将告訴應用程式可以從記憶體系統中擷取怎樣的保證,此外還定義了一些特殊的指令(稱為記憶體栅欄),當需要共享資料時,這些指令就能實作額外的存儲協調保證。為了使Java開發人員無須關心不同架構上記憶體模型之間的差異,Java還提供了自己的記憶體模型JMM,并且JVM通過在适當的位置上插入記憶體栅欄來屏蔽JMM與底層平台記憶體模型之間的差異。
程式執行一種簡單的假設:想象在程式中之存在唯一的操作執行順序,而不考慮這些操作在何種處理器上執行,并且在每次讀取變量時,都能獲得在執行序列中最近一次寫入該變量的值。這種樂觀的模型被稱為串行一緻性。軟體開發人員經常會錯誤地假設存在串行一緻性。但是在任何一款現代多處理器架構中都不會提供這種串行一緻性,JMM也是如此。馮諾依曼模型這種經典的穿行計算模型,隻能近似描述現代多處理器的行為。
在現在支援共享記憶體的多處理和編譯器中,當跨線程共享資料時,會出現一些奇怪的情況,除非通過使用記憶體栅欄來防止這種情況的發生。幸運的是,Java程式不需要制定記憶體栅欄的位置,隻需要通過正确地使用同步就可以。
1.2 重排序
程式清單16-1 如果沒有包含足夠的同步,将産生奇怪的結果
public class ReorderingDemo {
static int x = 0, y = 0;
static int a = 0, b = 0;
public static void main(String[] args) throws Exception {
x = y = a = b = 0;
Thread one = new Thread() {
public void run() {
a = 1;
x = b;
}
};
Thread two = new Thread() {
public void run() {
b = 1;
y = a;
}
};
one.start();
two.start();
one.join();
two.join();
System.out.println(x + ", " + y);
}
程式清單16-1 ReorderingDemo 說明了在沒有正确的同步情況下,即使要推斷最簡單的并發程式的行為也很難。圖16-1給出了一種可能由于不同執行順序而輸出的結果。
這種各種使操作延遲或者看似混亂執行的不同原因,都可以歸為重排序。
ReorderingDemo很簡單,但是要列舉出他所有可能的結果卻非常困難。記憶體級别的重排序會使程式的行為不可預測。如果沒有同步,那麼推斷出執行順序将是非常困難的,而要確定在程式中正确地使用同步卻是非常容易的。同步将限制編譯器、運作時和硬體對記憶體操作的重排序的方式,進而在實施重排序時不會破壞JMM提供的可見性保證。
注:在大多數主流的處理器架構中,記憶體模型都非常強大,使得讀取volatile變量的性能與讀取非volatile變量的性能大緻相當。
1.3 Java記憶體模型簡介
JMM是通過各種操作來定義,包括對變量的讀寫操作,螢幕monitor的加鎖和釋放操作,以及線程的啟動和合并操作,JMM為程式中所有的操作定義了一個偏序關系,稱為Happens-before,要想保證執行操作B的線程看到A的結果(無論A和B是否在同一個線程中執行),那麼A和B之間必須滿足Happens-before關系。如果沒有這個關系,那麼JVM可以對他們任意的重排序。
當一個變量被多個線程讀取并且至少被一個線程寫入時,如果在讀操作和寫操作之間沒有依照Happens-before來排序,那麼就會産生資料競争的問題。在正确使用同步的程式中不存在資料競争,并會表現出串行一緻性,這意味着程式中的所有操作都會按照一種固定的和全局的順序執行。
圖16-2給出了當兩個線程使用同一個鎖進行同步時,在他們之間的Happens-before關系。線上程A内部的所有操作都按照他們在源程式中的先後順序來排序,線上程B内部的操作也是如此。由于A釋放了鎖M,并且B随後獲得了鎖M,是以A中所有在釋放鎖之前的操作,也就位于B中請求鎖之後的所有操作之前。如果這兩個線程是在不同的鎖上進行同步的,那麼就不能推斷他們之間的動作順序,因為他們之間不存在Happens-before關系。
1.4 借助同步
由于Happens-Before的排序功能很強大,是以有時候可以”借助(Piggyback)”現有同步機制的可見性屬性。這需要将Happens-Before的程式規則與其他某個順序規則(通常是螢幕鎖規則或者volatile變量規則)結合起來,進而對某個未被鎖保護的變量的通路操作進行排序。這項技術由于對語句的順序非常敏感,是以很容易出錯。他是一項進階技術,并且隻有當需要最大限度地提升某些類(例如ReentrantLock)的性能時,才應該使用這項技術。同時,因為在使用中很容易出錯,是以也要謹慎使用。
在FutureTask的保護方法AbstractQueuedSynchronizer中說明了如何使用這種“借助”技術。
AQS維護了一個辨別同步器狀态的整數,FutureTask用這個整數來儲存任務的狀态:正在運作、已完成和已取消。但FutureTask還維護了其他一些變量,例如計算的結果。當一個線程調用set方來儲存結果并且另一線程調用get來擷取該結果時,這兩個線程最好按照Happens-Before進行排序。這可以通過将執行結果的引用聲明為volatile類型來實作,但利用現在的同步機制可以更容易地實作相同的功能。
程式清單16-2 說明如何借助同步的FutureTask的内部類
FutureTask在設計時能夠確定,在調用 tryAccquireShared 之前總能成功調用 tryReleaseShard 。tryReleaseShard會寫入一個volatile類型的變量,而tryAccquireShard将讀取這個變量。程式清單16-2給出了innerGet和innerSet等方法,在儲存和擷取result時将調用這些方法。由于innerSet将在調用releaseShared(這又将調用tryReleaseShard)之前寫入result,并且innerGet将在調用acquireShared(這又将調用tryAccquireShared)之後讀取result,是以将程式順訊規則與volatile變量規則結合在一起,就可以確定innerSet中的寫入操作在innerGer之前之前。
之是以将這項技術稱為“借助”,是因為它使用了一種現有的Happens- Before順序來確定對象X的可見性,而不是專門為了釋出X而建立一種Happens-Before順序。在類庫中提供的其他Happens-Before排序包括:
- 将一個元素放入一個線程安全容器的操作将在另一個線程從該容器中獲得這個元素的操作之前執行
- 在CountDownLatch上的倒數操作将線上程從閉鎖上的await方法傳回之前執行
- 釋放Semaphore許可的操作将在從該Semaphore上獲得一個許可之前執行
- Future表示的任務的所有操作将在從Future.get中傳回之前執行
- 向Executor送出一個Runnable或Callable的操作将在任務開始執行之前執行
- 一個線程到達CyclicBarrier或Exchange的操作将在其他到達該栅欄或交換點的線程被釋放之前執行。如果CyclicBarrier使用一個栅欄操作,那麼到達栅欄的操作将在栅欄操作之前執行,而栅欄操作又會線上程從栅欄中釋放之前執行。
2、釋出
第三章介紹了如何安全的或者不正确的釋出一個對象,其中介紹的各種技術都依賴JMM的保證,而造成釋出不正确的原因就是在“釋出一個共享對象”與“另外一個線程通路該對象”之間缺少一種happens-before關系。
2.1 不安全的釋出
當缺少happens-before關系時,就可能會發生重排序,這就解釋了為什麼在沒有充分同步的情況下釋出一個對象會導緻另一個線程看到一個隻被部分構造的對象。假入初始化一個對象時需要寫入多個變量(多個域),在釋出該對象時,則可能出現如下情況,導緻釋出了一個被部分構造的對象:
init field a
init field b
釋出ref
init field c
錯誤的延遲初始化将導緻不正确的釋出,如下程式清單16-3。
注:除了不可變對象以外,使用被另一個線程初始化的對象通常都是不安全的,除非對象的釋出操作是在使用該對象的線程開始使用之前執行
程式清單16-3 不安全的延遲初始化
public class UnsafeLazyInitialization {
private static Object resource;
public static Object getInstance(){
if (resource == null){
resource = new Object(); //不安全的釋出
}
return resource;
}
}
2.2 安全釋出
借助于類庫中現在的同步容器、使用鎖保護共享變量、或都使用共享的volatile類型變量,都可以保證對該變量的讀取和寫入是按照happens-before關系來排序。
注:happens-before事實上可以比安全釋出承諾更強的可見性與排序性
2.3 安全初始化模式
方式一:加鎖保證可見性與排序性
getInstance的代碼路徑很短,隻包括一個判斷預見和一個預測分支,是以如果在沒有被多個線程頻繁調用或者在不會出現激烈競争的情況下,可以提供較為滿意的性能。
程式清單16-4 線程安全的延遲初始化
public class SafeLazyInitialization {
private static Object resource;
public synchronized static Object getInstance(){
if (resource == null){
resource = new Object();
}
return resource;
}
}
方式二:提前初始化
在初始化器中采用了特殊的方式來處理靜态域(或者在靜态初始化代碼塊中初始化的值),并提供了額外的線程安全性保證。靜态初始化是由JVM在類的初始化階段執行,即在類被加載後并且被線程使用之前。由于JVM将在初始化期間獲得一個鎖,并且每個線程都至少擷取一次這個鎖以確定這個類已經加載,是以在靜态初始化期間,記憶體寫入操作将自動對所有線程可見。
是以,無論是在被構造期間還是被引用時,靜态初始化的對象都不需要顯示的同步。
程式清單16-5 提前初始化
public class EagerInitialization {
private static Object resource = new Object();
public static Object getInstance(){
return resource;
}
}
方式三:延遲初始化展位模式,建議
通過靜态初始化和JVM的延遲加載機制結合起來可以形成一種延遲初始化的技術,進而在常見的代碼路徑中不需要同步。
程式清單16-6 掩藏初始化占位類模式
public class ResourceFactory {
private static class ResourceHolder{
public static Object resource = new Object();
}
public static Object getInstance(){
return ResourceHolder.resource;
}
}
方式四:DCL雙重加鎖機制,注意保證volatile類型,否則出現一緻性問題(jdk5.0+)
DCL實際是一種糟糕的方式,是一種anti-pattern,它隻在JAVA1.4時代好用,因為早期同步的性能開銷較大,用來避免不必要的開銷或者降低程式的啟動時間,但是目前DCL已經被廣泛的廢棄不用,因為促使該模式出現的驅動力已經不在(無競争同步的執行速度很慢,以及jvm啟動時很慢),他不是一個高效的優化措施。
程式清單16-7 雙重加鎖
public class DoubleCheckedLocking {
private static volatile Object resource;
public static Object getInstance(){
if (resource == null){
synchronized (DoubleCheckedLocking.class){
if (resource == null){
resource = new Object();
}
}
}
return resource;
}
}
3、初始化過程中的安全性
final不會被重排序。
- 程式清單16-8中的states因為是final的是以可以被安全的釋出。即使沒有volatile,沒有鎖。但是,如果除了構造函數外其他方法也能修改states。如果類中還有其他非final域,那麼其他線程仍然可能看到這些域上不正确的值。也導緻了構造過程中的escape。
寫final的重排規則:
- JMM禁止編譯器把final域的寫重排序到構造函數之外。
- 編譯器會在final域的寫之後,構造函數return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數之外。也就是說:寫final域的重排序規則可以確定:在對象引用為任意線程可見之前,對象的final域已經被正确初始化過了。
讀final的重排規則:
- 在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。也就是說:讀final域的重排序規則可以確定:在讀一個對象的final域之前,一定會先讀包含這個final域的對象的引用。
如果final域是引用類型,那麼增加如下限制:
- 在構造函數内對一個final引用的對象的成員域的寫入,與随後在構造函數外把這個被構造對象的引用指派給一個引用變量,這兩個操作之間不能重排序。
程式清單16-8 不可變對象的初始化安全性
@ThreadSafepublic
class SafeStates {
private final Map<String, String> states;
public SafeStates() {
states = new HashMap<String, String>();
states.put("alaska", "AK");
states.put("alabama", "AL");
states.put("wyoming", "WY");
}
public String getAbbreviation(String s) {
return states.get(s);
}
}
了解更多知識,關注我。 👉👉👉