我是如何用 ThreadLocal 虐面試官的?

陳皮的JavaLib發表於2021-06-29

我是陳皮,一個在網際網路 Coding 的 ITer,微信搜尋「陳皮的JavaLib」第一時間閱讀最新文章,回覆【資料】,即可獲得我精心整理的技術資料,電子書籍,一線大廠面試資料和優秀簡歷模板。


ThreadLocal 簡介

Threadlocal 類提供了執行緒區域性變數功能。意思可以在指定執行緒內部儲存資料,並且哪個執行緒儲存的資料只能執行緒它自己有許可權取得。

底層原理其實是線上程內部維護一個 Map 變數,然後 Threadlocal 物件作為 key,要儲存的資料作為 value。而 Threadlocal 類作為一個設定和訪問這個執行緒區域性變數的入口。

Threadlocal 物件一般定義為私有靜態的,而且通過它的 get 和 set 方法設定和獲取執行緒區域性變數。

private static final ThreadLocal<UserContext> THREAD_LOCAL = new ThreadLocal<>();

如何使用 ThreadLocal

ThreadLocal 使用方法很簡單,它提供了三個公開的方法供外部呼叫。

  • void set(T value):設定執行緒區域性變數
  • T get():獲取執行緒區域性變數
  • void remove():刪除執行緒區域性變數
package com.chenpi;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
public class ThreadLocalTest {

    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    public static void main(String[] args) {
        // 設定執行緒區域性變數
        THREAD_LOCAL.set("我是陳皮,個人公眾號【陳皮的JavaLib】");
        // 使用執行緒區域性變數
        peelChenpi();
        // 刪除執行緒區域性變數
        THREAD_LOCAL.remove();
        // 使用執行緒區域性變數
        peelChenpi();
    }

    public static void peelChenpi() {
        System.out.println(THREAD_LOCAL.get());
    }
}

// 輸出結果
我是陳皮,個人公眾號【陳皮的JavaLib】
null

ThreadLocal 原始碼分析

ThreadLocal 底層原理是線上程內部維護一個 Map 變數,然後 Threadlocal 物件作為 key,要儲存的資料作為 value。而 Threadlocal 類作為一個設定和訪問這個執行緒區域性變數的入口。

Thread 類中定義了一個 ThreadLocalMap 型別的變數 threadLocals,每個執行緒都有自己專屬的 threadLocals 變數,ThreadLocalMap 類是由 ThreadLocal 維護的一個靜態內部類。

ThreadLocal.ThreadLocalMap threadLocals = null;

Thread 的 threadLocals 變數是預設訪問許可權的,只能被同個包下的類訪問,所以我們是不能直接使用 Thread 的 threadLocals 變數的,這也就是為什麼能控制不同執行緒只能獲取自己的資料,達到了執行緒隔離。Threadlocal 類是訪問它的入口。

Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

ThreadLocal 類中的靜態內部類 ThreadLocalMap 部分原始碼如下,底層是維護的了一個 Entry 型別陣列 table。

static class ThreadLocalMap {

        // Map中的Entry物件,弱引用型別,key是ThreadLocal物件,value是執行緒區域性變數
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
    
        // 初始化容量16,必須是2的冪次方
        private static final int INITIAL_CAPACITY = 16;
    
        // 儲存資料的陣列,可擴容,長度必須是2的冪次方
        private Entry[] table;

        // table陣列的大小
        private int size = 0;

        // table陣列的閾值,達到則擴容
        private int threshold; // Default to 0
        
}

為什麼 ThreadLocalMap 內部儲存機構是維護一個陣列呢?因為一個執行緒是可以通過多個不同的 ThreadLocal 物件來設定多個執行緒區域性變數的,這些區域性變數都是儲存在自己執行緒的同一個 ThreadLocalMap 物件中。通過不同的 ThreadLocal 物件可以取得當前執行緒的不同區域性變數值。

package com.chenpi;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
public class ThreadLocalTest {

    private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    private static final ThreadLocal<String> THREAD_LOCAL01 = new ThreadLocal<>();

    public static void main(String[] args) {
        THREAD_LOCAL.set("我是陳皮");
        System.out.println(THREAD_LOCAL.get());

        THREAD_LOCAL01.set("陳皮是我");
        System.out.println(THREAD_LOCAL01.get());
    }
}

那同一個執行緒的 ThreadLocalMap 物件的陣列 table,當前執行緒的不同 ThreadLocal 是如何確定陣列下標,如果陣列下標衝突又是怎麼解決的呢?其實它不同於 HashMap 底層陣列+連結串列+紅黑樹的儲存結構,它只有 Entry 陣列。

ThreadLocal 有個靜態的初始雜湊值 nextHashCode,然後每新建一個 ThreadLocal 物件都會在此雜湊值的基礎上自增一次,自增量為0x61c88647。

// 每 new 一個 ThreadLocal 物件都會自增一次雜湊值
private final int threadLocalHashCode = nextHashCode();

// 初始雜湊值,靜態變數
private static AtomicInteger nextHashCode =
    new AtomicInteger();

// 自增量
private static final int HASH_INCREMENT = 0x61c88647;

// 自增一次
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

然後計算 table 陣列下標是通過以下演算法確定的,如果下標衝突,則下標會往後挪一位繼續判斷,直到不衝突為止。

// 首次建立 ThreadLocalMap 物件時,第一個元素的下標計算
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 後續元素的下標計算
int i = key.threadLocalHashCode & (len-1);
// 下標衝突時計算下一個下標的方法
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

我們看 ThreadLocal 類的 set 方法原始碼,它是設定執行緒區域性變數的入口方法,實現原理也很簡單。

  • 首先獲取當前執行緒的 ThreadLocalMap 變數
  • 如果 ThreadLocalMap 變數存在,則將 ThreadLocal 物件和 T 資料以鍵值對的形式儲存到 ThreadLocalMap 變數中
  • 如果 ThreadLocalMap 變數不存在,則新建 ThreadLocalMap 變數並繫結到當前執行緒中,再將 ThreadLocal 物件和 T 資料以鍵值對的形式儲存到 ThreadLocalMap 變數中
// 設定執行緒區域性變數
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

ThreadLocal 類的 get 方法,它是訪問執行緒區域性變數的入口方法,實現原理也很簡單。

  • 首先獲取當前執行緒的 ThreadLocalMap 變數
  • 如果 ThreadLocalMap 變數存在,則將 ThreadLocal 物件作為 key,在 ThreadLocalMap 變數中查詢對應的執行緒區域性變數
  • 如果 ThreadLocalMap 變數不存在,則新建 ThreadLocalMap 變數並繫結到當前執行緒中,再將 ThreadLocal 物件和 null 以鍵值對的形式儲存到 ThreadLocalMap 變數中
// 訪問執行緒區域性變數
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

protected T initialValue() {
    return null;
}

ThreadLocal 類的 remove 方法,直接清除執行緒中 ThreadLocalMap 物件中以當前 ThreadLocal 物件為 key 的 Entry物件。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

你是否發現,ThreadLocal 類中的所有方法都是沒有加鎖的,因為 ThreadLocal 最終操作的都是對當前執行緒的 ThreadLocalMap 物件進行操作,既然執行緒處理自己的區域性變數,就肯定不會有執行緒安全問題。

注意,同一個 ThreadLocal 變數在父執行緒中被設定值後,在子執行緒中是獲取這個值的。即不具備繼承性。具有繼承性的是 InheritableThreadLocal 類,下期文章再講解這個。


ThreadLocal 應用

ThreadLocal 具有執行緒隔離,執行緒安全的效果,如果資料是以執行緒為作用域並且不同執行緒具有不同的資料的時候,採用 ThreadLocal 是個不錯的選擇。

例如對於要使用者登入的服務,對於每一個請求,我們可能需要校驗使用者是否登入,以及在登入後,後續的請求中會使用到使用者資訊,那我們就可以將登入校驗過的使用者資訊放入執行緒區域性變數中。

首先定義一個使用者資訊類,存放使用者登入校驗過的使用者資訊。

package com.chenpi;

import lombok.Data;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
@Data
public class UserContext {

    private String userId;
    private String userName;
}

定義一個持有使用者資訊的管理工具類,主要使用者管理當前執行緒的使用者資訊。

package com.chenpi;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
public class UserContextHolder {

    private static final ThreadLocal<UserContext> THREAD_LOCAL = new ThreadLocal<>();

    private UserContextHolder() {}

    public static void setUserContext(UserContext userContext) {
        THREAD_LOCAL.set(userContext);
    }

    public static UserContext getUserContext() {
        return THREAD_LOCAL.get();
    }

    public static void removeUserContext() {
        THREAD_LOCAL.remove();
    }
}

對需要使用者許可權的介面進行攔截,然後將使用者資訊儲存到當前執行緒內部。注意,當請求完成後,需要將使用者資訊進行清除,避免記憶體洩露問題。

package com.chenpi;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

/**
 * @Description 使用者許可權驗證攔截
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
@Component
public class UserPermissionInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) {

        if (handler instanceof HandlerMethod) {

            HandlerMethod handlerMethod = (HandlerMethod) handler;

            // 獲取使用者許可權校驗註解
            UserAuthenticate userAuthenticate =
                    handlerMethod.getMethod().getAnnotation(UserAuthenticate.class);
            if (null == userAuthenticate) {
                userAuthenticate = handlerMethod.getMethod().getDeclaringClass()
                        .getAnnotation(UserAuthenticate.class);
            }
            if (userAuthenticate != null && userAuthenticate.permission()) {
                // 驗證使用者資訊
                UserContext userContext = userContextManager.getUserContext(request);
                // 將使用者資訊儲存到執行緒內部
                UserContextHolder.setUserContext(userContext);
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
            Object handler, @Nullable Exception ex) {
        // 請求完後,清除當前執行緒的使用者資訊,避免記憶體洩露和使用者資訊混亂
        UserContextHolder.removeUserContext();
    }
}

至此,我們就能在當前請求的同一執行緒內,不用通過方法引數顯示傳遞使用者資訊,可以通過工具類隨時隨地獲取到當前使用者資訊了。

而且你會發現,如果方法呼叫鏈 A - B - C,AB 不需要使用者資訊,C 需要使用者資訊,那你需要層層通過方法引數傳遞使用者資訊。而使用 ThreadLocal 後,不用通過方法引數層層傳遞使用者資訊,避免了依賴汙染,程式碼也更加簡潔。

package com.chenpi;

import org.springframework.stereotype.Service;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/6/27
 * @Version 1.0
 */
@Service
public class UserService {

    public void chenPiDeJavaLib() {
        UserContext userContext = UserContextHolder.getUserContext();
    }
}

相關文章