深度揭祕Netty中的FastThreadLocal為什麼比ThreadLocal效率更高?

跟著Mic學架構發表於2021-11-23

file

閱讀這篇文章之前,建議先閱讀和這篇文章關聯的內容。

1. 詳細剖析分散式微服務架構下網路通訊的底層實現原理(圖解)

2. (年薪60W的技巧)工作了5年,你真的理解Netty以及為什麼要用嗎?(深度乾貨)

3. 深度解析Netty中的核心元件(圖解+例項)

4. BAT面試必問細節:關於Netty中的ByteBuf詳解

5. 通過大量實戰案例分解Netty中是如何解決拆包黏包問題的?

6. 基於Netty實現自定義訊息通訊協議(協議設計及解析應用實戰)

7. 全網最詳細最齊全的序列化技術及深度解析與應用實戰

8. 手把手教你基於Netty實現一個基礎的RPC框架(通俗易懂)

9. (年薪60W分水嶺)基於Netty手寫實現RPC框架進階篇(帶註冊中心和註解)

FastThreadLocal的實現與J.U.C包中的ThreadLocal非常類似。

瞭解過ThreadLocal原理的同學應該都清楚,它有幾個關鍵的物件.

  1. Thread
  2. ThreadLocalMap
  3. ThreadLocal

同樣,Netty專門為FastThreadLocal量身打造了FastThreadLocalThreadInternalThreadLocalMap兩個重要的類。下面我們看下這兩個類是如何實現的。

PS,如果不懂ThreadLocal的朋友,可以看我這篇文章:ThreadLocal的使用及原理分析

FastThreadLocalThread是對Thread類的一層包裝,每個執行緒對應一個InternalThreadLocalMap例項。只有FastThreadLocalFastThreadLocalThread組合使用時,才能發揮 FastThreadLocal的效能優勢。首先看下FastThreadLocalThread的原始碼定義:

public class FastThreadLocalThread extends Thread {

    private InternalThreadLocalMap threadLocalMap;
    // 省略其他程式碼
}

可以看出 FastThreadLocalThread 主要擴充套件了 InternalThreadLocalMap 欄位,我們可以猜測到 FastThreadLocalThread 主要使用 InternalThreadLocalMap 儲存資料,而不再是使用 Thread 中的 ThreadLocalMap。所以想知道 FastThreadLocalThread 高效能的奧祕,必須要了解 InternalThreadLocalMap 的設計原理。

InternalThreadLocalMap

public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {

    private static final int DEFAULT_ARRAY_LIST_INITIAL_CAPACITY = 8;

    private static final int STRING_BUILDER_INITIAL_SIZE;

    private static final int STRING_BUILDER_MAX_SIZE;

    public static final Object UNSET = new Object();

    private BitSet cleanerFlags;
    private InternalThreadLocalMap() {
        indexedVariables = newIndexedVariableTable();
    }
    private static Object[] newIndexedVariableTable() {
        Object[] array = new Object[INDEXED_VARIABLE_TABLE_INITIAL_SIZE];
        Arrays.fill(array, UNSET);
        return array;
    }
    public static int lastVariableIndex() {
        return nextIndex.get() - 1;
    }

    public static int nextVariableIndex() {
        int index = nextIndex.getAndIncrement();
        if (index < 0) {
            nextIndex.decrementAndGet();
            throw new IllegalStateException("too many thread-local indexed variables");
        }
        return index;
    }
    // 省略

}

從 InternalThreadLocalMap 內部實現來看,與 ThreadLocalMap 一樣都是採用陣列的儲存方式。

瞭解ThreadLocal的同學都知道,它內部也是採用陣列的方式來實現hash表,對於hash衝突,採用了線性探索的方式來實現。

但是 InternalThreadLocalMap 並沒有使用線性探測法來解決 Hash 衝突,而是在 FastThreadLocal 初始化的時候分配一個陣列索引 index,index 的值採用原子類 AtomicInteger 保證順序遞增,通過呼叫 InternalThreadLocalMap.nextVariableIndex() 方法獲得。然後在讀寫資料的時候通過陣列下標 index 直接定位到 FastThreadLocal 的位置,時間複雜度為 O(1)。如果陣列下標遞增到非常大,那麼陣列也會比較大,所以 FastThreadLocal 是通過空間換時間的思想提升讀寫效能。

下面通過一幅圖描述 InternalThreadLocalMap、index 和 FastThreadLocal 之間的關係。

image-20211123112056607

通過上面 FastThreadLocal 的內部結構圖,我們對比下與 ThreadLocal 有哪些區別呢?

FastThreadLocal 使用 Object 陣列替代了 Entry 陣列,Object[0] 儲存的是一個Set<FastThreadLocal<?>> 集合。

從陣列下標 1 開始都是直接儲存的 value 資料,不再採用 ThreadLocal 的鍵值對形式進行儲存。

假設現在我們有一批資料需要新增到陣列中,分別為 value1、value2、value3、value4,對應的 FastThreadLocal 在初始化的時候生成的陣列索引分別為 1、2、3、4。如下圖所示。

image-20211123112505405

至此,我們已經對 FastThreadLocal 有了一個基本的認識,下面我們結合具體的原始碼分析 FastThreadLocal 的實現原理。

FastThreadLocal的set方法原始碼分析

在講解原始碼之前,我們回過頭看下上文中的 ThreadLocal 示例,如果把示例中 ThreadLocal 替換成 FastThread,應當如何使用呢?

public class FastThreadLocalTest {

    private static final FastThreadLocal<String> THREAD_NAME_LOCAL = new FastThreadLocal<>();
    private static final FastThreadLocal<TradeOrder> TRADE_THREAD_LOCAL = new FastThreadLocal<>();
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            int tradeId = i;
            String threadName = "thread-" + i;
            new FastThreadLocalThread(() -> {
                THREAD_NAME_LOCAL.set(threadName);
                TradeOrder tradeOrder = new TradeOrder(tradeId, tradeId % 2 == 0 ? "已支付" : "未支付");
                TRADE_THREAD_LOCAL.set(tradeOrder);
                System.out.println("threadName: " + THREAD_NAME_LOCAL.get());
                System.out.println("tradeOrder info:" + TRADE_THREAD_LOCAL.get());
            }, threadName).start();

        }
    }
}

可以看出,FastThreadLocal 的使用方法幾乎和 ThreadLocal 保持一致,只需要把程式碼中 Thread、ThreadLocal 替換為 FastThreadLocalThread 和 FastThreadLocal 即可,Netty 在易用性方面做得相當棒。下面我們重點對示例中用得到 FastThreadLocal.set()/get() 方法做深入分析。

首先看下 FastThreadLocal.set() 的原始碼:

public final void set(V value) {
    if (value != InternalThreadLocalMap.UNSET) {
        InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
        setKnownNotUnset(threadLocalMap, value);
    } else {
        remove();
    }
}

FastThreadLocal.set() 方法實現並不難理解,先抓住程式碼主幹,一步步進行拆解分析。set() 的過程主要分為三步:

  1. 判斷 value 是否為預設值,如果等於預設值,那麼直接呼叫 remove() 方法。這裡我們還不知道預設值和 remove() 之間的聯絡是什麼,我們暫且把 remove() 放在最後分析。
  2. 如果 value 不等於預設值,接下來會獲取當前執行緒的 InternalThreadLocalMap。
  3. 然後將 InternalThreadLocalMap 中對應資料替換為新的 value。

InternalThreadLocalMap.get()

先來看InternalThreadLocalMap.get()方法:

public static InternalThreadLocalMap get() {
    Thread thread = Thread.currentThread();
    if (thread instanceof FastThreadLocalThread) {
        return fastGet((FastThreadLocalThread) thread);
    } else {
        return slowGet();
    }
}

如果thread例項型別是FastThreadLocalThread,則呼叫fastGet()。

InternalThreadLocalMap.get() 邏輯很簡單.

  1. 如果當前執行緒是 FastThreadLocalThread 型別,那麼直接通過 fastGet() 方法獲取 FastThreadLocalThread 的 threadLocalMap 屬性即可
  2. 如果此時 InternalThreadLocalMap 不存在,直接建立一個返回。

關於 InternalThreadLocalMap 的初始化在上文中已經介紹過,它會初始化一個長度為 32 的 Object 陣列,陣列中填充著 32 個預設物件 UNSET 的引用。

private static InternalThreadLocalMap fastGet(FastThreadLocalThread thread) {
  InternalThreadLocalMap threadLocalMap = thread.threadLocalMap();
  if (threadLocalMap == null) {
    thread.setThreadLocalMap(threadLocalMap = new InternalThreadLocalMap());
  }
  return threadLocalMap;
}

否則,則呼叫slowGet(),從程式碼實現來看,slowGet() 是針對非 FastThreadLocalThread 型別的執行緒發起呼叫時的一種兜底方案。如果當前執行緒不是 FastThreadLocalThread,內部是沒有 InternalThreadLocalMap 屬性的,Netty 在 UnpaddedInternalThreadLocalMap 中儲存了一個 JDK 原生的 ThreadLocal,ThreadLocal 中存放著 InternalThreadLocalMap,此時獲取 InternalThreadLocalMap 就退化成 JDK 原生的 ThreadLocal 獲取。

private static InternalThreadLocalMap slowGet() {
  InternalThreadLocalMap ret = slowThreadLocalMap.get();
  if (ret == null) {
    ret = new InternalThreadLocalMap();
    slowThreadLocalMap.set(ret);
  }
  return ret;
}

setKnownNotUnset

獲取 InternalThreadLocalMap 的過程已經講完了,下面看下 setKnownNotUnset() 如何將資料新增到 InternalThreadLocalMap 的。

private void setKnownNotUnset(InternalThreadLocalMap threadLocalMap, V value) {
    if (threadLocalMap.setIndexedVariable(index, value)) {
        addToVariablesToRemove(threadLocalMap, this);
    }
}

setKnownNotUnset() 主要做了兩件事:

  1. 找到陣列下標 index 位置,設定新的 value。
  2. 將 FastThreadLocal 物件儲存到待清理的 Set 中。

首先我們看下第一步 threadLocalMap.setIndexedVariable() 的原始碼實現:

public boolean setIndexedVariable(int index, Object value) {
    Object[] lookup = indexedVariables;
    if (index < lookup.length) {
        Object oldValue = lookup[index];
        lookup[index] = value;
        return oldValue == UNSET;
    } else {
        expandIndexedVariableTableAndSet(index, value);
        return true;
    }
}

indexedVariables 就是 InternalThreadLocalMap 中用於存放資料的陣列,如果陣列容量大於 FastThreadLocal 的 index 索引,那麼直接找到陣列下標 index 位置將新 value 設定進去,事件複雜度為 O(1)。在設定新的 value 之前,會將之前 index 位置的元素取出,如果舊的元素還是 UNSET 預設物件,那麼返回成功。

如果陣列容量不夠了怎麼辦呢?InternalThreadLocalMap 會自動擴容,然後再設定 value。接下來看看 expandIndexedVariableTableAndSet() 的擴容邏輯:

private void expandIndexedVariableTableAndSet(int index, Object value) {
    Object[] oldArray = indexedVariables;
    final int oldCapacity = oldArray.length;
    int newCapacity = index;
    newCapacity |= newCapacity >>>  1;
    newCapacity |= newCapacity >>>  2;
    newCapacity |= newCapacity >>>  4;
    newCapacity |= newCapacity >>>  8;
    newCapacity |= newCapacity >>> 16;
    newCapacity ++;

    Object[] newArray = Arrays.copyOf(oldArray, newCapacity);
    Arrays.fill(newArray, oldCapacity, newArray.length, UNSET);
    newArray[index] = value;
    indexedVariables = newArray;
}

可以看出 InternalThreadLocalMap 實現陣列擴容幾乎和 HashMap 完全是一模一樣的,所以多讀原始碼還是可以給我們很多啟發的。InternalThreadLocalMap 以 index 為基準進行擴容,將陣列擴容後的容量向上取整為 2 的次冪。然後將原陣列內容拷貝到新的陣列中,空餘部分填充預設物件 UNSET,最終把新陣列賦值給 indexedVariables。

思考關於基準擴容

思考:為什麼 InternalThreadLocalMap 以 index 為基準進行擴容,而不是原陣列長度呢?

假設現在初始化了 70 個 FastThreadLocal,但是這些 FastThreadLocal 從來沒有呼叫過 set() 方法,此時陣列還是預設長度 32。當第 index = 70 的 FastThreadLocal 呼叫 set() 方法時,如果按原陣列容量 32 進行擴容 2 倍後,還是無法填充 index = 70 的資料。所以使用 index 為基準進行擴容可以解決這個問題,但是如果 FastThreadLocal 特別多,陣列的長度也是非常大的。

回到 setKnownNotUnset() 的主流程,向 InternalThreadLocalMap 新增完資料之後,接下就是將 FastThreadLocal 物件儲存到待清理的 Set 中。我們繼續看下 addToVariablesToRemove() 是如何實現的:

addToVariablesToRemove

private static void addToVariablesToRemove(InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {
    Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);
    Set<FastThreadLocal<?>> variablesToRemove;
    if (v == InternalThreadLocalMap.UNSET || v == null) {
        variablesToRemove = Collections.newSetFromMap(new IdentityHashMap<FastThreadLocal<?>, Boolean>());
        threadLocalMap.setIndexedVariable(variablesToRemoveIndex, variablesToRemove);
    } else {
        variablesToRemove = (Set<FastThreadLocal<?>>) v;
    }

    variablesToRemove.add(variable);
}

variablesToRemoveIndex 是採用 static final 修飾的變數,在 FastThreadLocal 初始化時 variablesToRemoveIndex 被賦值為 0。InternalThreadLocalMap 首先會找到陣列下標為 0 的元素.

  1. 如果該元素是預設物件 UNSET 或者不存在,那麼會建立一個 FastThreadLocal 型別的 Set 集合,然後把 Set 集合填充到陣列下標 0 的位置。
  2. 如果陣列第一個元素不是預設物件 UNSET,說明 Set 集合已經被填充,直接強轉獲得 Set 集合即可。這就解釋了 InternalThreadLocalMap 的 value 資料為什麼是從下標為 1 的位置開始儲存了,因為 0 的位置已經被 Set 集合佔用了。

思考關於Set集合設計

思考:為什麼 InternalThreadLocalMap 要在陣列下標為 0 的位置存放一個 FastThreadLocal 型別的 Set 集合呢?這時候我們回過頭看下 remove() 方法。

public final void remove(InternalThreadLocalMap threadLocalMap) {
  if (threadLocalMap == null) {
    return;
  }

  Object v = threadLocalMap.removeIndexedVariable(index);
  removeFromVariablesToRemove(threadLocalMap, this);

  if (v != InternalThreadLocalMap.UNSET) {
    try {
      onRemoval((V) v);
    } catch (Exception e) {
      PlatformDependent.throwException(e);
    }
  }
}

在執行 remove 操作之前,會呼叫 InternalThreadLocalMap.getIfSet() 獲取當前 InternalThreadLocalMap。

有了之前的基礎,理解 getIfSet() 方法就非常簡單了。

  1. 如果是 FastThreadLocalThread 型別,直接取 FastThreadLocalThread 中 threadLocalMap 屬性。
  2. 如果是普通執行緒 Thread,從 ThreadLocal 型別的 slowThreadLocalMap 中獲取。

找到 InternalThreadLocalMap 之後,InternalThreadLocalMap 會從陣列中定位到下標 index 位置的元素,並將 index 位置的元素覆蓋為預設物件 UNSET。

接下來就需要清理當前的 FastThreadLocal 物件,此時 Set 集合就派上了用場,InternalThreadLocalMap 會取出陣列下標 0 位置的 Set 集合,然後刪除當前 FastThreadLocal。最後 onRemoval() 方法起到什麼作用呢?Netty 只是留了一處擴充套件,並沒有實現,使用者需要在刪除的時候做一些後置操作,可以繼承 FastThreadLocal 實現該方法。

FastThreadLocal.get()原始碼分析

再來看一下 FastThreadLocal.get() 的原始碼:

public final V get() {
    InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
    Object v = threadLocalMap.indexedVariable(index);
    if (v != InternalThreadLocalMap.UNSET) {
        return (V) v;
    }

    return initialize(threadLocalMap);
}

首先根據當前執行緒是否是 FastThreadLocalThread 型別找到 InternalThreadLocalMap,然後取出從陣列下標 index 的元素,如果 index 位置的元素不是預設物件 UNSET,說明該位置已經填充過資料,直接取出返回即可。

public Object indexedVariable(int index) {
  Object[] lookup = indexedVariables;
  return index < lookup.length? lookup[index] : UNSET;
}

如果 index 位置的元素是預設物件 UNSET,那麼需要執行初始化操作。可以看到,initialize() 方法會呼叫使用者重寫的 initialValue 方法構造需要儲存的物件資料.

private V initialize(InternalThreadLocalMap threadLocalMap) {
    V v = null;
    try {
        v = initialValue();
    } catch (Exception e) {
        PlatformDependent.throwException(e);
    }

    threadLocalMap.setIndexedVariable(index, v);
    addToVariablesToRemove(threadLocalMap, this);
    return v;
}

initialValue方法的構造方式如下。

private final FastThreadLocal<String> threadLocal = new FastThreadLocal<String>() {
  @Override
  protected String initialValue() {
    return "hello world";
  }
};

構造完使用者物件資料之後,接下來就會將它填充到陣列 index 的位置,然後再把當前 FastThreadLocal 物件儲存到待清理的 Set 中。整個過程我們在分析 FastThreadLocal.set() 時都已經介紹過,就不再贅述了。

到此為止,FastThreadLocal 最核心的兩個方法 set()/get() 我們已經分析完了。下面有兩個問題我們再深入思考下。

  1. FastThreadLocal 真的一定比 ThreadLocal 快嗎?答案是不一定的,只有使用FastThreadLocalThread 型別的執行緒才會更快,如果是普通執行緒反而會更慢。
  2. FastThreadLocal 會浪費很大的空間嗎?雖然 FastThreadLocal 採用的空間換時間的思路,但是在 FastThreadLocal 設計之初就認為不會存在特別多的 FastThreadLocal 物件,而且在資料中沒有使用的元素只是存放了同一個預設物件的引用,並不會佔用太多記憶體空間。

版權宣告:本部落格所有文章除特別宣告外,均採用 CC BY-NC-SA 4.0 許可協議。轉載請註明來自 Mic帶你學架構
如果本篇文章對您有幫助,還請幫忙點個關注和贊,您的堅持是我不斷創作的動力。歡迎關注「跟著Mic學架構」公眾號公眾號獲取更多技術乾貨!

相關文章