併發編程的挑戰 / 併發機制的底層實現原理 (Java併發編程的藝術筆記)

上下文切換

對於單核處理器來說,CPU通過給每個線程分配CPU時間片來實現多線程執行代碼機制,因爲時間片非常短(一般是幾十毫秒),所以CPU通過不停的切換線程執行,使我們感覺多個線程是同時執行的。

CPU通過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務,但在切換前會保存上一個任務的狀態。當下次切換到這個任務時,就可以再加載這個任務的狀態。任務從保存到再加載的過程是一次上下文切換。

併發執行一定比串行快嗎?

不一定。在書中給的例子中,當併發執行累加操作不超過百萬次時,速度會比串行執行累加操作慢。這是由於線程有創建和上下文切換的開銷。

如何減少上下文切換

方法有無鎖併發編程,CAS算法,使用最少線程和使用協程。

  • 無鎖併發編程:多線程競爭鎖時,會引起上下文切換。所以多線程在處理數據時,可以用一些方法避免使用鎖,比如將數據的ID按照Hash算法取模分段,不同的線程處理不同段的數據。
  • CAS算法:Java的Atomic包使用CAS算法來更新數據,不需要加鎖。
  • 使用最少線程:避免創建不需要的線程。比如任務很少,卻創建了很多線程來處理,這樣會造成大量線程出於等待狀態。
  • 協程:在單線程裏實現多任務的調度,並維持多個任務間的切換。

 

jstack簡單使用

jstack是jdk自帶的工具,我們可以通過它來判斷程序出現的一些問題。

(1)首先以管理員身份運行cmd,進入到jdk的安裝目錄的bin文件下後,輸入jps即可直接顯示java進程的pid

此處的waitingThread的代碼如下,就是一個等待線程。

public class waitingThread extends Thread{
    @Override
    public void run() {
        try {
            synchronized (this) {
                wait();
            }
        }catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        waitingThread thread = new waitingThread();
        thread.setName("myThread");
        thread.start();
    }
}

(2)接着輸入jstack 137336,找到相關線程的信息:

(3)同樣,我們試一下死循環代碼

    public static void main(String[] args) {
        while(true) {

        }
    }

 


死鎖

下面我們演示一個死鎖場景,也許在現實中不會寫出這樣的代碼。但在一些更爲複雜的場景中,可能會遇到類似的問題,比如t1拿到鎖後,因爲一些異常情況沒有釋放鎖(死循環),又或者是t1拿到一個數據庫鎖,在釋放鎖的時候拋出了異常,沒釋放掉鎖。

public class DeadLockDemo {
    private static String A = "A";
    private static String B = "B";

    private void deadLock() {
        Thread thread = new Thread(new Runnable() {
            public void run() {
                synchronized (A) {
                    try {
                        Thread.currentThread().sleep(2000);
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (B) {
                        System.out.println(1);
                    }
                }
            }
        });
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                synchronized (B) {
                    synchronized (A) {
                        System.out.println("2");
                    }
                }
            }
        });
        thread.start();
        thread1.start();
    }

    public static void main(String[] args){
        new DeadLockDemo().deadLock();
    }
}

藉助jstack,我們可以發現問題的所在

 

避免死鎖的幾個常見方法:

  • 避免一個線程同時獲取多個鎖
  • 避免一個線程在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源
  • 嘗試使用定時鎖lock.tryLock(timeout)來代替使用內部鎖機制。
  • 對於數據庫鎖,加鎖和解鎖必須在一個數據庫連接裏,否則會出現解鎖失敗的情況。

 

資源限制

(1)什麼是資源限制

資源限制是指在進行併發編程時,程序的執行速度受限於計算機硬件或軟件資源。例如,服務器的帶寬只有2Mb/s,某個資源的下載速度是1Mb/s,系統如果啓動10個線程下載資源,下載速度是不會變成10Mb/s的。

  • 硬件資源限制:帶寬的上傳/下載速度;硬盤讀寫速度;CPU處理速度
  • 軟件資源限制:數據庫的連接數;socket連接數

(2)資源限制引發的問題

在併發編程中,將代碼執行速度加快的原則是將代碼中串行執行的部分變成併發執行,但有時受限於資源,仍在串行執行。此時程序會變得更慢,因爲增加了上下文切換和資源調度的時間。

(3)如何解決資源限制

對於硬件資源限制,可以考慮使用集羣並行執行程序,讓程序在多機上運行。比使用ODPS,Hadoop或者自己搭建服務器集羣,不同的及其處理不同的數據。

對於軟件資源限制,可以考慮使用資源池將資源複用。比如使用連接池將數據庫和Socket連接複用,

 


volatile

volatile的定義與實現原理

在此之前,先來看一下與原理相關的CPU術語

 

 

volatile是輕量級的synchronized,保證了在多處理器開發時共享變量的“可見性”,即當一個線程修改一個共享變量時,另外一個線程能讀到這個修改的值。若volatile變量修飾符使用恰當的話,它比synchronized的使用和執行成本更低,因爲它不會引起線程上下文切換和調度。

通常爲了提高處理速度,處理器不會直接和內存進行通信,而是將系統內存的數據督導內部緩存後,再進行操作,但操作不知何時會寫到內存中。若是對volatile修飾的共享變量進行寫操作時,JVM會向處理器發送一條Lock前綴的指令,該指令在多核處理器下會引發兩件事情:

  • 將當前處理器緩存行的數據寫回到系統內存,
  • 在多處理器下,爲保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每個處理器通過嗅探在總線上傳播的數據來檢查自己緩存的值是否已過期,若是則將當前處理器的緩存行設置爲無效狀態,當處理器對這個數據進行修改操作時,會重新從系統內存中把數據重新讀到處理器緩存裏。

下面解釋這兩條的實現原則:

(1)Lock前綴指令會引起處理器緩存回寫到內存裏

          Lock前綴指令 導致 在執行指令的期間,會聲言處理器的LOCK信號。在多處理器環境中,LOCK信號確保在聲言該信號期間,處理器可以獨佔任何共享內存。如果具體到CPU層面,對 Intel486 and Pentium processors,在鎖操作時,LOCK#信號會觸發總線鎖定,LOCK#發起方獲取總線操作權限;但在P6和目前的處理器中,如訪問的內存區域已經緩存中處理器內部,則不會聲言LOCK信號,相反,它會鎖定這塊內存區域的緩存並回寫到內存,並使用緩存一致性機制來確保修改的原子性(緩存一致性機制會阻止同時修改兩個以上處理器緩存的內存區域數據),這種操作被稱爲緩存鎖定。

 

(2)一個處理器的緩存回寫到內存會導致其他處理器的緩存無效

          在多核處理器系統中進行操作時,IA-32和Intel 64處理器能嗅探其他處理器訪問系統內存和它們的內部緩存。處理器緩存使用嗅探技術保證它們的內部緩存,系統內存和其他處理器的緩存數據在總線上保持一致。例如,在Pentium和P6 family處理器中,如果通過嗅探一個處理器檢測到其他處理器打算寫內存地址,而這個地址當前處於共享狀態,那麼正在嗅探的處理器將使它的緩存行無效,在下次訪問相同內存地址時,強制執行緩存行填充。

 

 


synchronized

Java中每一個對象都可以作爲鎖,具體表現爲:

  • 對於普通同步方法,鎖是當前實例對象
  • 對於靜態同步方法,鎖是當前類的Class對象
  • 對於同步方法塊,鎖是synchronized括號裏的對象。

當一個線程試圖訪問同步代碼塊時,它首先得得到鎖,退出或拋出異常時必須釋放鎖。

對於代碼塊同步,JVM通過使用monitorenter和monitorexit指令實現。monitorenter指令是在編譯後插入到同步代碼塊的開始位置,而monitorexit是插入到方法結束處和異常處。JVM要保證每個monitorenter必須有對應的monitorexit與其配對。任何對象都有一個monitor與之關聯,當且一個monitor被持有後,它將處於鎖定狀態。線程執行到monitorenter 指令時,將會嘗試獲取對象所對應的monitor的所有權,即嘗試獲得對象的鎖。

 

Java對象頭

在JVM中,對象在內存除了本身的數據外,還有個對象頭。synchronized用的鎖是存在Java對象頭裏。若對象是數組類型,則JVM用3個字節寬存儲對象頭;若對象是非數組類型,則用2字寬存儲。

對象頭裏的Mark Word默認 存儲對象的HashCode,分代年齡和鎖標記位。在運行期間,Mark Word存儲的數據會隨着鎖標誌位的變化而變化。當對象狀態爲偏向鎖時,Mark Word存儲的是偏向的線程ID;若狀態爲輕量級鎖時,Mark Word存儲的是指向線程棧中鎖記錄的指針;當狀態爲重量級鎖時,Mark Word存儲指向堆中的互斥量對象的指針。

 

 

鎖的升級與對比

Java SE 1.6爲了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和輕量級鎖。在Java SE 1.6中,鎖一共有四種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀 態和重量級鎖狀態。這幾個狀態會隨着競爭情況而逐漸升級,鎖可以升級但不能降級。

                                                                                    1.偏向鎖

1.1偏向鎖的初始化

大多情況下,鎖不僅不存在多線程競爭,而且總是由同一線程多次獲得,爲了讓線程獲得鎖的代價更低,引入了偏向鎖。

  1. 當線程第一次訪問同步塊時,先檢查對象頭Mark Word的鎖標誌位,以此判斷對象鎖是否處於無鎖狀態或偏向鎖狀態:若不是偏向鎖,則升級爲輕量鎖,進行CAS競爭鎖;
  2. 若是則檢查對象頭的Mark Word中記錄的是否是當前線程ID:若是則表明當前線程已獲得對象鎖,以後該進程進入同步塊時,不需要CAS進行加鎖,而是往當前線程的棧幀中添加一條Displaced Mark Word爲空的Lock Record,用來統計重入的次數。當退出同步塊的時候,釋放偏向鎖時,則依次刪除對應Lock Record,但不會修改對象頭中的Thread Id;
  3. 若對象頭Mark Word中Thread Id不是當前線程ID,則進行CAS操作,企圖將當前線程ID替換進Mark Word:如果當前對象鎖狀態處於匿名偏向鎖狀態,則會替換成功(將Mark Word中的Thread id由匿名0改成當前線程ID,在當前線程棧幀中找到內存地址最高的可用Lock Record,將線程ID存入),獲取到鎖,執行同步代碼塊;
  4. 若對象鎖已經被其他線程佔用,則會替換失敗,開始偏向鎖撤銷。這也是偏向鎖的特點:一旦出現線程競爭,就會撤銷偏向鎖。

1.2偏向鎖的撤銷

  1. 偏向鎖的撤銷需要等待全局安全點(safe point,在該狀態下所有線程都是暫停的),暫停持有偏向鎖的線程,並檢查持有偏向鎖的線程狀態(遍歷當前JVM的所有線程,如果能找到,則說明偏向的線程還存活):若線程還存活,則檢查線程是否在執行同步代碼塊,若是,則升級爲輕量級鎖,進行CAS競爭鎖。
  2. 若持有偏向鎖的線程還未存活,或持有偏向鎖的線程未在執行同步代碼塊中的代碼,則進行校驗是否允許重偏向,如果不允許重偏向,則撤銷偏向鎖,將Mark Word設置爲無鎖狀態,然後升級爲輕量級鎖,進行CAS競爭鎖;
  3. 如果允許重偏向,設置爲匿名偏向鎖狀態,CAS將偏向鎖重新指向線程A(在對象頭和線程棧幀的鎖記錄中存儲當前線程ID);
  4. 喚醒暫停的線程,從安全點繼續執行代碼。

下圖的線程1演示了偏向鎖初始化的流程,線程2演示了偏向鎖撤銷的流程。

 

                                                                                    2.輕量級鎖

2.1輕量級加鎖

線程在執行同步塊前,JVM首先會在在當前線程的棧幀中創建用於存儲鎖記錄的空間,並將對象頭中的的Mark Word複製到鎖記錄中。然後線程嘗試使用CAS將對象頭中的的Mark Word替換爲指向鎖記錄的指針。若成功,當前線程獲得鎖;若失敗,則表示其他線程競爭鎖,當前線程嘗試使用自旋來獲取鎖。

自旋鎖:當一個線程在獲取鎖的時候,如果鎖已經被其它線程獲取,那麼該線程將循環等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖纔會退出循環。

2.1輕量級解鎖

輕量級解鎖時,會使用原子的CAS操作將鎖記錄替換回對象頭,若成功,則表示沒有競爭發生;若失敗,則表示當前鎖存在競爭,鎖就會膨脹成重量級鎖。

下圖是兩個線程同時爭奪鎖,導致鎖膨脹的流程。

 

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

 

                                                                                    3.鎖的優缺點對比

 


原子操作

原子操作即爲不可被中斷的一個或一系列操作。在瞭解其原理前,需要了解一些相關術語

 

 

處理器如何實現原子操作

基礎知識:

CPU和物理內存之間的通信速度遠遠慢於CPU的處理速度,因此CPU有自己的內部緩存,根據規則將內存中斷數據讀取到內部緩存中,以此來加快讀取的速度。

現在的服務器大多數多CPU,每個CPU裏有多個內核,而每個內核都維護自己的緩存。

32位IA-32處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的原子操作。

首先,處理器會自動保證基本內存操作的原子性,比如說當一個處理器讀取一個字節時,其他處理器不能訪問這個字節的內存地址。

但對於Pentium6和最新的處理器,他們雖然能自動保證單處理器對同一個緩存行裏進行16/32/54位的操作是原子的,但是複雜的內存操作處理器是不能自動保證其原子性的,比如跨總線寬度,跨多個緩存行和跨頁表的訪問。不過,處理器提供了總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性。

(1)總線鎖保證原子性

          如果多個處理器同時對共享變量進行讀改寫操作(例如 i++),那麼共享變量就會被多個處理器同時進行操作,這樣的操作就不是原子的,共享變量的值和期望不一致。

如下圖所示,進行兩次i++操作,有可能結果是2。

 

造成這樣的原因可能是多個處理器同時從各自的緩存中讀取變量i,分別加1操作,然後分別寫入系統內存。如果我們希望保證讀改寫操作是原子的,就必須保證CPU1讀改寫共享變量時,CPU2不能操作 緩存了該共享變量內存地址的緩存。

所謂的總線鎖就是使用處理器提供的一個LOCK信號,當一個處理器在總線鎖輸出此信號時,其他處理器的請求將被阻塞,這樣該處理器就可以獨佔共享內存。

總線:是所有CPU與芯片組連接的主幹道,負責CPU與外界所有部件的通信,包括高速緩存,內存,北橋。其控制總線向各個部件發送控制信號,通過地址總線發送地址信號指定它要訪問的部件,通過數據總線雙向傳輸。

(2)緩存鎖保證原子性

         總線鎖鎖定期間,其他處理器不能操作其他內存地址的數據,因此總線鎖的開銷較大。因此,目前處理器在某些場合下使用緩存鎖代替總線鎖來進行優化。

        頻繁使用的內存會緩存在處理器的L1,L2和L3高速緩存裏,那麼原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖。在目前的處理器中可以使用緩存鎖的方式來實現複雜的原子性。所謂的緩存鎖定,是指在如果內存區域被緩存在處理器的緩存行中,並且在Lock操作期間被鎖定,那麼當它執行鎖操作回寫到內存時,處理器不會在總線上聲言LOCK信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性。緩存一致性機制會阻止同時修改兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時,會使緩存行無效。

緩存一致性機制就整體來說,是當某塊CPU對緩存中的數據進行操作了之後,就通知其他CPU放棄儲存在它們內部的緩存,或者從主內存中重新讀取。

處理器不適用緩存鎖定的情況

  • 當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行時,處理器會調用總線鎖定。
  • 有些處理器不支持緩存鎖定。

 

Java如何實現原子操作

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

(1)循環CAS

    自旋CAS實現思路就是循環進行CAS操作直到成功爲止。下面的代碼實現了一個基本CAS線程安全的計數器和非線程安全的計數器。

public class Counter {
    private AtomicInteger atomicI = new AtomicInteger(0);
    private int i = 0;
    private void safeCount() {
        for(;;) {
            int i = atomicI.get();
            boolean res = atomicI.compareAndSet(i, ++i);
            if(res) {
                break;
            }
        }
    }

    private void count() {
        i++;
    }


    public static void main(String[] args) {
        final Counter cas = new Counter();
        List<Thread> threadList = new ArrayList<>(600);
        long start = System.currentTimeMillis();

        for(int j = 0; j < 100; j++) {
            Thread th = new Thread(new Runnable() {
                @Override
                public void run() {
                    for(int i = 0; i < 10000; i++) {
                        cas.count();
                        cas.safeCount();
                    }
                }
            });
            threadList.add(th);
        }
        for(Thread tmp : threadList) {
            tmp.start();
        }
        for(Thread tmp : threadList) {
            try {
                tmp.join();
            }catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println(cas.i);
        System.out.println(cas.atomicI.get());
        System.out.println(System.currentTimeMillis() - start);
    }
}

 

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

在Java併發包中有一些併發框架也使用了自旋CAS的方式來實現原子操作,比如LinkedTransferQueue類的Xfer()方法。雖然CAS可以高效解決原子操作,但是CAS仍存在問題:ABA問題,循環時間開銷大,以及只能保證一個共享變量的原子操作。

1)ABA問題:如果一個值原來是A,變成了B,又變成A,那麼使用CAS進行檢查時會發現它的值沒有變化,但實際上卻變化了。

問題的解決思路是使用版本號:在變量前面追加上版本,每次變量更新時,版本號加1,那麼A→B→A就會變成1A→2B→3A。JDK的Atomic包裏提供了一個類AtomicStampedReference來解決問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

2)循環時間長,開銷大:自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷。

如果JVM能支持處理器提供的pause指令,那麼效率會有一定的提升。pause指令有兩個作用:

  • 它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間 取決於具體實現的版本,在一些處理器上延遲時間是零;
  • 它可以避免在退出循環的時候 因內存順序衝突(Memory Order Violation)而引起CPU流水線被清空(CPU Pipeline Flush),從而 提高CPU的執行效率。

3)只能保證一個共享變量的原子操作:如果是對多個共享變量,循環CAS就無法保證操作的原子性。

此時可以用鎖;或者將多個共享變量合併成一個共享變量來操作,比如將兩個共享變量:i = 2,j  = a  合併成ij = 2a,然後用CAS操作ij。JDK提供AtomicReference類保證引用對象的之間的原子性,就可把多個變量放在一個對象裏進行CAS操作。

 

(2)使用鎖機制實現原子操作

JVM裏的鎖機制有偏向鎖,輕量級鎖和互斥鎖。除了偏向鎖,JVM實現鎖的方式都採用了循環CAS,即當一個線程想進入同步塊時,使用循環CAS的方式來獲取鎖,當它退出同步塊的時 候使用循環CAS釋放鎖。

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章