Java 源碼剖析(05)--淺談對鎖的理解


在併發編程中有兩個重要的概念:線程和鎖,多線程是一把雙刃劍,它在提高程序性能的同時,也帶來了編碼的複雜性,對開發者的要求也提高了一個檔次。而鎖的出現就是爲了保障多線程在同時操作一組資源時的數據一致性,當我們給資源加上鎖之後,只有擁有此鎖的線程才能操作此資源,而其他線程只能排隊等待使用此鎖。

1)死鎖

鎖是指在併發編程中,當有多個線程同時操作一個資源時,爲了保證數據操作的正確性,我們需要讓多線程排隊一個一個的操作此資源,而這個過程就是給資源加鎖和釋放鎖的過程,就好像去公共廁所一樣,必須一個一個排隊使用,並且在使用時需要鎖門和開門一樣。
死鎖是指兩個線程同時佔用兩個資源,又在彼此等待對方釋放鎖資源,如下圖所示:
在這裏插入圖片描述
死鎖的代碼演示如下:

import java.util.concurrent.TimeUnit;

public class LockExample {
    public static void main(String[] args) {
        deadLock(); // 死鎖
    }

    /**
     * 死鎖
     */
    private static void deadLock() {
        Object lock1 = new Object();
        Object lock2 = new Object();
        // 線程一擁有 lock1 試圖獲取 lock2
        new Thread(() -> {
            synchronized (lock1) {
                System.out.println("獲取 lock1 成功");
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 試圖獲取鎖 lock2
                synchronized (lock2) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }).start();
        // 線程二擁有 lock2 試圖獲取 lock1
        new Thread(() -> {
            synchronized (lock2) {
                System.out.println("獲取 lock2 成功");
                try {
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                // 試圖獲取鎖 lock1
                synchronized (lock1) {
                    System.out.println(Thread.currentThread().getName());
                }
            }
        }).start();
    }
}

以上程序執行結果如下:

獲取 lock1 成功
獲取 lock2 成功

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

2)知識擴展

2.1)悲觀鎖和樂觀鎖

悲觀鎖指的是數據對外界的修改採取保守策略,它認爲線程很容易會把數據修改掉,因此在整個數據被修改的過程中都會採取鎖定狀態,直到一個線程使用完,其他線程纔可以繼續使用。我們來看一下悲觀鎖的實現流程,以 synchronized 爲例,代碼如下:

public class LockExample {
    public static void main(String[] args) {
        synchronized (LockExample.class) {
            System.out.println("lock");
        }
    }
}

我們使用反編譯工具查到的結果如下:

Compiled from "LockExample.java"
public class com.lagou.interview.ext.LockExample {
  public com.lagou.interview.ext.LockExample();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return
 
  public static void main(java.lang.String[]);
    Code:
       0: ldc           #2                  // class com/lagou/interview/ext/LockExample
       2: dup
       3: astore_1
       4: monitorenter // 加鎖
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String lock
      10: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      13: aload_1
      14: monitorexit // 釋放鎖
      15: goto          23
      18: astore_2
      19: aload_1
      20: monitorexit
      21: aload_2
      22: athrow
      23: return
    Exception table:
       from    to  target type
           5    15    18   any
          18    21    18   any
}

可以看出被 synchronized 修飾的代碼塊,在執行之前先使用 monitorenter 指令加鎖,然後在執行結束之後再使用 monitorexit 指令釋放鎖資源,在整個執行期間此代碼都是鎖定的狀態,這就是典型悲觀鎖的實現流程。

樂觀鎖和悲觀鎖的概念恰好相反,樂觀鎖認爲一般情況下數據在修改時不會出現衝突,所以在數據訪問之前不會加鎖,只是在數據提交更改時,纔會對數據進行檢測

  • 樂觀鎖有一個優點,它在提交的時候才進行鎖定的,因此不會造成死鎖。

Java 中的樂觀鎖大部分都是通過 CAS(Compare And Swap,比較並交換)操作實現的,CAS 是一個多線程同步的原子指令,CAS 操作包含三個重要的信息,即內存位置、預期原值和新值。如果內存位置的值和預期的原值相等的話,那麼就可以把該位置的值更新爲新值,否則不做任何修改。

CAS 可能會造成 ABA 的問題

ABA 問題指的是,線程拿到了最初的預期原值 A,然而在將要進行 CAS 的時候,被其他線程搶佔了執行權,把此值從 A 變成了
B,然後其他線程又把此值從 B 變成了 A,然而此時的 A 值已經並非原來的 A 值了,但最初的線程並不知道這個情況,在它進行 CAS 的時候,只對比了預期原值爲 A 就進行了修改,這就造成了 ABA 的問題。
以警匪劇爲例,假如某人把裝了 100W 現金的箱子放在了家裏,幾分鐘之後要拿它去贖人,然而在趁他不注意的時候,進來了一個小偷,用空箱子換走了裝滿錢的箱子,當某人進來之後看到箱子還是一模一樣的,他會以爲這就是原來的箱子,就拿着它去贖人了,這種情況肯定有問題,因爲箱子已經是空的了,這就是 ABA 的問題。

ABA 的常見處理方式是添加版本號,每次修改之後更新版本號,拿上面的例子來說,假如每次移動箱子之後,箱子的位置就會發生變化,而這個變化的位置就相當於“版本號”,當某人進來之後發現箱子的位置發生了變化就知道有人動了手腳,就會放棄原有的計劃,這樣就解決了 ABA 的問題。

JDK 在 1.5 時提供了 AtomicStampedReference 類也可以解決 ABA 的問題,此類維護了一個“版本號” Stamp,每次在比較時不止比較當前值還比較版本號,這樣就解決了 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)));
    }
    //.......省略其他源碼
}

可以看出它在修改時會進行原值比較和版本號比較,當比較成功之後會修改值並修改版本號。

2.2)可重入鎖

可重入鎖也叫遞歸鎖,指的是同一個線程,如果外面的函數擁有此鎖之後,內層的函數也可以繼續獲取該鎖。在 Java 語言中 ReentrantLock 和 synchronized 都是可重入鎖。下面我們用 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 時表示此鎖爲空閒狀態。

2.3)共享鎖和獨佔鎖

只能被單線程持有的鎖叫獨佔鎖,可以被多線程持有的鎖叫共享鎖。

獨佔鎖指的是在任何時候最多隻能有一個線程持有該鎖,比如 synchronized 就是獨佔鎖,而 ReadWriteLock 讀寫鎖允許同一時間內有多個線程進行讀操作,它就屬於共享鎖。

獨佔鎖可以理解爲悲觀鎖,當每次訪問資源時都要加上互斥鎖,而共享鎖可以理解爲樂觀鎖,它放寬了加鎖的條件,允許多線程同時訪問該資源。

3)小結

本文主要講了悲觀鎖和樂觀鎖,其中悲觀鎖的典型應用爲 synchronized,它的特性爲獨佔式互斥鎖
而樂觀鎖相比於悲觀鎖而言,擁有更好的性能,但樂觀鎖可能會導致 ABA 的問題,常見的解決方案是添加版本號來防止 ABA 問題的發生。同時,還講了可重入鎖,在 Java 中,synchronized 和 ReentrantLock 都是可重入鎖。最後,講了獨佔鎖和共享鎖,其中獨佔鎖可以理解爲悲觀鎖,而共享鎖可以理解爲樂觀鎖。

共享鎖,又稱爲讀鎖,獲得共享鎖之後,可以查看但無法修改和刪除數據;
排他鎖,又稱爲寫鎖、獨佔鎖。獲准排他鎖後,既能讀數據,又能修改數據

——————————————————————————————————————————————
關注公衆號,回覆 【算法】,獲取高清算法書!
在這裏插入圖片描述

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