Java 中常見的細粒度鎖實現

rookiedev發表於2020-11-23

上篇文章大致說了下 ReentrantLock 類的使用,對 ReentrantLock 類有了初步的認識之後讓我們一起來看下基於 ReentrantLock 的幾種細粒度鎖實現。

這裡我們還是接著用之前 synchronize 關鍵字加鎖實現執行緒安全 文章中舉的賬戶扣款的例子好了,不過這裡為了更貼近系統的功能實現,我們換一下思路,功能實現不變,只是把錢轉換成我們系統中的使用的禮券好了,使用者每次在系統中購買某項功能需要支付一定的禮券。那既然要實現細粒度鎖,那就意味著不同使用者賬戶扣除禮券的操作互不影響,只需要保證相同賬戶下的禮券扣除操作是執行緒安全的即可,而且僅僅是扣除禮券的那一小部分的程式碼塊,對於賬戶的校驗我們可以接受併發執行。

分段鎖

我們先來想象這樣一個場景好了,可能我們的系統只有那麼一小部分的忠實使用者,他們更願意在我們的系統中通過禮券購買,從而使用一些特殊的功能,更多的使用者只是在使用我們系統的基本功能。接下來我們需要考慮這樣兩種情況:

  • 同一使用者在同一時間通過禮券購買系統的付費功能,也就是在扣款時需要保證執行緒安全的情況
  • 不同使用者在同一時間通過禮券購買系統的付費功能,這是系統的付費使用者併發量,這個和我們需要建立的細粒度鎖的數量有關。

基於上面我們描述的場景可以看出,對於第一種情況只要有禮券消費就可能存在,雖然不常出現但肯定存在,那麼我們就需要通過細粒度鎖來控制禮券的扣除操作。而第二種情況也是存在的,但是由於付費使用者的基數不是很大,所以這種情況也可以說很少了,這也就意味著在同一時間我們實際要建立的鎖的數量並不是很多。這裡稍微解釋一下,因為在禮券消費的時候我們肯定是要建立鎖來保證執行緒安全,而且鎖是和使用者繫結的,同一時間對於同一使用者只會建立一把鎖,而同一時間如果很多使用者都在消費禮券,那這一刻就是有多少使用者就需要建立多少把鎖了。

好了,通過上面的分析我們來看下分段鎖是如何實現細粒度鎖的。

public class SegmentLock<T> {

    /**
     * 預設預先建立的鎖數量.
     */
    private int DEFAULT_LOCK_COUNT = 20;

    private final ConcurrentHashMap<Integer, ReentrantLock> lockMap = new ConcurrentHashMap<>();

    public SegmentLock() {
        init(null, false);
    }

    public SegmentLock(Integer count, boolean isFair) {
        init(count, isFair);
    }

    private void init(Integer count, boolean isFair) {
        if (count != null && count != 0) {
            this.DEFAULT_LOCK_COUNT = count;
        }
        // 預先初始化指定數量的鎖
        for (int i = 0; i < this.DEFAULT_LOCK_COUNT; i++) {
            this.lockMap.put(i, new ReentrantLock(isFair));
        }
    }

    public ReentrantLock get(T key) {
        return this.lockMap.get((key.hashCode() >>> 1) % DEFAULT_LOCK_COUNT);
    }

    public void lock(T key) {
        ReentrantLock lock = this.get(key);
        lock.lock();
    }

    public void unlock(T key) {
        ReentrantLock lock = this.get(key);
        lock.unlock();
    }
}

由於上一篇 synchronized 的替代品 ReentrantLock 文章中我們對 ReentrantLock 已經有了瞭解了,上面的程式碼看起來就很簡單,SegmentLock 類構造方法中呼叫 init 方法預先初始化指定數量的鎖,然後就是提供了鎖的獲取以及加鎖和解鎖的方法。這裡需要注意的一個地方就是我們獲取鎖的方式是:使用 key 的 hashCode 值向右無符號位移一位得到的值再對鎖的數量取餘,然後再用這個值作為索引去 Map 中獲取鎖。至於這裡為什麼要無符號位移呢,那是因為 hashCode 值有可能是得到一個負數,取餘之後還是一個負數,用一個負數索引去 Map 中取值得到就是 null,會導致後面在使用時產生 NPE。接下來一起來看看如何使用:

private static SegmentLock<String> segmentLock = new SegmentLock<>();

private static void consume(String userId, int amount) throws InterruptedException {
  System.out.println("verify that the account is normal...");
  TimeUnit.MILLISECONDS.sleep(500);
  ReentrantLock lock = segmentLock.get(userId);
  lock.lock();
  try {
    System.out.println("enter the deduction code block");
    Integer userAccountBalance = accountMap.get(userId);
    if (userAccountBalance >= amount) {
      // deduction
      TimeUnit.MILLISECONDS.sleep(2000);
      userAccountBalance -= amount;
      accountMap.put(userId, userAccountBalance);
      System.out.println(Thread.currentThread().getName() + " deduction success");
    } else {
      TimeUnit.MILLISECONDS.sleep(1000);
      System.out.println(Thread.currentThread().getName() + " deduction failed, insufficient account balance.");
    }
  } finally {
    lock.unlock();
  }
}

從使用程式碼中可以看出,基本上和 ReentrantLock 的使用一樣,只是由於我們預先建立好了一定數量的鎖,直接根據使用者 id 取鎖,然後再進行加鎖解鎖的操作,這可以減少鎖建立的效能開銷,這對於併發付費的使用者量不大的情況下效能會有很好的提升,這也是為什麼採用分段鎖的原因。

這裡有個問題就是如果剛好兩個使用者 id 的 hash 值一樣或者說 hash 值取餘的結果一樣,那麼這兩個使用者獲取的就是同一把鎖,那在同時進行禮券消費時,一個使用者先獲取了鎖並執行了加鎖操作,另一個使用者也獲取了這把鎖,在執行加鎖的時候就需要等了,因為上一個使用者已經用這把鎖進行加鎖操作了,等到上一個使用者執行成功鎖釋放了之後就能進行加鎖了。

但你想想,本身付費使用者基數就不大,這種情況出現的概率其實很小了,所以在這裡其實問題不大,只是提醒一下可能會有這種情況的出現,那麼就需要注意加鎖的程式碼塊如果本來執行時間就很長的情況,這裡可能會讓其中一個使用者等待的時間加倍,當然一般也不建議鎖住一個資源很長時間,也就是要加鎖的程式碼塊執行時間很長。

優點:對於付費使用者基數不大時,由於預建立了一部分鎖,所以在付費加鎖時效能表現很好。

缺點:可能會出現不同使用者獲取到相同鎖的情況,導致使用者需要等待上一使用者釋放鎖後才能加鎖往下執行。

雜湊鎖

對於分段鎖,其實我們是設定了一個併發付費使用者量不是很大的場景,那如果說我們的系統隨著慢慢的運營迭代已經俘獲了更多的忠實使用者,越來越多的使用者認可我們的系統,這時候可能併發付費使用者量已經上來了,而且有少部分使用者已經開始抱怨我們的系統在付費的時候有卡頓,需要等一段時間。

這很可能就是我們所實現的分段鎖數量已經不夠了,很多使用者 id 的 hash 值經過取餘之後結果是一樣的,那麼獲取的鎖也是一樣的,這時就需要等了,嚴重的時候可能多個使用者同一時間獲取的都是同一把鎖,這時等待的時間就更長了。

對此我們就需要進行優化改進了,既然是鎖不夠那就需要建立更多的鎖,那是不是可以直接預建立更多數量的鎖呢,但這時付費使用者基數已經上來了,我們可能需要為每一個使用者分配一把鎖,這種方式恐怕不行,一是我們不知道哪些使用者需要用,還有就是一上來就建立這麼多的鎖也不現實啊。

那麼我們可以為每一個來付費的使用者建立一把鎖,付費結束之後再移除,注意不是預建立,而是付費時再建立,建立好之後同時會把鎖放到 Map 中,以防同一個使用者併發付費,這時就可以直接 Map 中獲取同時加鎖的次數加一,等到鎖釋放的時候再看下加鎖的次數,如果等於一則從 Map 中移除,否則暫先不移除,只是釋放鎖就好了。來看看程式碼實現:

public class HashLock<T> {
    private boolean fair = false;
    private final SegmentLock<T> segmentLock = new SegmentLock<>();
    private final ConcurrentHashMap<T, ReentrantLockCount> lockMap = new ConcurrentHashMap<>();

    public HashLock() {

    }

    public HashLock(boolean fair) {
        this.fair = fair;
    }

    public void lock(T key) {
        ReentrantLockCount lock;
        // 通過分段鎖來保證獲取鎖時的執行緒安全
        this.segmentLock.lock(key);
        try {
            lock = this.lockMap.get(key);
            if (lock == null) {
                lock = new ReentrantLockCount(this.fair);
                this.lockMap.put(key, lock);
            } else {
                // map 中已經存在說明鎖已經建立,直接數量加一
                lock.incrementAndGet();
            }
        } finally {
            this.segmentLock.unlock(key);
        }
        lock.lock();
    }

    public void unlock(T key) {
        ReentrantLockCount reentrantLockCount = this.lockMap.get(key);
        // 判斷加鎖的次數等於一的話可以將 map 中的鎖移除
        if (reentrantLockCount.getCount() == 1) {
            this.segmentLock.lock(key);
            try {
                if (reentrantLockCount.getCount() == 1) {
                    this.lockMap.remove(key);
                }
            } finally {
                this.segmentLock.unlock(key);
            }
        }
        reentrantLockCount.unlock();
    }

    static class ReentrantLockCount {
        private ReentrantLock reentrantLock;
        // 記錄加鎖的次數
        private AtomicInteger count = new AtomicInteger(1);

        public ReentrantLockCount(boolean fair) {
            this.reentrantLock = new ReentrantLock(fair);
        }

        public void lock() {
            this.reentrantLock.lock();
        }

        public void unlock() {
            this.count.decrementAndGet();
            this.reentrantLock.unlock();
        }

        public int incrementAndGet() {
            return this.count.incrementAndGet();
        }

        public int getCount() {
            return this.count.get();
        }
    }
}

上面程式碼實現主要看 lock 和 unlock 方法就好了,當然還有一個包裝了 ReentrantLock 的內部類 ReentrantLockCount,其中有一個欄位來統計加鎖的次數,這是為了避免同一個使用者進行併發付費的時候重複建立鎖,直接 Map 中獲取,釋放的時候如果加鎖次數只有一次就可以直接移除。上面的 lock 方法和 unlock 方法採用了分段鎖來保證鎖的獲取過程和移除過程是執行緒安全,不然可能導致鎖的重複建立和重複移除問題。至於使用其實和分段鎖差不多的:

private static HashLock<String> hashLock = new HashLock<>();
private static void consume1(String userId, int amount) {
  System.out.println("verify that the account is normal...");
  TimeUnit.MILLISECONDS.sleep(500);
  hashLock.lock(userId);
  try {
    System.out.println("enter the deduction code block");
    Integer userAccountBalance = accountMap.get(userId);
    if (userAccountBalance >= amount) {
      // deduction
      TimeUnit.MILLISECONDS.sleep(2000);
      userAccountBalance -= amount;
      accountMap.put(userId, userAccountBalance);
      System.out.println(Thread.currentThread().getName() + " deduction success");
    } else {
      TimeUnit.MILLISECONDS.sleep(1000);
      System.out.println(Thread.currentThread().getName() + " deduction failed, insufficient account balance.");
    }
  } finally {
    hashLock.unlock(userId);
  }
}

使用程式碼其實和分段鎖的使用差不多的,至於裡面加的那些睡眠可以不用管,只是為了測試的時候更能看出效果而已。

優點:很好地解決了不同使用者共用鎖的問題

缺點:需要通過分段鎖來維護鎖的獲取和移除,同時還要維護加鎖的次數,分段鎖這裡鎖的數量會成為效能的瓶頸,而且稍有不慎鎖沒釋放成功可能會產生記憶體洩漏的問題。

弱引用鎖

上面對 HashLock 缺點中也提到由於需要通過分段鎖來維護鎖的獲取和移除,同時分段鎖鎖的數量可能會成為效能的瓶頸。那麼有沒有更好地解決辦法呢。既然說到了這裡那肯定是有的,這裡就涉及到了之前 談談 Java 中的各種引用型別 這篇文章中的知識點了,利用弱引用的特性,這樣就能夠拿掉分段鎖,把鎖物件的資源回收交給 Java 虛擬機器,然後對於已經被回收的鎖進行移除,能有效避免不小心發生記憶體洩漏的問題。程式碼實現:

public class WeakHashLock<T> {

    /**
     * map 中鎖數量閾值.
     */
    private static final int LOCK_SIZE_THRESHOLD = 1000;
    private ReferenceQueue<ReentrantLock> queue = new ReferenceQueue<>();
    private ConcurrentHashMap<T, WeakRefLock<T, ReentrantLock>> lockMap = new ConcurrentHashMap<>();

    public ReentrantLock get(T key) {
        // 可以設定一個閾值,當鎖的數量超過這個閾值時移除一部分被回收的鎖
        if (this.lockMap.size() > LOCK_SIZE_THRESHOLD) {
            clearEmptyRef();
        }

        WeakRefLock<T, ReentrantLock> weakRefLock = this.lockMap.get(key);
        ReentrantLock lock = weakRefLock == null ? null : weakRefLock.get();
        while (lock == null) {
            lockMap.putIfAbsent(key, new WeakRefLock<>(new ReentrantLock(), this.queue, key));
          	// 再次從 Map 中獲取,保證同一使用者獲取的鎖是一致的
            weakRefLock = lockMap.get(key);
            lock = weakRefLock == null ? null : weakRefLock.get();
            if (lock != null) {
                return lock;
            }
            // 這裡注意如果堆資源過於緊張可能會返回空的情況,需要移除一部分被回收的鎖
            clearEmptyRef();
        }
        return lock;
    }

    @SuppressWarnings("unchecked")
    public void clearEmptyRef() {
        Reference<? extends ReentrantLock> ref;
        while ((ref = this.queue.poll()) != null) {
            WeakRefLock<T, ? extends ReentrantLock> weakRefLock = (WeakRefLock<T, ? extends ReentrantLock>) ref;
            this.lockMap.remove(weakRefLock.key);
        }
    }


    static final class WeakRefLock<T, K> extends WeakReference<K> {

        private final T key;

        public WeakRefLock(K referent, ReferenceQueue<? super K> queue, T key) {
            super(referent, queue);
            this.key = key;
        }
    }
}

上面程式碼中主要是利用了弱引用的特性,拿掉了鎖的獲取和建立時維護加鎖次數的判斷過程,在獲取鎖時直接從 Map 中獲取,如果拿到為空則建立,同時這裡要解釋一下程式碼裡面清理被回收的鎖的過程。第一處在 Map 中的鎖數量超過設定的閾值後將已經被回收的鎖進行移除,主要是為了不讓 Map 中存放過多的已經被回收的鎖佔用資源,第二處移除主要是以防資源過於緊張的情況,剛剛建立的弱引用鎖立即就被回收了,這時急需移除一部分已經被回收的鎖。當然如果資源真的都已經緊張到這個程度了的話,也應該考慮考慮提高一下機器的配置了。使用程式碼:

private static WeakHashLock<String> weakHashLock = new WeakHashLock<>();

private static void consume2(String userId, int amount) {
  System.out.println("verify that the account is normal...");
  TimeUnit.MILLISECONDS.sleep(500);
  ReentrantLock lock = weakHashLock.get(userId);
  lock.lock();
  try {
    System.out.println("enter the deduction code block");
    Integer userAccountBalance = accountMap.get(userId);
    if (userAccountBalance >= amount) {
      // deduction
      TimeUnit.MILLISECONDS.sleep(2000);
      userAccountBalance -= amount;
      accountMap.put(userId, userAccountBalance);
      System.out.println(Thread.currentThread().getName() + " deduction success");
    } else {
      TimeUnit.MILLISECONDS.sleep(1000);
      System.out.println(Thread.currentThread().getName() + " deduction failed, insufficient account balance.");
    }
  } finally {
    lock.unlock();
  }
}

使用上這裡就不多說了,都基本上差不多,主要的區別還是鎖的實現上不同而已。

優點:利用了弱引用的特性,解除了分段鎖那部分程式碼帶來的效能瓶頸問題,將回收操作交給 Java 虛擬機器。

缺點:獲取鎖的程式碼實現看起來有點繁瑣,應該還有更優雅的方式實現。

好了,到這裡就基本上將細粒度鎖的實現方式都說完了,每種實現方式優缺點也大概總結了一下,根據優缺點其實也就能夠知道每種實現方式所適用的場景,在選擇的時候根據業務需求來進行選擇。對於最後一種實現方式其實還有更優雅的實現,我們接下來再用一篇文章來說下最後一種實現方式。

微信公眾號:「rookiedev」,Java 後臺開發,勵志終身學習,堅持原創乾貨輸出,你可選擇現在就關注我,或者看看歷史文章再關注也不遲。長按二維碼關注,我們一起努力變得更優秀!

rookiedev

本文由部落格群發一文多發等運營工具平臺 OpenWrite 釋出

相關文章