並發編程之 ThreadLocal

前言

了解過 SimpleDateFormat 時間工具類的朋友都知道,該工具類非常好用,可以利用該類可以將日期轉換成文本,或者將文本轉換成日期,時間戳同樣也可以。

以下程式碼,我們採用通用的 SimpleDateFormat 對象,在執行緒池 threadPool 中,將對應的 i 值調用 sec2Date 方法來實現日期轉換,並且 sec2Date 方法是用 synchronized 修飾的,在多執行緒競爭的場景下,來達到執行緒安全的目的。

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");

    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new ThreadLocal2().sec2Date(finalI)));
        }
        threadPool.shutdown();
    }

    private synchronized String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format = dateFormat.format(date);
        return format;
    }

}

輸出結果:

但是在結果中,我們不難看出,還是會輸出重複值,即使我們用了 synchronized 修飾方法,還是會出現執行緒不安全的情況。之所以出現這種現象,並非是我們編寫的程式碼出了問題,畢竟在我們平時開發中,通過 synchronized 關鍵字確實能達到執行緒安全的目的,這裡其實是 SimpleDateFormat 內部並不是執行緒安全的 導致的。

主要原因:當兩個及以上執行緒同時使用相同的 SimpleDateFormat 對象(如 static 修飾)的話,就拿上面調用的 format 方法時,format 方法內部就會出現多個執行緒會同時調用 calendar.setTime 方法時,在多執行緒競爭的情況下,發生幻讀,就會導致重複值的發生。

下面,我們去看下 SimpleDateFormat 的 format 源碼,去探究下為什麼會執行緒不安全。

以上源碼就是 SimpleDateFormat 類下的 format 方法的源碼,我們不需要過多了解裡面具體的實現細節,我們只需要關注紅色框住的內容,即 calendar.setTime(date);,該 calendar 是 SimpleDateFormat 的父類 DateFormat 定義的一個成員變數。

由此我們可以得到一個結論:在多執行緒競爭的情況下,它們就會共享這個 calendar 成員變數,並去調用它的 calendar.setTime(date) 修改值,這樣就會導致 date 變數被其他執行緒給修改或覆蓋掉,就會導致最終的結果會出現重複的情況,因此 SimpleDateFormat 是執行緒不安全的。

解決方案一:我們只需要用 synchronized 直接修飾 dateFormat 變數,讓每次只有一個執行緒能夠操作 dateFormat 的權利,說白了就是讓 synchronized 修飾的這塊程式碼去串列執行,就可以避免發生執行緒不安全的情況。

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");


    public static void main(String[] args) {

        for (int i = 0; i < 1000; i++) {

            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new SynchronizedTest().sec2Date(finalI)));

        }
        threadPool.shutdown();

    }

    private String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format;
        synchronized (dateFormat) {
            format = dateFormat.format(date);
        }
        return format;
    }

}

解決方案二:原理如同方案一相同(一個是鎖住 dateFormat 變數,另一個是鎖著整個 SynchronizedTest 類 )

public class SynchronizedTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    public static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");


    public static void main(String[] args) {

        for (int i = 0; i < 1000; i++) {

            int finalI = i;
            threadPool.submit(() -> System.out.println(finalI + "---" + new SynchronizedTest().sec2Date(finalI)));

        }
        threadPool.shutdown();

    }

    private String sec2Date(int seconds) {
        Date date = new Date(seconds * 1000L);
        String format;
        synchronized (SynchronizedTest.class) {
            format = dateFormat.format(date);
        }
        return format;
    }

}

但是加 synchronized 這種方式雖然也能保證執行緒安全,但是這種方式效率會比較低,畢竟同一時刻下,只能有一個執行緒能夠執行程式,這顯然不是最好的方案,下面我們來了解下更高效的方式,就是利用 ThreadLocal 類來實現。

ThreadLocal

介紹:每個執行緒需要一個獨享的對象,每個 Thread 內有自己的實例副本,這些實例副本是不共享的,讓某個需要用到的對象在執行緒間隔離,即每個執行緒都有自己的獨立的對象。

使用ThreadLocal 的好處

  • 達到執行緒安全
  • 不需要加鎖,提高執行效率
  • 合理利用記憶體,節省開銷

以下程式碼,我們構建了一個內部類 ThreadSafeFormatter 類,在類內部定義 ThreadLocal 的成員變數,並重寫了 initialValue 方法,返回的參數就是 new 出來的 SimpleDateFormat 對象。

public class ThreadLocalTest {

    public static ExecutorService threadPool = Executors.newFixedThreadPool(10);
    
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            int finalI = i;
            threadPool.submit(() -> System.out.println(new ThreadLocalTest().sec2Date(finalI)));
        }
    }

    private String sec2Date(int seconds) {
        //	在 ThreadLocal 第一個 get 的時候把對象初始化出來,對象的初始化時機可以由我們控制
        SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal2.get();
        return dateFormat.format(seconds * 1000);
    }

    static class ThreadSafeFormatter {
        //	方式一(原始方式)
        public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal = new ThreadLocal<SimpleDateFormat>() {
            // 初始化
            @Override
            protected SimpleDateFormat initialValue() {
                return new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
            }
        };
        //	方式二(Lambda表達式)
        public static ThreadLocal<SimpleDateFormat> dateFormatThreadLocal2 = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
    }
}

輸出結果:

結果中我們可以看出,沒有輸出重複的時間值(可以多運行幾次觀察下),因此我們通過 ThreadLocal 這種方式就達到了執行緒安全,並且還節省了系統的開銷,合理利用了記憶體。

由此我們可以得到一個結論:每個執行緒的 SimpleDateFormat 是獨立的,一共有 10 個。每個執行緒會平均執行 100 個任務,每個執行緒之間都是復用一個 SimpleDateFormat 對象。

ThreadLocal 源碼分析

在了解 ThreadLocal 源碼之前,我們先了解以下 Thread,ThreadLocalMap 以及 ThreadLocal 三者之間的關係。

首先,我們創建的每一個 Thread 對象中都持有一個 ThreadLocalMap 成員變數,而 ThreadLocalMap 中可以存放著很多的 key 為 ThreadLocal 的鍵值對。

主要方法介紹

  • T initialValue() : 初始化,返回當前執行緒對應的「初始值」,這是一個延遲載入的方法,只有在調用get的時候,才會觸發。
  • void set(T t) : 為這個執行緒設置一個新值。
  • T get() : 得到這個執行緒對應的value。如果是首次調用 get() ,則會調用 initialize 來得到這個值。
  • void remove() :刪除對應這個執行緒的值。

initialValue

SimpleDateFormat dateFormat = ThreadSafeFormatter.dateFormatThreadLocal2.get();

在上述程式碼,我們並沒有顯式地調用這個 initialValue 方法,而是調用了 get 方法,而在 get 方法中,它會去調用

setInitialValue 方法,在 該方法內部它才會去調用我們重寫的 initialValue 方法。

如果沒有重寫 initialValue 時,默認會返回 null

如果執行緒先前調用了set方法,在這種情況下,不會為執行緒調用本 initialValue 方法,而是直接用之前 set 進去的值。

在通常情況下,每個執行緒最多只能調用一次 initialValue 方法,但是如果已經調用了 remove 方法之後,再調用 get 方法,則可以再次調用 initialValue 方法。

get

get 方法是先取出當前執行緒的 ThreadLocalMap ,然後調用 map.getEntry 方法,把本 ThreadLocal 的引用作為參數傳入,取出 map 中屬於本 ThreadLocal 的value。

public T get() {
    //	獲取當前執行緒
    Thread t = Thread.currentThread();
    //	獲取當前執行緒的  threadLocals 成員變數
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // this 指的是 ThreadLocal 對象,通過 map.getEntry 來獲取我們通過 set 方法設置進去的 value 值
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

set

跟 get 一樣,同樣是先獲取當前執行緒的引用,然後再獲取當前執行緒的 threadLocals 成員變數,如果 threadLocals 為null ,即還未初始化,就會執行 createMap 方法來進行初始化。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        //	this 指的是 ThreadLocal 對象,value 就是想要設置進去的值
        map.set(this, value);
    else
        createMap(t, value);
}
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

map.set(this, value); 需要注意的是,這個 map 以及 map 中的 key 和 value 都是保存在 Thread 執行緒中的,而不是保存在 ThreadLocal 中。

remove

原理跟 get 和 set 類似,這裡就不贅述了。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

ThreadLocal 的記憶體泄露

記憶體泄漏:當某個對象不再有引用,但是所佔用的記憶體不能被回收。

下面我們來看 ThreadLocal 的靜態內部類 ThreadLocalMap ,ThreadLocalMap 的 Entry 其實就是存放每一個ThreadLocal 和 value 鍵值對的集合。

Entry 靜態類的構造方法,分別執行了 super(k); value = v; 其中 super(k) 去父類中進行初始化,而從 Entry extends 的父類我們可以看出,WeakReference 父類是一個弱引用類,則說明了 k 值是一個弱引用的, 而 value 就是一個強引用。

強引用:任何時候都不會被回收,即使發生 GC 的時候也不會被回收(賦值就是一種強引用)

弱引用:對象只被弱引用關聯,在下一次 GC 時會被回收。(可以理解為只要觸發一次GC,就可以掃描到並被回收掉)

由此我們可以得知,ThreadLocalMap 的每一個 Entry 都是一個對 key 的弱引用,但是每一個 Entry 都包含了一個對 value 的強引用。而由於執行緒池中的執行緒池存活時間都比較長,那麼 Entry 的 key 是可以被回收掉的,但是 value 無法被回收,就會發生記憶體泄漏。

JDK 的設計者也考慮到了這個不足之處,所以在經常調用的方法,比如 set, remove, rehash 會主動去掃描 key 為 null 的 Entry,並把對應的 value 設置 null,這樣 value 對象也可以被 GC 給回收掉。

另外在阿里巴巴 Java 開發手冊也明確指出,應該顯式地調用 remove 方法,刪除 Entry 對象,避免記憶體泄漏。

【強制】 必須回收自定義的 ThreadLocal 變數,尤其在執行緒池場景下,執行緒經常會被複用,如果不清理自定義的 ThreadLocal 變數,可能會影響到後續業務邏輯和造成記憶體泄漏等問題。盡量在程式碼中使用 try-finally 塊進行回收。

objThreadLocal.set(someObject);
try{
	...
} finally {
 objThreadLocal.remove();
}
Tags: