談談java中的鎖

談談java中的鎖

寫在前面
  在目前的編程潮流中,併發編程是一個重要的方向。談到併發,自然就能想到java中多線程和鎖。衆所周知,多線程幫助程序提高了性能,比如一件事1個人幹需要十小時,而十個人幹只需要1小時。但是提高了程序性能的同時,安全性問題也就隨之而來。比如買票,一個線程已經買了最後一張票,但並沒有修改票的狀態,此時恰好另一個線程也要買這張票,發現票未售空,也發起了買票動作。這時候就發生了一票兩賣的情況。爲了避免這種情況,java引入了鎖的概念,在資源上加上鎖,也就是在這種票上加上鎖,只有擁有這個鎖的線程纔可以操作,這樣就保證數據的一致性。下面來具體總結下鎖的相關知識:
  鎖按照功能可分爲樂觀鎖、悲觀鎖、可重入鎖、共享鎖、獨佔鎖等等。下面看看每種鎖的特點。
  悲觀鎖指的是數據對外界的修改採取保守策略,它認爲線程很容易會把數據修改掉,因此在整個數據被修改的過程中都會採取鎖定狀態,直到一個線程使用完,其他線程纔可以繼續使用。
舉例來說關鍵字synchronized 就屬於獨佔式悲觀鎖,是通過 JVM 隱式實現的,synchronized 只允許同一時刻只有一個線程操作資源。
  樂觀鎖和悲觀鎖的概念恰好相反,樂觀鎖認爲一般情況下數據在修改時不會出現衝突,所以在數據訪問之前不會加鎖,只是在數據提交更改時,纔會對數據進行檢測。
  Java 中的樂觀鎖大部分都是通過 CAS(Compare And Swap,比較並交換)操作實現的,CAS 是一個多線程同步的原子指令,CAS 操作包含三個重要的信息,即內存位置、預期原值和新值。如果內存位置的值和預期的原值相等的話,那麼就可以把該位置的值更新爲新值,否則不做任何修改。
  CAS 可能會造成 ABA 的問題,ABA 問題指的是,線程拿到了最初的預期原值 A,然而在將要進行 CAS 的時候,被其他線程搶佔了執行權,把此值從 A 變成了 B,然後其他線程又把此值從 B 變成了 A,然而此時的 A 值已經並非原來的 A 值了,但最初的線程並不知道這個情況,在它進行 CAS 的時候,只對比了預期原值爲 A 就進行了修改,這就造成了 ABA 的問題。
  ABA 的常見處理方式是添加版本號,每次修改之後更新版本號,拿上面的例子來說,假如每次移動箱子之後,箱子的位置就會發生變化,而這個變化的位置就相當於“版本號”,當某人進來之後發現箱子的位置發生了變化就知道有人動了手腳,就會放棄原有的計劃,這樣就解決了 ABA 的問題。

public class AtomicStampedReference<V> {
    private static class Pair<T> {
        final T reference;
        final int stamp; // “版本號”
        private Pair(T reference, int stamp) {
            this.reference = reference;
            this.stamp = stamp;
        }
        static <T> Pair<T> of(T reference, int stamp) {
            return new Pair<T>(reference, stamp);
        }
    }
    // 比較並設置
    public boolean compareAndSet(V   expectedReference,
                                 V   newReference,
                                 int expectedStamp, // 原版本號
                                 int newStamp) { // 新版本號
        Pair<V> current = pair;
        return
            expectedReference == current.reference &&
            expectedStamp == current.stamp &&
            ((newReference == current.reference &&
              newStamp == current.stamp) ||
             casPair(current, Pair.of(newReference, newStamp)));
    }
    //.......省略其他源碼
}

  可以看出它在修改時會進行原值比較和版本號比較,當比較成功之後會修改值並修改版本號。
  可重入鎖也叫遞歸鎖,指的是同一個線程,如果外面的函數擁有此鎖之後,內層的函數也可以繼續獲取該鎖。在 Java 語言中 ReentrantLock 和 synchronized 都是可重入鎖。

public class LockExample {
    public static void main(String[] args) {
        reentrantA(); // 可重入鎖
    }
    /**
     * 可重入鎖 A 方法
     */
    private synchronized static void reentrantA() {
        System.out.println(Thread.currentThread().getName() + ":執行 reentrantA");
        reentrantB();
    }
    /**
     * 可重入鎖 B 方法
     */
    private synchronized static void reentrantB() {
        System.out.println(Thread.currentThread().getName() + ":執行 reentrantB");
    }
}

  以上代碼的執行結果如下:

複製 main:執行 reentrantA
     main:執行 reentrantB

  從結果可以看出 reentrantA 方法和 reentrantB 方法的執行線程都是“main” ,我們調用了 reentrantA 方法,它的方法中嵌套了 reentrantB,如果 synchronized 是不可重入的話,那麼線程會被一直堵塞。
  可重入鎖的實現原理,是在鎖內部存儲了一個線程標識,用於判斷當前的鎖屬於哪個線程,並且鎖的內部維護了一個計數器,當鎖空閒時此計數器的值爲 0,當被線程佔用和重入時分別加 1,當鎖被釋放時計數器減 1,直到減到 0 時表示此鎖爲空閒狀態。
  共享鎖和獨佔鎖
  只能被單線程持有的鎖叫獨佔鎖,可以被多線程持有的鎖叫共享鎖。
  獨佔鎖指的是在任何時候最多隻能有一個線程持有該鎖,比如 synchronized 就是獨佔鎖,而 ReadWriteLock 讀寫鎖允許同一時間內有多個線程進行讀操作,它就屬於共享鎖。
  獨佔鎖可以理解爲悲觀鎖,當每次訪問資源時都要加上互斥鎖,而共享鎖可以理解爲樂觀鎖,它放寬了加鎖的條件,允許多線程同時訪問該資源。
  說到鎖首先想到的必然是synchronized,下面主要看下它是如何實現的?
  在 Java 中每個對象都隱式包含一個 monitor(監視器)對象,加鎖的過程其實就是競爭 monitor 的過程,當線程進入字節碼 monitorenter 指令之後,線程將持有 monitor 對象,執行 monitorexit 時釋放 monitor 對象,當其他線程沒有拿到 monitor 對象時,則需要阻塞等待獲取該對象。
  synchronized關鍵字的運用主要包括三方面:
    鎖代碼塊(鎖對象可指定,可爲this、XXX.class、全局變量)
    鎖普通方法(鎖對象是this,即該類實例本身)
    鎖靜態方法(鎖對象是該類,即XXX.class)
首先我們看第一種情況,鎖代碼塊,代碼如下

public class SyncDemo{
 private int a = 0; 
public void add(){
 synchronized(this)
{
 System.out.println("a values " + ++a); 
} } }

通過反編譯工具得到如下結果:
在這裏插入圖片描述
  由反編譯結果可以看出:synchronized代碼塊主要是靠monitorenter和monitorexit這兩個原語來實現同步的。當線程進入monitorenter獲得執行代碼的權利時,其他線程就不能執行裏面的代碼,直到鎖Owner線程執行monitorexit釋放鎖後,其他線程纔可以競爭獲取鎖。
這裏主要涉及到的兩個方法如下:
  (1)、monitorenter
每個對象有一個監視器鎖(monitor)。當monitor被佔用時就會處於鎖定狀態,線程執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:
如果monitor的進入數爲0,則該線程進入monitor,然後將進入數設置爲1,該線程即爲monitor的所有者。
  如果線程已經佔有該monitor,只是重新進入,則進入monitor的進入數加1.
  如果其他線程已經佔用了monitor,則該線程進入阻塞狀態,直到monitor的進入數爲0,再重新嘗試獲取monitor的所有權。
  上述第2點就涉及到了可重入鎖,意思就是說當一個線程已經獲取一個鎖時,它可以再獲取無數次,從代碼的角度上將就是有無數個相同的synchronized語句塊嵌套在一起。在進入時,monitor的進入數+1;退出時就-1,直到爲0的時候纔可以被其他線程競爭獲取。
  (2)、monitorexit
  執行monitorexit的線程必須是objectref所對應的monitor的所有者。
  指令執行時,monitor的進入數減1,如果減1後進入數爲0,那線程退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的線程可以嘗試去獲取這個 monitor 的所有權。

public class SyncDemo{ 
private int a = 0; 
public synchronized void add(){
 System.out.println(
 "a values " + ++a);
  }
}

  從反編譯結果來看,這裏沒有monitorenter和monitorexit,但是常量池中多了ACC_SYNCHRONIZED標示符。JVM就是根據該標示符來實現方法的同步的:當方法調用時會檢查方法的 ACC_SYNCHRONIZED 訪問標誌是否被設置,如果設置了,執行線程將先獲取monitor,獲取成功之後才能執行方法體,方法執行完後再釋放monitor。在方法執行期間,其他任何線程都無法再獲得同一個monitor對象。這種方式與語句塊沒什麼本質區別,都是通過競爭monitor的方式實現的。只不過這種方式是隱式的實現方法。
 &ems這裏,我們將以上兩種方法進行一下說明:
  首先是代碼塊,當程序運行到monitorenter時,競爭monitor,成功後繼續運行後續代碼,直到monitorexit才釋放monitor;而ACC_SYNCHRONIZED則是通過標誌位來提示線程去競爭monitor。也就是說,monitorenter和ACC_SYNCHRONIZED只是起標誌作用,並無實質操作。
  常量池中用ACC_STATIC標誌了這是一個靜態方法,然後用ACC_SYNCHRONIZED標誌位提醒線程去競爭monitor。由於靜態方法是屬於類級別的方法(即不用創建對象就可以被調用),所以這是一個類級別(XXX.class)的鎖,即競爭某個類的monitor。
  鎖的競爭過程
  上面說明了如何提醒線程去爭奪鎖,所以接下來我們看下線程是怎樣競爭鎖的。其實總的來說,JVM中是通過隊列來控制線程去競爭鎖的。鎖的競爭過程看下圖:
在這裏插入圖片描述
  (1)、多個線程請求鎖,首先進入Contention List,它可以接納所有請求線程,而且是一個後進先出(LIFO)的虛擬隊列,通過結點Node和next指針構造。
  (2)(3)、ContentionList會被線程併發訪問,EntryList爲了降低線程對ContentionList隊尾的爭用而構造出來。當Owner釋放鎖時,會從ContentionList中遷移線程到EntryList,並會指定EntryList中的某個線程(一般爲Head結點)爲Ready Thread,也就是說某個時刻最多隻有一個線程正在競爭鎖。
  (4)、Owner並不是直接把鎖交給OnDeck線程,而是將競爭鎖的權利交給OnDeck(將鎖釋放了),然後讓OnDeck自己去競爭。競爭成功後,OnDeck線程就變成Owner;否則繼續留在EntryList的隊頭。
  (5)(6)、當線程調用wait方法被阻塞時,進入WaitSet;當其他線程調用notifyAll()(notify())方法後,阻塞隊列的(某個)線程就會進入EntryList中。
  處於ContetionList、EntryList、WaitSet的線程均處於阻塞狀態。而線程被阻塞涉及到用戶態與內核態的切換(Liunx),系統切換嚴重影響鎖的性能。解決這個問題的辦法就是自旋。自旋就是線程不斷進行內部循環,即for循環什麼也不做,防止線程wait()阻塞,在自旋過程中不斷嘗試獲取鎖,如果自旋期間,Owner剛好釋放鎖,此時自旋線程就可以去競爭鎖。如果自旋了一段時間還沒獲取到鎖,那沒辦法,只能調用wait()阻塞了。
  爲什麼自旋了一段時間後又調用wait()方法呢?因爲自旋是要消耗CPU的,而且還有線程上下文切換,因爲CPU還可以調度線程,只不過執行的是空的for循環罷了。
   所以,synchronized是什麼時候進行自旋的?答案是在進入ContetionList之前,因爲它自旋一定時間後還沒獲取鎖,最後它只好在ContetionList中阻塞等待了。
鎖升級過程
   鎖狀態一種有四種,從級別由低到高依次是:無鎖、偏向鎖,輕量級鎖,重量級鎖,鎖狀態只能升級,不能降級。鎖升級其實就是從偏向鎖到輕量級鎖再到重量級鎖升級的過程,這是 JDK 1.6 提供的優化功能,也稱之爲鎖膨脹。
在這裏插入圖片描述
   偏向鎖是指在無競爭的情況下設置的一種鎖狀態。偏向鎖的意思是它會偏向於第一個獲取它的線程,當鎖對象第一次被獲取到之後,會在此對象頭中設置標示爲“01”,表示偏向鎖的模式,並且在對象頭中記錄此線程的 ID,這種情況下,如果是持有偏向鎖的線程每次在進入的話,不再進行任何同步操作,如 Locking、Unlocking 等,直到另一個線程嘗試獲取此鎖的時候,偏向鎖模式纔會結束,偏向鎖可以提高帶有同步但無競爭的程序性能。但如果在多數鎖總會被不同的線程訪問時,偏向鎖模式就比較多餘了,此時可以通過 -XX:-UseBiasedLocking 來禁用偏向鎖以提高性能。
   輕量鎖是相對於重量鎖而言的,在 JDK 1.6 之前,synchronized 是通過操作系統的互斥量(mutex lock)來實現的,這種實現方式需要在用戶態和核心態之間做轉換,有很大的性能消耗,這種傳統實現鎖的方式被稱之爲重量鎖
   而輕量鎖是通過比較並交換(CAS,Compare and Swap)來實現的,它對比的是線程和對象的 Mark Word(對象頭中的一個區域),如果更新成功則表示當前線程成功擁有此鎖;如果失敗,虛擬機會先檢查對象的 Mark Word 是否指向當前線程的棧幀,如果是,則說明當前線程已經擁有此鎖,否則,則說明此鎖已經被其他線程佔用了。當兩個以上的線程爭搶此鎖時,輕量級鎖就膨脹爲重量級鎖,這就是鎖升級的過程,也是 JDK 1.6 鎖優化的內容。
  什麼是死鎖
  死鎖是指兩個線程同時佔用兩個資源,又在彼此等待對方釋放鎖資源。上一段代碼如下:

public class LockExample {
	public static void main(String[] args) {
		deadlock();
	}
	public static  void deadlock(){
       Object lock1 = new Object();
       Object lock2 = new Object();
       new Thread(()->{
    	   synchronized (lock1) {
			System.out.println("獲取lock1成功!");
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (Exception e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
    	   synchronized (lock2) {
			System.out.println(Thread.currentThread().getName());
		}
       }).start(); ;
       new Thread(()->{
    	   synchronized (lock2) {
			System.out.println("獲取lock2成功!");
			try {
				TimeUnit.SECONDS.sleep(2);
			} catch (Exception e) {
				// TODO Auto-generated catch block
				e.printStackTrace();
			}
		}
    	   synchronized (lock1) {
			System.out.println(Thread.currentThread().getName());
		}
       }).start(); ;
	}
}

  可以看出當我們使用線程一擁有鎖 lock1 的同時試圖獲取 lock2,而線程二在擁有 lock2 的同時試圖獲取 lock1,這樣就會造成彼此都在等待對方釋放資源,於是就形成了死鎖。

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