【多線程】瞭解Java鎖機制


在這裏插入圖片描述

  • 從線程是否需要對資源加鎖可以分爲 悲觀鎖 和 樂觀鎖
  • 從資源已被鎖定,線程是否阻塞可以分爲 自旋鎖
  • 從多個線程併發訪問資源,也就是 Synchronized 可以分爲 無鎖、偏向鎖、 輕量級鎖 和 重量級鎖
  • 從鎖的公平性進行區分,可以分爲公平鎖 和 非公平鎖
  • 從根據鎖是否重複獲取可以分爲 可重入鎖 和 不可重入鎖
  • 從那個多個線程能否獲取同一把鎖分爲 共享鎖 和 排他鎖(獨享鎖)

什麼是串行、併發、並行

  1. 串行:一個線程執行到底,相當於單線程。
  2. 併發:多個線程交替執行,搶佔cpu的時間片,但是速度很快,在外人看來就像是多個線程同時執行。
  3. 並行:多個線程在不同的cpu中同時執行。

併發與並行的區別

  1. 併發嚴格的說不是同時執行多個線程,只是線程交替執行且速度很快,相當於同時執行
  2. 而並行是同時執行多個線程,也就是多個cpu核心同時執行多個線程。

從串行到並行,從並行到分佈式

淺談鎖的作用

1.鎖有什麼作用呢

我們用手機鎖是爲了保障我們的隱私安全,使用門鎖是爲了保障我們的財產安全,準確的來說我們使用鎖就是爲了安全。那麼在生活中我們可以加鎖來保障自己的隱私和財產安全

2.Java中的鎖有什麼作用呢

Java中的鎖準確的來說也是爲了保證安全,不過不同的是Java中加鎖準確的來說是爲了保證併發安全,同時也是爲了解決內存中的一致性,原子性,有序性三種問題。在Java中提供了各式各樣的鎖,每種鎖都有其自身的特點和適用範圍。所以我們都要熟悉鎖的區別和原理才能正確的使用。

3.爲什麼要用鎖

鎖-是爲了解決併發操作引起的髒讀、數據不一致的問題。

一.Java鎖分類

1.公平鎖/非公平鎖

  • 公平鎖指多個線程按照申請鎖的順序來獲取鎖。
  • 非公平鎖指多個線程獲取鎖的順序並不是按照申請鎖的順序,有可能後申請的線程比先申請的線程優先獲取鎖。有可能,會造成優先級反轉或者飢餓現象

對於Java ReentrantLock而言,通過構造函數指定該鎖是否是公平鎖,默認是非公平鎖。非公平鎖的優點在於吞吐量比公平鎖大。
對於Synchronized而言,也是一種非公平鎖。由於其並不像ReentrantLock是通過AQS的來實現線程調度,所以並沒有任何辦法使其變成公平鎖。

在鎖中也是有公平和不公平滴,公平鎖如其名講究的是一個公平,所以多個線程同時申請鎖的話,線程會放入一個隊列中,在隊列中第一個進入隊列的線程才能獲取鎖資源,講究的是先到先得。

公平鎖: 食堂打飯的時候,我同學一放學去食堂排隊這樣的話才能儘快的打上飯,在排隊的過程食堂阿姨是公平的每個人排隊的話都能吃到飯,線程也是如此。
非公平鎖:我那個同學去食堂排隊打飯了但是有人卻插隊,食堂阿姨卻不公平直接給插隊的人打飯卻不給他打,劃重點非公平鎖先到不一定先得。
公平鎖也是有缺點的:當一個線程獲取資源後在隊列中的其他的線程就只能在阻塞,CPU的使用公平鎖比非公平鎖的效率要低很多。因爲CPU喚醒阻塞線程的開銷比非公平鎖大。我們來看一個一個例子:
在這裏插入圖片描述

在Java中ReentrantLock提供了公平鎖和非公平鎖的實現。
在這裏插入圖片描述

使用公平鎖

ReentrantLock默認就是非公平的鎖,我們來看一下公平鎖的例子:
在這裏插入圖片描述
執行結果
在這裏插入圖片描述

  • 公平鎖的輸出結果是按照順序來的,先到先得。

使用非公平鎖
在這裏插入圖片描述
執行結果
在這裏插入圖片描述
使用非公平鎖的話最後輸出的結果是完全沒有順序的,先到不一定先得。

線程的阻塞與掛起區別

2.可重入鎖/不可重入鎖

可重入鎖:又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖

廣義上的可重入鎖指的是可重複可遞歸調用的鎖,在外層使用鎖之後,在內層仍然可以使用,並且不發生死鎖(前提得是同一個對象或者class),這樣的鎖就叫做可重入鎖。ReentrantLock和synchronized都是可重入鎖

// 演示可重入鎖的代碼層意思
synchronized void setA() throws Exception{   
    Thread.sleep(1000);
    setB();   // 因爲獲取了setA()的鎖(即獲取了方法外層的鎖)、此時調用setB()將會自動獲取setB()的鎖,如果不自動獲取的話方法B將不會執行           
}
 
synchronized void setB() throws Exception{
    Thread.sleep(1000);
}

上面的代碼就是一個可重入鎖的一個特點,如果不是可重入鎖的話,setB可能不會被當前線程執行,可能造成死鎖。

不可重入鎖: 與可重入鎖相反,不可遞歸調用,遞歸調用就發生死鎖

看到一個經典的講解,使用自旋鎖來模擬一個不可重入鎖,代碼如下:

import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

   private AtomicReference<Thread> owner = new AtomicReference<Thread>();

   public void lock() {
       Thread current = Thread.currentThread();
       //這句是很經典的“自旋”語法,AtomicInteger中也有
       for (;;) {
           if (!owner.compareAndSet(null, current)) {
               return;
           }
       }
   }

   public void unlock() {
       Thread current = Thread.currentThread();
       owner.compareAndSet(current, null);
   }
}

代碼也比較簡單,使用原子引用類來存放線程,同一線程兩次調用lock()方法,如果不執行unlock()釋放鎖的話,第二次調用自旋的時候就會產生死鎖,這個鎖就不是可重入的,而實際上同一個線程不必每次都去釋放鎖再來獲取鎖,這樣的調度切換是很耗資源的。

把它變成一個可重入鎖:

import java.util.concurrent.atomic.AtomicReference;

public class UnreentrantLock {

   private AtomicReference<Thread> owner = new AtomicReference<Thread>();
   private int state = 0;

   public void lock() {
       Thread current = Thread.currentThread();
       if (current == owner.get()) {
           state++;
           return;
       }
       //這句是很經典的“自旋”式語法,AtomicInteger中也有
       for (;;) {
           if (!owner.compareAndSet(null, current)) {
               return;
           }
       }
   }

   public void unlock() {
       Thread current = Thread.currentThread();
       if (current == owner.get()) {
           if (state != 0) {
               state--;
           } else {
               owner.compareAndSet(current, null);
           }
       }
   }
}

在執行每次操作之前,判斷當前鎖持有者是否是當前對象,採用state計數,不用每次去釋放鎖。

ReentrantLock中可重入鎖實現
這裏看非公平鎖的鎖獲取方法

final boolean nonfairTryAcquire(int acquires) {
   final Thread current = Thread.currentThread();
   int c = getState();
   if (c == 0) {
       if (compareAndSetState(0, acquires)) {
           setExclusiveOwnerThread(current);
           return true;
       }
   }
   //就是這裏
   else if (current == getExclusiveOwnerThread()) {
       int nextc = c + acquires;
       if (nextc < 0) // overflow
           throw new Error("Maximum lock count exceeded");
       setState(nextc);
       return true;
   }
   return false;
}

AQS中維護了一個private volatile int state來計數重入次數,避免了頻繁的持有釋放操作,這樣既提升了效率,又避免了死鎖。

3.獨享鎖/共享鎖

獨享鎖和共享鎖在你去讀C.U.T包下的ReeReentrantLockReentrantReadWriteLock你就會發現,它倆一個是獨享鎖,一個是共享鎖。

  • 獨享鎖是指該鎖一次只能被一個線程所持有

    每次只有一個線程能霸佔這個鎖資源,而其他線程就只能等待當前獲取鎖資源的線程釋放鎖才能再次獲取鎖,ReentrantLock就是獨佔鎖,其實準確的說獨佔鎖也是悲觀鎖,因爲悲觀鎖搶佔資源後就只能等待釋放其他線程才能再次獲取到鎖資源。。

  • 共享鎖是指該鎖可被多個線程共有

    共享鎖其實也是樂觀鎖它放寬了鎖的策略允許多個線程同時獲取鎖。在併發包中ReadWriteLock就是一個典型的共享鎖。它允許一個資源可以被多個讀操作訪問,或者被一個 寫操作訪問,但兩者不能同時進行。
    .
    典型的就是ReentrantReadWriteLock裏的讀鎖,它的讀鎖是可以被共享的,但是它的寫鎖確每次只能被獨佔。另外讀鎖的共享可保證併發讀是非常高效的,但是讀寫和寫寫,寫讀都是互斥的。

獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。對於Synchronized而言,當然是獨享鎖。

4.互斥鎖/讀寫鎖

上面講的獨享鎖/共享鎖就是一種廣義的說法,互斥鎖/讀寫鎖就是具體的實現

互斥鎖

  • 在訪問共享資源之前進行加鎖操作,在訪問完成之後進行解鎖操作。加鎖後,任何其他試圖再次加鎖的線程會被阻塞,直到當前進程解鎖。

  • 如果解鎖時有一個以上的線程阻塞,那麼所有該鎖上的線程都被變成就緒狀態, 第一個變爲就緒狀態的線程又執行加鎖操作,那麼其他的線程又會進入等待。在這種方式下,只有一個線程能夠訪問被互斥鎖保護的資源

讀寫鎖
讀寫鎖既是互斥鎖,又是共享鎖,read模式是共享,write模式是互斥(排它鎖)的。

  • 讀寫鎖有三種狀態:讀加鎖狀態寫加鎖狀態不加鎖狀態
  • 讀寫鎖在Java中的具體實現就是ReadWriteLock
    • 一次只有一個線程可以佔有寫模式的讀寫鎖,但是多個線程可以同時佔有讀模式的讀寫鎖。
    • 只有一個線程可以佔有寫狀態的鎖,但可以有多個線程同時佔有讀狀態鎖,這也是它可以實現高併發的原因。
    • 當其處於寫狀態鎖下,任何想要嘗試獲得鎖的線程都會被阻塞,直到寫狀態鎖被釋放;如果是處於讀狀態鎖下,允許其它線程獲得它的讀狀態鎖,但是不允許獲得它的寫狀態鎖,直到所有線程的讀狀態鎖被釋放; 爲了避免想要嘗試寫操作的線程一直得不到寫狀態鎖,當讀寫鎖感知到有線程想要獲得寫狀態鎖時,便會阻塞其後所有想要獲得讀狀態鎖的線程。 所以讀寫鎖非常適合資源的讀操作多於寫操作的情況

5.樂觀鎖/悲觀鎖

樂觀鎖與悲觀鎖不是指具體的什麼類型的鎖,而是指看待併發同步的角度

  • 悲觀鎖 認爲對於同一個數據的併發操作,一定是會發生修改的,哪怕沒有修改,也會認爲修改。因此對於同一個數據的併發操作,悲觀鎖採取加鎖的形式悲觀的認爲,不加鎖的併發操作一定會出問題。

    總是假設最壞的情況,每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞直到它拿到鎖(共享資源每次只給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。傳統的關係型數據庫裏邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronizedReentrantLock獨佔鎖就是悲觀鎖思想的實現。
    在這裏插入圖片描述
    線程A搶佔到資源後線程B就陷入了阻塞中,然後就等待線程A釋放資源。
    在這裏插入圖片描述當線程A釋放完資源後線程B就去獲取鎖開始操作資源˛悲觀鎖保證了資源同時只能一個線程進行操作

  • 樂觀鎖 則認爲對於同一個數據的併發操作,是不會發生修改的 。在更新數據的時候,會採用嘗試更新,不斷重新的方式更新數據。樂觀的認爲,不加鎖的併發操作是沒有事情的。

    總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號機制CAS算法實現。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,像數據庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。
    在這裏插入圖片描述

應用場景

  • 悲觀鎖適合寫操作非常多的場景,樂觀鎖適合讀操作非常多的場景,不加鎖會帶來大量的性能提升

  • 悲觀鎖在Java中的使用,就是利用各種鎖樂觀鎖在Java中的使用,是無鎖編程,常常採用的是CAS算法典型的例子就是原子類,通過CAS自旋實現原子操作的更新重量級鎖 是悲觀鎖的一種自旋鎖、輕量級鎖與偏向鎖 屬於樂觀鎖

使用樂觀鎖和悲觀鎖
在這裏插入圖片描述
可以使用synchronized關鍵字來實現悲觀鎖,樂觀鎖可以使用併發包下提供的原子類。

6.分段鎖

分段鎖其實是一種鎖的設計,並不是具體的一種鎖,對於ConcurrentHashMap而言,其併發的實現就是通過分段鎖的形式來實現高效的併發操作。

併發容器類的加鎖機制是基於粒度更小的分段鎖,分段鎖也是提升多併發程序性能的重要手段之一。

  • 以ConcurrentHashMap來說一下分段鎖的含義以及設計思想,ConcurrentHashMap中的分段鎖稱爲Segment,它即類似於HashMap(JDK7與JDK8中HashMap的實現)的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表;同時又是一個ReentrantLockSegment繼承了ReentrantLock)。
  • 當需要put元素的時候,並不是對整個hashMap進行加鎖,而是先通過hashCode來知道他要放在那一個分段中,然後對這個分段進行加鎖,所以當多線程put的時候,只要不是放在一個分段中,就實現了真正的並行的插入。但是,在統計size的時候,可就是獲取hashMap全局信息的時候,就需要獲取所有的分段鎖才能統計。
      
    分段鎖的設計目的是: 細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。

在併發程序中,串行操作是會降低可伸縮性,並且上下文切換也會減低性能。在鎖上發生競爭時將同時導致這兩種問題,使用獨佔鎖時保護受限資源的時候,基本上是採用串行方式—-每次只能有一個線程能訪問它。所以對於可伸縮性來說最大的威脅就是獨佔鎖。

我們一般有三種方式降低鎖的競爭程度:

  1. 減少鎖的持有時間
  2. 降低鎖的請求頻率
  3. 使用帶有協調機制的獨佔鎖,這些機制允許更高的併發性。

在某些情況下我們可以將鎖分解技術進一步擴展爲一組獨立對象上的鎖進行分解,這稱爲分段鎖。

其實說的簡單一點就是:

容器裏有多把鎖,每一把鎖用於鎖容器其中一部分數據,那麼當多線程訪問容器裏不同數據段的數據時,線程間就不會存在鎖競爭,從而可以有效的提高併發訪問效率,這就是ConcurrentHashMap所使用的鎖分段技術,首先將數據分成一段一段的存儲,然後給每一段數據配一把鎖,當一個線程佔用鎖訪問其中一個段數據的時候,其他段的數據也能被其他線程訪問。
.
比如:在ConcurrentHashMap中使用了一個包含16個鎖的數組,每個鎖保護所有散列桶的1/16,其中第N個散列桶由第(N mod 16)個鎖來保護。假設使用合理的散列算法使關鍵字能夠均勻的分部,那麼這大約能使對鎖的請求減少到越來的1/16。也正是這項技術使得ConcurrentHashMap支持多達16個併發的寫入線程。

7.偏向鎖/輕量級鎖/重量級鎖

鎖的狀態:

  1. 無鎖狀態
  2. 偏向鎖狀態
  3. 輕量級鎖狀態
  4. 重量級鎖狀態
  • 鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
  • 四種狀態會隨着競爭的情況逐漸升級,而且是不可逆的過程,即不可降級。
  • 這四種狀態都不是Java語言中的鎖,而是Jvm爲了提高鎖的獲取與釋放效率而做的優化(使用synchronized時)。

這四種鎖是指鎖的狀態,並且是 針對 Synchronized。在Java5通過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是 通過對象監視器在對象頭中的字段來表明的偏向鎖是指一段同步代碼一直被一個線程所訪問,那麼該線程會自動獲取鎖。降低獲取鎖的代價。

  • 偏向鎖的適用場景始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前沒有其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級爲輕量級鎖,升級爲輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致 stop the word 操作;在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向鎖的時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況下應當 禁用

  • 輕量級鎖:是指當 鎖是偏向鎖的時候另一個線程所訪問,偏向鎖就會升級爲輕量級鎖其他線程會通過 自旋 的形式嘗試獲取鎖,不會阻塞,提高性能

  • 重量級鎖:是指當 鎖爲輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞該鎖膨脹爲重量級鎖重量級鎖會讓其他申請鎖的線程進入阻塞性能降低

8.自旋鎖

我們知道CAS算法是樂觀鎖的一種實現方式,CAS算法中又涉及到自旋鎖,

8.1.簡單瞭解一下CAS算法?

CAS是英文單詞Compare and Swap比較並交換),是一種有名的無鎖算法無鎖編程,即不使用鎖的情況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的情況下實現變量的同步,所以也叫非阻塞同步(Non-blocking Synchronization)

Java提供的非阻塞原子性操作。在不使用鎖的情況下實現多線程下的同步。在併發包中(java.util.concurrent)原子性類都是使用CAS來實現樂觀鎖的。CAS通過硬件保證了比較更新的原子性,在JDK中Unsafe提供了一系列的compareAndSwap*方法,這裏就不深究Unsafe這個類了。

CAS算法涉及到三個操作數:

  • 預期值的 A
  • 內存中的V
  • 將要修改的B

CAS操作過程就是將內存中的將要被修改的數據與預期的值進行比較,如果這兩個值相等就修改值爲新值,否則就不做操作也就是說CAS需要三個操作值:

簡單的來說CAS就是一個死循環,在循環中判斷預期的值和內存中的值是否相等,如果相等的話就執行修改,如果如果不相等的話就繼續循環,直到執行成功後退出。

CAS的問題

CAS雖然很牛逼但是它也存在一些問題比如ABA問題

  • 舉個例子:現在有內存中有一個共享變量X的值爲A,這個時候出現一個變量想要去修改變量X的值,首先會獲取X的值這個時候獲取的是A,然後使用CAS操作把X變量修改成B。這樣看起來是沒有問題,那如果在線程1獲取變量X之後,執行CAS之前出現一個線程2把X的值修改成B然後CAS操作執行又修改成了了A,雖然最後執行的結果共享變量的值爲A但是此A已經不是線程1獲取的A了。

這就是經典的ABA問題。產生ABA問題是因爲變量的狀態值發生了環形轉換,A可以到B,B可以到A,如果A到B,B到C就不會發生這種問題。

解決辦法:

  • Java 5後加入了AtomicStampedReference方法給每個變量加入了一個時間戳來避免ABA問題。

循環開銷大的問題
CAS一直循環直到預期和內存相等修改成功。同時還有隻能保證一個共享變量的原子性的問題,不過在Java 5之後加入了AtomicReference類來保證引用對象之間的原子性。

8.2.什麼是自旋鎖?

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

嘗試獲取鎖的線程不會立即阻塞,而是採用循環的方式去嘗試獲取鎖減少線程上下文切換的消耗,缺點是循環會消耗CPU。
它是爲實現保護共享資源而提出一種鎖機制。其實,自旋鎖與互斥鎖比較類似,它們都是爲了解決對某項資源的互斥使用。無論是互斥鎖,還是自旋鎖,在任何時刻,最多只能有一個保持者,也就說,在任何時刻最多只能有一個線程獲得鎖。但是兩者在調度機制上略有不同。對於互斥鎖,如果資源已經被佔用,資源申請者只能進入睡眠狀態。但是自旋鎖不會引起調用者睡眠,如果自旋鎖已經被別的執行線程保持,調用者就一直循環在那裏看是否該自旋鎖的保持者已經釋放了鎖,”自旋”一詞就是因此而得名。
  • 自旋鎖原理非常簡單,如果持有鎖的線程能在很短時間內釋放鎖資源,那麼那些等待競爭鎖的線程就不需要做內核態用戶態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的線程釋放鎖後即可立即獲取鎖,這樣就避免用戶線程內核切換的消耗。
  • 自旋鎖儘可能的減少線程的阻塞適用於鎖的競爭不激烈,且佔用鎖時間非常短的代碼塊來說性能能大幅度的提升,因爲自旋的消耗小於線程阻塞掛起再喚醒的操作的消耗
  • 但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,因爲自旋鎖在獲取鎖前一直都是佔用cpu做無用功,同時有 大量線程在競爭一個鎖,會導致獲取鎖的時間很長, 線程自旋的消耗大於線程阻塞掛起操作的消耗其它需要cpu的線程又不能獲取到cpu,造成cpu的浪費

8.3.Java如何實現自旋鎖?

簡單栗子:

public class SpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   public void lock() {
       Thread current = Thread.currentThread();
       // 利用CAS
       while (!cas.compareAndSet(null, current)) {
           // DO nothing
       }
   }
   public void unlock() {
       Thread current = Thread.currentThread();
       cas.compareAndSet(current, null);
   }
}

lock()方法利用的CAS,當第一個線程A獲取鎖的時候,能夠成功獲取到,不會進入while循環,如果此時線程A沒有釋放鎖,另一個線程B又來獲取鎖,此時由於不滿足CAS,所以就會進入while循環,不斷判斷是否滿足CAS,直到線程A調用unlock() 方法釋放了該鎖。

8.4.自旋鎖存在的問題

  1. 如果某個線程持有鎖的時間過長,就會導致其它等待獲取鎖的線程進入循環等待,消耗CPU。使用不當會造成CPU使用率極高。
  2. 上面Java實現的自旋鎖不是公平的,即無法滿足等待時間最長的線程優先獲取鎖。不公平的鎖就會存在“線程飢餓”問題。
  • 線程飢餓:線程因無法訪問所需資源而無法執行下去的情況。
  • “不患寡,而患不均(不應擔心財富不多,只需擔心財富分配不均)”,如果線程優先級“不均”,在CPU繁忙的情況下,優先級低的線程得到執行的機會很小,就可能發生線程“飢餓”;持有鎖的線程,如果執行的時間過長,也可能導致“飢餓”問題。
  • 解決“飢餓”問題的方案很簡單,有三種方案:

一是保證資源充足
二是公平地分配資源
三就是避免持有鎖的線程長時間執行
這三個方案中,方案一和方案三的適用場景比較有限,因爲很多場景下,資源的稀缺性是沒辦法解決的,持有鎖的線程執行的時間也很難縮短。倒是方案二的適用場景相對來說更多一些。
舉例剖析線程死鎖與飢餓的區別
死鎖與活鎖的區別,死鎖與飢餓的區別
java多線程中的死鎖、活鎖、飢餓、無鎖都是什麼鬼?

8.5.自旋鎖的優點

  1. 自旋鎖不會使線程狀態發生切換,一直處於用戶態,即線程一直都是active(活動)的;不會使線程進入阻塞狀態,減少了不必要的上下文切換,執行速度快
  2. 非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入內核態,當獲取到鎖的時候需要從內核態恢復,需要線程上下文切換。(線程被阻塞後便進入內核(Linux)調度狀態,這個會導致系統在用戶態與內核態之間來回切換,嚴重影響鎖的性能)

8.6.可重入的自旋鎖和不可重入的自旋鎖

8.3.那段代碼,仔細分析一下就可以看出,它是不支持重入的,即當一個線程第一次已經獲取到了該鎖,在鎖釋放之前又一次重新獲取該鎖,第二次就不能成功獲取到。由於不滿足CAS,所以第二次獲取會進入while循環等待,而如果是可重入鎖,第二次也是應該能夠成功獲取到的。

而且,即使第二次能夠成功獲取,那麼當第一次釋放鎖的時候,第二次獲取到的鎖也會被釋放,而這是不合理的。

爲了實現可重入鎖,我們需要引入一個計數器,用來記錄獲取鎖的線程數。

public class ReentrantSpinLock {
   private AtomicReference<Thread> cas = new AtomicReference<Thread>();
   private int count;
   public void lock() {
       Thread current = Thread.currentThread();
       if (current == cas.get()) { // 如果當前線程已經獲取到了鎖,線程數增加一,然後返回
           count++;
           return;
       }
       // 如果沒獲取到鎖,則通過CAS自旋
       while (!cas.compareAndSet(null, current)) {
           // DO nothing
       }
   }
   public void unlock() {
       Thread cur = Thread.currentThread();
       if (cur == cas.get()) {
           if (count > 0) {// 如果大於0,表示當前線程多次獲取了該鎖,釋放鎖通過count減一來模擬
               count--;
           } else {// 如果count==0,可以將鎖釋放,這樣就能保證獲取鎖的次數與釋放鎖的次數是一致的了。
               cas.compareAndSet(cur, null);
           }
       }
   }
}

8.7.自旋鎖與互斥鎖

  1. 自旋鎖與互斥鎖都是爲了實現保護資源共享的機制。
  2. 無論是自旋鎖還是互斥鎖,在任意時刻,都最多只能有一個保持者。
  3. 獲取互斥鎖的線程,如果鎖已經被佔用,則該線程將進入睡眠狀態;獲取自旋鎖的線程則不會睡眠,而是一直循環等待鎖釋放。

8.8.自旋鎖總結

  1. 自旋鎖:線程獲取鎖的時候,如果鎖被其他線程持有,則當前線程將循環等待,直到獲取到鎖。
  2. 自旋鎖等待期間,線程的狀態不會改變,線程一直是用戶態並且是活動的(active)。
  3. 自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的線程耗盡CPU。
  4. 自旋鎖本身無法保證公平性,同時也無法保證可重入性。
  5. 基於自旋鎖,可以實現具備公平性和可重入性質的鎖。

二.Synchronized與Lock

Java鎖機制可歸爲 Sychornized鎖Lock鎖 兩類。Synchronized是 基於JVM來保證數據同步 的,而Lock則是 在硬件層面,依賴特殊的CPU指令實現數據同步的

Synchronized是一個非公平、悲觀、獨享、互斥、可重入的重量級鎖
ReentrantLock是一個 默認非公平但可實現公平的、悲觀、獨享、互斥、可重入、重量級鎖
ReentrantReadWriteLocK是一個 默認非公平但可實現公平的、悲觀、寫獨享、讀共享、讀寫、可重入、重量級鎖

1. Synchronized

Java 5之前使用synchronized關鍵字保證同步,它可以把任意一個非NULL的對象當作鎖。

  • 作用於方法時,鎖住的是對象的實例 (this)
  • 當作用於靜態方法時,鎖住的是Class實例,又因爲Class的相關數據存儲在永久帶PermGen(jdk1.8則是metaspace),永久帶是全局共享的,因此靜態方法鎖相當於類的一個全局鎖,會鎖所有調用該方法的線程
  • synchronized作用於一個對象實例時,鎖住的是所有以該對象爲鎖的代碼塊

Synchronized的實現如下圖所示:
在這裏插入圖片描述

它有多個隊列,當多個線程一起訪問某個對象監視器的時候,對象監視器會將這些線程存儲在不同的容器中。

  • Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中

  • Entry List:Contention List中那些有資格成爲候選資源的線程被移動到Entry List中

  • Wait Set:哪些調用wait方法被阻塞的線程被放置在這裏

  • OnDeck:任意時刻,最多隻有一個線程正在競爭鎖資源,該線程被成爲OnDeck

  • Owner:當前已經獲取到所資源的線程被稱爲Owner

  • !Owner:當前釋放鎖的線程

    • ContentionList並不是真正意義上的一個隊列,僅僅是一個虛擬隊列。它只有Node以及對應的Next指針構成,並沒有Queue的數據結構。每次新加入Node會在隊頭進行,通過CAS改變第一個節點爲新增節點,同時新增階段的next指向後續節點,而取數據都在隊列尾部進行。
    • JVM每次從隊列的尾部取出一個數據用於鎖競爭候選者(OnDeck),但是併發情況下,ContentionList會被大量的併發線程進行CAS訪問,爲了降低對尾部元素的競爭,JVM會將一部分線程移動到EntryList中作爲候選競爭線程。Owner線程會在unlock時,將ContentionList中的部分線程遷移到EntryList中,並指定EntryList中的某個線程爲OnDeck線程(一般是最先進去的那個線程)。Owner線程並不直接把鎖傳遞給OnDeck線程,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行爲稱之爲“競爭切換”。
    • OnDeck線程獲取到鎖資源後會變爲Owner線程,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。
    • 處於ContentionList、EntryList、WaitSet中的線程都處於阻塞狀態,該阻塞是由操作系統來完成的(Linux內核下采用pthread_mutex_lock內核函數實現的)。該線程被阻塞後則進入內核調度狀態,會導致系統在用戶和內核之間進行來回切換,嚴重影響鎖的性能。爲了緩解上述性能問題,JVM引入了自旋鎖(上邊已經介紹過自旋鎖)。原理非常簡單,如果Owner線程能在很短時間內釋放鎖資源,那麼哪些等待競爭鎖的線程可以稍微等一等(自旋)而不是立即阻塞,當Owner線程釋放鎖後可立即獲取鎖,進而避免用戶線程和內核的切換。但是Owner可能執行的時間會超過設定的閾值,爭用線程在一定時間內還是獲取不到鎖,這是爭用線程會停止自旋進入阻塞狀態。基本思路就是先自旋等待一段時間看能否成功獲取,如果不成功再執行阻塞,儘可能的減少阻塞的可能性,這對於佔用鎖時間比較短的代碼塊來說性能能大幅度的提升!
    • Synchronized在線程進入ContentionList時,等待的線程會先嚐試自旋獲取鎖,如果獲取不到就進入ContentionList,這明顯對於已經進入隊列的線程是不公平的,還有一個不公平的事情就是自旋獲取鎖的線程還可能直接搶佔OnDeck線程的鎖資源。

2.Lock鎖

與synchronized不同的是,Lock鎖是純Java實現的,與底層的JVM無關。在java.util.concurrent.locks包中有很多Lock的實現類,常用的有ReentrantLock、ReadWriteLock(實現類ReentrantReadWriteLock),實現都依賴java.util.concurrent.AbstractQueuedSynchronizer類(簡稱AQS)。

2.1.Lock的實現

  • 獲取鎖:首先判斷當前狀態是否允許獲取鎖,如果是就獲取鎖,否則就阻塞操作或者獲取失敗,也就是說如果是獨佔鎖就可能阻塞,如果是共享鎖就可能失敗。另外如果是阻塞線程,那麼線程就需要進入阻塞隊列。當狀態位允許獲取鎖時就修改狀態,並且如果進了隊列就從隊列中移除。

  • **釋放鎖:**這個過程就是修改狀態位,如果有線程因爲狀態位阻塞的話,就喚醒隊列中的一個或者更多線程。阻塞和喚醒,JDK1.5之前的API中並沒有阻塞一個線程,然後在將來的某個時刻喚醒它(wait/notify是基於synchronized下才生效的,在這裏不算),JDK5之後利用JNI在LockSupport 這個類中實現了相關的特性。

  • 有序隊列:在AQS中採用CLH隊列來解決隊列的有序問題。ReentrantLock把所有的Lock都委託給Sync類進行處理,該類繼承自AQS。其中Sync又有兩個final static的子類NonfairSync和FairSync用於支持非公平鎖和公平鎖。Reentrant.lock()的調用過程(默認爲非公平鎖)

如圖:
在這裏插入圖片描述
通過上面的過程圖和AbstractQueuedSynchronizer的註釋可以看出,AbstractQueuedSynchronizer抽象了大多數Lock的功能,而只把tryAcquire(int)委託給子類進行多態實現。tryAcquire用於判斷對應線程事都能夠獲取鎖,無論成功與否,AbstractQueuedSynchronizer都將處理後面的流程。AQS會把所有請求鎖的線程組成一個CLH的隊列,當一個線程執行完畢釋放鎖(Lock.unlock())的時候,AQS會激活其後繼節點,正在執行的線程不在隊列當中,而那些等待的線程全部處於阻塞狀態,經過源碼分析,我們可以清楚的看到最終是通過LockSupport.park()實現的,而底層是調用sun.misc.Unsafe.park()本地方法,再進一步,HotSpot在Linux中中通過調用pthread_mutex_lock函數把線程交給系統內核進行阻塞。

其運行流程如圖所示:
在這裏插入圖片描述
與synchronized相同的是,這個也是一個虛擬隊列,並不存在真正的隊列示例,僅存在節點之間的前後關係。(注:原生的CLH隊列用於自旋鎖,JUC將其改造爲阻塞鎖)。和synchronized還有一相同點是當獲取鎖失敗的時候,不是立即進行阻塞,而是先自旋一段時間看是否能獲取鎖,這對那些已經在阻塞隊列裏面的線程顯然不公平(非公平鎖的實現,公平鎖通過有序隊列強制線程順序進行),但會極大的提升吞吐量。如果自旋還是獲取失敗了,則創建一個節點加入隊列尾部,加入方法仍採用CAS操作,併發對隊尾CAS操作有可能會發生失敗,AQS是採用自旋循環的方法,直到CAS成功。

鎖的實現依賴於lock()方法,Lock()方法首先是調用acquire(int)方法,不管是公平鎖還是非公平鎖

public final void acquire(int arg) {
         if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
             selfInterrupt();
}

Acquire()方法默認首先調用tryAcquire(int)方法,而此時公平鎖和不公平鎖的實現就不一樣了。

Sync.NonfairSync.TryAcquire (非公平鎖):nonfairTryAcquire方法是lock方法間接調用的第一個方法,每次調用都會首先調用這個方法,實現代碼:

final boolean nonfairTryAcquire(int acquires) {
        final Thread current = Thread.currentThread();
        int c = getState();
        if (c == 0) {
            if (compareAndSetState(0, acquires)) {
                setExclusiveOwnerThread(current);
                return true;
            }
        }
        else if (current == getExclusiveOwnerThread()) {
            int nextc = c + acquires;
            if (nextc < 0) // overflow
                throw new Error("Maximum lock count exceeded");
            setState(nextc);
            return true;
        }
        return false;
    }

該方法首先會判斷當前線程的狀態,如果c== 0 說明沒有線程正在競爭鎖。(反過來,如果c!=0則說明已經有其他線程已經擁有了鎖)。如果c==0,則通過CAS將狀態設置爲acquires(獨佔鎖的acquires爲1),後續每次重入該鎖都會+1,每次unlock都會-1,當數據爲0時則釋放鎖資源。其中精妙的部分在於:併發訪問時,有可能多個線程同時檢測到c爲0,此時執行compareAndSetState(0, acquires))設置,可以預見,如果當前線程CAS成功,則其他線程都不會再成功,也就默認當前線程獲取了鎖,直接作爲running線程,很顯然這個線程並沒有進入等待隊列。如果c!=0,首先判斷獲取鎖的線程是不是當前線程,如果是當前線程,則表明爲鎖重入,繼續+1,修改state的狀態,此時並沒有鎖競爭,也非CAS,因此這段代碼也非實現了偏向鎖。

2.2.Lock與synchronized 的區別

  1. ReentrantLock 擁有Synchronized相同的併發性和內存語義,此外還多了鎖投票,定時鎖等候和中斷鎖等候。線程A和B都要獲取對象O的鎖定,假設A獲取了對象O鎖,B將等待A釋放對O的鎖定,如果使用synchronized ,如果A不釋放,B將一直等下去,不能被中斷。如果使用ReentrantLock,如果A不釋放,可以使B在等待了足夠長的時間以後,中斷等待,而幹別的事情

ReentrantLock獲取鎖定的方式:
a. lock(),如果獲取了鎖立即返回,如果別的線程持有鎖,當前線程則一直處於休眠狀態,直到獲取鎖
b. tryLock(),如果獲取了鎖立即返回true,如果別的線程正持有鎖,立即返回false
c. tryLock(long timeout,TimeUnit unit),如果獲取了鎖定立即返回true,如果別的線程正持有鎖,會等待參數給定的時間,在等待的過程中,如果獲取了鎖定,就返回true,如果等待超時,返回false
d. lockInterruptibly,如果獲取了鎖定立即返回,如果沒有獲取鎖定,當前線程處於休眠狀態,直到或者鎖定,或者當前線程被別的線程中斷

  1. synchronized是在JVM層面上實現的,不但可以通過一些監控工具監控synchronized的鎖定,而且在代碼執行時出現異常,JVM會自動釋放鎖定,但是使用Lock則不行,Lock是通過代碼實現的,要保證鎖定一定會被釋放,就必須將unLock()放到finally{}中
  2. 在資源競爭不是很激烈的情況下,Synchronized的性能要優於ReetrantLock,但是在資源競爭很激烈的情況下,Synchronized的性能會下降幾十倍,但是ReetrantLock的性能能維持常態

Java5的多線程任務包對於同步的性能方面有了很大的改進,在原有synchronized關鍵字的基礎上,又增加了ReentrantLock,以及各種Atomic類。以下是對於其性能的優劣對比:

synchronized:

  • 在資源競爭不是很激烈的情況下,偶爾會有同步的情形下,synchronized是很合適的。原因在於,編譯程序通常會儘可能的進行優化synchronize,另外可讀性非常好

ReentrantLock:

  • ReentrantLock提供了多樣化的同步,比如有時間限制的同步,可以被Interrupt的同步(synchronized的同步是不能Interrupt的)等。在資源競爭不激烈的情形下,性能稍微比synchronized差點點,但是當同步非常激烈的時候,ReentrantLock性能能維持常態

Atomic:

  • 與ReentrantLock類似,不激烈情況下,性能比synchronized略遜,而激烈的時候,也能維持常態。激烈的時候,Atomic的性能會優於ReentrantLock一倍左右。但是其有一個缺點,就是隻能同步一個值,一段代碼中只能出現一個Atomic的變量,多於一個同步無效,因爲其不能在多個Atomic之間同步
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章