本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節,我們來探討一個特殊的概念,執行緒本地變數,在Java中的實現是類ThreadLocal,它是什麼?有什麼用?實現原理是什麼?讓我們接下來逐步探討。
基本概念和用法
執行緒本地變數是說,每個執行緒都有同一個變數的獨有拷貝,這個概念聽上去比較難以理解,我們先直接來看類TheadLocal的用法。
ThreadLocal是一個泛型類,接受一個型別引數T,它只有一個空的構造方法,有兩個主要的public方法:
public T get()
public void set(T value)
複製程式碼
set就是設定值,get就是獲取值,如果沒有值,返回null,看上去,ThreadLocal就是一個單一物件的容器,比如:
public static void main(String[] args) {
ThreadLocal<Integer> local = new ThreadLocal<>();
local.set(100);
System.out.println(local.get());
}
複製程式碼
輸出為100。
那ThreadLocal有什麼特殊的呢?特殊發生在有多個執行緒的時候,看個例子:
public class ThreadLocalBasic {
static ThreadLocal<Integer> local = new ThreadLocal<>();
public static void main(String[] args) throws InterruptedException {
Thread child = new Thread() {
@Override
public void run() {
System.out.println("child thread initial: " + local.get());
local.set(200);
System.out.println("child thread final: " + local.get());
}
};
local.set(100);
child.start();
child.join();
System.out.println("main thread final: " + local.get());
}
}
複製程式碼
local是一個靜態變數,main方法建立了一個子執行緒child,main和child都訪問了local,程式的輸出為:
child thread initial: null
child thread final: 200
main thread final: 100
複製程式碼
這說明,main執行緒對local變數的設定對child執行緒不起作用,child執行緒對local變數的改變也不會影響main執行緒,它們訪問的雖然是同一個變數local,但每個執行緒都有自己的獨立的值,這就是執行緒本地變數的含義。
除了get/set,ThreadLocal還有兩個方法:
protected T initialValue()
public void remove()
複製程式碼
initialValue用於提供初始值,它是一個受保護方法,可以通過匿名內部類的方式提供,當呼叫get方法時,如果之前沒有設定過,會呼叫該方法獲取初始值,預設實現是返回null。remove刪掉當前執行緒對應的值,如果刪掉後,再次呼叫get,會再呼叫initialValue獲取初始值。看個簡單的例子:
public class ThreadLocalInit {
static ThreadLocal<Integer> local = new ThreadLocal<Integer>(){
@Override
protected Integer initialValue() {
return 100;
}
};
public static void main(String[] args) {
System.out.println(local.get());
local.set(200);
local.remove();
System.out.println(local.get());
}
}
複製程式碼
輸出值都是100。
使用場景
ThreadLocal有什麼用呢?我們來看幾個例子。
DateFormat/SimpleDateFormat
ThreadLocal是實現執行緒安全的一種方案,比如對於DateFormat/SimpleDateFormat,我們在32節介紹過日期和時間操作,提到它們是非執行緒安全的,實現安全的一種方式是使用鎖,另一種方式是每次都建立一個新的物件,更好的方式就是使用ThreadLocal,每個執行緒使用自己的DateFormat,就不存在安全問題了,線上程的整個使用過程中,只需要建立一次,又避免了頻繁建立的開銷,示例程式碼如下:
public class ThreadLocalDateFormat {
static ThreadLocal<DateFormat> sdf = new ThreadLocal<DateFormat>() {
@Override
protected DateFormat initialValue() {
return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
}
};
public static String date2String(Date date) {
return sdf.get().format(date);
}
public static Date string2Date(String str) throws ParseException {
return sdf.get().parse(str);
}
}
複製程式碼
需要說明的是,ThreadLocal物件一般都定義為static,以便於引用。
ThreadLocalRandom
即使物件是執行緒安全的,使用ThreadLocal也可以減少競爭,比如,我們在34節介紹過Random類,Random是執行緒安全的,但如果併發訪問競爭激烈的話,效能會下降,所以Java併發包提供了類ThreadLocalRandom,它是Random的子類,利用了ThreadLocal,它沒有public的構造方法,通過靜態方法current獲取物件,比如:
public static void main(String[] args) {
ThreadLocalRandom rnd = ThreadLocalRandom.current();
System.out.println(rnd.nextInt());
}
複製程式碼
current方法的實現為:
public static ThreadLocalRandom current() {
return localRandom.get();
}
複製程式碼
localRandom就是一個ThreadLocal變數:
private static final ThreadLocal<ThreadLocalRandom> localRandom =
new ThreadLocal<ThreadLocalRandom>() {
protected ThreadLocalRandom initialValue() {
return new ThreadLocalRandom();
}
};
複製程式碼
上下文資訊
ThreadLocal的典型用途是提供上下文資訊,比如在一個Web伺服器中,一個執行緒執行使用者的請求,在執行過程中,很多程式碼都會訪問一些共同的資訊,比如請求資訊、使用者身份資訊、資料庫連線、當前事務等,它們是執行緒執行過程中的全域性資訊,如果作為引數在不同程式碼間傳遞,程式碼會很囉嗦,這時,使用ThreadLocal就很方便,所以它被用於各種框架如Spring中,我們看個簡單的示例:
public class RequestContext {
public static class Request { //...
};
private static ThreadLocal<String> localUserId = new ThreadLocal<>();
private static ThreadLocal<Request> localRequest = new ThreadLocal<>();
public static String getCurrentUserId() {
return localUserId.get();
}
public static void setCurrentUserId(String userId) {
localUserId.set(userId);
}
public static Request getCurrentRequest() {
return localRequest.get();
}
public static void setCurrentRequest(Request request) {
localRequest.set(request);
}
}
複製程式碼
在首次獲取到資訊時,呼叫set方法如setCurrentRequest/setCurrentUserId進行設定,然後就可以在程式碼的任意其他地方呼叫get相關方法進行獲取了。
基本實現原理
ThreadLocal是怎麼實現的呢?為什麼對同一個物件的get/set,每個執行緒都能有自己獨立的值呢?我們直接來看程式碼。
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);
}
複製程式碼
它呼叫了getMap,getMap的程式碼為:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
複製程式碼
返回執行緒的例項變數threadLocals,它的初始值為null,在null時,set呼叫createMap初始化,程式碼為:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
複製程式碼
從以上程式碼可以看出,每個執行緒都有一個Map,型別為ThreadLocalMap,呼叫set實際上是線上程自己的Map裡設定了一個條目,鍵為當前的ThreadLocal物件,值為value。ThreadLocalMap是一個內部類,它是專門用於ThreadLocal的,與一般的Map不同,它的鍵型別為WeakReference<ThreadLocal>
,我們沒有提過WeakReference,它與Java的垃圾回收機制有關,使用它,便於回收記憶體,具體我們就不探討了。
get方法的程式碼為:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
return (T)e.value;
}
return setInitialValue();
}
複製程式碼
通過執行緒訪問到Map,以ThreadLocal物件為鍵從Map中獲取到條目,取其value,如果Map中沒有,呼叫setInitialValue,其程式碼為:
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}
複製程式碼
initialValue()就是之前提到的提供初始值的方法,預設實現就是返回null。
remove方法的程式碼也很直接,如下所示:
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
複製程式碼
簡單總結下,每個執行緒都有一個Map,對於每個ThreadLocal物件,呼叫其get/set實際上就是以ThreadLocal物件為鍵讀寫當前執行緒的Map,這樣,就實現了每個執行緒都有自己的獨立拷貝的效果。
執行緒池與ThreadLocal
我們在78節介紹過執行緒池,我們知道,執行緒池中的執行緒是會重用的,如果非同步任務使用了ThreadLocal,會出現什麼情況呢?可能是意想不到的,我們看個簡單的示例:
public class ThreadPoolProblem {
static ThreadLocal<AtomicInteger> sequencer = new ThreadLocal<AtomicInteger>() {
@Override
protected AtomicInteger initialValue() {
return new AtomicInteger(0);
}
};
static class Task implements Runnable {
@Override
public void run() {
AtomicInteger s = sequencer.get();
int initial = s.getAndIncrement();
// 期望初始為0
System.out.println(initial);
}
}
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(2);
executor.execute(new Task());
executor.execute(new Task());
executor.execute(new Task());
executor.shutdown();
}
}
複製程式碼
對於非同步任務Task而言,它期望的初始值應該總是0,但執行程式,結果卻為:
0
0
1
複製程式碼
第三次執行非同步任務,結果就不對了,為什麼呢?因為執行緒池中的執行緒在執行完一個任務,執行下一個任務時,其中的ThreadLocal物件並不會被清空,修改後的值帶到了下一個非同步任務。那怎麼辦呢?有幾種思路:
- 第一次使用ThreadLocal物件時,總是先呼叫set設定初始值,或者如果ThreaLocal重寫了initialValue方法,先呼叫remove
- 使用完ThreadLocal物件後,總是呼叫其remove方法
- 使用自定義的執行緒池
我們分別來看下,對於第一種,在Task的run方法開始處,新增set或remove程式碼,如下所示:
static class Task implements Runnable {
@Override
public void run() {
sequencer.set(new AtomicInteger(0));
//或者 sequencer.remove();
AtomicInteger s = sequencer.get();
//...
}
}
複製程式碼
對於第二種,將Task的run方法包裹在try/finally中,並在finally語句中呼叫remove,如下所示:
static class Task implements Runnable {
@Override
public void run() {
try{
AtomicInteger s = sequencer.get();
int initial = s.getAndIncrement();
// 期望初始為0
System.out.println(initial);
}finally{
sequencer.remove();
}
}
}
複製程式碼
以上兩種方法都比較麻煩,需要更改所有非同步任務的程式碼,另一種方法是擴充套件執行緒池ThreadPoolExecutor,它有一個可以擴充套件的方法:
protected void beforeExecute(Thread t, Runnable r) { }
複製程式碼
線上程池將任務r交給執行緒t執行之前,會線上程t中先執行beforeExecure,可以在這個方法中重新初始化ThreadLocal。如果知道所有需要初始化的ThreadLocal變數,可以顯式初始化,如果不知道,也可以通過反射,重置所有ThreadLocal,反射的細節我們會在後續章節進一步介紹。
我們建立一個自定義的執行緒池MyThreadPool,示例程式碼如下:
static class MyThreadPool extends ThreadPoolExecutor {
public MyThreadPool(int corePoolSize, int maximumPoolSize,
long keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
try {
//使用反射清空所有ThreadLocal
Field f = t.getClass().getDeclaredField("threadLocals");
f.setAccessible(true);
f.set(t, null);
} catch (Exception e) {
e.printStackTrace();
}
super.beforeExecute(t, r);
}
}
複製程式碼
這裡,使用反射,找到執行緒中儲存ThreadLocal物件的Map變數threadLocals,重置為null。使用MyThreadPool的示例程式碼如下:
public static void main(String[] args) {
ExecutorService executor = new MyThreadPool(2, 2, 0,
TimeUnit.MINUTES, new LinkedBlockingQueue<Runnable>());
executor.execute(new Task());
executor.execute(new Task());
executor.execute(new Task());
executor.shutdown();
}
複製程式碼
使用以上介紹的任意一種解決方案,結果就符合期望了。
小結
本節介紹了ThreadLocal的基本概念、用法用途、實現原理、以及和執行緒池結合使用時的注意事項,簡單總結來說:
- ThreadLocal使得每個執行緒對同一個變數有自己的獨立拷貝,是實現執行緒安全、減少競爭的一種方案。
- ThreadLocal經常用於儲存上下文資訊,避免在不同程式碼間來回傳遞,簡化程式碼。
- 每個執行緒都有一個Map,呼叫ThreadLocal物件的get/set實際就是以ThreadLocal物件為鍵讀寫當前執行緒的該Map。
- 線上程池中使用ThreadLocal,需要注意,確保初始值是符合期望的。
從65節到現在,我們一直在探討併發,至此,基本就結束了,下一節,讓我們一起簡要回顧總結一下。
(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…,另外,與之前章節一樣,本節程式碼基於Java 7, Java 8有些變動,我們會在後續章節統一介紹Java 8的更新)
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),從入門到高階,深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。