天天看點

Java多線程與并發-原理

一、synchronized

線程安全問題的主要誘因

1.存在共享資料(也稱臨界資源)

2.存在多條線程共同操作這些共享資料

解決問題的根本方法:

同一時刻有且隻有一個線程在操作共享資料,其他線程必須等到該線程處理完資料後再對共享資料進行操作;

互斥鎖的特性

互斥性:即在同一時間隻允許一個線程持有某個對象鎖,通過這種特性來實作多線程的協調機制,這樣在同一時間隻有一個線程對需要同步的代碼塊(複合操作)進行通路。互斥性也被稱為操作的原子性。

可見性:必須確定在鎖被釋放之前,對共享變量所做的修改,對于随後獲得該鎖的另一個線程是可見的(即在獲得鎖時應獲得最新共享變量的值),否則另一個線程可能是在本地緩存的某個副本上繼續操作,進而引起不一緻。

synchronized鎖的不是代碼,鎖的都是對象

對于synchronized鎖有兩類,分别是對象鎖和類鎖;

擷取對象鎖的兩種用法

1.同步代碼塊(synchronized(this),synchronized(類執行個體對象)),鎖是小括号()中的執行個體對象。

2.同步非靜态方法(synchronized method),鎖是目前對象的執行個體對象。

擷取類鎖的兩種用法

1.同步代碼塊(synchronized(類.class)),鎖是小括号()中的類對象(Class對象)。

2.同步靜态方法(synchronized static method),鎖是目前對象的類對象(Class對象)。

對象鎖和類鎖的總結

1.有線程通路對象的同步代碼塊時,另外的線程可以通路該對象的非同步代碼塊;

2.若鎖住的是同一個對象,一個線程在通路對象的同步代碼塊時,另一個通路對象的同步代碼塊的線程會被阻塞;

3.若鎖住的是同一個對象,一個線程在通路對象的同步方法時,另一個通路對象同步方法的線程會被阻塞;

4.若鎖住的是同一個對象,一個線程在通路對象的同步代碼塊時,另一個通路對象同步方法的線程會被阻塞,反之亦然;

5.同一個類的不同對象的對象鎖互不幹擾;

6.類鎖由于也是一種特殊的對象鎖,是以表現和上述1,2,3,4一緻,而由于一個類隻有一把對象鎖,是以同一個類的不同對象使用類鎖将會是同步的;

7.類鎖和對象鎖互不幹擾;

二、synchronized底層實作原理

synchronized實作的基礎是Java對象頭和Monitor;

Java對象頭

對象頭的主要結構是由Mark Word和Class Metadata Address組成:

Mark Word:預設存儲對象的hashCode,分代年齡,鎖類型,鎖标志位等資訊;

Class Metadata Address:類型指針指向對象的類中繼資料,JVM通過這個指針确定對象是哪個類的資料;

Monitor

每個Java對象天生自帶了一把看不見的鎖,就是Monitor;Monitor會存在于每個對象的對象頭中,這也是為什麼在Java中所有對象都可以當做一個把鎖的原因;

咱們先來看一段編譯前的代碼:

public class MonitorTest {

    public void synTest(){
        synchronized (this){
            System.out.println("Hello World");
        }
    }

    public synchronized void synTestWithSynchronized(){
        System.out.println("Hello World");
    }

}
           

接着來看編譯後的代碼,看看monitor是如何上鎖的:

咱們先找到synTest方法,發現在進入代碼塊時會先執行monitorenter方法,也就是擷取鎖,之後在運作完代碼塊時第13句執行了monitorexit方法,該方法是指将鎖釋放,但是我們發現在釋放完鎖之後在19句又一次的執行了monitorexit方法,這是因為這個monitorexit是在出現異常時才會執行的,因為如果線程出現了異常就需要主動釋放目前鎖,否則會出現死鎖;

public void synTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                  // String Hello World
         9: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

           

最後我們接着看一下synTestWithSynchronized方法:

可以發現使用synchronized修飾的方法和代碼塊不太一樣,因為synchronized修飾的方法在編譯後隻會在flags中添加ACC_SYNCHRONIZED标志,表示在該方法執行前調用monitorenter方法,在方法執行結束了調用monitorexit方法,雖然編譯後的代碼不一緻,但是效果是一樣的

public synchronized void synTestWithSynchronized();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8

           

什麼是鎖的重入

從互斥鎖的設計上來說,當一個線程試圖操作一個由其他線程持有的對象鎖的臨界資源時,将會處于阻塞狀态,但當一個線程再次請求自己持有對象鎖的臨界資源時,這種情況屬于重入,也就是說父親的鎖可以給兒子使用;

為什麼有些人對synchronized嗤之以鼻

1.早期版本中,synchronized屬于重量級鎖,依賴于Mutex Lock實作

2.線程之間的切換需要從使用者态轉換到核心态,開銷較大

Java6以後,synchronized性能得到了很大的提升;

自旋鎖

許多情況下,共享資料的鎖定狀态持續時間較短,這是如果使用切換線程的方式去擷取鎖有點不值得;

通過讓線程執行忙循環等待鎖的釋放,不讓出CPU,也就是說讓線程在門外等着,不要走開,這樣就不用去消耗切換線程所帶來的開銷的了;

缺點:若鎖被其他線程長時間占用,就會帶來許多線程上的開銷,因為等待鎖的線程此時其實是沒有在幹活的,隻是在幹等,如果等待的時間過長,就會大量浪費CPU資源,是以可以通過設定PreBlockSpin來設計需要擷取鎖的線程在嘗試擷取鎖失敗幾次後就不再嘗試;

自适應自旋鎖

由于線程每次等待的次數和時間都不是固定的,是以PreBlockSpin的設定就比較困難,這時,自适應自旋鎖就誕生了,它有以下兩點特性:

1.自旋的次數不再固定;

2.由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀态來決定;

鎖消除

JIT編譯時,對運作上下文進行掃描,去除不可能存在競争的鎖;

這麼解釋比較抽象,大家直接看下面一段代碼就能了解了:

public class StringBufferTest {

    public void add(String s){
        //StringBuffer是線程安全的,由于sb隻會在append中使用,不可能被其他線程引用
        //是以sb屬于不可能共享的資源,JVM會自動消除内部的鎖
        StringBuffer sb = new StringBuffer();
        sb.append(s);
    }


    public static void main(String[] args) {
        StringBufferTest stringBufferTest = new StringBufferTest();
        for (int i=0;i<100;i++){
            stringBufferTest.add("Hello");
        }
    }

}
           

鎖粗化

如果一個對象在循環中瘋狂的執行加鎖,釋放鎖的操作,那麼JVM就會通過擴大加鎖的範圍,避免反複加鎖和解鎖;

synchronized的四種狀态

無鎖、偏向鎖、輕量級鎖、重量級鎖;

鎖膨脹方向:無鎖 > 偏向鎖 > 輕量級鎖 > 重量級鎖

偏向鎖

大多數情況下,鎖不存在多線程競争,總是由同一線程多次獲得,是以偏向鎖的意義就是為了減少同一線程擷取鎖的代價;

核心思想:

如果一個線程獲得了鎖,那麼鎖就進入偏向模式,此時Mark Word的結構也變為了偏向鎖結構,當該線程再次請求鎖時,無需在做任何同步操作(比如CAS),即擷取鎖的過程隻需要檢查Mark Word的鎖标記為為偏向鎖以及目前線程Id等于Mark Word的ThreadID即可,這樣就省去了大量有關鎖申請的操作;

不使用于鎖競争比較激烈的多線程場合;

輕量級鎖

輕量級鎖是由偏向鎖更新來的,偏向鎖運作在一個線程進入同步塊的情況下,當第二個線程加入鎖争用的時候,偏向鎖就會更新為輕量級鎖。

适應的場景:線程交替執行同步塊。

若存在同一時間通路同一鎖的情況,就會導緻輕量級鎖膨脹為重量級鎖。

輕量級加鎖過程:

Java多線程與并發-原理
Java多線程與并發-原理

解鎖過程:

Java多線程與并發-原理

鎖的記憶體語義

當線程釋放鎖時,Java記憶體模型會把線程對應的本地記憶體中的共享變量重新整理到主記憶體中;

而當線程擷取鎖時,Java記憶體模型會把該線程對應的本地記憶體置為無效,進而使得被螢幕保護的臨界區代碼必須從主記憶體中讀取共享變量。

偏向鎖、輕量級鎖、重量級鎖的彙總

Java多線程與并發-原理

三、synchronized和ReentrantLock

ReentrantLock(再入鎖)

1.位于java.util.concurrent.locks包;

2.和CountDownLatch、FutureTask、Semaphore一樣基于AQS實作;

3.能夠實作比synchronized更細粒度的控制,如控制fairness(公平性);

4.調用lock()之後,必須調用unlock()釋放鎖;

5.性能未必比synchronized高,并且也是可重入的;

ReentrantLock公平性的設定

1.ReentrantLock reentrantLock = new ReentrantLock(true);

2.參數為true時,傾向于将鎖賦予等待時間最久的線程;

3.公平鎖:擷取鎖的順序按先後調用lock方法的順序(慎用),因為會有額外消耗;

4.非公平鎖:搶占的順序不一定,看運氣;

5.synchronized是非公平鎖;

synchronized和ReentrantLock的差別

1.synchronized是關鍵字,ReentrantLock是類;

2.ReentrantLock可以對擷取鎖的等待時間進行設定,避免死鎖;

3.ReentrantLock可以擷取各種鎖的資訊;

4.ReentrantLock可以靈活地實作多路通知;

5.機制:sync操作Mark Word,lock調用Unsafe類的park()方法;

四、JMM的記憶體可見性

Java記憶體模型JMM

Java記憶體模型(即Java Memory Model,簡稱JMM)本身是一種抽象的概念,并不真實存在,它描述的是一組規則或規範,通過這組規範定義了程式中各個變量(包括執行個體字段,靜态字段和構成數組對象的元素)的通路方式。

Java中的主記憶體

1.存儲Java執行個體對象,包括成員變量、類資訊、常量、靜态變量等;

2.屬于資料共享的區域,多線程并發操作時會引發線程安全問題;

Java中的工作記憶體

1.存儲目前方法的所有本地變量資訊,本地變量對其他線程不可見,即使是不同線程執行相同代碼,那麼這兩個線程之間的本地變量也是不可見的;

2.還存儲着位元組碼行号訓示器和Native方法資訊;

3.屬于線程私有資料區域,不存線上程安全問題;

JMM與Java記憶體區域劃分是同步的概念層次

1.JMM描述的是一組規則,圍繞原子性、有序性、可見性展開;

2.相似點就是都存在共享區域和私有區域;

主記憶體與工作記憶體的資料存儲類型以及操作方式歸納

1.方法裡的基本資料類型本地變量将直接存儲在工作記憶體的棧幀結構中;

2.引用類型的本地變量:引用存儲在工作記憶體中,執行個體存儲在主記憶體中,是以工作記憶體中的隻是一個副本;

3.成員變量、static變量、類資訊均會被存儲在主記憶體中;

4.主記憶體共享的方式是線程各拷貝一份資料到工作記憶體,操作完成後重新整理會主記憶體;

指令重排序需要滿足的條件

1.在單線程環境下不能改變程式運作的結果;

2.存在資料依賴關系的不允許重排序;

總結一下就是無法通過happens-before原推導出來的,才能進行指令的重排序;

happens-before的八大原則

1.程式次序規則:一個線程内,按照代碼順序,書寫在前面的操作先行發生于書寫在後面的操作;

2.鎖定規則:一個unLock操作先行發生于後面對同一個鎖的lock操作;

3.volatile變量規則:對一個變量的寫操作先行發生于後面對這個變量的讀操作;

4.傳遞規則:如果操作A先行發生于操作B,而操作B又先行發生于操作C,則可以得出操作A先行發生于操作C;

5.線程啟動規則:Thread對象的start()方法先行發生于線程的每一個動作;

6.線程中斷規則:對線程interrupt()方法的調用先行發生于被中斷線程的代碼檢測到中斷時間的發生;

7.線程終結規則:線程中所有的操作都先行發生于線程的終止檢測,我們可以通過Thread.join()方法結束、Thread.isAlive()的傳回值手段檢測到線程已經終止執行;

8.對象終結規則:一個對象的初始化完成先行發生于他的finalize()方法的開始;

happens-before的概念

如果兩個操作不滿足上述任意一個happens-before原則,那麼這兩個操作就沒有順序的保障,JVM可以對這兩個操作進行重排序;

如果操作A happens-before操作B,那麼操作A在記憶體上所做的操作對操作B都是可見的。

volatile的可見性

1.當寫一個volatile變量時,JMM會把該線程對應的工作記憶體中的共享變量重新整理到主記憶體中;

2.當讀取一個volatile變量時,JMM會把該線程對應的工作記憶體置為無效,是以volatile變量立即可見。

volatile如何禁止重排優化

記憶體保障(Memory Barrier)

1.保證特定操作的執行順序

2.保證某些變量的記憶體可見性

通過插入記憶體屏障指令禁止在記憶體屏障前後的指令執行重排序優化;

強制重新整理出各種CPU的緩存資料,是以任何CPU上的線程都能讀取到這些資料的最新版本;

volatile和synchronized的差別

1.volatile本質是在告訴JVM目前變量在寄存器(工作記憶體)中的值是不确定的,需要從主記憶體中讀取;synchronized則是鎖定目前變量,隻有目前線程可以通路該變量,其他線程被阻塞住直到該線程完成變量操作為止;

2.volatile僅能使用在變量級别;synchronized則可以使用在變量、方法和類級别;

3.volatile僅能實作變量的修改可見性,不能保證原子性;而synchronized則可以保證變量修改的可見性和原子性;

4.volatile不會造成線程的阻塞;synchronized可能會造成線程的阻塞;

5.volatile标記的變量不會被編譯器優化;synchronized标記的變量可以被編譯器優化;

五、CAS

一種高效實作線程安全性的方法

1.支援原子更新操作,适用于計數器,序列發生器等場景;

2.屬于樂觀鎖機制,号稱lock-free;

3.CAS操作失敗時由開發者決定是繼續嘗試,還是執行别的操作;

CAS思想

包含三個操作數——記憶體位置(V)、預期原值(A)和新值(B)

CAS多數情況下對開發者來說是透明的

1.J.U.C的atomic包提供了常用的原子性資料類型以及引用、數組等相關原子類型和更新操作工具,是很多線程安全程式的首選;

2.Unsafe類雖提供CAS服務,但因能夠操縱任意記憶體位址讀寫而有隐患;

3.Java9以後,可以使用Variable Handle API來替代Unsafe;

CAS的缺點

1.若循環時間長,則開銷很大;

2.隻能保證一個共享變量的原子操作;

3.ABA問題, 可通過AtomicStampedReference來解決;

六、Java線程池

利用Executors建立不同的線程池滿足不同場景的需求

1.newFixedThreadPool(int nThreads)

建立指定工作線程數量的線程池,如果線程池滿了,則會先将該線程添加到池隊列中,等到有線程退出後,再加入到線程池中運作;

2.newCachedThreadPool()

用于處理大量短時間工作任務的線程池,有以下幾個特點:

(1)、試圖緩存線程并重用,當無緩存線程可用時,就會建立新的工作線程;

(2)、如果線程閑置的時間超過門檻值,則會被終止并移出緩存;

(3)、系統長時間閑置的時候,不會消耗什麼資源;

3.newSingleThreadExecutor()

建立唯一的工作者線程來執行任務,如果線程異常結束,會有另外一個線程取代它;

4.newSingleThreadScheduledExecutor()和newScheduledThreadPool(int corePoolSize)

定時或者周期性的工作排程,兩者的差別在于單一工作線程還是多個線程,前者和newSingleThreadExecutor()一緻,如果線程異常結束,會有另外一個線程取代它;

5.newWorkStealingPool()

jdk8中才引入的,内部會建構ForkJoinPool,利用working-stealing算法,并行地處理任務,不保證處理順序;

Fork/Join架構

把大任務分割成若幹個小任務并行執行,最終彙總每個小任務結果後得到大任務結果的架構,充分使用計算機的計算能力;

Fork/Join架構采用Work-Stealing算法配置設定任務,該算法可以讓某個線程從其他隊列裡竊取任務來執行,比如說目前有2個線程A和B,A的任務完成了,但是發現B的隊列中還有好多任務待處理,那麼A就會去把B的任務拿過來處理,為了擷取任務的效率,采用了雙端政策,也就是說被竊取的線程(B)從隊列的頭部擷取任務,而竊取的線程(A)從隊列的尾部擷取任務,這樣就大大提高了性能;

為什麼要使用線程池

1.降低資源消耗,重複利用已建立的線程來降低建立和銷毀線程所帶來的消耗;

2.提高線程的可管理性,線程是一種稀缺資源,如果無限制的建立線程,會給伺服器帶來大量的消耗,使用線程池可以統一的配置設定,調優和監控;

Executor的架構

Executor 架構是一個根據一組執行政策調用,排程,執行和控制的異步任務的框

架,目的是将任務如何送出,如何運作分離開來的機制;

Java多線程與并發-原理

J.U.C的三個Executor接口

1.Executor:運作新任務的簡單接口,将任務送出和任務執行細節解耦;

2.ExecutorService:具備管理執行器和任務生命周期的方法,送出任務機制更完善;

3.ScheduledExecutorService:支援Future和定期執行任務;

ThreadPoolExecutor的設計與實作

因為ThreadPoolExecutor是最基礎的一種線程池,其他的線程池都是基于ThreadPoolExecutor上擴充的,是以這裡直接介紹ThreadPoolExecutor就可以;

從下圖可以清晰的了解到應用送出任務後,先是送出到WorkQueue隊列中去等待,之後送出到内部線程池即工作線程集合,該集合管理線程的建立和銷毀,當線程壓力較大時,會新增線程,如果線程壓力較小,則會閑置一段時間後,結束線程;ThreadFactory提供了線程池所需的建立邏輯;如果任務送出被拒絕,比如線程池出去shutdown狀态,此時先來的線程需要有機制去處理,Java提供了許多的機制,如果需要自定義機制,則需實作RejectExecutorHandler即可;

Java多線程與并發-原理

ThreadPoolExecutor的構造函數

1.corePoolSize:核心線程數量;

2.maximumPoolSize:線程不夠用時能夠建立的最大線程數;

3.workQueue:任務等待隊列;

4.keepAliveTime:閑置線程被銷毀的時間;

5.threadFactory:建立新線程的工廠,預設是Executors.defaultThreadFactory();

6.handler:線程池的飽和政策,Java提供了4中政策,分别是:

(1).AbortPolicy:直接抛出異常,這是預設政策;

(2).CallerRunsPolicy:用調用者所在的線程來執行任務;

(3).DiscardOldestPolicy:丢棄隊列中靠最前的任務,并執行目前任務;

(4)DiscardPolicy:直接丢棄任務;

(5)實作RejectedExecutionHandler接口自定義handler;

新任務調劑execute執行後的判斷邏輯

1.如果運作的線程少于corePoolSize,則建立新線程來處理任務,即使線程池中的其他線程是空閑的;

2.如果線程池中的線程數量大于等于corePoolSize且小于maximumPoolSize,則隻有當workQueue滿時才建立新的線程去處理任務;

3.如果設定的corePoolSize和maximumPoolSize相同,則建立的線程池的大小是固定的,這時如果有新任務送出,若workQueue未滿,則将請求放入workQueue中,等待有空閑的線程去從workQueue中取任務并處理;

4.如果運作的線程數量大于等于maximumPoolSize,這時如果workQueue已經滿了,則通過handler所指定的政策來處理任務;

以下是一個大緻的邏輯圖:

Java多線程與并發-原理

線程池的狀态

1.RUNNING:能接收新送出的任務,并且也能處理阻塞隊列中的任務;

2.SHUTDOWN:不能接受新送出的任務,但可以處理存量任務;

3.STOP:不再接收新送出的任務,也不處理存量任務;

4.TIDYING:所有的任務都已終止;

5.TERMINATED:terminated()方法執行完後進入該狀态,什麼不做的,隻是一個辨別;

下圖為線程池的狀态轉換圖:

Java多線程與并發-原理

下圖為工作線程的生命周期邏輯圖:

Java多線程與并發-原理

線程池的大小如何標明

1.CPU密集型:線程數=按照CPU核數或者核數+1設定

2.I/O密集型:線程數=CPU核數*(1+平均等待時間/平均工作時間)