線程安全——synchronized 和 ReentrantLock ,看完後絕不後悔系列

前面我們介紹了很多關於多線程的內容,在多線程中有一個很重要的課題需要我們攻克,那就是線程安全問題。線程安全問題指的是在多線程中,各線程之間因爲同時操作所產生的數據污染或其他非預期的程序運行結果。

在此,如果有對併發編程感興趣的或者想系統掌握併發編程的,這邊推薦一個欄目,

線程安全

1)非線程安全事例

比如 A 和 B 同時給 C 轉賬的問題,假設 C 原本餘額有 100 元,A 給 C 轉賬 100 元,正在轉的途中,此時 B 也給 C 轉了 100 元,這個時候 A 先給 C 轉賬成功,餘額變成了 200 元,但 B 事先查詢 C 的餘額是 100 元,轉賬成功之後也是 200 元。當 A 和 B 都給 C 轉賬完成之後,餘額還是 200 元,而非預期的 300 元,這就是典型的線程安全的問題。

enter image description here

2)非線程安全代碼示例

上面的內容沒看明白沒關係,下面來看非線程安全的具體代碼:

class ThreadSafeTest {
    static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -› addNumber());
        Thread thread2 = new Thread(() -› addNumber());
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("number:" + number);
    }
    public static void addNumber() {
        for (int i = 0; i ‹ 10000; i++) {
            ++number;
        }
    }
}

以上程序執行結果如下:

number:12085

每次執行的結果可能略有差異,不過幾乎不會等於(正確的)累計之和 20000。

3)線程安全的解決方案

線程安全的解決方案有以下幾個維度:

  • 數據不共享,單線程可見,比如 ThreadLocal 就是單線程可見的;

  • 使用線程安全類,比如 StringBuffer 和 JUC(java.util.concurrent)下的安全類(後面文章會專門介紹);

  • 使用同步代碼或者鎖。

線程同步和鎖

1)synchronized

① synchronized 介紹

synchronized 是 Java 提供的同步機制,當一個線程正在操作同步代碼塊(synchronized 修飾的代碼)時,其他線程只能阻塞等待原有線程執行完再執行。

② synchronized 使用

synchronized 可以修飾代碼塊或者方法,示例代碼如下:

// 修飾代碼塊
synchronized (this) {
    // do something
}
// 修飾方法
synchronized void method() {
    // do something
}

使用 synchronized 完善本文開頭的非線程安全的代碼。

方法一:使用 synchronized 修飾代碼塊,代碼如下:

class ThreadSafeTest {
    static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread sThread = new Thread(() -› {
            // 同步代碼
            synchronized (ThreadSafeTest.class) {
                addNumber();
            }
        });
        Thread sThread2 = new Thread(() -› {
            // 同步代碼
            synchronized (ThreadSafeTest.class) {
                addNumber();
            }
        });
        sThread.start();
        sThread2.start();
        sThread.join();
        sThread2.join();
        System.out.println("number:" + number);
    }
    public static void addNumber() {
        for (int i = 0; i ‹ 10000; i++) {
            ++number;
        }
    }
}

以上程序執行結果如下:

number:20000

方法二:使用 synchronized 修飾方法,代碼如下:

class ThreadSafeTest {
    static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        Thread sThread = new Thread(() -› addNumber());
        Thread sThread2 = new Thread(() -› addNumber());
        sThread.start();
        sThread2.start();
        sThread.join();
        sThread2.join();
        System.out.println("number:" + number);
    }
    public synchronized static void addNumber() {
        for (int i = 0; i ‹ 10000; i++) {
            ++number;
        }
    }
}

以上程序執行結果如下:

number:20000

③ synchronized 實現原理

synchronized 本質是通過進入和退出的 Monitor 對象來實現線程安全的。
以下面代碼爲例:

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

當我們使用 javap 編譯之後,生成的字節碼如下:

Compiled from "SynchronizedTest.java"
public class com.interview.other.SynchronizedTest {
  public com.interview.other.SynchronizedTest();
    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/interview/other/SynchronizedTest
       2: dup
       3: astore_1
       4: monitorenter
       5: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       8: ldc           #4                  // String Java
      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
}

可以看出 JVM(Java 虛擬機)是採用 monitorenter 和 monitorexit 兩個指令來實現同步的,monitorenter 指令相當於加鎖,monitorexit 相當於釋放鎖。而 monitorenter 和 monitorexit 就是基於 Monitor 實現的。

2)ReentrantLock

① ReentrantLock 介紹

ReentrantLock(再入鎖)是 Java 5 提供的鎖實現,它的功能和 synchronized 基本相同。再入鎖通過調用 lock() 方法來獲取鎖,通過調用 unlock() 來釋放鎖。

② ReentrantLock 使用

ReentrantLock 基礎使用,代碼如下:

Lock lock = new ReentrantLock();
lock.lock();    // 加鎖
// 業務代碼...
lock.unlock();    // 解鎖

使用 ReentrantLock 完善本文開頭的非線程安全代碼,請參考以下代碼:

public class LockTest {
    static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        // ReentrantLock 使用
        Lock lock = new ReentrantLock();
        Thread thread1 = new Thread(() -› {
            try {
                lock.lock();
                addNumber();
            } finally {
                lock.unlock();
            }
        });
        Thread thread2 = new Thread(() -› {
            try {
                lock.lock();
                addNumber();
            } finally {
                lock.unlock();
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
        System.out.println("number:" + number);
    }
    public static void addNumber() {
        for (int i = 0; i ‹ 10000; i++) {
            ++number;
        }
    }
}

嘗試獲取鎖

ReentrantLock 可以無阻塞嘗試訪問鎖,使用 tryLock() 方法,具體使用如下:

Lock reentrantLock = new ReentrantLock();
// 線程一
new Thread(() -› {
    try {
        reentrantLock.lock();
        Thread.sleep(2 * 1000);
 
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        reentrantLock.unlock();
    }
}).start();
// 線程二
new Thread(() -› {
    try {
        Thread.sleep(1 * 1000);
        System.out.println(reentrantLock.tryLock());
        Thread.sleep(2 * 1000);
        System.out.println(reentrantLock.tryLock());
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

以上代碼執行結果如下:

false

true

嘗試一段時間內獲取鎖

tryLock() 有一個擴展方法 tryLock(long timeout, TimeUnit unit) 用於嘗試一段時間內獲取鎖,具體實現代碼如下:

Lock reentrantLock = new ReentrantLock();
// 線程一
new Thread(() -› {
    try {
        reentrantLock.lock();
        System.out.println(LocalDateTime.now());
        Thread.sleep(2 * 1000);
    } catch (InterruptedException e) {
        e.printStackTrace();
    } finally {
        reentrantLock.unlock();
    }
}).start();
// 線程二
new Thread(() -› {
    try {
        Thread.sleep(1 * 1000);
        System.out.println(reentrantLock.tryLock(3, TimeUnit.SECONDS));
        System.out.println(LocalDateTime.now());
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}).start();

以上代碼執行結果如下:

2019-07-05 19:53:51

true

2019-07-05 19:53:53

可以看出鎖在休眠了 2 秒之後,就被線程二直接獲取到了,所以說 tryLock(long timeout, TimeUnit unit) 方法內的 timeout 參數指的是獲取鎖的最大等待時間。

③ ReentrantLock 注意事項

使用 ReentrantLock 一定要記得釋放鎖,否則該鎖會被永久佔用。

相關面試題

1.ReentrantLock 常用的方法有哪些?

答:ReentrantLock 常見方法如下:

  • lock():用於獲取鎖

  • unlock():用於釋放鎖

  • tryLock():嘗試獲取鎖

  • getHoldCount():查詢當前線程執行 lock() 方法的次數

  • getQueueLength():返回正在排隊等待獲取此鎖的線程數

  • isFair():該鎖是否爲公平鎖

2.ReentrantLock 有哪些優勢?

答:ReentrantLock 具備非阻塞方式獲取鎖的特性,使用 tryLock() 方法。ReentrantLock 可以中斷獲得的鎖,使用 lockInterruptibly() 方法當獲取鎖之後,如果所在的線程被中斷,則會拋出異常並釋放當前獲得的鎖。ReentrantLock 可以在指定時間範圍內獲取鎖,使用 tryLock(long timeout,TimeUnit unit) 方法。

3.ReentrantLock 怎麼創建公平鎖?

答:new ReentrantLock() 默認創建的爲非公平鎖,如果要創建公平鎖可以使用 new ReentrantLock(true)。

4.公平鎖和非公平鎖有哪些區別?

答:公平鎖指的是線程獲取鎖的順序是按照加鎖順序來的,而非公平鎖指的是搶鎖機制,先 lock() 的線程不一定先獲得鎖。

5.ReentrantLock 中 lock() 和 lockInterruptibly() 有什麼區別?

答:lock() 和 lockInterruptibly() 的區別在於獲取線程的途中如果所在的線程中斷,lock() 會忽略異常繼續等待獲取線程,而 lockInterruptibly() 則會拋出 InterruptedException 異常。
題目解析:執行以下代碼,在線程中分別使用 lock() 和 lockInterruptibly() 查看運行結果,代碼如下:

 Lock interruptLock = new ReentrantLock();
interruptLock.lock();
Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
        try {
            interruptLock.lock();
            //interruptLock.lockInterruptibly();  // java.lang.InterruptedException
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
});
thread.start();
TimeUnit.SECONDS.sleep(1);
thread.interrupt();
TimeUnit.SECONDS.sleep(3);
System.out.println("Over");
System.exit(0);

執行以下代碼會發現使用 lock() 時程序不會報錯,運行完成直接退出;而使用 lockInterruptibly() 則會拋出異常 java.lang.InterruptedException,這就說明:在獲取線程的途中如果所在的線程中斷,lock() 會忽略異常繼續等待獲取線程,而 lockInterruptibly() 則會拋出 InterruptedException 異常。

6.synchronized 和 ReentrantLock 有什麼區別?

答:synchronized 和 ReentrantLock 都是保證線程安全的,它們的區別如下:

  • ReentrantLock 使用起來比較靈活,但是必須有釋放鎖的配合動作;

  • ReentrantLock 必須手動獲取與釋放鎖,而 synchronized 不需要手動釋放和開啓鎖;

  • ReentrantLock 只適用於代碼塊鎖,而 synchronized 可用於修飾方法、代碼塊等;

  • ReentrantLock 性能略高於 synchronized。

7.ReentrantLock 的 tryLock(3, TimeUnit.SECONDS) 表示等待 3 秒後再去獲取鎖,這種說法對嗎?爲什麼?

答:不對,tryLock(3, TimeUnit.SECONDS) 表示獲取鎖的最大等待時間爲 3 秒,期間會一直嘗試獲取,而不是等待 3 秒之後再去獲取鎖。

8.synchronized 是如何實現鎖升級的?

答:在鎖對象的對象頭裏面有一個 threadid 字段,在第一次訪問的時候 threadid 爲空,JVM(Java 虛擬機)讓其持有偏向鎖,並將 threadid 設置爲其線程 id,再次進入的時候會先判斷 threadid 是否尤其線程 id 一致,如果一致則可以直接使用,如果不一致,則升級偏向鎖爲輕量級鎖,通過自旋循環一定次數來獲取鎖,不會阻塞,執行一定次數之後就會升級爲重量級鎖,進入阻塞,整個過程就是鎖升級的過程。

總結

本文介紹了線程同步的兩種方式 synchronized 和 ReentrantLock,其中 ReentrantLock 使用更加靈活,效率也率高,不過 ReentrantLock 只能修飾代碼塊,使用 ReentrantLock 需要開發者手動釋放鎖,如果忘記釋放則該鎖會一直被佔用。synchronized 使用場景更廣,可以修飾普通方法、靜態方法和代碼塊等。

下一篇:java併發包

在公衆號菜單中可自行獲取專屬架構視頻資料,包括不限於 java架構、python系列、人工智能系列、架構系列,以及最新面試、小程序、大前端均無私奉獻,你會感謝我的哈

往期精選

分佈式數據之緩存技術,一起來揭開其神祕面紗

分佈式數據複製技術,今天就教你真正分身術

數據分佈方式之哈希與一致性哈希,我就是個神算子

分佈式存儲系統三要素,掌握這些就離成功不遠了

想要設計一個好的分佈式系統,必須搞定這個理論

分佈式通信技術之發佈訂閱,乾貨滿滿

分佈式通信技術之遠程調用:RPC

消息隊列Broker主從架構詳細設計方案,這一篇就搞定主從架構

消息中間件路由中心你會設計嗎,不會就來學學

消息隊列消息延遲解決方案,跟着做就行了

秒殺系統每秒上萬次下單請求,我們該怎麼去設計

【分佈式技術】分佈式系統調度架構之單體調度,非掌握不可

CDN加速技術,作爲開發的我們真的不需要懂嗎?

煩人的緩存穿透問題,今天教就你如何去解決

分佈式緩存高可用方案,我們都是這麼幹的

每天百萬交易的支付系統,生產環境該怎麼設置JVM堆內存大小

你的成神之路我已替你鋪好,沒鋪你來捶我

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