並行程式設計之 ThreadLocal

2022-11-01 12:03:10

前言

瞭解過 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();
}