天天看點

java并發中的各種鎖

一、重入鎖

廣義上的可重入鎖指的是可重複可遞歸調用的鎖,就是說,一個重入鎖可以被一個任務(該任務已經持有該鎖的情況下)多次獲得,這樣的鎖就叫做可重入鎖。ReentrantLock和synchronized都是可重入鎖。

public synchronized void get() {
        set();//在已經擷取該對象鎖的情況下調用set()方法再次擷取鎖
    }

    public synchronized void set() {
        
    }
           
二、不可重入鎖

即隻可被擷取一次的鎖,不可重入鎖的一個例子是 自旋鎖

三、自旋鎖

1、自旋鎖

Java沒有自旋鎖的API,是以自旋鎖更像是一種鎖優化技術

原子操作+自循環。 線程不休眠,一直循環嘗試對資源通路,直到可用。這時該線程處于 自旋 狀态,直到擷取到鎖才會退出循環。這種會讓其他線程處于自旋狀态的鎖稱為 自旋鎖。

自旋鎖是一種非阻塞鎖,也就是說,如果某線程需要擷取自旋鎖,但該鎖已經被其他線程占用時,該線程不會被挂起(該線程并沒有sleep或者wait,而是在"運作",CPU仍會給它配置設定時間),而是處于 自旋 狀态,并不斷的消耗CPU的時間,不停的試圖擷取自旋鎖。

自旋鎖通常是采用讓目前線程不停地的在循環體内執行實作的,java中的自旋鎖通常采用CAS方式實作,當循環的條件被其他線程改變時 才能進入臨界區

下面是一個典型實作:

class SpinLock {
    //該AtomicReference儲存了一個Thread對象
	AtomicReference<Thread> lockOwner = new AtomicReference<Thread>();
	private int count;
	
	//如果一個線程利用該鎖調用lock()方法,該方法首先會識别該線程,然後判斷是否為空。
	public void lock() {
		Thread cur = Thread.currentThread();//識别該線程
		
        //如果lockOwner為空(即沒有被線程持有),就将lockOwner設定為該線程,并跳出循環,
        //這時,該線程進入臨界區
        //如果過一段時間,另一個線程也嘗試擷取該鎖,即調用lock.lock(),
        //此時lockOwner在compareAndSet()這個地方會發現已經有持有者了,便會讓這
        //第二個線程進入while循環中自旋
		while (!owner.compareAndSet(null, cur)){ 
		//第二個線程一直在while内部,直到前一個線程釋放鎖(鎖調用unlock()),
		//第二個線程才能進入臨界區。
		}
	}
	public void unLock() {
		Thread cur = Thread.currentThread();
			owner.compareAndSet(cur, null);
		}
	}
}
           

自旋等待的時間必須要有一定的限度,如果自旋超過了限定次數(通常是10次)沒有成功獲得鎖,就應當挂起線程。

自旋鎖可能引發的問題:

1.過多占據CPU時間: 如果鎖的目前持有者長時間不釋放該鎖,那麼等待者将長時間的占據cpu時間片,導緻CPU資源的浪費,是以可以設定一個時間,當鎖持有者超過這個時間不釋放鎖時,等待者會放棄CPU時間片阻塞;

2.死鎖問題: 試想一下,有一個線程連續兩次試圖獲得自旋鎖(比如在遞歸程式中),第一次這個線程獲得了該鎖,當第二次試圖加鎖的時候,檢測到鎖已被占用(其實是被自己占用),那麼這時,線程會一直等待自己釋放該鎖,而不能繼續執行,這樣就引起了死鎖。是以遞歸程式使用自旋鎖應該遵循以下原則:遞歸程式決不能在持有自旋鎖時調用它自己,也決不能在遞歸調用時試圖獲得相同的自旋鎖。

适用場景

如果是多核處理器,如果預計線程等待鎖的時間很短,短到比線程兩次上下文切換時間要少的情況下,使用自旋鎖是劃算的。原因如下:

先看看CPU的兩種工作模式:

(1)Kernel Mode

在核心模式下,代碼具有對硬體的所有控制權限。可以執行所有CPU指令,可以通路任意位址的記憶體。核心模式是為作業系統最底層,最可信的函數服務的。在核心模式下的任何異常都是災難性的,将會導緻整台機器停機。

(2)User Mode

在使用者模式下,代碼沒有對硬體的直接控制權限,也不能直接通路位址的記憶體。程式是通過調用系統接口(System APIs)來達到通路硬體和記憶體。在這種保護模式下,即時程式發生崩潰也是可以恢複的。在你的電腦上大部分程式都是在使用者模式下運作的。

我們通常所說的線程是輕量級程序。輕量級程序是基于核心線程實作的,是以各種程序操作,如建立/析構及同步,都需要進行系統調用。而系統調用的代價相對較高,需要在使用者态(User Mode)和核心态(Kernel Mode)中來回切換;每個輕量級程序都需要有一個核心線程的支援,是以輕量級程序需要消耗一定的核心資源(如核心線程的棧空間),是以一個系統支援的輕量級程序是有限的。

自旋鎖出現的原因是人們發現大多數時候鎖的占用隻會持續很短的時間,甚至低于切換到kernal mode所花的時間,是以在切換到kernal mode前讓線程等待有限的時間(自适應自旋,也就是自旋鎖中的那個循環),如果在此時間内能夠擷取到鎖就避免了很多無謂的時間,若不能則再進入kernal mode競争鎖。

2、自适應自旋鎖

自适應 意味着自旋的時間(次數)不再固定,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀态來決定。如果在同一個鎖對象上,自旋等待剛剛成功獲得過鎖,并且持有鎖的線程正在運作中,那麼虛拟機就會認為這次自旋也是很有可能再次成功,進而它将允許自旋等待持續相對更長的時間。如果對于某個鎖,自旋很少成功獲得過,那在以後嘗試擷取這個鎖時将可能省略掉自旋過程,直接阻塞線程,避免浪費處理器資源。

四、樂觀鎖和悲觀鎖

1、樂觀鎖

樂觀鎖非常樂觀,每次取資料時認為不會有線程對資料修改,在更新時會判斷其他線程在這之前有沒有修改。

樂觀鎖,可以使用版本号機制和CAS算法實作

版本号機制

具體實作是給資料庫表加一個 version 字段,用來記錄資料庫表被更新的次數,表被修改時,version加一。線程更新資料庫時會讀到version的值,在送出更新時,目前讀到的version的值 >= 資料庫中的version值才更新。

CAS算法

java.util.concurrent.atomic包下面的原子變量類就是使用CAS實作的。

CAS算法是一種有名的無鎖算法。無鎖程式設計,即不使用鎖的情況下實作多線程之間的變量同步。

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

(1) 需要讀寫的記憶體值 V

(2) 進行比較的值 A

(3) 拟寫入的新值 B

當且僅當 V 的值等于 A時,CAS通過原子方式用新值B來更新V的值,否則不會執行任何操作(比較和替換是一個原子操作)。一般情況下是一個自旋操作,即不斷的重試。

2、悲觀鎖

總是假設最壞的情況,每次去拿資料的時候都認為别人會修改資料,是以每次在拿資料的時候都會上鎖,這樣别人想拿這個資料就會阻塞直到它拿到鎖(共享資源每次隻給一個線程使用,其它線程阻塞,用完後再把資源轉讓給其它線程)。傳統的關系型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。Java中synchronized擷取的對象鎖和ReentrantLock等獨占鎖就是悲觀鎖思想的實作。

五、競争鎖和非競争鎖

非競争鎖

非競争鎖分兩種情況:

1、如果一個鎖自始至終隻被一個線程使用, JVM 有能力優化它帶來的絕大部分損耗。

2、一個鎖被多個線程使用過,但是在任意時刻,隻有一個線程嘗試擷取該鎖,這個情況開銷稍微大一些。

JVM對非競争鎖做了很多 優化,使它們幾乎不會對性能造成影響,常見的優化有以下幾種:

(1) 如果一個鎖對象隻能由目前線程通路,那麼其他線程無法獲得該鎖并發生同步 , 是以 JVM 可以去除對這個鎖的請求。

(2) 逸出分析 (escape analysis) 可以識别本地對象的引用是否在堆中被暴露。如果沒有,就可以将本地對象的引用變為線程本地的 (thread local) 。

(3) 編譯器還可以進行鎖的粗化 (lock coarsening) 。把鄰近的 synchronized 塊用相同的鎖合并起來,以減少不必要的鎖的擷取和釋放。

是以,不要過分擔心非競争鎖帶來的開銷,要關注那些真正發生了鎖競争的臨界區中性能的優化。

競争鎖:

多個線程同時嘗試擷取的鎖稱為競争鎖,這種情況是 JVM 無法優化的,而且通常會發生從使用者态到核心态的切換。

在保證程式正确性的前提下,解決同步帶來的性能損失的第一步不是去除鎖,而是降低鎖的競争。通常,有以下三類方法可以降低鎖的競争:減少持有鎖的時間,降低請求鎖的頻率,或者用其他協調機制取代獨占鎖。

refered from https://www.ibm.com/developerworks/cn/java/j-lo-lock/index.html

六、無鎖 、偏向鎖 、輕量級鎖 和 重量級鎖

該部分内容參考自

https://juejin.im/post/5bee576fe51d45710c6a51e0#heading-3

以及

https://www.zhihu.com/question/39009953

這四種鎖是指鎖的狀态,專門針對synchronized的。在介紹這四種鎖狀态之前還需要介紹一些額外的知識。

首先為什麼Synchronized能實作線程同步?

在回答這個問題之前我們需要了解兩個重要的概念:“Java對象頭”、“Monitor”。

Java對象頭

synchronized是悲觀鎖,在操作同步資源之前需要給同步資源先加鎖,這把鎖就是存在Java對象頭裡的,而Java對象頭又是什麼呢?

我們以Hotspot虛拟機為例,Hotspot的對象頭主要包括兩部分資料:Mark Word(标記字段)、Klass Pointer(類型指針)。

Mark Word: 預設存儲對象的HashCode,分代年齡和鎖标志位資訊。這些資訊都是與對象自身定義無關的資料,是以Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體存儲盡量多的資料。它會根據對象的狀态複用自己的存儲空間,也就是說在運作期間Mark Word裡存儲的資料會随着鎖标志位的變化而變化。

Klass Point: 對象指向它的類中繼資料的指針,虛拟機通過這個指針來确定這個對象是哪個類的執行個體。

Monitor

Monitor可以了解為一個同步工具或一種同步機制,通常被描述為一個對象。每一個Java對象就有一把看不見的鎖,稱為内部鎖或者Monitor鎖。

Monitor是線程私有的資料結構,每一個線程都有一個可用monitor record清單,同時還有一個全局的可用清單。每一個被鎖住的對象都會和一個monitor關聯,同時monitor中有一個Owner字段存放擁有該鎖的線程的唯一辨別,表示該鎖被這個線程占用。

現在話題回到synchronized,synchronized通過Monitor來實作線程同步,Monitor是依賴于底層的作業系統的Mutex Lock(互斥鎖)來實作的線程同步。

如同我們在自旋鎖中提到的“阻塞或喚醒一個Java線程需要作業系統切換CPU狀态來完成,這種狀态轉換需要耗費處理器時間。如果同步代碼塊中的内容過于簡單,狀态轉換消耗的時間有可能比使用者代碼執行的時間還要長”。這種方式就是synchronized最初實作同步的方式,這就是JDK 6之前synchronized效率低的原因。這種依賴于作業系統Mutex Lock所實作的鎖我們稱之為“重量級鎖”,JDK 6中為了減少獲得鎖和釋放鎖帶來的性能消耗,引入了“偏向鎖”和“輕量級鎖”。

是以目前鎖一共有 4種狀态,級别從低到高依次是:無鎖、偏向鎖、輕量級鎖和重量級鎖。這幾個狀态會随着競争情況逐漸更新。鎖狀态隻能更新不能降級。

通過上面的介紹,我們對synchronized的加鎖機制以及相關知識有了一個了解,那麼下面我們給出四種鎖狀态對應的的Mark Word内容,然後再分别講解四種鎖狀态的思路以及特點:

java并發中的各種鎖

鎖的優缺點對比:

java并發中的各種鎖

1、無鎖

無鎖沒有對資源進行鎖定,所有的線程都能通路并修改同一個資源,但同時隻有一個線程能修改成功。

無鎖的特點就是 修改操作在循環内進行,線程會不斷的嘗試修改共享資源。如果沒有沖突就修改成功并退出,否則就會繼續循環嘗試。如果有多個線程修改同一個值,必定會有一個線程能修改成功,而其他修改失敗的線程會不斷重試直到修改成功。上面我們介紹的CAS原理及應用即是無鎖的實作。無鎖無法全面代替有鎖,但無鎖在某些場合下的性能是非常高的。

2、偏向鎖

偏向鎖是指一段同步代碼一直被一個線程所通路,那麼該線程會自動擷取鎖,降低擷取鎖的代價。

在大多數情況下,鎖總是由同一線程多次獲得,不存在多線程競争,是以出現了偏向鎖。其目标就是在隻有一個線程執行同步代碼塊時能夠提高性能。

當一個線程通路同步代碼塊并擷取鎖時,會在Mark Word裡存儲鎖偏向的線程ID。線上程進入和退出同步塊時不再通過CAS操作來加鎖和解鎖,而是檢測Mark Word裡是否存儲着指向目前線程的偏向鎖。引入偏向鎖是為了在無多線程競争的情況下盡量減少不必要的輕量級鎖執行路徑,因為輕量級鎖的擷取及釋放依賴多次CAS原子指令,而偏向鎖隻需要在置換ThreadID的時候依賴一次CAS原子指令即可。

偏向鎖隻有遇到其他線程嘗試競争偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動釋放偏向鎖。偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有位元組碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處于被鎖定狀态。撤銷偏向鎖後恢複到無鎖(标志位為“01”)或輕量級鎖(标志位為“00”)的狀态。

偏向鎖在JDK 6及以後的JVM裡是預設啟用的。可以通過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,關閉之後程式預設會進入輕量級鎖狀态。

3、輕量級鎖

是指當鎖是偏向鎖的時候,被另外的線程所通路,偏向鎖就會更新為輕量級鎖,其他線程會通過自旋的形式嘗試擷取鎖,不會阻塞,進而提高性能。

在代碼進入同步塊的時候,如果同步對象鎖狀态為無鎖狀态(鎖标志位為“01”狀态,是否為偏向鎖為“0”),虛拟機首先将在目前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用于存儲鎖對象目前的Mark Word的拷貝,然後拷貝對象頭中的Mark Word複制到鎖記錄中。

拷貝成功後,虛拟機将使用CAS操作嘗試将對象的Mark Word更新為指向Lock Record的指針,并将Lock Record裡的owner指針指向對象的Mark Word。

如果這個更新動作成功了,那麼這個線程就擁有了該對象的鎖,并且對象Mark Word的鎖标志位設定為“00”,表示此對象處于輕量級鎖定狀态。

如果輕量級鎖的更新操作失敗了,虛拟機首先會檢查對象的Mark Word是否指向目前線程的棧幀,如果是就說明目前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明多個線程競争鎖。

若目前隻有一個等待線程,則該線程通過自旋進行等待。但是當自旋超過一定的次數,或者一個線程在持有鎖,一個在自旋,又有第三個來訪時,輕量級鎖更新為重量級鎖。

4、重量級鎖

更新為重量級鎖時,鎖标志的狀态值變為“10”,此時Mark Word中存儲的是指向重量級鎖的指針,此時等待鎖的線程都會進入阻塞狀态。

七、公平鎖和非公平鎖

公平鎖 是指多個線程按照申請鎖的順序來擷取鎖,線程直接進入隊列中排隊,隊列中的第一個線程才能獲得鎖。公平鎖的優點是等待鎖的線程不會餓死。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。

非公平鎖 是多個線程加鎖時直接嘗試擷取鎖,擷取不到才會到等待隊列的隊尾等待。但如果此時鎖剛好可用,那麼這個線程可以無需阻塞直接擷取到鎖,是以非公平鎖有可能出現 後申請鎖的線程先擷取鎖 的場景。非公平鎖的優點是可以減少喚起線程的開銷,整體的吞吐效率高,因為線程有幾率不阻塞直接獲得鎖,CPU不必喚醒所有線程。缺點是處于等待隊列中的線程可能會餓死,或者等很久才會獲得鎖。

Java中的ReentrantLock 預設的lock() 方法采用的是非公平鎖。

java.util.concurrent.locks.AbstractQueuedSynchronizer類很重要,幾乎所有locks包下的工具類鎖都包含了該類的static子類,足以可見這個類在java并發鎖工具類當中的地位。這個類提供了對作業系統層面線程操作方法的封裝調用,可以幫助并發設計者設計出很多優秀的API

更多驚喜:https://www.zhihu.com/question/36964449