博客 / 詳情

返回

Java中鎖的解決方案

前言

在上一篇文章中,介紹了什麼是鎖,以及鎖的使用場景,本文繼續給大家繼續做深入的介紹,介紹JAVA為我們提供的不同種類的鎖。

JAVA為我們提供了種類豐富的鎖,每種鎖都有不同的特性,鎖的使用場景也各不相同。由於篇幅有限,在這裏只給大家介紹比較常用的幾種鎖。我會通過鎖的定義,核心代碼剖析,以及使用場景來給大家介紹JAVA中主流的幾種鎖。

樂觀鎖 與 悲觀鎖

樂觀鎖與悲觀鎖應該是每個開發人員最先接觸的兩種鎖。小編最早接觸的就是這兩種鎖,但是不是在JAVA中接觸的,而是在數據庫當中。當時的應用場景主要是在更新數據的時候,更新數據這個場景也是使用鎖的非常主要的場景之一。更新數據的主要流程如下:

  1. 檢索出要更新的數據,供操作人員查看;
  2. 操作人員更改需要修改的數值;
  3. 點擊保存,更新數據;

這個流程看似簡單,但是我們用多線程的思維去考慮,這也應該算是一種互聯網思維吧,就會發現其中隱藏着問題。我們具體看一下

  1. A檢索出數據;
  2. B檢索出數據;
  3. B修改了數據;
  4. A修改數據,系統會修改成功嗎?

當然啦,A修改成功與否,要看程序怎麼寫。咱們拋開程序,從常理考慮,A保存數據的時候,系統要給提示,説“您修改的數據已被其他人修改過,請重新查詢確認”。那麼我們程序中怎麼實現呢?

  1. 在檢索數據,將數據的版本號(version)或者最後更新時間一併檢索出來;
  2. 操作員更改數據以後,點擊保存,在數據庫執行update操作
  3. 執行update操作時,用步驟1檢索出的版本號或者最後更新時間與數據庫中的記錄作比較;
  4. 如果版本號或最後更新時間一致,則可以更新;
  5. 如果不一致,就要給出上面的提示;

上述的流程就是樂觀鎖的實現方式。在JAVA中樂觀鎖並沒有確定的方法,或者關鍵字,它只是一個處理的流程、策略。咱們看懂上面的例子之後,再來看看JAVA中樂觀鎖。

樂觀鎖呢,它是假設一個線程在取數據的時候不會被其他線程更改數據,就像上面的例子那樣,但是在更新數據的時候會校驗數據有沒有被修改過。它是一種比較交換的機制,簡稱CAS (Compare And Swap)機制。一旦檢測到有衝突產生,也就是上面説到的版本號或者最後更新時間不一致,它就會進行重試,直到沒有衝突為止。樂觀鎖的機制如圖所示:

咱們看一下JAVA中最常用的i++,咱們思考一個問題,i++它的執行順序是什麼樣子的?它是線程安全的嗎?當多個線程併發執行i++的時候,會不會有問題?接下來咱們通過程序看一下:

package cn.pottercoding.lock;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author 程序員波特
 * @since 2024年01月12日
 *
 * i++ 線程安全問題測試
 */
public class ThreadTest {

    private int i = 0;

    public static void main(String[] args) {
        ThreadTest test = new ThreadTest();

        // 線程池,50個固定線程
        ExecutorService executorService = Executors.newFixedThreadPool(50);
        CountDownLatch countDownLatch = new CountDownLatch(5000);

        for (int i = 0; i < 5000; i++) {
            executorService.execute(() -> {
                test.i++;
                countDownLatch.countDown();
            });
        }

        executorService.shutdown();

        try {
            countDownLatch.await();
            System.out.println("執行完成後,i = " + test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

上面的程序中,我們模擬了50個線程同時執行i++,總共執行5000次,按照常規的理解,得到的結果應該是5000,我們運行一下程序,看看執行的結果如何?

執行完成後,i=4975
執行完成後,i=4986
執行完成後,i=4971

這是運行3次以後得到的結果,可以看到每次執行的結果都不一樣,而且不是5000,這是為什麼呢?這就説明i++並不是一個原子性的操作,在多線程的情況下並不安全。我們把i++的詳細執行步驟拆解一下:

  1. 從內存中取出i的當前值;
  2. 將i的值加1;
  3. 將計算好的值放入到內存當中;

這個流程和我們上面講解的數據庫的操作流程是一樣的。在多線程的場景下,我們可以想象一下,線程A和線程B同時從內存取出的值,假如i的值是1000,然後線程A和線程B再同時執行+1的操作,然後把值再放入內存當中,這時,內存中的值是1001,而我們期望的是1002,正是這個原因導致了上面的錯誤。那麼我們如何解決呢?在JAVA1.5以後,JDK官方提供了大量的原子類,這些類的內部都是基於CAS機制的,也就是使用了樂觀鎖。我們將上面的程序稍微改造一下,如下:

package cn.pottercoding.lock;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * @author 程序員波特
 * @since 2024年01月12日
 *
 * 原子類測試
 */
public class AtomicTest {

    private AtomicInteger i = new AtomicInteger(0);

    public static void main(String[] args) {
        AtomicTest test = new AtomicTest();

        // 線程池,50個固定線程
        ExecutorService executorService = Executors.newFixedThreadPool(50);
        CountDownLatch countDownLatch = new CountDownLatch(5000);

        for (int i = 0; i < 5000; i++) {
            executorService.execute(() -> {
                test.i.incrementAndGet();
                countDownLatch.countDown();
            });
        }

        executorService.shutdown();

        try {
            countDownLatch.await();
            System.out.println("執行完成後,i = " + test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我們將變量的類型改為AtomicInteger ,AtomicInteger 是一個原子類。我們在之前調用i++的地方改成了i.incrementAndGet(),incrementAndGet()方法採用了CAS機制,也就是説使用了樂觀鎖。我們再運行一下程序,看看結果如何。

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

我們同樣執行了3次,3次的結果都是5000,符合了我們預期。這個就是樂觀鎖。我們對樂觀鎖稍加總結,樂觀鎖在讀取數據的時候不做任何限制,而是在更新數據的時候,進行數據的比較,保證數據的版本一致時再更新數據。根據它的這個特點,可以看出樂觀鎖適用於讀操作多,而寫操作少的場景。

悲觀鎖與樂觀鎖恰恰相反,悲觀鎖從讀取數據的時候就顯示的加鎖,直到數據更新完成,釋放鎖為止。在這期間只能有一個線程去操作,其他的線程只能等待。在JAVA中,悲觀鎖可以使用synchronized關鍵字或者ReentrantLock類來實現。還是,上面的例子,我們分別使用這兩種方式來實現一下。首先是使用synchronized關鍵字來實現:

package cn.pottercoding.lock;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author 程序員波特
 * @since 2024年01月12日
 *
 * 使用 synchronized 關鍵字來實現自增
 */
public class SynchronizedTest {

    private int i = 0;

    public static void main(String[] args) {
        SynchronizedTest test = new SynchronizedTest();

        // 線程池,50個固定線程
        ExecutorService executorService = Executors.newFixedThreadPool(50);
        CountDownLatch countDownLatch = new CountDownLatch(5000);

        for (int i = 0; i < 5000; i++) {
            executorService.execute(() -> {
                // 修改部分,開始
                synchronized (test) {
                    test.i++;
                }

                // 修改部分結束
                countDownLatch.countDown();
            });
        }

        executorService.shutdown();

        try {
            countDownLatch.await();
            System.out.println("執行完成後,i = " + test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我們唯一的改動就是增加了synchronized塊,它鎖住的對象是test,在所有線程中,誰獲得了test對象的鎖,誰才能執行i++操作。我們使用了synchronized悲觀鎖的方式,使得i++線程安全我們運行一下,看看結果如何。

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

我們運行3次,結果都是5000,符合預期。接下來,我們再使用Reent rantLock類來實現悲觀鎖。代碼如下:

package cn.pottercoding.lock;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * @author 程序員波特
 * @since 2024年01月12日
 */
public class LockTest {
    private int i = 0;

    Lock lock = new ReentrantLock();

    public static void main(String[] args) {
        LockTest test = new LockTest();

        // 線程池,50個固定線程
        ExecutorService executorService = Executors.newFixedThreadPool(50);
        CountDownLatch countDownLatch = new CountDownLatch(5000);

        for (int i = 0; i < 5000; i++) {
            executorService.execute(() -> {
                // 修改部分,開始
                test.lock.lock();
                test.i++;
                test.lock.unlock();

                // 修改部分結束
                countDownLatch.countDown();
            });
        }

        executorService.shutdown();

        try {
            countDownLatch.await();
            System.out.println("執行完成後,i = " + test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

我們在類中顯示的增加了 Lock lock= new ReentrantLock();,而且在i++之前增加了 lock.lock(),加鎖操作,在i++之後增加了lock.unlock()釋放鎖的操作。我們同樣運行3次,看看結果。

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

3次運行結果都是5000,完全符合預期。我們再來總結一下悲觀鎖,悲觀鎖從讀取數據的時候就加了鎖,而且在更新數據的時候,保證只有一個線程在執行更新操作,沒有像樂觀鎖那樣進行數據版本的比較。所以悲觀鎖適用於讀相對少,寫相對多的操作。

公平鎖與非公平鎖

前面我們介紹了樂觀鎖與悲觀鎖,這一小節我們將從另外一個維度去講解鎖一公平鎖與非公平鎖。從名字不難看出,公平鎖在多線程情況下,對待每一個線程都是公平的;而非公平鎖恰好與之相反。從字面上理解還是有些晦澀難懂,我們還是舉例説明,場景還是去超市買東西,在儲物櫃存儲東西的例子。儲物櫃只有一個,同時來了3個人使用儲物櫃,這時A先搶到了櫃子,A去使用,B和C自覺進行排隊。A使用完以後,後面排隊中的第一個人將繼續使用櫃子,這就是公平鎖。在公平鎖當中,所有的線程都自覺排隊,一個線程執行完以後,排在後面的線程繼續使用。

非公平鎖則不然,A在使用櫃子的時候,B和C並不會排隊,A使用完以後,將櫃子的鑰匙往後一拋,B和C誰搶到了誰用,甚至可能突然跑來一個D,這個D搶到了鑰匙,那麼D將使用櫃子,這個就是非公平鎖。

公平鎖如圖所示:

多個線程同時執行方法,線程A搶到了鎖,A可以執行方法。其他線程則在隊列裏進行排隊,A執行完方法後,會從隊列裏取出下一個線程B,再去執行方法。以此類推,對於每一個線程來説都是公平的,不會存在後加入的線程先執行的情況。

非公平鎖入下圖所示:

多個線程同時執行方法,線程A搶到了鎖,A可以執行方法。其他的線程並沒有排隊,A執行完方法,釋放鎖後,其他的線程誰搶到了鎖,誰去執行方法。會存在後加入的線程,反而先搶到鎖的情況。

公平鎖與非公平鎖都在ReentrantLock類裏給出了實現,我們看一下 ReentrantLock的源碼。

ReentrantLock有兩個構造方法,默認的構造方法中,sync=new NonfairSync();我們可以從字面意思看出它是一個非公平鎖。再看看第二個構造方法,它需要傳入一個參數,參數是一個布爾型true 是公平鎖,false 是非公平鎖。從上面的源碼我們可以看出sync 有兩個實現類,分別是FairSyncNonfairSync,我們再看看獲取鎖的核心方法,首先是公平鎖FairSync 的,

然後是非公平鎖NonfairSync的,

通過對比兩個方法,我們可以看出唯一的不同之處在於!hasQueuedPredecessors()這個方法,很明顯這個方法是一個隊列,由此可以推斷,公平鎖是將所有的線程放在一個隊列中,一個線程執行完成後,從隊列中取出下一個線程,而非公平鎖則沒有這個隊列。這些都是公平鎖與非公平鎖底層的實現原理,我們在使用的時候不用追到這麼深層次的代碼,只需要瞭解公平鎖與非公平鎖的含義,並且在調用構造方法時,傳入 truefalse即可。

總結

JAVA中鎖的種類非常多,在這一節中,我們找了非常典型的幾個鎖的類型給大家做了介紹。樂觀鎖與悲觀鎖是最基礎的,也是大家必須掌握的。大家在工作中不可避免的都要使用到樂觀鎖和悲觀鎖。從公平鎖與非公平鎖這個維度上看,大家平時使用的都是非公平鎖,這也是默認的鎖的類型。如果要使用公平鎖,大家可以在秒殺的場景下使用,在秒殺的場景下,是遵循先到先得的原則,是需要排隊的,所以這種場景下是最適合使用公平鎖的。

本文已收錄至的我的公眾號【程序員波特】,關注我,第一時間獲取我的最新動態。

user avatar an_653b347d1d3da 頭像 deltaf 頭像 tracy_5cb7dfc1f3f67 頭像 knifeblade 頭像 luoshenshen 頭像 mysteryjack 頭像 thinkfault 頭像 xiaoxiaofeng_java 頭像 codecraft 頭像 iot_full_stack 頭像 u_16213664 頭像 u_16656615 頭像
14 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.