1. 程式人生 > >二、Java併發機制的底層實現原理

二、Java併發機制的底層實現原理

Java程式碼編譯後變成java位元組碼,位元組碼被類載入器載入到JVM裡,JVM執行位元組碼,最終需要轉化為彙編指令在CPU上執行,java中所使用的併發機制依賴於JVM的實現和CPU的執行。

2.1 volatile的應用
在多執行緒併發程式設計中,synchronized和volatile都扮演重要的角色,volatile是輕量級的synchronized,它在多處理器開發中保證共享變數的“可見性”。可見性是指當一個執行緒修改一個共享變數時,另一個執行緒能讀到這個修改的值,如果volatile變數使用恰當,它比synchronized的使用和執行成本更低,因為它不會引起執行緒上下文的切換和排程。

注:那些是共享變數呢?
1)使用同一個Runnable物件,將需要共享的資料,放到Runnable物件裡面
2)將共享變數封裝到一個物件裡,把物件傳遞給每個Runnable物件,或者將Runnable類定義為內部類,將共享變數定義為外部類的成員變數
3)直接全域性定義一個靜態變數

1、volatile的定義和實現原理
定義:java程式語言允許執行緒訪問共享變數,為了確保共享變數被準確和一致地更新,執行緒應該通過排它鎖單獨獲取這個變數。Java提供了volatile,在某些情況下比鎖更加方便,如果一個欄位被宣告為volatile,java執行緒記憶體模型確保所有執行緒看到這個變數的值是一致的。
為了提高處理速度,處理器不直接和記憶體進行通訊,而是先將系統記憶體的資料讀到內部快取後再進行操作,但操作完不知道何時寫到記憶體,如果對volatile修飾的變數進行寫操作,JVM就會想處理器傳送一條lock字首的指令,將這個變數所在的快取行的資料寫回到系統記憶體,如果其他處理器的值還是舊值,再執行計算操作就會有問題。在多處理器下,為了保證各處理器的快取是一直的,就會實現快取一致性協議,每個處理器通過嗅探在總線上傳播的資料來檢查自己快取的值是不是過期了,當處理器發現自己快取行對應的記憶體地址被修改,就會將當前處理器的快取行置為無效狀態,當處理器對這個資料進行修改操作時,會重新從系統記憶體中把資料讀到處理器快取裡。

Volatile的兩條實現原則。
1)Lock字首指令會引起處理器快取回寫到記憶體。在多處理器中,目前的處理方法是:如果訪問的記憶體區域已經快取在處理器內部,會鎖定這個記憶體區域的快取並寫回到記憶體,並使用快取一致性機制確保修改的原子性,此操作稱為“快取鎖定”,快取一致性機制會阻止同時修改由兩個以上處理器快取的記憶體區域資料。

2)一個處理器的快取回寫到記憶體會導致其他處理器的快取無效。Intel64處理器使用MESI(修改、獨佔、共享、無效)控制協議去維護內部快取和其他處理器快取的一致性。在多核處理器系統中進行操作時,處理器能嗅探其他處理器訪問系統記憶體和它們的內部快取。處理器使用嗅探技術保證它的內部快取、系統記憶體和其他處理器的快取的資料在總線上保持一致。

2、volatile的使用優化
JDK7的併發包中有一個佇列集合類LinkedTransferQueue,它在使用volatile變數時,用一種追加位元組的方式來優化隊列出隊和入隊的效能。程式碼如下

/** 佇列的頭部節點 */
private transient final PaddedAtomicReference < QNode > head;

/** 佇列的尾部節點 */

private transient final PaddedAtomicReference < QNode > tail;


    static final class PaddedAtomicReference < T > extends AtomicReference < T > {

        // 使用很多4個位元組的引用追加到64個位元組
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;

        PaddedAtomicReference(T r) {

            super(r);

        }

}

public class AtomicReference < V > implements java.io.Serializable {

    private volatile V value;

    //省略其他程式碼 

追加位元組能優化效能?LinkedTransferQueue這個類,使用一個內部類型別定義佇列的頭結點和尾節點,這個內部類PaddedAtomicReference相對於父類只做了一件事,將共享變數追加到64位元組。一個物件的引用佔用4個位元組,它追加了15個變數(60個位元組),一共64個位元組。

為什麼追加64位元組能提高併發程式設計的效率呢?
因為對於大多數處理器的L1、L2或L3快取的告訴快取行是64個位元組寬,不支援部分填充緩衝行,如果佇列的頭結點或尾節點都不足64位元組的話,處理器會將它們讀到同一高速緩衝行,在多個處理器下每個處理器都會快取同樣的頭尾節點,當一個處理器試圖修改頭結點時,會將整個快取行鎖定,那麼在快取一致性機制的作用下,會導致其他處理器不能訪問自己快取記憶體中的尾節點,而佇列的入隊和出隊操作則需要不停修改頭節點和尾節點,所以在多處理器的情況下將會嚴重影響到佇列的入隊和出隊效率。Doug lea使用追加到64位元組的方式來填滿高速緩衝區的快取行,避免頭節點和尾節點載入到同一個快取行,使頭、尾節點在修改時不會互相鎖定。
那麼是不是在使用volatile變數時都應該追加到64位元組呢?不是的。在兩種場景下不應該
使用這種方式。
·快取行非64位元組寬的處理器。如P6系列和奔騰處理器,它們的L1和L2快取記憶體行是32個位元組寬。
·共享變數不會被頻繁地寫。因為使用追加位元組的方式需要處理器讀取更多的位元組到高速緩衝區,這本身就會帶來一定的效能消耗,如果共享變數不被頻繁寫的話,鎖的機率也非常小,就沒必要通過追加位元組的方式來避免相互鎖定。

不過這種追加位元組的方式在Java 7下可能不生效,因為Java 7變得更加智慧,它會淘汰或重新排列無用欄位,需要使用其他追加位元組的方式。除了volatile,Java併發程式設計中應用較多的是synchronized,下面一起來看一下。

2.2 synchronized的實現原理與應用
在多執行緒併發程式設計中synchronized一直是元老級角色,很多人都會稱呼它為重量級鎖。但是,隨著Java SE 1.6對synchronized進行了各種優化之後,有些情況下它就並不那麼重了。本文詳細介紹Java SE 1.6中為了減少獲得鎖和釋放鎖帶來的效能消耗而引入的偏向鎖和輕量級鎖,以及鎖的儲存結構和升級過程。
先來看下利用synchronized實現同步的基礎:Java中的每一個物件都可以作為鎖。具體表現為以下3種形式。
·對於普通同步方法,鎖是當前例項物件。
·對於靜態同步方法,鎖是當前類的Class物件。
·對於同步方法塊,鎖是Synchonized括號裡配置的物件。

當一個執行緒試圖訪問同步程式碼塊時,它首先必須得到鎖,退出或丟擲異常時必須釋放鎖。
那麼鎖到底存在哪裡呢?鎖裡面會儲存什麼資訊呢?
從JVM規範中可以看到Synchonized在JVM裡的實現原理,JVM基於進入和退出Monitor物件來實現方法同步和程式碼塊同步,但兩者的實現細節不一樣。程式碼塊同步是使用monitorenter和monitorexit指令實現的,而方法同步是使用另外一種方式實現的,細節在JVM規範裡並沒有詳細說明。但是,方法的同步同樣可以使用這兩個指令來實現。
monitorenter指令是在編譯後插入到同步程式碼塊的開始位置,而monitorexit是插入到方法結束處和異常處,JVM要保證每個monitorenter必須有對應的monitorexit與之配對。任何物件都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。執行緒執行到monitorenter指令時,將會嘗試獲取物件所對應的monitor的所有權,即嘗試獲得物件的鎖。
2.2.1 Java物件頭
synchronized用的鎖是存在Java物件頭裡的。如果物件是陣列型別,則虛擬機器用3個字寬(Word)儲存物件頭,如果物件是非陣列型別,則用2字寬儲存物件頭。在32位虛擬機器中,1字寬等於4位元組,即32bit,如表2-2所示。
這裡寫圖片描述
Java物件頭裡的Mark Word裡預設儲存物件的HashCode、分代年齡和鎖標記位。32位JVM的Mark Word的預設儲存結構如表2-3所示。
這裡寫圖片描述
在執行期間,Mark Word裡儲存的資料會隨著鎖標誌位的變化而變化。Mark Word可能變化為儲存以下4種資料,如表2-4所示。
這裡寫圖片描述
在64位虛擬機器下,Mark Word是64bit大小的,其儲存結構如表2-5所示。
這裡寫圖片描述
2.2.2 鎖的升級與對比
Java SE 1.6為了減少獲得鎖和釋放鎖帶來的效能消耗,引入了“偏向鎖”和“輕量級鎖”,在Java SE 1.6中,鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態,這幾個狀態會隨著競爭情況逐漸升級。鎖可以升級但不能降級,意味著偏向鎖升級成輕量級鎖後不能降級成偏向鎖。這種鎖升級卻不能降級的策略,目的是為了提高獲得鎖和釋放鎖的效率,下文會詳細分析。
1.偏向鎖
HotSpot[1]的作者經過研究發現,大多數情況下,鎖不僅不存在多執行緒競爭,而且總是由同一執行緒多次獲得,為了讓執行緒獲得鎖的代價更低而引入了偏向鎖。當一個執行緒訪問同步塊並獲取鎖時,會在物件頭和棧幀中的鎖記錄裡儲存鎖偏向的執行緒ID,以後該執行緒在進入和退出同步塊時不需要進行CAS操作來加鎖和解鎖,只需簡單地測試一下物件頭的Mark Word裡是否儲存著指向當前執行緒的偏向鎖。如果測試成功,表示執行緒已經獲得了鎖。如果測試失敗,則需要再測試一下Mark Word中偏向鎖的標識是否設定成1(表示當前是偏向鎖):如果沒有設定,則使用CAS競爭鎖;如果設定了,則嘗試使用CAS將物件頭的偏向鎖指向當前執行緒。
(1)偏向鎖的撤銷
偏向鎖使用了一種等到競爭出現才釋放鎖的機制,所以當其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖。偏向鎖的撤銷,需要等待全域性安全點(在這個時間點上沒有正在執行的位元組碼)。它會首先暫停擁有偏向鎖的執行緒,然後檢查持有偏向鎖的執行緒是否活著,如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態;如果執行緒仍然活著,擁有偏向鎖的棧會被執行,遍歷偏向物件的鎖記錄,棧中的鎖記錄和物件頭的Mark Word要麼重新偏向於其他執行緒,要麼恢復到無鎖或者標記物件不適合作為偏向鎖,最後喚醒暫停的執行緒。圖2-1中的執行緒1演示了偏向鎖初始化的流程,執行緒2演示了偏向鎖撤銷的流程。
這裡寫圖片描述
(2) 關閉偏向鎖
偏向鎖在Java 6和Java 7裡是預設啟用的,但是它在應用程式啟動幾秒鐘之後才啟用,如有必要可以使用JVM引數來關閉延遲:-XX:BiasedLockingStartupDelay=0。如果你確定應用程式裡所有的鎖通常情況下處於競爭狀態,可以通過JVM引數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程式預設會進入輕量級鎖狀態。
2.輕量級鎖
(1)輕量級鎖加鎖
執行緒在執行同步塊之前,JVM會先在當前執行緒的棧楨中建立用於儲存鎖記錄的空間,並將物件頭中的Mark Word複製到鎖記錄中,官方稱為Displaced Mark Word。然後執行緒嘗試使用CAS將物件頭中的Mark Word替換為指向鎖記錄的指標。如果成功,當前執行緒獲得鎖,如果失敗,表示其他執行緒競爭鎖,當前執行緒便嘗試使用自旋來獲取鎖。
(2)輕量級鎖解鎖
輕量級解鎖時,會使用原子的CAS操作將Displaced Mark Word替換回到物件頭,如果成功,則表示沒有競爭發生。如果失敗,表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。圖2-2是兩個執行緒同時爭奪鎖,導致鎖膨脹的流程圖。
這裡寫圖片描述
因為自旋會消耗CPU,為了避免無用的自旋(比如獲得鎖的執行緒被阻塞住了),一旦鎖升級成重量級鎖,就不會再恢復到輕量級鎖狀態。當鎖處於這個狀態下,其他執行緒試圖獲取鎖時,都會被阻塞住,當持有鎖的執行緒釋放鎖之後會喚醒這些執行緒,被喚醒的執行緒就會進行新一輪的奪鎖之爭。
3.鎖的優缺點對比
表2-6是鎖的優缺點的對比。
這裡寫圖片描述
2.3 原子操作的實現原理
原子(atomic)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為“不可被中斷的一個或一系列操作”。在多處理器上實現原子操作就變得有點複雜。讓我們一起來聊一聊在Intel處理器和Java裡是如何實現原子操作的。
1.術語定義
在瞭解原子操作的實現原理前,先要了解一下相關的術語,如表2-7所示。
這裡寫圖片描述
2.處理器如何實現原子操作
32位IA-32處理器使用基於對快取加鎖或匯流排加鎖的方式來實現多處理器之間的原子操作。首先處理器會自動保證基本的記憶體操作的原子性。處理器保證從系統記憶體中讀取或者寫入一個位元組是原子的,意思是當一個處理器讀取一個位元組時,其他處理器不能訪問這個位元組的記憶體地址。Pentium 6和最新的處理器能自動保證單處理器對同一個快取行裡進行16/32/64位的操作是原子的,但是複雜的記憶體操作處理器是不能自動保證其原子性的,比如跨匯流排寬度、跨多個快取行和跨頁表的訪問。但是,處理器提供匯流排鎖定和快取鎖定兩個機制來保證複雜記憶體操作的原子性。
(1)使用匯流排鎖保證原子性
第一個機制是通過匯流排鎖保證原子性。如果多個處理器同時對共享變數進行讀改寫操作(i++就是經典的讀改寫操作),那麼共享變數就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變數的值會和期望的不一致。舉個例子,如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2,如圖2-3所示。
這裡寫圖片描述
原因可能是多個處理器同時從各自的快取中讀取變數i,分別進行加1操作,然後分別寫入系統記憶體中。那麼,想要保證讀改寫共享變數的操作是原子的,就必須保證CPU1讀改寫共享變數的時候,CPU2不能操作快取了該共享變數記憶體地址的快取。
處理器使用匯流排鎖就是來解決這個問題的。所謂匯流排鎖就是使用處理器提供的一個LOCK#訊號,當一個處理器在總線上輸出此訊號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享記憶體。
(2)使用快取鎖保證原子性第二個機制是通過快取鎖定來保證原子性。在同一時刻,我們只需保證對某個記憶體地址的操作是原子性即可,但匯流排鎖定把CPU和記憶體之間的通訊鎖住了,這使得鎖定期間,其他處理器不能操作其他記憶體地址的資料,所以匯流排鎖定的開銷比較大,目前處理器在某些場合下使用快取鎖定代替匯流排鎖定來進行優化。
頻繁使用的記憶體會快取在處理器的L1、L2和L3快取記憶體裡,那麼原子操作就可以直接在處理器內部快取中進行,並不需要宣告匯流排鎖,在Pentium 6和目前的處理器中可以使用“快取鎖定”的方式來實現複雜的原子性。所謂“快取鎖定”是指記憶體區域如果被快取在處理器的快取行中,並且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到記憶體時,處理器不在總線上聲言LOCK#訊號,而是修改內部的記憶體地址,並允許它的快取一致性機制來保證操作的原子性,因為快取一致性機制會阻止同時修改由兩個以上處理器快取的記憶體區域資料,當其他處理器回寫已被鎖定的快取行的資料時,會使快取行無效,在如圖2-3所示的例子中,當CPU1修改快取行中的i時使用了快取鎖定,那麼CPU2就不能同時快取i的快取行。
但是有兩種情況下處理器不會使用快取鎖定。
第一種情況是:當操作的資料不能被快取在處理器內部,或操作的資料跨多個快取行(cache line)時,則處理器會呼叫匯流排鎖定。
第二種情況是:有些處理器不支援快取鎖定。對於Intel 486和Pentium處理器,就算鎖定的記憶體區域在處理器的快取行中也會呼叫匯流排鎖定。
針對以上兩個機制,我們通過Intel處理器提供了很多Lock字首的指令來實現。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些運算元和邏輯指令(如ADD、OR)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。
3.Java如何實現原子操作
在Java中可以通過鎖和迴圈CAS的方式來實現原子操作。(1)使用迴圈CAS實現原子操作JVM中的CAS操作正是利用了處理器提供的CMPXCHG指令實現的。自旋CAS實現的基本思路就是迴圈進行CAS操作直到成功為止,以下程式碼實現了一個基於CAS執行緒安全的計數器方法safeCount和一個非執行緒安全的計數器count。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
    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.currentTimeMillis();
        for (int j = 0; j < 100; j++) {
            Thread t = new Thread(new Runnable() { //宣告一個執行緒,執行累加
                @Override
                public void run() {
                    for (int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            ts.add(t);
        }
        for (Thread t : ts) {
            t.start();
        }
        // 等待所有執行緒執行完成
        for (Thread t : ts) {
            try {
                t.join();
            } 
            catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }
        /** 
        * 使用CAS實現執行緒安全計數器
        */
        private void safeCount() {
            for (;;) {
                int i = atomicI.get();
                boolean suc = atomicI.compareAndSet(i, ++i);
                if (suc) {
                    break;
                }
            }
        }
        /**
        * 
        * 非執行緒安全計數器
        * 
        */
        private void count() {
            i++;
        }

}

這裡寫圖片描述
從Java 1.5開始,JDK的併發包裡提供了一些類來支援原子操作,如AtomicBoolean(用原子方式更新的boolean值)、AtomicInteger(用原子方式更新的int值)和AtomicLong(用原子方式更新的long值)。這些原子包裝類還提供了有用的工具方法,比如以原子的方式將當前值自增1和自減1。

(2)CAS實現原子操作的三大問題
在Java併發包中有一些併發框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue類的Xfer方法。CAS雖然很高效地解決了原子操作,但是CAS仍然存在三大問題。ABA問題,迴圈時間長開銷大,以及只能保證一個共享變數的原子操作。
1)ABA問題。因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。從Java 1.5開始,JDK的Atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個
類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。

public boolean compareAndSet(
    V expectedReference, // 預期引用
    V newReference, // 更新後的引用
    int expectedStamp, // 預期標誌
    int newStamp // 更新後的標誌
)

2)迴圈時間長開銷大。自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令,那麼效率會有一定的提升。pause指令有兩個作用:第一,它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零;第二,它可以避免在退出迴圈的時候因記憶體順序衝突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),從而提高CPU的執行效率。
3)只能保證一個共享變數的原子操作。當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,迴圈CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如,有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用物件之間的原子性,就可以把多個變數放在一個物件裡來進行CAS操作。
(3)使用鎖機制實現原子操作
鎖機制保證了只有獲得鎖的執行緒才能夠操作鎖定的記憶體區域。JVM內部實現了很多種鎖機制,有偏向鎖、輕量級鎖和互斥鎖。有意思的是除了偏向鎖,JVM實現鎖的方式都用了迴圈CAS,即當一個執行緒想進入同步塊的時候使用迴圈CAS的方式來獲取鎖,當它退出同步塊的時候使用迴圈CAS釋放鎖。

2.4 本章小結
本章我們一起研究了volatile、synchronized和原子操作的實現原理。Java中的大部分容器
和框架都依賴於本章介紹的volatile和原子操作的實現原理,瞭解這些原理對我們進行併發程式設計會更有幫助。

參考《Java併發程式設計的藝術》