作者:湯圓
個人部落格: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
後記
其實這裡沒有很深入地去解析原始碼部分知識,主要是精力和能力有限,後面再慢慢深入吧