背景 在測試環境上遇到一個詭異的問題,部分業務邏輯會記錄用戶ID到資料庫,但記錄的數據會串,比如當前用戶的操作記錄會被其他用戶覆蓋, 而且這個現象是每次重啟後一小段時間內就正常 問題 線上程池內部使用了InheritableThreadLocal存放用戶登錄信息,再獲取用戶信息後,由於沒有及時rem ...
背景
在測試環境上遇到一個詭異的問題,部分業務邏輯會記錄用戶ID到資料庫,但記錄的數據會串,比如當前用戶的操作記錄會被其他用戶覆蓋, 而且這個現象是每次重啟後一小段時間內就正常
問題
線上程池內部使用了InheritableThreadLocal存放用戶登錄信息,再獲取用戶信息後,由於沒有及時remove,導致下次請求還是得到舊的用戶數據
排查過程
1.通過臨時列印日誌,確認整個鏈路中用戶ID是否一致
2.確認寫日誌方法是否有被修改,最後確認是寫日誌這塊開了線程池後導致問題
思考
1.為什麼在之前測試過程沒有復現
之前依賴的日誌記錄二方jar包進行過一次升級,原先的版本在記錄日誌沒有開啟線程池,後面升級版本後,加入了線程池
2.分析為什麼用了InheritableThreadLocal還會出現數據錯亂(覆蓋)
ThreadLocal 在 ThreadLocalMap 中是以一個弱引用身份被Entry中的Key引用的,因此如果ThreadLocal沒有外部強引用來引用它,那麼ThreadLocal會在下次JVM垃圾收集時被回收。這個時候就會出現Entry中Key已經被回收,出現一個null Key的情況,外部讀取ThreadLocalMap中的元素是無法通過null Key來找到Value的。因此如果當前線程的生命周期很長(在本篇案例中,由於線程池的核心線程沒有被回收,一直存在),那麼其內部的ThreadLocalMap對象也一直生存下來,這些null key就存在一條強引用鏈的關係一直存在:Thread --> ThreadLocalMap-->Entry-->Value,這條強引用鏈會導致Entry不會回收,Value也不會回收;所以出現數據錯亂的原因在於核心線程一直沒有被回收,然後 InheritableThreadLocal 也未及時remove,導致核心線程一直存放著老
3.本地求證
為了能復現測試環境問題,我寫了一個demo在本地去復現這個問題,開啟了一個線程器並設置了2個核心線程,調用4次
根據上面的運行日誌結果,可以發現第二次的設值並沒有真正改變
分析線程池的核心線程是如何不被回收的,源碼跟蹤如下
1.跟進線程池execute主方法入口
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true)) //未超過核心線程數,則新增 Worker 對象,true表示核心線程
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
由上可知核心線程通過addWorker方法創建
2.跟進addWorker方法
private boolean addWorker(Runnable firstTask, boolean core) {
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);
if (wc >= CAPACITY ||
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
w = new Worker(firstTask);
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
t.start(); //執行核心線程
workerStarted = true;
}
}
} finally {
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
由上可知核心線程在addWorker方法內部通過 t.start()執行
3.跟進Work類裡面的run方法
由上可知通過While去不斷輪詢getTask方法來保證核心線程被創建後一直處於阻塞狀態,所以可知核心線程數創建後會通過阻塞來保證不被回收
總結
1.每次用完後ThreadLocal後及時remove
2.儘量不要線上程池裡使用 ThreadLocal,很多時候開發關註的點比較多,疏忽的過程也在所難免,例如本次解決方案是我先把用戶ID線上程池調用前查詢出來,在傳進去