Java 細粒度鎖續篇

rookiedev發表於2020-12-23

在上篇文章中大概介紹了 Java 中細粒度鎖的幾種實現方式,並且針對每種方式都做了優缺點說明,在使用的時候就需要根據業務需求選擇更合適的一種。上篇文章中的最後一種弱引用鎖的實現方式,我在裡面也說了其實還有更優雅的實現,其實也算不上更優雅,只是看起來更優雅,原理還是一樣的,今天我打算用一篇文章的篇幅來好好說下。

首先,我們來再次回顧一下,這裡為什麼可以利用弱引用的特性拿掉分段鎖呢?分段鎖在這裡主要是為了保證每次在建立和移除鎖時的執行緒安全,而採用了弱引用之後,我們不需要每次建立之後都進行移除,因為當弱引用指向的物件引用被釋放之後 Java 會在下一次的 GC 將這弱引用指向的物件回收掉,在經過 GC 之後,當弱引用指向的物件被回收時,弱引用將會進入建立時指定的佇列,然後我們通過佇列中的值來將這些存放在 Map 中的弱引用移除掉,所以我們才能夠順利的拿掉分段鎖。

WeakHashMap

你注意看弱引用鎖的程式碼實現,裡面在我們獲取鎖的時候有個手動去清理 Map 中被回收的鎖的過程,如果你看過之前的 談談 Java 中的各種引用型別 這篇文章的話,你應該知道 Java 提供了一個 WeakHashMap 類,他是使用弱引用作為 key,它在 GC 決定將弱引用所指向的 key 物件回收之後,會將當前儲存的 entry 也自動移除,這個是怎麼實現的呢?

其實原理也是一樣的,利用弱引用指向的物件被回收時,弱引用將會進入建立時指定的佇列這一特性,然後通過輪詢佇列來移除元素。只不過將移除的操作完全包裹在 WeakHashMap 類裡面了,你可以看到裡面所有的 public 的增刪改查方法都直接或間接呼叫了expuntgeStaleEntries() 方法,而 expuntgeStaleEntries 方法中就是在輪詢佇列移除被回收的 key 所對應的元素。

private void expungeStaleEntries() {
  for (Object x; (x = queue.poll()) != null; ) {
    synchronized (queue) {
      @SuppressWarnings("unchecked")
      Entry<K,V> e = (Entry<K,V>) x;
      int i = indexFor(e.hash, table.length);

      Entry<K,V> prev = table[i];
      Entry<K,V> p = prev;
      while (p != null) {
        Entry<K,V> next = p.next;
        if (p == e) {
          if (prev == e)
            table[i] = next;
          else
            prev.next = next;
          // Must not null out e.next;
          // stale entries may be in use by a HashIterator
          e.value = null; // Help GC
          size--;
          break;
        }
        prev = p;
        p = next;
      }
    }
  }
}

既然 Java 已經給我們提供了相應功能的類,那我們是不是可以在弱引用鎖的實現中直接使用 WeakHashMap 呢?這樣我們就不用在獲取鎖的時候做手動移除的操作了,WeakHashMap 內部已經幫我們做了。

但如果你稍微看一下 WeakHashMap 類的描述就能發現他不是執行緒安全的,在該類裡面有這樣一段描述:

Like most collection classes, this class is not synchronized. A synchronized {@code WeakHashMap} may be constructed using the {@link Collections#synchronizedMap Collections.synchronizedMap} method.

正因為如此,在弱引用的實現中才採用 ConcurrentHashMap 來儲存鎖,只不過 ConcurrentHashMap 類沒有提供弱引用的實現,也就沒有提供自動為我們移除元素的功能,所以才會在獲取鎖的時候做一個移除元素的操作,相信看到這裡你應該大概明白了使用弱引用作為 key 的 WeakHashMap 是怎麼做到當弱引用被回收的時候自動把對應的元素給移除了。

那如果說按照上面描述裡面所說的通過 Collections 工具類的 synchronizedMap 方法來實現執行緒安全呢?先來看程式碼實現:

public class WeakHashLock<T> {

    public final Map<T, WeakReference<ReentrantLock>> weakHashMap =
            Collections.synchronizedMap(new WeakHashMap<>());

    public ReentrantLock get(T key){
        return this.weakHashMap.computeIfAbsent(key, lock -> new WeakReference<>(new ReentrantLock())).get();
    }
}

上面程式碼中 WeakHashLock 類中只有一個 get 方法根據 key 獲取鎖物件,不存在的話建立一個新的鎖物件返回,看起來是不是很簡單,但不幸的是通過 Collections 工具類的 synchronizedMap 方法來實現的執行緒安全方式效能不是很好,為什麼這麼說呢,我們可以看下 synchronizedMap 方法實現:

// synchronizedMap 方法實現
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
  return new SynchronizedMap<>(m);
}

// SynchronizedMap 類構造方法
SynchronizedMap(Map<K,V> m) {
  this.m = Objects.requireNonNull(m);
  mutex = this;
}

SynchronizedMap(Map<K,V> m, Object mutex) {
  this.m = m;
  this.mutex = mutex;
}

public int size() {
  synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
  synchronized (mutex) {return m.isEmpty();}
}
public V get(Object key) {
  synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
  synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
  synchronized (mutex) {return m.remove(key);}
}

從程式碼實現可以看出,synchronizedMap 方法會建立一個SynchronizedMap 例項返回,在該例項的構造方法中將自己賦值給用來同步的物件,然後 SynchronizedMap 類中的方法都使用該同步的物件進行同步,以致於我們做的每一個操作都需要進行同步,其實就相當於給 WeakHashMap 類中例項方法都加上了 synchronized 關鍵字,這種實現方式效能難免會大打折扣。

ConcurrentReferenceHashMap

這種方式不可取的原因主要是因為 WeakHashMap 不是執行緒安全的,那有沒有執行緒安全的並且實現了弱引用來儲存元素的 Map 呢?當然上篇文章中的實現是一種方式,那如果也想像 WeakHashMap 一樣將這些移除的操作完全封裝到 Map 類裡面呢。我們可以看下 org.springframework.util 包下的 ConcurrentReferenceHashMap 類,該類就很好的實現了我們想要的效果,在該類的描述中就提到了這樣一段話:

This class can be used as an alternative to {@code Collections.synchronizedMap(new WeakHashMap<K, Reference<V>>())} in order to support better performance when accessed concurrently. This implementation follows the same design constraints as {@link ConcurrentHashMap} with the exception that {@code null} values and {@code null} keys are supported.

從描述中可以看到 ConcurrentReferenceHashMap 類可以用來替代使用 synchronizedMap 方法保證執行緒安全的 WeakHashMap 類,以便在併發訪問時提供更好的效能。那就來看下采用 ConcurrentReferenceHashMap 類的實現方式:

public class WeakHashLock<T> {
    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    private static final float DEFAULT_LOAD_FACTOR = 0.75f;
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    private static final ConcurrentReferenceHashMap.ReferenceType DEFAULT_REFERENCE_TYPE =
            ConcurrentReferenceHashMap.ReferenceType.WEAK;

    private final ConcurrentReferenceHashMap<T, ReentrantLock> referenceHashMap;

    /**
     * Create mutex factory with default settings.
     */
    public WeakHashLock() {
        this.referenceHashMap = new ConcurrentReferenceHashMap<>(DEFAULT_INITIAL_CAPACITY,
                DEFAULT_LOAD_FACTOR,
                DEFAULT_CONCURRENCY_LEVEL,
                DEFAULT_REFERENCE_TYPE);
    }

    public WeakHashLock(int concurrencyLevel,
                         ConcurrentReferenceHashMap.ReferenceType referenceType) {
        this.referenceHashMap = new ConcurrentReferenceHashMap<>(DEFAULT_INITIAL_CAPACITY,
                DEFAULT_LOAD_FACTOR,
                concurrencyLevel,
                referenceType);
    }

    public ReentrantLock get(T key) {
        return this.referenceHashMap.computeIfAbsent(key, lock -> new ReentrantLock());
    }

}

上面程式碼實現同樣非常簡單,相比上面 WeakHashMap 的方式多了兩個構造方法而已,但不同於使用 synchronizedMap 方法來保證執行緒安全的方式,效能會提高很多。如果你感興趣的話可以去看下這個類的內部實現,原理都是利用了弱引用的特性,只不過實現方式有點不同而已。

這裡我想要提醒兩點,一個是 ConcurrentReferenceHashMap 中預設的引用型別是軟引用。

private static final ReferenceType DEFAULT_REFERENCE_TYPE = ReferenceType.SOFT;

另外一個要注意的是 ConcurrentReferenceHashMap 中有的方法返回的結果是 GC 之後但還沒有清理被回收元素之前的結果,什麼意思呢,我們來看一個示例:

ConcurrentReferenceHashMap<String, String> referenceHashMap = new ConcurrentReferenceHashMap<>(16, 0.75f, 1, ConcurrentReferenceHashMap.ReferenceType.WEAK);
referenceHashMap.put("key", "value");
// 經過 GC 標記之後,弱引用已經進入建立時指定的佇列中,這時可以去輪詢佇列移除元素了
System.gc();
// isEmpty 和 size 方法返回的結果是還沒有移除元素的結果
System.out.println(referenceHashMap.isEmpty()); // false
System.out.println(referenceHashMap.size()); // 1
// get 方法中呼叫了移除元素的方法
System.out.println(referenceHashMap.get("key")); // null
System.out.println(referenceHashMap.isEmpty()); // true
System.out.println(referenceHashMap.size()); // 0

上面測試結果可以看到,在 GC 標記之後呼叫 isEmpty 和 size 方法得到的返回結果都表明集合中是還有元素,而呼叫 get 方法得到的卻是個 null,然後再呼叫 isEmpty 和 size 方法得到的結果表示集合為空,這其實是因為前面兩個方法裡面沒有做移除元素的操作,而 get 方法是先做了一次移除元素然後再去獲取值,這裡提醒下這個細節問題,避免以為 ConcurrentReferenceHashMap 沒有實現移除元素的功能。

好了,上面都是利用弱引用特性再配合 ReentrantLock 實現了細粒度鎖,這裡就再順便看下利用弱引用特性配合 synchronized 關鍵字的實現方式吧。同樣,原理是一樣,只不過從 ReentrantLock 再回到 synchronized,前面說了這麼多的原理,就不再贅述了,直接看程式碼實現吧:

// 用於同步的物件
public class Mutex<T> {

    private final T key;

    public Mutex(T key) {
        this.key = key;
    }
    
    public static <T> Mutex<T> of(T key) {
        return new Mutex<>(key);
    }

    public T getKey() {
        return key;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Mutex<?> xMutex = (Mutex<?>) o;
        return Objects.equals(key, xMutex.key);
    }

    @Override
    public int hashCode() {
        return Objects.hash(key);
    }
}
public class MutexFactory<T> {

    private static final int DEFAULT_INITIAL_CAPACITY = 16;
    private static final float DEFAULT_LOAD_FACTOR = 0.75f;
    private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    private static final ConcurrentReferenceHashMap.ReferenceType DEFAULT_REFERENCE_TYPE =
            ConcurrentReferenceHashMap.ReferenceType.WEAK;

    private final ConcurrentReferenceHashMap<T, Mutex<T>> referenceHashMap;

    public MutexFactory() {
        this.referenceHashMap = new ConcurrentReferenceHashMap<>(DEFAULT_INITIAL_CAPACITY,
                DEFAULT_LOAD_FACTOR,
                DEFAULT_CONCURRENCY_LEVEL,
                DEFAULT_REFERENCE_TYPE);
    }

    public MutexFactory(int concurrencyLevel,
                        ConcurrentReferenceHashMap.ReferenceType referenceType) {
        this.referenceHashMap = new ConcurrentReferenceHashMap<>(DEFAULT_INITIAL_CAPACITY,
                DEFAULT_LOAD_FACTOR,
                concurrencyLevel,
                referenceType);
    }

    public Mutex<T> getMutex(T key) {
        return this.referenceHashMap.computeIfAbsent(key, Mutex::new);
    }
		// 提供強制移除已經被回收的弱引用元素
    public void purgeUnreferenced() {
        this.referenceHashMap.purgeUnreferencedEntries();
    }
}

由於我們一般實現的細粒度基本上是基於使用者或者其他的需要同步的物件,上面是通過構建一個互斥物件作為 ConcurrentReferenceHashMap 的 value,然後我們就可以使用 synchronized 關鍵字來鎖定該 value 物件達到同步的效果,使用方式如下:

MutexFactory<String> mutexFactory = new MutexFactory<>();
public void save(String userId) throws InterruptedException {
  synchronized (mutexFactory.getMutex(userId)){
    // do something
  }
}

這種同步方式業務程式碼看起來簡單些,對於一些簡單的需求就可以直接使用這種方式,當然如果需要提供 API 級別的加鎖方式或者需要構建帶條件的加鎖方式那還是使用 ReentrantLock。

對於加鎖這一塊雖然說了這麼多,也許你已經打算採用這些方式去實現你想要的效果了,可是呢隨著微服務大行其道,一個系統往往啟動了好幾個例項,每個例項對應一個 JVM 虛擬機器,而我們前面說的這些都是在只有一個虛擬機器的前提下才有用,這就意味著我們前面說的這些加鎖方式基本上已經派不上用場了。

那隨之而來的解決方案就是我們經常聽到並且感覺很高大上,卻很少用到的分散式鎖了,這一塊我雖然使用過,也去查閱過相關資料,但我自認為沒有完全真正掌握底層的原理,還需要進一步的實踐,只好再找機會整理整理後再輸出了。

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

rookiedev

相關文章