分門別類總結Java中的各種鎖,讓你徹底記住

前言

本文需要具備一定的多線程基礎才能更好的理解。

學習java多線程時,最頭疼的知識點之一就是java中的鎖了,什麼互斥鎖、排它鎖、自旋鎖、死鎖、活鎖等等,細分的話可以羅列出20種左右的鎖,光是看着這些名字就足以讓人望而卻步了,更別說一個個去理解它們的含義了。其實我要在這裏告訴大家,我們看到的其實只是假象,其實根本沒有這麼多鎖,或者這樣說,這裏邊有很多鎖其實就是一個東西,當我們從不同的側重點去看的時候,它們就會衍生出不同的名字。本文就是着重將這些鎖進行分門別類的總結,另外,本文不着重闡述鎖的實現原理,大家有興趣可以自行去查找,資料有很多,本文着重讓大家理解這些鎖的概。好了,廢話不多說,進入正題。

   

一、由ReentrantLock和synchronized實現的一系列鎖

jdk1.5的java.util.concurrent併發包中的Lock接口和1.5之前的synchronized或許是我們最常用的同步方式,這兩種同步方式特別是Lock的ReentrantLock實現,經常拿來進行比較,其實他們有很多相似之處,其實它們在實現同步的思想上大致相同,只不過在一些細節的策略上(諸如拋出異常是否自動釋放鎖)有所不同。前邊說過了,本文着重講鎖的實現思想和不同鎖的概念與分類,不對實現原理的細節深究,因此我在下面介紹第一類鎖的時候經常講他們放在一起來說。我們先來說一下Lock接口的實現之一ReentrantLock。當我們想要創建ReentrantLock實例的時候,jdk爲我們提供兩種重載的構造函數,如圖:


fair是什麼意思?公平的意思,沒錯,這就是我們要說的第一種鎖。


1.從其它等待中的線程是否按順序獲取鎖的角度劃分--公平鎖與非公平鎖

我先做個形象比喻,比如現在有一個餐廳,一次最多隻允許一個持有鑰匙的人進入用餐,那麼其他沒拿到鑰匙的人就要在門口等着,等裏面那個人吃完了,他出來他把鑰匙扔地上,後邊拿到鑰匙的人才能進入餐廳用餐。
  • 公平鎖:是指多個線程在等待同一個鎖時,必須按照申請鎖的先後順序來一次獲得鎖。所用公平鎖就好像在餐廳的門口安裝了一個排隊的護欄,誰先來的誰就站的靠前,無法進行插隊,當餐廳中的人用餐結束後會把鑰匙交給排在最前邊的那個人,以此類推。公平鎖的好處是,可以保證每個排隊的人都有飯吃,先到先吃後到後吃。但是弊端是,要額外安裝排隊裝置。
  • 非公平鎖:理解了公平鎖,非公平鎖就很好理解了,它無非就是不用排隊,當餐廳裏的人出來後將鑰匙往地上一扔,誰搶到算誰的。但是這樣就造成了一個問題,那些身強體壯的人可能總是會先搶到鑰匙,而那些身體瘦小的人可能一直搶不到,這就有可能將一直搶不到鑰匙,最後導致需要很長時間才能拿到鑰匙甚至一直拿不到直至餓死。

    公平鎖與非公平所的總結:

(1) 公平鎖的好處是等待鎖的線程不會餓死,但是整體效率相對低一些;非公平鎖的好處是整體效率相對高一些,但是有些線程可能會餓死或者說很早就在等待鎖,但要等很久纔會獲得鎖。其中的原因是公平鎖是嚴格按照請求所的順序來排隊獲得鎖的,而非公平鎖時可以搶佔的,即如果在某個時刻有線程需要獲取鎖,而這個時候剛好鎖可用,那麼這個線程會直接搶佔,而這時阻塞在等待隊列的線程則不會被喚醒。

(2) 在java中,公平鎖可以通過new ReentrantLock(true)來實現;非公平鎖可以通過new ReentrantLock(false)或者默認構造函數new ReentrantLock()實現。

(3)synchronized是非公平鎖,並且它無法實現公平鎖。


2.從能否有多個線程持有同一把鎖的角度劃分--互斥鎖

互斥鎖的概念非常簡單,也就是我們常說的同步,即一次最多只能有一個線程持有的鎖,當一個線程持有該鎖的時候其它線程無法進入上鎖的區域。在Java中synchronized就是互斥鎖,從宏觀概念來講,互斥鎖就是通過悲觀鎖的理念引出來的,而非互斥鎖則是通過樂觀鎖的概念引申的。


3.從一個線程能否遞歸獲取自己的鎖的角度劃分--重入鎖(遞歸鎖)

我們知道,一條線程若想進入一個被上鎖的區域,首先要判斷這個區域的鎖是否已經被某條線程所持有。如果鎖正在被持有那麼線程將等待鎖的釋放,但是這就引發了一個問題,我們來看這樣一段簡單的代碼:

public class ReentrantDemo {
	private Lock mLock;

	public ReentrantDemo(Lock mLock) {
		this.mLock = mLock;
	}

	public void outer() {
		mLock.lock();
		inner();
		mLock.unlock();
	}

	public void inner() {
		mLock.lock();
		// do something
		mLock.unlock();
	}
}

當線程A調用outer()方法的時候,會進入使用傳進來mlock實例來進行mlock.lock()加鎖,此時outer()方法中的這片區域的鎖mlock就被線程A持有了,當線程B想要調用outer()方法時會先判斷,發現這個mlock這把鎖被其它線程持有了,因此進入等待狀態。我們現在不考慮線程B,單說線程A,線程A進入outer()方法後,它還要調用inner()方法,並且inner()方法中使用的也是mlock()這把鎖,於是接下來有趣的事情就來了。按正常步驟來說,線程A先判斷mlock這把鎖是否已經被持有了,判斷後發現這把鎖確實被持有了,但是可笑的是,是A自己持有的。那你說A能否在加了mlock鎖的outer()方法中調用加了mlock鎖的inner方法呢?答案是如果我們使用的是可重入鎖,那麼遞歸調用自己持有的那把鎖的時候,是允許進入的。

  • 可重入鎖:可以再次進入方法A,就是說在釋放鎖前此線程可以再次進入方法A(方法A遞歸)。
  • 不可重入鎖(自旋鎖):不可以再次進入方法A,也就是說獲得鎖進入方法A是此線程在釋放鎖錢唯一的一次進入方法A。

下面這段代碼演示了不可重入鎖

public class Lock{  
    private boolean isLocked = false;  
    public synchronized void lock()  
        throws InterruptedException{  
        while(isLocked){  
            wait();  
        }  
        isLocked = true;  
    }  

    public synchronized void unlock(){  
        isLocked = false;  
        notify();  
    }  
}  

可以看到,當isLocked被設置爲true後,在線程調用unlock()解鎖之前不管線程是否已經獲得鎖,都只能wait()。


4.從編譯器優化的角度劃分--鎖消除和鎖粗化

鎖消除和鎖粗化,是編譯器在編譯代碼階段,對一些沒有必要的、不會引起安全問題的同步代碼取消同步(鎖消除)或者對那些多次執行同步的代碼且它們可以可併到一次同步的代碼(鎖粗化)進行的優化手段,從而提高程序的執行效率。

鎖消除

對一些代碼上要求同步,但是被檢測到不可能存在共享數據競爭的鎖進行消除。鎖消除的主要判斷依據是來源於逃逸分析的數據支持,如果判斷在一段代碼中,堆上的所有數據都不會逃逸出去從而能被其他線程訪問到,那就可以把他們當做棧上數據對待,認爲他們是線程私有的,同步加鎖自然就無需進行。

來看這樣一個方法:

    public String concatString(String s1, String s2, String s3)
    {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        sb.append(s3);
        return sb.toString();
    }

源碼中StringBuffer 的append方法定義如下:

    public synchronized StringBuffer append(StringBuffer sb) {
        super.append(sb);
        return this;
    }
可見append的方法使用synchronized進行同步,我們知道對象的實例總是存在於堆中被多有線程共享,即使在局部方法中創建的實例依然存在於堆中,但是對該實例的引用是線程私有的,對其他線程不可見。即上邊代碼中雖然StringBuffer的實例是共享數據,但是對該實例的引用確實每條線程內部私有的。不同的線程引用的是堆中存在的不同的StringBuffer實例,它們互不影響互不可見。也就是說在concatString()方法中涉及了同步操作。但是可以觀察到sb對象它的作用域被限制在方法的內部,也就是sb對象不會“逃逸”出去,其他線程無法訪問。因此,雖然這裏有鎖,但是可以被安全的消除,在即時編譯之後,這段代碼就會忽略掉所有的同步而直接執行了。

鎖粗化

原則上,我們在編寫代碼的時候,總是要將同步塊的作用範圍限制的儘量小——只在共享數據的實際作用域中才進行同步,這樣是爲了使得需要同步的操作數量儘可能變小,如果存在鎖禁止,那等待的線程也能儘快拿到鎖。大部分情況下,這些都是正確的。但是,如果一系列的聯繫操作都是同一個對象反覆加上和解鎖,甚至加鎖操作是出現在循環體中的,那麼即使沒有線程競爭,頻繁地進行互斥同步操作也導致不必要的性能損耗。

舉個案例,類似上面鎖消除的concatString()方法。如果StringBuffer sb = new StringBuffer();定義在方法體之外,那麼就會有線程競爭,但是每個append()操作都對同一個對象反覆加鎖解鎖,那麼虛擬機探測到有這樣的情況的話,會把加鎖同步的範圍擴展到整個操作序列的外部,即擴展到第一個append()操作之前和最後一個append()操作之後,這樣的一個鎖範圍擴展的操作就稱之爲鎖粗化。


5.在不同的位置使用synchronized--類鎖和對象鎖

這是最常見的鎖了,synchronized作爲鎖來使用的時候,無非就只能出現在兩個地方(其實還能修飾變量,但作用是保證可見性,這裏討論鎖,故不闡述):代碼塊、方法(一般方法、靜態方法)。由於可以使用不同的類型來作爲鎖,因此分成了類鎖和對象鎖。

  • 類鎖:使用字節碼文件(即.class)作爲鎖。如靜態同步函數(使用本類的.class),同步代碼塊中使用.class。
  • 對象鎖:使用對象作爲鎖。如同步函數(使用本類實例,即this),同步代碼塊中是用引用的對象。

下面代碼涵蓋了所有synchronized的使用方式:

public class Demo {
	public Object obj = new Object();

	public static synchronized void method1() { //1.靜態同步函數,使用本類字節碼做類鎖(即Demo.class)
	}

	public void method2() {
		synchronized (Demo.class) { //同步代碼塊,使用字節碼做類鎖
		}
	}

	public synchronized void method3() { //同步函數,使用本類對象實例即this做對象鎖
	}

	public void method4() {
		synchronized (this) { //同步代碼塊,使用本類對象實例即this做對象鎖
		}
	}

	public void method5() {
		synchronized (obj) { //同步代碼塊,使用共享數據obj實例做對象鎖。
		}
	}
}

二、從鎖的設計理念來分類--悲觀鎖、樂觀鎖

如果將鎖在宏觀上進行大的分類,那麼所只有兩類,即悲觀鎖和樂觀鎖。

悲觀鎖

悲觀鎖是就是悲觀思想,即認爲寫多,遇到併發寫的可能性高,每次去拿數據的時候都認爲別人會修改,所以每次在讀寫數據的時候都會上鎖,這樣別人想讀寫這個數據就會block直到拿到鎖。java中的悲觀鎖就是Synchronized,AQS框架下的鎖則是先嚐試cas樂觀鎖去獲取鎖,獲取不到,纔會轉換爲悲觀鎖,如RetreenLock。

樂觀鎖

樂觀鎖是一種樂觀思想,即認爲讀多寫少,遇到併發寫的可能性低,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,採取在寫時先讀出當前版本號,然後加鎖操作(比較跟上一次的版本號,如果一樣則更新),如果失敗則要重複讀-比較-寫的操作。

樂觀鎖的實現思想--CAS(Compare and Swap)無鎖

CAS並不是一種實際的鎖,它僅僅是實現樂觀鎖的一種思想,java中的樂觀鎖(如自旋鎖)基本都是通過CAS操作實現的,CAS是一種更新的原子操作,比較當前值跟傳入值是否一樣,一樣則更新,否則失敗。

關於CAS的原理,有興趣可以參看 JAVA CAS實現原理與使用。另外,在Java中,java.util.concurrent.atomic包下的原子類也都是基於CAS實現的。

前兩節的結構圖



三、數據庫中常用到的鎖--共享鎖、排它鎖

共享鎖和排它鎖多用於數據庫中的事物操作,主要針對讀和寫的操作。而在Java中,對這組概念通過ReentrantReadWriteLock進行了實現,它的理念和數據庫中共享鎖與排它鎖的理念幾乎一致,即一條線程進行讀的時候,允許其他線程進入上鎖的區域中進行讀操作;當一條線程進行寫操作的時候,不允許其他線程進入進行任何操作。即讀+讀可以存在,讀+寫、寫+寫均不允許存在

  • 共享鎖:也稱讀鎖S鎖如果事務T對數據A加上共享鎖後,則其他事務只能對A再加共享鎖,不能加排它鎖。獲准共享鎖的事務只能讀數據,不能修改數據。 
  • 排它鎖也稱獨佔鎖寫鎖X鎖如果事務T對數據A加上排它鎖後,則其他事務不能再對A加任何類型的鎖。獲得排它鎖的事務即能讀數據又能修改數據。

    

四、對鎖的不同效率進行的分類--偏向鎖、輕量級鎖和重量級鎖

由於不同的鎖的實現原理不同,故它們的效率肯定也會不盡相同,那麼我們在不同的應用場景下究竟該選擇何種鎖呢?基於這個問題,鎖被分成了偏向鎖、輕量級鎖和重量級鎖以便應對不同的應用場景。具體請參考:java 中的鎖 -- 偏向鎖、輕量級鎖、自旋鎖、重量級鎖

五、由於併發問題產生的鎖--死鎖、活鎖

死鎖

1.什麼是死鎖

所謂死鎖是指多個線程因競爭資源而造成的一種僵局(互相等待),若無外力作用,這些進程都將無法向前推進。下面我通過一些實例來說明死鎖現象。

先看生活中的一個實例,2個人一起吃飯但是隻有一雙筷子,2人輪流吃(同時擁有2只筷子才能吃)。某一個時候,一個拿了左筷子,一人拿了右筷子,2個人都同時佔用一個資源,等待另一個資源,這個時候甲在等待乙吃完並釋放它佔有的筷子,同理,乙也在等待甲吃完並釋放它佔有的筷子,這樣就陷入了一個死循環,誰也無法繼續吃飯。

在計算機系統中也存在類似的情況。例如,某計算機系統中只有一臺打印機和一臺輸入 設備,進程P1正佔用輸入設備,同時又提出使用打印機的請求,但此時打印機正被進程P2 所佔用,而P2在未釋放打印機之前,又提出請求使用正被P1佔用着的輸入設備。這樣兩個進程相互無休止地等待下去,均無法繼續執行,此時兩個進程陷入死鎖狀態。

2.死鎖形成的必要條件

產生死鎖必須同時滿足以下四個條件,只要其中任一條件不成立,死鎖就不會發生:

  • 互斥條件:進程要求對所分配的資源(如打印機)進行排他性控制,即在一段時間內某 資源僅爲一個進程所佔有。此時若有其他進程請求該資源,則請求進程只能等待。
  • 不剝奪條件:進程所獲得的資源在未使用完畢之前,不能被其他進程強行奪走,即只能 由獲得該資源的進程自己來釋放(只能是主動釋放)。
  • 請求和保持條件:進程已經保持了至少一個資源,但又提出了新的資源請求,而該資源 已被其他進程佔有,此時請求進程被阻塞,但對自己已獲得的資源保持不放。
  • 循環等待條件:存在一種進程資源的循環等待鏈,鏈中每一個進程已獲得的資源同時被 鏈中下一個進程所請求。即存在一個處於等待狀態的進程集合{Pl, P2, ..., pn},其中Pi等 待的資源被P(i+1)佔有(i=0, 1, ..., n-1),Pn等待的資源被P0佔有,如圖2-15所示。

活鎖

活鎖和死鎖在表現上是一樣的兩個線程都沒有任何進展,但是區別在於:死鎖,兩個線程都處於阻塞狀態,說白了就是它不會再做任何動作,我們通過查看線程狀態是可以分辨出來的。而活鎖呢,並不會阻塞,而是一直嘗試去獲取需要的鎖,不斷的try,這種情況下線程並沒有阻塞所以是活的狀態,我們查看線程的狀態也會發現線程是正常的,但重要的是整個程序卻不能繼續執行了,一直在做無用功。舉個生動的例子的話,兩個人都沒有停下來等對方讓路,而是都有很有禮貌的給對方讓路,但是兩個人都在不斷朝路的同一個方向移動,這樣只是在做無用功,還是不能讓對方通過。


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