1. 程式人生 > >Java並發機制和底層實現原理

Java並發機制和底層實現原理

差距 32處理器 們的 trac 結點 exce jdk cep 定性

  Java代碼在編譯後會變成Java字節碼,字節碼被類加載器加載到JVM裏,JVM執行字節碼轉化為匯編指令在CPU上執行。Java中的並發機制依賴於JVM的實現和CPU的指令。

  

  Java語言規範第三版中對volatile的定義如下:Java編程語言允許線程訪問共享變量,為了確保共享變量能被準確和一致的更新,線程應該確保通過排它鎖單獨獲得這個變量。Java語言提供了volatile。若一個字段被聲明為volatile,Java線程內存模型確保所有線程看到這個變量的值是一致的。volatile不會引起線程上下文切換和調度。

  CPU術語

術語 英文 術語描述
內存屏障 memory barries 是一組處理器指令,用於實現對內存操作的順序限制
緩沖行 cache line 緩存中可以分配的最小存儲單位。處理器填寫緩存線時會加載整個緩存線,需要使用多個主內存讀周期
原子操作 atomic operations 不可中斷的一個或一些列操作
緩存行填充 cache line fill 當處理器識別到從內存中讀取操作時可緩存的,處理器讀取整個緩存行到適當的緩存
緩存命中 cache hit 若進行高速緩存行填充的操作的內存位置仍是下次處理器訪問的地址時,處理器從緩存中讀取操作數,而不是從內存讀取
寫命中 write hit  當處理器將操作數寫回到一個內存緩存的區域時,它首先會檢查這個緩存的內存地址是否在緩存行中,而不是寫回到內存,這個操作被稱為寫命中
寫缺失 write misses the cache 一個有效的緩存行被寫入到不存在的內存區域

  假設instance是一個volatile變量,instance = new Singleton();

  轉變為匯編為:

    0x01a3deld: movb ¥0x0, 0x1104800(%esi);

    0x01a3de24: lock add1 $0x0,(%esp)

  有volatile變量修飾的共享變量進行寫操作時會有lock字樣。Lock前綴的指令在多核處理器下會引發兩件事:1)將當前處理器緩存行的數據寫回到系統內存 2)這個寫回內存的操作會使其他CPU裏緩存了該內存地址的數據無效。

  為了提高處理速度,處理器不直接和內存進行通信,而是先將系統內存的數據讀到內部緩存後再進行操作,但操作完不知道何時會寫到內存。若聲明了volatile的變量進行寫操作,JVM會向處理器發送一條Lock前綴的指令,將這個變量所在緩存行的數據寫回到系統內存。在多處理器下,為了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是不是過期了,當處理器發現自己緩存行對應的內存地址被修改,就會將當前處理器的緩存行設置成無效狀態,當處理器對這個數據進行修改操作的時候,會重新從系統內存中把數據讀到處理器緩存裏。

  

  volatile的兩條實現原則:

    1) Lock前綴指令會引起處理器緩存會寫到內存。Lock前綴誌林更導致在執行指令期間,聲言處理器的LOCK#信號。在多處理器環境中,LOCK#信號確保在聲言該信號期間,處理器可以獨占任何共享內存。LOCK#信號一般不鎖總線,而是鎖緩存。對於Intel486和Pentium處理器,在鎖操作時,總是在總線上聲言LOCK#信號。但在P6和目前的處理器中,若訪問的內存區域已經緩存在處理器內部,則不會聲言LOCK#信號。相反,它會鎖定這塊內存預期的緩存並會寫到內存,並使用緩存一致性機制來確保修改的原子性,此操作被稱為“緩存鎖定”,緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據。

    2)一個處理器的緩存回寫到內存會導致其他處理器的緩存無效。IA-32處理器和Intel64處理器使用MESI(修改,獨占,共享,無效)控制協議去維護內部緩存和其他處理器緩存的一致性。在多核處理器系統中進行操作的時候,IA-32和Intel64處理器能嗅探其他處理器訪問系統內存和他們的內部緩存。處理器使用嗅探技術保證它的內部緩存,系統內存和其他處理器的緩存的數據在總是線上保持一致。例如,在Pentium和P6 family處理器中,若通過嗅探一個處理器來檢測其他處理器打算寫內存地址,而這個地址當前處於共享狀態,那麽正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執行緩存行填充。

  

  volatile的優化

    JDK7的concurrent包中新增了LinkedTransferQueue,它在使用volatile變量時用追加字節碼的方式來優化隊列出隊和入隊的性能。LinkedTransferQueue使用一個內部類類型PaddedAtomicReference<QNode>來定義隊列的頭結點和為節點,而這個內部類相對於父類AtomicReference只做了一件事,就是將共享變量追加到64字節。因為Intel Core I7,Core,Atom和NetBurst幾Core Solo和Pentium M處理器的L1, L2或L3緩存的高速緩存行是64個字節寬,不支持部分填充緩存行。若隊列的頭節點或尾節點不足64字節。處理器將他們都讀到同一個高速緩存行中,在多處理器下每個處理器都會緩存同樣的頭、尾節點。當一個處理器試圖修改頭節點時,會將整個緩存行鎖定。這樣在緩存一致性機制的作用下,會導致其他處理器不能訪問自己高速緩存中的尾節點,而隊列的入隊和出隊操作則需要不停修改頭節點和尾節點,所在在多處理器的情況下將會嚴重影響到隊列的入隊和出隊效率。使用追加64字節的方式來填滿高速緩沖區的緩存行,避免頭節點和尾節點加載到統一緩存行,使頭,尾節點在修改時不會互相鎖定。

    在下面兩種情況使用volatile變量不應該追加到64字節:

      1) 緩存行非64字節寬的處理器。若P6系列和奔騰處理器。他們的L1和L2高速緩存行是32個字節寬

      2)共享變量不會被頻繁的寫。追加字節碼的方式需要處理器讀取跟多的字節到高速緩沖區,會有一定性能消耗。但Java7會淘汰或重新排列無用字段。

  synchronized的實現原理

    Java中的每一個對象都可以作為鎖。具體表現為以下3種方式:

      1. 對於普通同步方法,鎖是當前實例對象

      2. 對於靜態同步方法,鎖是當前類的Class對象

      3. 對於同步方法快,鎖是Synchronized括號裏的配置對象

    從JVM規範中可以看到JVM基於進入和退出Monitor對象來實現方法同步和代碼塊同步。但兩者的實現細節不一樣。代碼塊同步是使用monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的。monitorenter指令在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束出和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何對象都有一個monitor與之關聯,並且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter指令後。將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

  Synchronized用的鎖是存在Java對象頭裏的。若對象是數組類型,則虛擬機用3個字寬(word)存儲對象頭,若對象是非數組類型,則用2字寬存儲對象頭。在32位虛擬機中,1字寬等於4字節,即32位。

    Java對象頭的長度

長度 內容 說明
32/64 bit Mark Word 存儲對象的hashCode或鎖信息等
32/64 bit Class Metadata Address 存儲到對象類型數據的指針
32/64 bit Array length 數組的長度

  Java對象頭裏的Mark Word裏默認存儲對象的HashCode,分代年齡和鎖標記位。

    32位Mark Word的默認存儲

鎖狀態 25bit 4bit 1bit是否是偏向鎖 2bit鎖標誌位
無鎖狀態 對象的hashCode 對象分代年齡 0 01

  Mark Word的狀態變化

鎖狀態

25bit

23bit/2bit

4bit

1bit

是否是偏向鎖

2bit

鎖標誌位

輕量級鎖 指向棧中鎖記錄的指針 00
重量級鎖 指向互斥量的指針 10
GC標記 11
偏向鎖 線程ID|Epoch|對象分代年齡|1 01

  在64位虛擬機下,Mark Word是64位大小的

鎖狀態 25bit 31bit

1bit

cms_free

4bit

分代年齡

1bit

偏向鎖

2bit

鎖標誌位

無鎖 unused hashCode 0 01
偏向鎖 ThreadID(54bit)Epoch(2bit) 1 01

  Java SE1.6為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE1.6中,鎖一共有4種狀態,級別從低到高一次是:無狀態鎖,偏向鎖狀態,輕量級鎖狀態和重量級鎖狀態。鎖可以升級但不可以被降級,目的是為了提高獲得鎖和釋放鎖的效率。

  

  偏向鎖:

    當一個線程訪問同步塊並獲取鎖時,會在對象頭和棧幀中的鎖記錄裏存儲鎖偏向的線程ID,以後該線程在進入和退出同步塊時不需要進行CAS操作來加鎖或解鎖,只需要簡單的測試下對象頭的Mark Word裏面是否存儲著指向當前線程的偏向鎖。若測試成功,則表示該線程已經獲得了鎖。若測試失敗,則需在測試下Mark Word中偏向鎖的標識是否設置成1,若沒有,則使用CAS競爭鎖,若設置了,則嘗試使用CAS將對象頭的偏向鎖指向當前線程。

    偏向鎖使用了等到競爭出現才釋放鎖的機制,所以當有其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖。偏向鎖的撤銷,需要等待全局安全點(當前時間點沒有正在執行的字節碼)。它會首先暫停擁有偏向鎖的線程,然後檢查持有偏向鎖的線程是否還活著,若線程不處於活動狀態,則將對象頭設置成無鎖狀態;若線程仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向對象的鎖記錄,棧中的所記錄和對象頭的Mark Word要麽重新偏向於其他線程,要麽恢復到無鎖或標記對象不合適作為偏向鎖,最後喚醒暫停的線程

技術分享

    偏向鎖在Java 6和Java 7裏是默認開啟的,但它在應用程序啟動幾秒後才激活,可以使用JVM參數來關閉延遲:-XX:BiasedLockingStartipDelay=0。通過JVM關閉偏向鎖:-XX:-UseBiasedLocking=false,此時程序會默認進入輕量級鎖狀態。

  

  輕量級鎖

    線程在執行同步塊之前,JVM會現在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的Mark Word復制到所記錄中,官方成為Displaced Mark Word。然後線程嘗試使用CAS將對象頭中的Mark Word替換為指向鎖記錄的指針。若成功,當前線程獲得鎖,若失敗,表示其他線程競爭鎖,當前線程便嘗試使用自旋來獲取鎖。

    輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到對象頭,若成功,責編是沒有競爭發生。若失敗,表示當前鎖存在競爭,鎖就會彭長成重量級鎖。

  

技術分享

    因為自旋會消耗CPU,為了避免無用的自旋,一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖的狀態。當鎖處於重量級狀態下,其他線試圖獲取鎖時,會被阻塞住,當持有鎖的線程釋放鎖之後會喚醒這些線程,被喚醒的線程就會進行新一輪的奪鎖之爭。

優點 缺點 使用場景
偏向鎖 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納米級的差距 若線程間存在鎖競爭會帶來額外的鎖撤銷的消耗 適用於只有一方線程訪問同步快場景
輕量級鎖 競爭的線程不會阻塞,提高了程序的響應速度 若始終得不到鎖競爭的線程使用自旋會消耗CPU 追求響應時間;同步快執行速度非常快
重量級鎖 線程競爭不適用自旋,不會消耗CPU 線程阻塞;響應時間慢 追求吞吐量;同步塊執行速度較長

  原子操作的實現原理

    原子操作時不可被中斷的一個或一系列操作。

術語名稱 英文 解釋
緩存行 cache line 緩存的最小操作單位
比較並交換 compare and swap CAS操作需要輸入兩個數值,一個舊值和一個新值,在操作期間先比較舊值有沒有變化,若沒有變化才交換成新值,發生了變化則不交換
CPU流水線 CPU pipeline CPU流水線的工作方式就像工業生產上的裝配流水線,在CPU中由5~6個不同功能的電路單元組成一條指令處理流水線,然後將一條X86指令分成5~6步後再由這些電路單元分別執行,這樣就能實現在一個CPU時鐘周期完成一條指令,因此提高CPU的運算速度
內存順序沖突 momory order violation 內存順序沖突一般是由假共享引起的,假共享是指多個CPU同時修改同一個緩存行的不同部分而引起其中一個CPU的操作無效,當出現這個內存順序沖突時,CPU必須清空流水線

    32位IA-32處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的內存操作的原子性。處理器保證從系統內存中讀取或寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其他處理器不能訪問這個字節的內存地址。Pentium6和最新的處理器能自動保證單處理器對統一個緩存行裏進行16/32/64位的操作是原子的。但復雜的內存操作處理器是不能保證其原子性的,比如跨總線寬度,跨多個緩存行和跨頁表的訪。處理器提供總線鎖定和緩存鎖定兩個機制來保證復雜內存操作的內存操作的原子性。

    若多處理器同時對共享變量進行讀改寫操作,那麽共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的。要保證讀改寫共享變量的操作時原子的,就必須保證一個CPU改寫變量時,其余CPU不能操作緩存了該共享變量內存地址的緩存。總線鎖就是使用處理器提供的一個LOCK#信號,當以處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麽該處理器可以獨占共享內存。

    通過緩存鎖定來保證原子性。在同一時刻,只需要保證對某個內存地址的操作時原子性即可,但總線鎖定把CPU和內存之間的通信鎖住了,這使得鎖定期間其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,目前處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化。頻繁使用內存會緩存在處理器的L1,L2和L3高速緩存裏,那麽原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖,在Pentium 6和目前的處理器中可以使用“緩存鎖定”的方式來實現復雜的原子性。緩存鎖定是指在內存區域若被緩存在處理器的緩存行中,並且在Lock操作期間被鎖定,那麽當它執行鎖操作回寫到內存時,處理器不在總線上聲明Lock#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因此緩存一致性機制會阻止同時修改由兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

    有兩種情況下處理器不會使用緩存鎖定。第一種情況是當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行時,則處理器會調用總線鎖定。第二種情況是有些處理器不支持緩存鎖定。對於Intel 486和Pentium處理器,就算鎖定的內存區域在處理器的緩存行中也會調用總線鎖定。

    Java中可以通過鎖和循環CAS的方式來實現原子操作。

      JVM中的CAS操作時利用了處理器提供的CMPXCHG指令實現的。自旋的CAS實現的基本思路是循環進行CAS操作直到成功為止。

private AtomicInteger atomicI = new AtomicInteger(0);
private int i = 0;
public static void main(String[] args){
    final Counter cas = new Counter();
    List<Thread> ts = new ArrayList<Thread>(600);
    long start = System.currentTimeMills();
    for(int j = 0; j < 100; j ++){
        Thread t = new Thread(new Runnable(){
               public void run(){
                   for(int i = 0; i < 10000; i++){
                       cas.count();
                       cas.safeCount();
                   }
               }
           });
    }
    for(Thread t : ts){
        t.start();
    }

    for(Thread t : ts){
       try{
           t.join();
       }catch(InterupptedException e){
           e.printStackTrace();
       }
    }
    System.out.println(cas.i);
    System.out.println(cas.atomicI.get());
    System.out.println(System.currentTimeMillis() - start);
}

private void safeCount(){
    for(;;){
         int i = atomicI.get();
         boolean suc = atomicI.compareAndSet(i, ++i);
         if(suc){
             break;
         }
    }
}

private void count(){
    i++;
}

    CAS實現原子操作的三大問題:

       ABA問題。因此CAS需要在操作值時檢查值有沒有變化,若沒有變化則更新。單弱一個值原來是A,變成了B又變成了A,那麽使用CAS進行檢查時會發現它的值沒有變化。ABA問題的解決思路是使用版本號。在變量前面加上版本號,每次變量更新時都把版本號加1。Java 1.5開始,JDK的Atomic包提供了AtomicStampedReference類來解決ABA問題。這個類的compareAndSet先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,若全相等,則以原子方式將該引用和該標誌的值設為給定的更新值。

public boolean compareAndSet{
     V expectedReference,
     V newReference,
     int expectedStamp,
     int newStamp
}

        循環時間長開銷大。自旋CAS若長時間不成功,會給CPU帶來非常大的執行開銷。若JVM支持處理器的pause指令則效率會有一定的提升。pause指令有兩個作用:它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零。它還可以避免在退出循環時因內存順序沖突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),從而提高CPU的執行效率。

        只能保證一個共享變量的原子操作。當對一個共享變量執行操作時,可以使用循環CAS的方式保證原子操作,但對多個共享變量操作時,循環CAS就無法保證操作的原子性,此時可以用鎖。或者將多個共享變量組成一個共享變量來操作。JAVA 1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,可以把多個變量放在一個對象裏進行CAS操作。

  

    使用鎖機制來實現原子操作

      鎖機制保證了只有獲得鎖的線程才能夠操作鎖定的內存區域。JVM內部實現了很多種鎖機制,有偏向鎖,輕量級鎖和互斥鎖。JVM實現鎖的方式都是用了循環CAS,即當一個線程想進入到同步塊時使用循環CAS來獲取鎖,當它退出同步塊是使用循環CAS釋放鎖。

Java並發機制和底層實現原理