ThreadLocal 實踐與源碼解析
在多線程編程中,共享資源的管理和同步一直是開發人員面臨的挑戰之一。ThreadLocal 是 Java 提供的一種簡單而強大的機制,用于實現線程局部變量,即每個線程都有自己的獨立副本,互不干擾。這種機制不僅簡化了并發編程中的數據管理,還提高了代碼的可讀性和可維護性。
一、詳解ThreadLocal
1.什么是ThreadLocal?它有什么用?
為了保證特定變量對當前線程可見,我們就可以使用ThreadLocal關鍵字,ThreadLocal可以為每個線程創建這個變量的副本并存到每個線程的存儲空間中(關于這個存儲空間后文會展開講述),從而確保共享變量對每個線程隔離:
2.ThreadLocal基礎使用示例
如上文所說ThreadLocal最典型的用法就是維護各個線程各自需要獨享變量,基于ThreadLocal為每個將每個線程的id存到線程內部,彼此之間互不影響。
ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
Thread t1 = new Thread(() -> {
log.info("t1往THREAD_LOCAL存入變量:[{}]", Thread.currentThread().getName());
THREAD_LOCAL.set(Thread.currentThread().getName());
log.info("t1獲取THREAD_LOCAL的值為:[{}]", THREAD_LOCAL.get());
}, "t1");
Thread t2 = new Thread(() -> {
log.info("t2往THREAD_LOCAL存入變量:[{}]", Thread.currentThread().getName());
THREAD_LOCAL.set(Thread.currentThread().getName());
log.info("t2獲取THREAD_LOCAL的值為:[{}]", THREAD_LOCAL.get());
THREAD_LOCAL.remove();
log.info("t2刪除THREAD_LOCAL的后值為:[{}]", THREAD_LOCAL.get());
}, "t2");
t1.start();
t2.start();
ThreadUtil.sleep(1,TimeUnit.DAYS);
從輸出結果可以看出,兩個線程都用THREAD_LOCAL 在自己的內存空間中存儲了變量的副本,彼此互相隔離的使用
21:59:51.351 [t2] INFO MultiApplication - t2往THREAD_LOCAL存入變量:[t2]
21:59:51.351 [t1] INFO MultiApplication - t1往THREAD_LOCAL存入變量:[t1]
21:59:51.358 [t1] INFO MultiApplication - t1獲取THREAD_LOCAL的值為:[t1]
21:59:51.359 [t2] INFO MultiApplication - t2獲取THREAD_LOCAL的值為:[t2]
21:59:51.359 [t2] INFO MultiApplication - t2刪除THREAD_LOCAL的后值為:[null]
二、從兩種應用場景來介紹一下ThreadLocal
1.日期格式化工具類
我們創建100個線程使用同一個dateFormat完成日期格式化:
private static Logger logger = LoggerFactory.getLogger(MyThreadLocalDemo3.class);
static SimpleDateFormat dateFormat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS");
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
int finalI = i;
//線程池中的線程
threadPool.submit(()->{
new MyThreadLocalDemo3().caclData(finalI);
});
}
threadPool.shutdown();
}
/**
* 計算second后的日期
* @param second
* @return
*/
public String caclData(int second){
Date date=new Date(1000*second);
String dateStr = dateFormat.format(date);
logger.info("{}得到的時間字符串為:{}",Thread.currentThread().getId(),dateStr);
return dateStr;
}
從輸出結果可以看出,間隔幾毫秒的線程出現相同結果:
基于該問題我們使用ThreadLocal為線程分配SimpleDateFormat副本:
static ThreadLocal<SimpleDateFormat> threadLocal=ThreadLocal.withInitial(()->new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS"));
public static void main(String[] args) throws InterruptedException {
ExecutorService threadPool = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
int finalI = i;
//線程池中的線程
threadPool.submit(()->{
new MyThreadLocalDemo3().caclData(finalI);
});
}
threadPool.shutdown();
}
/**
* 計算second后的日期
* @param second
* @return
*/
public String caclData(int second){
Date date=new Date(1000*second);
SimpleDateFormat simpleDateFormat = threadLocal.get();
String dateStr = simpleDateFormat.format(date);
logger.info("{}得到的時間字符串為:{}",Thread.currentThread().getId(),dateStr);
return dateStr;
}
2.服務間調用的線程變量共享
我們日常web開發都會涉及到各種service的調用,例如某個controller需要調用完service1之后再調用service2。因為我們的controller和service都是單例的,所以如果我們希望多線程調用這些controller和service保證共享變量的隔離,也可以用到ThreadLocal。
為了實現這個示例,我們編寫了線程獲取共享變量的工具類:
public class MyUserContextHolder {
private static ThreadLocal<User> holder = new ThreadLocal<>();
public static ThreadLocal<User> getHolder() {
return holder;
}
}
service調用鏈示例如下,筆者創建service1之后,所有線程復用這個service完成了調用,并且在服務間調用直接通過ThreadLocal完成了線程副本共享:
public class MyThreadLocalGetUserId {
private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);
private static ExecutorService threadPool = Executors.newFixedThreadPool(10);
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
int finalI = i;
MyService1 service1 = new MyService1();
threadPool.submit(() -> {
service1.doWork1("username" + (finalI+1));
});
}
}
}
class MyService1 {
private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);
public void doWork1(String name) {
logger.info("service1 存儲userName:" + name);
ThreadLocal<String> holder = MyUserContextHolder.getHolder();
holder.set(name);
MyService2 service2 = new MyService2();
service2.doWork2();
}
}
class MyService2 {
private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);
public void doWork2() {
ThreadLocal<String> holder = MyUserContextHolder.getHolder();
logger.info("service2 獲取userName:" + holder.get());
MyService3 service3 = new MyService3();
service3.doWork3();
}
}
class MyService3 {
private static Logger logger = LoggerFactory.getLogger(MyThreadLocalGetUserId.class);
public void doWork3() {
ThreadLocal<String> holder = MyUserContextHolder.getHolder();
logger.info("service3獲取 userName:" + holder.get());
// 避免oom問題
holder.remove();
}
}
從輸出結果來看,在單例對象情況下,既保證了同一個線程間變量共享。
也保證了不同線程之間變量的隔離。
三、基于源碼了解ThreadlLocal工作原理
1.ThreadlLocal如何做到線程隔離的?
我們下面這段代碼為例進行分析,本質上ThreadLocal的withInitial指明了每個線程初始化時設置默認值:
ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SS"));
當我們執行get操作時,threadLocal 就會為當前線程完成內部map的初始化,然后通過initialValue獲取上一步聲明的SimpleDateFormat實例,由此保證每個線程內部都有一個獨有的SimpleDateFormat:
對應的我們給出ThreadlLocal的get的源碼,整體邏輯與上述差不多,即初始化線程內部的map,然后通過setInitialValue調用initialValue創建初始值存到線程的map中:
public T get() {
//獲取當前線程
Thread t = Thread.currentThread();
//拿到當前線程中的map
ThreadLocalMap map = getMap(t);
//如果map不為空則取用當前這個ThreadLocal作為key取出值,否則通過setInitialValue完成ThreadLocal初始化
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() {
//執行initialValue為當前線程創建變量value,在這里也就是我們要用的SimpleDateFormat
T value = initialValue();
//獲取當前線程map,有則直接以ThreadLocal為key將SimpleDateFormat 設置進去,若沒有先創建再設置
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
//返回SimpleDateFormat
return value;
}
2.ThreadLocalMap有什么特點?和HashMap有什么區別
我們通過源碼查看到這個map為ThreadLocalMap,它是由一個個Entry 構成的數組:
private Entry[] table;
并且每個Entry 的key是弱引用,這就意味著當觸發GC時,Entry 的key也就是ThreadLocal就會被回收。
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
除上面所說,thread中的map和hashmap還有一個不同點就是數據結構,因為threadLocal的適用場景特殊,所以大部分情況下其內部存儲空間不會存儲太多元素,所以出于簡單的考慮,線程中的map本質上就是一個數組,一旦發生沖突則直接通過線性探測法找到數組中空閑的位置將值存入:
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();
//......
}
//找到合適的位置將元素存入
tab[i] = new Entry(key, value);
//更新一下容量信息
int sz = ++size;
//......
}
四、ThreadLocal使用注意事項
1.內存泄漏問題
我們有下面這樣一段web代碼,每次請求test0就會像線程池中的線程存一個4M的byte數組:
RestController
public class TestController {
final static ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(100, 100, 1, TimeUnit.MINUTES,
new LinkedBlockingQueue<>());// 創建線程池,通過線程池,保證創建的線程存活
final static ThreadLocal<Byte[]> localVariable = new ThreadLocal<Byte[]>();// 聲明本地變量
@RequestMapping(value = "/test0")
public String test0(HttpServletRequest request) {
poolExecutor.execute(() -> {
Byte[] c = new Byte[4* 1024* 1024];
localVariable.set(c);// 為線程添加變量
});
return "success";
}
}
我們將這個代碼打成jar包部署到服務器上并啟動
java -jar -Xms100m -Xmx100m # 調整堆內存大小
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof # 表示發生OOM時輸出日志文件,指定path為/tmp/heapdump.hprof
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:/tmp/heapTest.log # 打印日志、gc時間以及指定gc日志的路徑
demo-0.0.1-SNAPSHOT.jar
只需頻繁調用幾次,就會輸出OutOfMemoryError
Exception in thread "pool-1-thread-5" java.lang.OutOfMemoryError: Java heap space
at com.example.jstackTest.TestController.lambda$test0$0(TestController.java:25)
at com.example.jstackTest.TestController$$Lambda$582/394910033.run(Unknown Source)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
問題的根本原因是我們沒有及時回收Thread從ThreadLocal中得到的變量副本。因為我們的使用的線程是來自線程池中,所以線程使用結束后并不會被銷毀,這就使得ThreadLocal中的變量副本會一直存儲與線程池中的線程中,導致OOM。
可能你會問了,不是說Java有GC回收機制嘛?為什么還會出現Thread中的ThreadLocalMap的value不會被回收呢?
我們上文提到ThreadLocal得到值,都會以ThreadLocal為key,ThreadLocal的initialValue方法得到的value作為值生成一個entry對象,存到當前線程的ThreadLocalMap中。 而我們的Entry的key是一個弱引用,一旦我們使用的threadLocal臨時變量用完被垃圾回收之后,這個key就會因為弱引用的原因被回收,而我們這個key所對應的value仍然被線程池中的線程的強引用引用著,所以就遲遲無法回收,隨著時間推移每個線程都出現這種情況導致OOM。
所以我們每個線程使用完ThreadLocal之后,一定要使用remove方法清楚ThreadLocalMap中的value:
localVariable.remove()
從源碼中可以看到remove方法會遍歷當前線程map然后將強引用之間的聯系切斷,確保下次GC可以回收掉可以無用對象。
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
//定位,并將entry清除
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
2.空指針問題
使用ThreadLocal存放包裝類的時候也需要注意添加初始化方法,否則在拆箱時可能會出現空指針問題。
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
Long num = threadLocal.get();
long sum=1+num;
}
輸出錯誤:
Exception in thread "main" java.lang.NullPointerException
at com.guide.base.MyThreadLocalNpe.main(MyThreadLocalNpe.java:11)
解決方式:
private static ThreadLocal<Long> threadLocal = ThreadLocal.withInitial(()->new Long(0));
3.線程重用問題
這個問題和OOM問題類似,在線程池中服用同一個線程未及時清理,導致下一次HTTP請求時得到上一次ThreadLocal存儲的結果。
ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> null);
* 線程池中使用threadLocal示例
*
* @param accountCode
* @return
*/
@GetMapping("/account/getAccountByCode/{accountCode}")
@SentinelResource(value = "getAccountByCode")
ResultData<Map<String, Object>> getAccountByCode(@PathVariable(value = "accountCode") String accountCode) throws InterruptedException {
Map<String, Object> result = new HashMap<>();
CountDownLatch countDownLatch = new CountDownLatch(1);
threadPool.submit(() -> {
String before = Thread.currentThread().getName() + ":" + threadLocal.get();
log.info("before:" + before);
result.put("before", before);
log.info("調用getByCode,請求參數:{}", accountCode);
QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("account_code", accountCode);
Account account = accountService.getOne(queryWrapper);
String after = Thread.currentThread().getName() + ":" + account.getAccountName();
result.put("after", account.getAccountName());
log.info("after:" + after);
threadLocal.set(account.getAccountName());
//完成計算后,使用countDown按下倒計時門閂,通知主線程可以執行后續步驟
countDownLatch.countDown();
});
//等待上述線程池完成
countDownLatch.await();
return ResultData.success(result);
}
從輸出結果可以看出,我們第二次進行HTTP請求時,threadLocal第一get獲得了上一次請求的值,出現臟數據。
C:\Users\xxx>curl http://localhost:9000/account/getAccountByCode/demoData
{"status":100,"message":"操作成功","data":{"before":"pool-2-thread-1:null","after":"pool-2-thread-1:demoData"},"success":true,"timestamp":1678410699943}
C:\Users\xxx>curl http://localhost:9000/account/getAccountByCode/Zsy
{"status":100,"message":"操作成功","data":{"before":"pool-2-thread-1:demoData","after":"pool-2-thread-1:zsy"},"success":true,"timestamp":1678410707473}
解決方法也很簡單,手動添加一個threadLocal的remove方法即可:
@GetMapping("/account/getAccountByCode/{accountCode}")
@SentinelResource(value = "getAccountByCode")
ResultData<Map<String, Object>> getAccountByCode(@PathVariable(value = "accountCode") String accountCode) throws InterruptedException {
Map<String, Object> result = new HashMap<>();
CountDownLatch countDownLatch = new CountDownLatch(1);
try {
threadPool.submit(() -> {
String before = Thread.currentThread().getName() + ":" + threadLocal.get();
log.info("before:" + before);
result.put("before", before);
log.info("調用getByCode,請求參數:{}", accountCode);
QueryWrapper<Account> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("account_code", accountCode);
Account account = accountService.getOne(queryWrapper);
String after = Thread.currentThread().getName() + ":" + account.getAccountName();
result.put("after", after);
log.info("after:" + after);
threadLocal.set(account.getAccountName());
//完成計算后,使用countDown按下倒計時門閂,通知主線程可以執行后續步驟
countDownLatch.countDown();
});
} finally {
threadLocal.remove();
}
//等待上述線程池完成
countDownLatch.await();
return ResultData.success(result);
}
五、ThreadLocal的不可繼承性
1.通過代碼證明ThreadLocal的不可繼承性
如下代碼所示,ThreadLocal子線程無法拿到主線程維護的內部變量
/**
* ThreadLocal 不具備可繼承性
*/
public class ThreadLocalInheritTest {
private static ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();
private static Logger logger = LoggerFactory.getLogger(ThreadLocalInheritTest.class);
public static void main(String[] args) {
THREAD_LOCAL.set("mainVal");
logger.info("主線程的值為: " + THREAD_LOCAL.get());
new Thread(() -> {
try {
//睡眠3s確保上述邏輯運行
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("子線程獲取THREAD_LOCAL的值為:[{}]", THREAD_LOCAL.get());
}).start();
}
}
2.使用InheritableThreadLocal實現主線程內部變量繼承
如下所示,我們將THREAD_LOCAL 改為InheritableThreadLocal類即可解決問題。
/**
* ThreadLocal 不具備可繼承性
*/
public class ThreadLocalInheritTest {
private static ThreadLocal<String> THREAD_LOCAL = new InheritableThreadLocal<>();
private static Logger logger = LoggerFactory.getLogger(ThreadLocalInheritTest.class);
public static void main(String[] args) {
THREAD_LOCAL.set("mainVal");
logger.info("主線程的值為: " + THREAD_LOCAL.get());
new Thread(() -> {
try {
//睡眠3s確保上述邏輯運行
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.info("子線程獲取THREAD_LOCAL的值為:[{}]", THREAD_LOCAL.get());
}).start();
}
}
3.基于源碼剖析原因
因為 ThreadLocal會將變量存儲在線程的 ThreadLocalMap中,所以我們先看看InheritableThreadLocal的getMap方法,從而定位到了inheritableThreadLocals:
ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}
然后我們到Thread類去定位這個變量的使用之處,所以我們在創建線程的地方打了個斷點:
從而定位到這段初始化,它會獲取主線程的ThreadLocalMap并將主線程ThreadLocalMap中的值存到子線程的ThreadLocalMap中。
private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}
this.name = name;
//獲取當前線程的主線程
Thread parent = currentThread();
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
//將主線程的map的值存到子線程中
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
//......
}
createInheritedMap內部就會調用ThreadLocalMap方法將主線程的ThreadLocalMap的值存到子線程的ThreadLocalMap中。
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];
//遍歷父線程數據復制到子線程map中
for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
//......
//定位當前子線程bucket位置將value存入
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}
六、ThreadLocal在Spring中的運用
其實針對日期格式化問題,Spring已經為我們內置好了相應的工具類即DateTimeContextHolder:
private static final ThreadLocal<DateTimeContext> dateTimeContextHolder =
new NamedThreadLocal<>("DateTimeContext");
該工具類和simpledateformate差不多,使用示例如下所示,是spring封裝的,使用起來也很方便:
public class DateTimeContextHolderTest {
protected static final Logger logger = LoggerFactory.getLogger(DateTimeContextHolderTest.class);
private final static DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private Set<String> set = new ConcurrentHashSet<String>();
@Test
public void test_withLocale_same() throws Exception {
ExecutorService threadPool = Executors.newFixedThreadPool(30);
for (int i = 0; i < 30; i++) {
int finalI = i;
threadPool.execute(() -> {
LocalDate currentdate = LocalDate.now();
int year = currentdate.getYear();
int month = currentdate.getMonthValue();
int day = 1 + finalI;
LocalDate date = LocalDate.of(year, month, day);
DateTimeFormatter fmt = DateTimeContextHolder.getFormatter(formatter, null);
String text = date.format(fmt);
set.add(text);
logger.info("轉換后的時間為" + text);
});
}
threadPool.shutdown();
while (!threadPool.isTerminated()) {
}
logger.info("查看去重后的數量"+set.size());
}
}
七、為什么JDK建議將ThreadLocal設置為static
我們都知道使用static是屬于類,存在于方法區中,即修飾的變量是全局共享的,這意味著當前ThreadLocal在通過static之后,即所有的實例對象都共享一個ThreadLocal。從而避免重復創建TSO(Thread Specific Object)即ThreadLocal所關聯的對象的創建的開銷。以及這種方案使得即使出現內存泄漏也是O(1)級別的內存泄露,場景如下:
- 假設使用線程非線程池模式,即線程結束后threadLocalMap就會被回收,這種情況下也只有在threadLocal第一次調用get到線程銷毀之間的時間段存在內存泄漏的情況。
- 如果使用的是全局線程池,因為線程池的線程并不會被回收,所以threadLocalMap中的entry一直存在于堆內存中,但由于該ThreadLocal屬于全局共享,所以大量線程進行操作時一定概率觸發expungeStaleEntry清除過期對象,一定程度上避免了內存泄漏的情況。
- 極端情況下,如果threadLocal創建之后只有線程池中的一個線程get或初始化后完全沒有線程再去使用,這就會導致threadLocalMap存在強引用而導致無法被回收,O(1)級別的內存泄漏由此誕生。
對應的實例變量的ThreadLocal的O(n)內存泄漏,這就不必多說。
八、小結
- ThreadLocal通過在將共享變量拷貝一份到每個線程內部的ThreadLocalMap保證線程安全。
- ThreadLocal使用完成后記得使用remove方法手動清理線程中的ThreadLocalMap過期對象,避免OOM和一些業務上的錯誤。
- ThreadLocal是不可被繼承了,如果想使用主線的的ThreadLocal,就必須使用InheritableThreadLocal。