博客 / 詳情

返回

【一文讀懂】你也要用ThreadLocal嗎?ThreadLocal源碼解析

🐻大家好,我是木木熊
🌍️公眾號:「程序員木木熊 」
本文以學習交流和分享為目的,如有不正確的地方,歡迎大家批評指正!!

前“戲”

一直996寫代碼的猿猿/媛媛們,你們是否經常出現,以下症狀:

  • 線上問題找不到日誌,日誌無法串聯,問題定位困難
  • 方法調用鏈路長,數據傳遞不暢
  • 長期使用SimpleDateFormat,導致時間混亂
  • 分頁插件PageHelper問題頻發

長此以往,心力交瘁,難以下班。現在只需要閲讀此文,你就能輕鬆掌握:

  • ThreadLocal的實現原理
  • 輕鬆解決多線程環境下的數據隔離問題
  • 優雅的實現方法間上下文傳遞
  • 自定義TraceId,查日誌不再困難

突然戲精上身,hhh。

MOVE回來,本文將介紹一下ThreadLocald常用的使用場景,通過源碼解析原理,展示具體代碼示例,總結實踐指南

大家以後面試遇到ThreadLocal題,再也不用打面試官啦!

ThreadLocal常見的使用場景

1.解決線程安全問題

對於一些線程不安全的類,如SimpleDateFormat,多線程場景下,使用ThreadLocal為每個線程維護一個獨立的副本,避免出現線程安全的問題。

2.上下文信息傳遞

接口請求鏈路中,一些上下文信息(如用户信息)常常需要在方法間一直傳遞。可以把這些信息放在ThreadLocal中,而不必將它們作為方法的參數逐層傳遞,代碼實現會更加優雅。

3.Spring事務實現

Spring通過@Transactional註解來實現數據庫事務,為了保證所有的操作都是在同一個連接上完成的,使用ThreadLocal來存儲數據庫連接Connection對象。

4.PageHelper分頁信息傳遞

PageHelper.startPage方法,將分頁信息存儲在ThreadLocal常量LOCAL_PAGE中,分頁攔截器PageInterceptor基於LOCAL_PAGE判斷是否執行分頁和組裝分頁SQL,執行完成後清除LOCAL_PAGE。

5.MDC記錄TraceId

為了串聯一個請求的日誌,通常會為請求設置TraceId,一般通過MDC來添加TraceId,並打印到日誌中,而MDC底層實現也是使用的ThreadLocal。

ThreadLocal源碼解析

1.如何初始化

ThreadLocal構造方法為空,沒有任何邏輯

// 構造方法
public ThreadLocal() {
}

進行set方法get方法調用時,如果判斷當前線程中threadLocals變量為空,就會調用createMap方法完成初始化。

createMap方法的邏輯也比較簡單,即為當前線程成員變量threadLocals賦值。

void createMap(Thread t, T firstValue) {
    //初始化ThreadLocalMap
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

2.核心類ThreadLocalMap

ThreadLocalMap是ThreadLocal的一個靜態內部類,其本質就是一個哈希表
哈希表的key為ThreadLocal,用來為某一個線程,存儲不同類型的ThreadLocal值。
哈希表的value為使用者設置的具體對象值。

每個線程都獨立持有自己的ThreadLocalMap,即成員變量threadLocals,通過上文提到的createMap方法進行初始化。

//ThreadLocalMap
static class ThreadLocalMap {
  private static final int INITIAL_CAPACITY = 16;
  //Hash表頭數組
  private Entry[] table;
  private int size = 0;
  private int threshold;
  ...
  //map的set方法
  private void set(ThreadLocal<?> key, Object value {
      Entry[] tab = table;
      int len = tab.length;
      int i = key.threadLocalHashCode & (len-1);
      ...
  }
  
  //map的get方法
  private Entry getEntry(ThreadLocal<?> key) {
      int i = key.threadLocalHashCode 
        & (table.length - 1);
      Entry e = table[i];
      ...
  }
}

這裏有兩點需要注意

  • ThreadLocalMap是存儲在Thread中的,即成員變量threadLocals
  • ThreadLocalMap的key不是線程對象,而是所使用的ThradLocal對象,即方法中的this

這兩點是實現ThreadLocal線程隔離和ThradLocalMap隨線程銷燬而銷燬的關鍵。

ThreadLocalMap的靜態內部類Entry繼承了WeakReference(弱引用),若一個對象只被弱引用所引用,那麼它將在下一次GC中被回收掉。後續當寫一篇JAVA中四種引用的區別。

// Entry繼承WeakReference
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

ThreadLocal使用弱引用是為了避免發生內存泄漏,是ThreadLocal的保護機制。

3.設值,取值和清空

ThreadLocal為了實現線程隔離,所有的操作,其實底層都是基於每個線程自己獨有的ThreadLocalMap進行操作,下面的set/get/remove操作,實際都是基於ThreadLocalMap底層的方法進行的。

設值-set方法,方法在進行set操作時,會有一個對Entry中key的判斷,如果為null,會進行清除。在後面的取值set方法和清除remove方法中也有類似的操作,這也是ThreadLocal應對內存泄漏的保護機制。

//ThreadLocal的set方法
public void set(T value) {
    // 獲取當前線程
    Thread t = Thread.currentThread();
    // 獲取當前線程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        //當前線程的ThreadLocalMap初始化
        createMap(t, value);
    }
}

//ThreadLocalMap的set方法
private void set(ThreadLocal<?> key, Object value {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    ...
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
      
        // k == key 把值設置到Entry的value中
        if (k == key) {
            e.value = value;
            return;
        }
        // 這裏的操作是在ThreadLocal被回收是,避免產生內存泄漏
        if (k == null) {
            //清除Key為null的數據
            replaceStaleEntry(key, value, i);
            return;
        }
    }
}

取值-get方法,這裏有一段邏輯getEntryAfterMiss,也是對key為null的數據進行清除操作。

// ThreadLocal的get方法
public T get() {
    Thread t = Thread.currentThread();
    //獲取當前線程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        //ThreadLocalMap的getEntry方法進行取值
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    //map為null時,初始化map,並最終初始化value值為null
    return setInitialValue();
}

//ThreadLocalMap的getEntry方法
private Entry getEntry(ThreadLocal<?> key) {
    //正常的取值操作
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        //未取到值時,進行後續清理操作等
        return getEntryAfterMiss(key, i, e);
}

清理-remove方法,Entry的clear方法,把弱引用置為null,後續的expungeStaleEntry方法同上面一樣,也是對key值為null的數據進行清理,防止內存泄漏。

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            //去掉弱應用的引用
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

//Reference類的clear操作
public void clear() {
    this.referent = null;
}

4.ThreadLocal內存泄漏分析

下圖大致描繪了ThreadLocal的對象內存關係(可能不夠嚴謹)

先説一下ThreadLocal導致內存泄漏需要滿足的三個條件:

  1. ThreadLocal被回收,即使用完後,且只存在弱引用時被GC
  2. 線程被複用,如線程池
  3. 未再調用set/get/remove方法

我們來描述一下這個過程:

  • 在方法中new了一個局部變量ThreadLocal對象,使用完後,跳出方法,相當於失去強引用。後續GC中,因為只存在弱引用key,改對象會被回收。
  • ThreadLocal被回收,但是因為Thread線程複用,依然持有對ThreadLocalMap的引用,之前ThradLocal對應的Entry依然被引用,只是它的key已經變成null,value還是之前的值,這些key為null的Entry節點的value無法被訪問。
  • 如果線程遲遲不結束或者一直被複用,那麼這些value會一直存在一條強引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,導致value永遠不會回收。

ThreadLocal內存泄漏的根源是ThreadLocalMap的生命週期跟Thread一樣長,如果沒有手動刪除對應key的value就會導致內存泄漏,而不是因為弱引用。

因此,使用ThreadLocal,謹記使用完後一定要進行remove操作,進行清除,避免內存泄漏的發生。並且如果不及時清理,除了內存泄漏,更嚴重的是導致業務數據的錯亂,進而出現莫名奇妙的bug。

ThreadLocal代碼實踐

1.存儲用户登錄信息,上下文傳遞

通常配合鑑權切面使用,在通過token獲取到用户信息後,存儲到ThreadLocal中,後續只需要通過ThreadLocal靜態常量就可以方便獲取登錄用户信息,而不需要在方法和類之間傳遞。具體實現如下

LoginUserContext類,持有ThreadLocal常量,並提供靜態get/set/clear方法,方便調用

// 持有登錄信息的類
public class LoginUserContext {
    // ThreadLocal靜態常量
    public static final ThreadLocal<LoginUser> LOCAL_USER = new ThreadLocal<>();

    //設置用户信息
    public static void setUser(LoginUser loginUser) {
        LOCAL_USER.set(loginUser);
    }

    //獲取用户信息
    public static LoginUser getUser() {
        return LOCAL_USER.get();
    }

    //清除用户信息
    public static void clear() {
        LOCAL_USER.remove();
    }
}

鑑權切面,執行完成後finally中清除登錄用户信息

@Aspect
@Component
public class PermissionAspect {
    // 對所有的Controller進行攔截
    @Pointcut("execution(* *..*Controller.*(..))")
    public void controllerAspect() {
    }
    @Around("controllerAspect()")
    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
        Object ret = null;
        try {
            //獲取登錄用户信息
            LoginUser loginUser = getLoginUser();
            if (loginUser == null) {
                throw new RuntimeException("token已過期或未登錄");
            }
            //用户信息設置到ThreadLocal
            LoginUserContext.setUser(loginUser);
            //執行業務邏輯
            ret = joinPoint.proceed();
        } finally {
            //清理用户信息ThreadLocal
            LoginUserContext.clear();
        }
        return ret;
    }

    //根據token獲取登錄用户信息
    private LoginUser getLoginUser() {
        HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
        String accessToken = request.getHeader("token");
        if (StringUtils.isBlank(accessToken)) {
            return null;
        }
        //...此處邏輯省略
        return new LoginUser();
    }
}

業務方法在調用時,只需要通過LoginUserContext的靜態方法就可以獲取到登錄用户信息

@Service
public class OrderLogic {
    public void createOrder(){
        //獲取用户信息
        LoginUser loginUser = LoginUserContext.getUser();
        //業務邏輯...
    }
}

2.MDC存儲TraceId,串聯日誌

MDC底層實現是一個記錄Map<String,String>的ThreadLocal,不同key值都可以放在MDC中。

public class LogbackMDCAdapter implements MDCAdapter {
    final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal();
    ...
}

如果想要串聯請求日誌,需要設計一個TraceId,通過攔截器或者Filter的方式,設置到MDC中,配置對應的日誌格式,讓日誌打印對應的traceId,這樣我們就能通過TraceId完整跟蹤一次請求的日誌。

public class WebAuthInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private WebUserUtil webUserUtil;
    
    @Override
    public boolean preHandle(...)
        //生成和設置traceId
        MDC.put("traceId",traceId);
    }
    @Override
    public void afterCompletion(...)
        //清除traceId
        MDC.clear();
    }
}

切記攔截器的後置邏輯需要清除TraceId

日誌格式配置,%X{traceId}來獲取MDC中的traceId,注意與put時key的名字保持一致

//此處省略了其他格式配置
<property name="PATTERN" value="...[%X{traceId}]..."/>

3.解決SimpleDateFormat線程安全問題

把SimpleDateFormat設置到ThreadLocal中,每個線程保留自己的副本,實現線程隔離,保證線程安全

public class ThreadLocalDateFormat {
    private static final ThreadLocal<SimpleDateFormat> dateFormatHolder = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    public static SimpleDateFormat getDateFormat() {
        return dateFormatHolder.get();
    }
}

// 使用方法
SimpleDateFormat dateFormat = ThreadLocalDateFormat.getThreadLocalDateFormat();
String formattedDate = dateFormat.format(new Date());

當然,也可以通過使用DateTimeFormatter,DateTimeFormatter是Java8引入的,它是不可變的且線程安全的。

4.PageHelper存儲分頁信息

pageHelper的實現也使用到了ThreadLocal,調用startPage是把分頁信息存儲到LOCAL_PAGE中,在對應SQL上拼接分頁信息,執行完SQL的邏輯後,會把分頁信息clear掉。

public abstract class PageMethod {
    protected static final ThreadLocal<Page> LOCAL_PAGE = 
              new ThreadLocal<Page>();
}

PageHelper使用不當極易導致bug,詳見我的另一篇文章介紹一次排查PageHelper的坑爹問題,
《坑爹啊,註釋無用代碼竟會導致bug!又被PageHelper坑了

ThreadLocal最佳實踐

1.使用完後一定要顯示的進行remove

每次使用完ThreadLocal,都調用它的remove()方法,清除數據。

在使用線程池的情況下,沒有及時清理ThreadLocal,不僅是內存泄漏的問題,更嚴重的是可能導致業務邏輯出現問題。

所以,使用ThreadLocal就跟加鎖完要解鎖一樣,用完就清理。

利用ThreadLocal來實現的一些組件和工具,也要按照這個最佳實踐,如PageHelper和MDC等。

2.把ThreadLocal設置為靜態常量

把ThreadLocal設置成靜態常量,並提供專門對外使用的靜態方法set/get/clear,這樣能避免重複創建,且使用更加方便。注意,此條恰好構成了導致內存泄漏的條件,必須配和第一條使用

以上就是木木熊,對於ThreadLocal的簡單介紹。部分問題並沒有進行深入探究,如果大家有什麼疑問和建議,歡迎評論區討論~~

歡迎大家點贊-評論-關注,另外也可以關注公眾號【程序員木木熊】,瞭解更多後端技術知識!!

微信公眾號海量Java、架構、面試、算法資料免費送~

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.