🐻大家好,我是木木熊
🌍️公眾號:「程序員木木熊 」
本文以學習交流和分享為目的,如有不正確的地方,歡迎大家批評指正!!
前“戲”
一直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導致內存泄漏需要滿足的三個條件:
- ThreadLocal被回收,即使用完後,且只存在弱引用時被GC
- 線程被複用,如線程池
- 未再調用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、架構、面試、算法資料免費送~