作者:湯圓
個人博客:javalover.cc
前言
前面在線程的安全性中介紹過全局變量(成員變量)和局部變量(方法或代碼塊內的變量),前者在多線程中是不安全的,需要加鎖等機制來確保安全,后者是線程安全的,但是多個方法之間無法共享
而今天的主角ThreadLocal,就填補了全局變量和局部變量之間的空白
簡介
ThreadLocal的作用主要有二:
-
線程之間的數據隔離:為每個線程創建一個副本,線程之間無法相互訪問
-
傳參的簡化:為每個線程創建的副本,在單個線程內是全局可見的,在多個方法之間不需要傳來傳去
其實上面的兩個作用,歸根到底都是副本的功勞,即每個線程單獨創建一個副本,就產生了上面的效果
ThreadLocal直譯為線程本地變量,巧妙地融合了全局變量和局部變量兩者的優點
下面我們分別舉兩個例子來說明它的作用
目錄
- 例子 - 數據隔離
- 例子 - 傳參優化
- 內部原理
正文
我們在接觸一個新東西時,首先應該是先用起來,然后再去探究內部原理
Thread Local的使用還是比較簡單的,類似Map,各種put/get
它的核心方法如下:
public void set(T value):保存當前副本到ThreadLocal中,每個線程單獨存放public T get():取出剛才保存的副本,每個線程只會取出自己的副本protected T initialValue():初始化副本,作用和set一樣,不過initialValue會自動執行,如果get()為空public void remove():刪除剛才保存的副本
1. 例子 - 數據隔離
這里我們用SimpleDateFormat舉例,因為這個類是線程不安全的(后面有空再單獨開篇),如果不做隔離,會有各種各樣的並發問題
我們先來看下線程不安全的例子,代碼如下:
public class ThreadLocalDemo {
// 線程不安全:在多個線程中執行時,有可能解析出錯
private SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd");
public void parse(String dateString){
try {
System.out.println(simpleDateFormat.parse(dateString));
} catch (ParseException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 30; i++) {
service.execute(()->{
demo.parse("2020-01-01");
});
}
}
}
多次運行,可能會出現下面的報錯:
Exception in thread "pool-1-thread-4" java.lang.NumberFormatException: empty String
關於SimpleDateFormat的不安全問題,在源碼注釋里有提到,如下:
Date formats are not synchronized. It is recommended to create separate format instances for each thread. If multiple threads access a format concurrently, it must be synchronized externally.
意思就是建議多線程使用時,要么每個線程單獨創建,要么加鎖
下面我們分別用加鎖和單獨創建來解決
線程安全的例子:加鎖
public class ThreadLocalDemo {
// 線程安全1:加內置鎖
private SimpleDateFormat simpleDateFormatSync = new SimpleDateFormat("yyyy-MM-dd");
public void parse1(String dateString){
try {
synchronized (simpleDateFormatSync){
System.out.println(simpleDateFormatSync.parse(dateString));
}
} catch (ParseException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 30; i++) {
service.execute(()->{
demo.parse1("2020-01-01");
});
}
}
}
線程安全的例子:通過ThreadLocal為每個線程創建一個副本
public class ThreadLocalDemo {
// 線程安全2:用ThreadLocal創建對象副本,做數據隔離
// 下面這個代碼可以簡化,通過 withInitialValue
private static ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<SimpleDateFormat>(){
// 初始化方法,每個線程只執行一次;比如線程池有10個線程,那么不管運行多少次,總的SimpleDateFormat副本只有10個
@Override
protected SimpleDateFormat initialValue() {
// 這里會輸出10次,分別是每個線程的id
System.out.println(Thread.currentThread().getId());
return new SimpleDateFormat("yyyy-MM-dd");
}
};
public void parse2(String dateString){
try {
System.out.println(threadLocal.get().parse(dateString));
} catch (ParseException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 30; i++) {
service.execute(()->{
demo.parse2("2020-01-01");
});
}
}
}
有的朋友可能會有疑問,這個例子為啥不直接創建局部變量呢?
這是因為如果創建局部變量,那么調用一次就會創建一個SimpleDateFormat,性能會比較低
而通過ThreadLocal為每個線程創建一個副本,那么基於這個線程的后續所有操作,都是訪問這個副本,無需再次創建
2. 例子 - 傳參優化
有時候,我們需要在多個方法之間進行傳參(比如用戶信息),此時就面臨一個問題:
- 如果將要傳遞的參數設置為全局變量,那么線程不安全
- 如果將要傳遞的參數設置為局部變量,那么傳參會很麻煩
這時就需要用到ThreadLocal了,正如開篇講得,它的作用就是融合全局和局部的優點,使得線程也安全,傳參也方便
下面是例子:
public class ThreadLocalDemo2 {
// 參數傳遞,程序繁瑣
public void fun1(int age){
System.out.println(age);
fun2(age);
}
private void fun2(int age){
System.out.println(age);
fun3(age);
}
private void fun3(int age){
System.out.println(age);
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadLocalDemo2 demo = new ThreadLocalDemo2();
for (int i = 0; i < 30; i++) {
final int j = i;
service.execute(()->{
demo.fun1(j);
});
}
}
}
這段代碼可能沒有實際意義,但是意思應該到了,就是表達傳遞參數的繁瑣性
下面我們看下用ThreadLocal來解決這個問題
public class ThreadLocalDemo2 {
// 簡化,ThreadLocal當全局變量來使用
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();
public void fun11(){
System.out.println(threadLocal.get());
fun22();
}
private void fun22(){
System.out.println(threadLocal.get());
fun33();
}
private void fun33(){
int age = threadLocal.get();
System.out.println(age);
}
public static void main(String[] args) {
ExecutorService service = Executors.newFixedThreadPool(10);
ThreadLocalDemo2 demo = new ThreadLocalDemo2();
for (int i = 0; i < 30; i++) {
final int j = i;
service.execute(()->{
try{
threadLocal.set(j);
demo.fun11();
}finally {
threadLocal.remove();
}
});
}
}
}
可以看到,這里我們不再把age參數傳來傳去,而是為每個線程創建一個副本age
這樣所有方法都可以訪問到副本,同時也保證了線程安全
不過要注意的是,這次的使用和上次不同,這次多了remove方法,它的作用就是刪除上面set的副本,這個下面再介紹
3. 內部原理
先來說說它是怎么做到數據隔離的
我們先來看下set方法:
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
可以看到,值是存在map里的(key是ThreadLocal對象,value就是為線程單獨創建的副本)
而這個map是怎么來的呢?再來看下面的代碼
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
可以看到,最終還是回到了Thread里面,這就是為啥線程之間實現了隔離,而線程內部實現了共享(因為是線程內的屬性,只有當前線程可見)
我們再看下get()方法,如下:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
可以看到,先找到當前線程內的map,然后再根據key取出value
最后一行的setInitialValue,就是在get為空時,重新執行的初始化動作
為什么要用ThreadLocal作為key,而不是線程id呢
是為了存儲多個變量
如果用了線程id作為key,那么map里一個線程只能存放一個變量
而用了ThreadLocal作為key,那么可以一個線程存放多個變量(通過創建多個ThreadLocal)
如下所示:
private static ThreadLocal<Integer> threadLocal1 = new ThreadLocal<Integer>();
private static ThreadLocal<Integer> threadLocal2 = new ThreadLocal<Integer>();
public void test(){
threadLocal1.set(1);
threadLocal2.set(2);
System.out.println(threadLocal1.get());
System.out.println(threadLocal2.get());
}
再來說下它的內存泄漏問題
我們先來看下ThreadLocalMap內部代碼:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
可以看到,內部節點Entry繼承了弱引用(在垃圾回收時,如果一個對象只有弱引用,則會被回收),然后在構造函數中通過super(k)將key設置為弱引用
因此在垃圾回收時,如果外部沒有指向ThreadLocal的強引用,那么就會直接把key回收掉
此時key=null,而value還在,但是又取不出來,久而久之,就會出現問題
解決辦法就是remove,通過在finally中remove,將副本從ThreadLocal中刪除,此時key和value都被刪除
總結
- ThreadLocal直譯為線程本地變量,它的作用就是通過為每個線程單獨創建一個副本,來保證線程間的數據隔離和簡化方法間的傳參
- 數據隔離的本質:Thread內部持有ThreadLocalMap對象,創建的副本都是存在這里,所以每個線程之間就實現了隔離
- 內存泄漏的問題:因為ThreadLocalMap中的key是弱引用,所以垃圾回收時,如果key指向的對象沒有強引用,那么就會被回收,此時value還存在,但是取不出來,時間長了,就有問題(當然如果線程退出,那value還是會被回收)
- 使用場景:面試等場合
參考內容:
- 《實戰Java高並發》
- 廖雪峰ThreadLocal:https://www.liaoxuefeng.com/wiki/1252599548343744/1306581251653666
后記
其實這里沒有很深入地去解析源碼部分知識,主要是精力和能力有限,后面再慢慢深入吧
