Java併發:ThreadLocal的簡單介紹

湯圓學Java發表於2021-05-27

作者:湯圓

個人部落格:javalover.cc

前言

前面在執行緒的安全性中介紹過全域性變數(成員變數)和區域性變數(方法或程式碼塊內的變數),前者在多執行緒中是不安全的,需要加鎖等機制來確保安全,後者是執行緒安全的,但是多個方法之間無法共享

而今天的主角ThreadLocal,就填補了全域性變數和區域性變數之間的空白

簡介

ThreadLocal的作用主要有二:

  1. 執行緒之間的資料隔離:為每個執行緒建立一個副本,執行緒之間無法相互訪問

  2. 傳參的簡化:為每個執行緒建立的副本,在單個執行緒內是全域性可見的,在多個方法之間不需要傳來傳去

其實上面的兩個作用,歸根到底都是副本的功勞,即每個執行緒單獨建立一個副本,就產生了上面的效果

ThreadLocal直譯為執行緒本地變數,巧妙地融合了全域性變數和區域性變數兩者的優點

下面我們分別舉兩個例子來說明它的作用

目錄

  1. 例子 - 資料隔離
  2. 例子 - 傳參優化
  3. 內部原理

正文

我們在接觸一個新東西時,首先應該是先用起來,然後再去探究內部原理

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都被刪除

總結

  1. ThreadLocal直譯為執行緒本地變數,它的作用就是通過為每個執行緒單獨建立一個副本,來保證執行緒間的資料隔離和簡化方法間的傳參
  2. 資料隔離的本質:Thread內部持有ThreadLocalMap物件,建立的副本都是存在這裡,所以每個執行緒之間就實現了隔離
  3. 記憶體洩漏的問題:因為ThreadLocalMap中的key是弱引用,所以垃圾回收時,如果key指向的物件沒有強引用,那麼就會被回收,此時value還存在,但是取不出來,時間長了,就有問題(當然如果執行緒退出,那value還是會被回收)
  4. 使用場景:面試等場合

參考內容:

後記

其實這裡沒有很深入地去解析原始碼部分知識,主要是精力和能力有限,後面再慢慢深入吧

相關文章