最近在看一個系統(tǒng)代碼時,發(fā)現(xiàn)系統(tǒng)里面在使用到了 ThreadLocal箕般,乍一看耐薯,好像很高級的樣子。我再仔細(xì)一看丝里,這個場景并不會存在線程安全問題曲初,完全只是在一個方法中傳參使用的啊1邸(震驚)
難道是我水平太低臼婆,看不懂這個高級用法?經(jīng)過和架構(gòu)師請教和確認(rèn)幌绍,這完全就是一個 ThreadLocal 濫用的典型案例啊颁褂!甚至,日常的業(yè)務(wù)系統(tǒng)中傀广,90%以上的 ThreadLocal 都在濫用或錯用颁独!快來看看說的是不是你~
ThreadLocal 簡介
ThreadLocal 也叫線程局部變量,是 Java 提供的一個工具類伪冰,它為每個線程提供一個獨立的變量副本誓酒,從而實現(xiàn)線程間的數(shù)據(jù)隔離。
ThreadLocal 中的關(guān)鍵方法如下:
方法定義 | 方法用途 |
---|---|
public T get() | 返回當(dāng)前線程所對應(yīng)線程局部變量 |
public void set(T value) | 設(shè)置當(dāng)前線程的線程局部變量的值 |
public void remove() | 刪除當(dāng)前線程局部變量的值 |
濫用:無傷大雅
在一些沒有必要進(jìn)行線程隔離的場景中使用“好像高級”的 ThreadLocal贮聂,看起來是挺唬人的靠柑,但這其實就是“紙老虎”寨辩。
濫用的典型案例是:在一個方法的內(nèi)部,將入?yún)⑿畔懭?ThreadLocal 進(jìn)行保存歼冰,在后續(xù)需要時從 ThreadLocal 中取出使用靡狞。一段簡單的示例代碼,可以參考:
public class TestService {
private static final String COMMON = "1";
private ThreadLocal<Map<String, Object>> commonThreadLocal = new ThreadLocal<>();
public void testThreadLocal(String commonId, String activityId) {
setCommonThreadLocal(commonId, activityId);
// 省略業(yè)務(wù)代碼①
doSomething();
// 省略業(yè)務(wù)代碼②
}
/**
* 將入?yún)懭?ThreadLocal
*
* @param commonId
* @param activityId
*/
private void setCommonThreadLocal(String commonId, String activityId) {
Map<String, Object> params = new HashMap<>();
params.put("commonId", commonId);
params.put("activityId", activityId);
this.commonThreadLocal.set(params);
}
/**
* 從 ThreadLocal 取出參數(shù)停巷,進(jìn)行業(yè)務(wù)處理
*/
private void doSomething() {
Map<String, Object> params = this.commonThreadLocal.get();
String commonId = (String) params.get("commonId");
if (StringUtils.equals(commonId, COMMON)) {
// 省略業(yè)務(wù)代碼
}
}
}
為什么說無傷大雅呢耍攘?因為這段代碼的寫入 ThreadLocal 和讀取 ThreadLocal 都是在同一個線程中進(jìn)行的,代碼可以正常運(yùn)行畔勤,并且運(yùn)行結(jié)果正確蕾各。
但是,還是這段代碼庆揪,也埋了一個“坑”式曲,稍有不慎,將可能導(dǎo)致錯誤的結(jié)果缸榛。如果在處理業(yè)務(wù)邏輯中(①或者②處)使用了多線程技術(shù)吝羞,創(chuàng)建了其他線程,在其他線程中去獲取ThreadLocal中寫入的值内颗,根據(jù)獲取到的值進(jìn)行相關(guān)業(yè)務(wù)邏輯處理钧排,很可能得到預(yù)期之外的結(jié)果,從而演化為一個錯誤案例均澳。
錯用:血淚教訓(xùn)
錯誤案例
以一個常見的 Web 應(yīng)用為例恨溜,方便起見,我在本機(jī) Idea 使用 Spring Boot 創(chuàng)建一個工程找前,在 Controller 中使用 ThreadLocal 來保存線程中的用戶信息糟袁,初識為 null。業(yè)務(wù)邏輯很簡單躺盛,先從 ThreadLocal 獲取一次值项戴,然后把入?yún)⒅械?uid 設(shè)置到 ThreadLocal 中,隨后再獲取一次值槽惫,最后返回兩次獲得的 uid周叮。代碼如下:
private static final ThreadLocal<String> USER_INFO_THREAD_LOCAL = ThreadLocal.withInitial(() -> null);
@RequestMapping("user")
public String user(@RequestParam("uid") String uid) {
//查詢 ThreadLocal 中的用戶信息
String before = USER_INFO_THREAD_LOCAL.get();
//設(shè)置用戶信息
USER_INFO_THREAD_LOCAL.set(uid);
//再查詢一次 ThreadLocal 中的用戶信息
String after = USER_INFO_THREAD_LOCAL.get();
return before + ";" + after;
}
啟動工程,使用 uid=1躯枢,uid=2 ……作為入?yún)⑦M(jìn)行測試则吟,結(jié)果如下:
http://localhost:8080/user?uid=1 :沒有問題!
http://localhost:8080/user?uid=2 :很穩(wěn)锄蹂!
多來幾次氓仲,結(jié)果還是很穩(wěn)的。
結(jié)果符合預(yù)期,這真的沒有問題嗎敬扛?
問到這里晰洒,你是不是也有點懷疑了?是不是我要翻車了啥箭?寫到這里就被迫結(jié)束了谍珊。NO!NO急侥!NO砌滞!繼續(xù)看!
我調(diào)整 application.properties 參數(shù)坏怪,方便復(fù)現(xiàn)問題:
server.tomcat.max-threads=1
繼續(xù)執(zhí)行上面的測試:
http://localhost:8080/user?uid=1 :沒有問題贝润!
http://localhost:8080/user?uid=2 :什么?uid2 讀取到了 uid1 的信息B料4蚓颉!
http://localhost:8080/user?uid=1 :什么鹏秋?uid1 也讀取到了 uid2 的信息W鹨稀!侣夷!
這豈不是亂套了横朋,全亂了?百拓?R度觥!
問題原因
為什么數(shù)據(jù)會錯亂呢耐版?
數(shù)據(jù)錯亂,究竟是怎么回事呢压汪?按理說粪牲,在設(shè)置用戶信息之前第一次獲取的值始終應(yīng)該是 null,然后設(shè)置之后再去讀取止剖,讀到的應(yīng)該是設(shè)置之后的值才對啊腺阳。
真相是這樣的,程序運(yùn)行在 Tomcat 中穿香,Tomcat 的工作線程是基于線程池的亭引,線程池其實是復(fù)用了一些固定的線程的。
如果線程被復(fù)用皮获,那么很可能從 ThreadLocal 獲取的值是之前其他用戶的遺留下的值焙蚓。
為什么調(diào)整線程池參數(shù),就測試出問題了呢?
Spring Boot 內(nèi)嵌的 Tomcat 服務(wù)器的默認(rèn)線程池最大線程數(shù)是 200购公,但通過修改 application.properties
或 application.yml
文件來調(diào)整萌京。關(guān)鍵參數(shù)如下:
- 最大工作線程數(shù) (server.tomcat.max-threads):默認(rèn)值為 200,Tomcat 可以同時處理的最大線程數(shù)宏浩。
- 最小工作線程數(shù) (server.tomcat.min-spare-threads):默認(rèn)值為 10知残,Tomcat 在啟動時初始化的線程數(shù)。
- 最大連接數(shù) (server.tomcat.max-connections):默認(rèn)值為 10000比庄,Tomcat 在任何時候可以接受的最大連接數(shù)求妹。
- 等待隊列長度 (server.tomcat.accept-count):默認(rèn)值為 100,當(dāng)所有線程都在使用時佳窑,等待隊列的最大長度制恍。
我調(diào)整參數(shù)(server.tomcat.max-threads=1)之后,很容易復(fù)用到之前的線程华嘹,復(fù)用線程情況下吧趣,觸發(fā)了代碼中隱藏的 Bug。
如果不調(diào)整的話耙厚,在較大流量的場景下也會觸發(fā)這個 Bug强挫。
解決辦法
那應(yīng)該如何修改呢?其實方案很簡單薛躬,在 finally 代碼塊中顯式清除 ThreadLocal 中的數(shù)據(jù)俯渤。這樣,即使復(fù)用了之前的線程型宝,也不會獲取到錯誤的用戶信息八匠。修正后的代碼如下:
private static final ThreadLocal<String> USER_INFO_THREAD_LOCAL = ThreadLocal.withInitial(() -> null);
@RequestMapping("right")
public String right(@RequestParam("uid") String uid) {
String before = USER_INFO_THREAD_LOCAL.get();
USER_INFO_THREAD_LOCAL.set(uid);
try {
String after = USER_INFO_THREAD_LOCAL.get();
return before + ";" + after;
} finally {
USER_INFO_THREAD_LOCAL.remove();
}
}
正確使用
前面是濫用和錯用的例子,那應(yīng)該如何正確使用 ThreadLocal 呢趴酣? 正確的使用場景包括:
- 在網(wǎng)關(guān)場景下梨树,使用 ThreadLocal 來存儲追蹤請求的 ID、請求來源等信息岖寞;
- RPC 等框架中使用 ThreadLocal 保存請求上下文信息抡四,例如壓測標(biāo)識等;
- ……
最常見的案例是用戶登錄攔截仗谆,從 HttpServletRequest 獲取到用戶信息指巡,并保存到 ThreadLocal 中,方便后續(xù)隨時取用隶垮,代碼如下:
public class ContextHttpInterceptor implements HandlerInterceptor {
private static final ThreadLocal<Context> contextThreadLocal = new ThreadLocal<Context>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object o) throws Exception {
try {
Context context = new Context();
String pin = request.getParameter("pin");
if (StringUtils.isNotBlank(pin)) {
context.setPin(pin);
}
contextThreadLocal.set(context);
} catch (Exception e) {
}
return true;
}
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse resposne, Object o,
ModelAndView modelAndView) throws Exception {
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse resposne,
Object o, Exception e) throws Exception {
contextThreadLocal.remove();
}
}
public class Context {
private String pin;
public String getPin() {
return pin;
}
public void setPin(String pin) {
this.pin = pin;
}
}
總結(jié)
本文給大家介紹了 ThreadLocal 的無傷大雅的濫用案例藻雪、血淚教訓(xùn)的錯誤案例,分析問題原因和解決方法狸吞,也給出了正確的案例勉耀,希望對大家理解和使用 ThreadLocal 有幫助指煎。
真正的高手往往使用最樸實無華的招數(shù),寫出無可挑剔的代碼瑰排;有時候炫技式的代碼可能會出錯贯要。
大師級程序員把系統(tǒng)當(dāng)作故事來講,而不是當(dāng)作程序來寫椭住。把故事講好崇渗,即方便自己閱讀,也方便別人閱讀京郑,共勉宅广。