Java並發:ThreadLocal的簡單介紹


作者:湯圓

個人博客: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. 使用場景:面試等場合

參考內容:

后記

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM