ThreadLocal記憶體溢出程式碼演示和原因分析!

ThreadLocal 翻譯成中文是執行緒本地變數的意思,也就是說它是執行緒中的私有變數,每個執行緒只能操作自己的私有變數,所以不會造成執行緒不安全的問題。

執行緒不安全是指,多個執行緒在同一時刻對同一個全局變數做寫操作時(讀操作不會涉及執行緒不安全問題),如果執行的結果和我們預期的結果不一致就稱之為執行緒不安全,反之,則稱為執行緒安全。

在 Java 語言中解決執行緒不安全的問題通常有兩種手段

  1. 使用鎖(使用 synchronized 或 Lock);
  2. 使用 ThreadLocal。

鎖的實現方案是在多執行緒寫入全局變數時,通過排隊一個一個來寫入全局變數,從而就可以避免執行緒不安全的問題了。比如當我們使用執行緒不安全的 SimpleDateFormat 對時間進行格式化時,如果使用鎖來解決執行緒不安全的問題,實現的流程就是這樣的:
image.png
從上述圖片可以看出,通過加鎖的方式雖然可以解決執行緒不安全的問題,但同時帶來了新的問題,使用鎖時執行緒需要排隊執行,因此會帶來一定的性能開銷。然而,如果使用的是 ThreadLocal 的方式,則是給每個執行緒創建一個 SimpleDateFormat 對象,這樣就可以避免排隊執行的問題了,它的實現流程如下圖所示:
image.png

PS:創建 SimpleDateFormat 也會消耗一定的時間和空間,如果執行緒復用 SimpleDateFormat 的頻率比較高的情況下,使用 ThreadLocal 的優勢比較大,反之則可以考慮使用鎖。

然而,在我們使用 ThreadLocal 的過程中,很容易就會出現記憶體溢出的問題,如下面的這個事例。

什麼是記憶體溢出?

記憶體溢出(Out Of Memory,簡稱 OOM)是指無用對象(不再使用的對象)持續佔有記憶體,或無用對象的記憶體得不到及時釋放,從而造成的記憶體空間浪費的行為就稱之為記憶體泄露。

記憶體溢出程式碼演示

在開始演示 ThreadLocal 記憶體溢出的問題之前,我們先使用「-Xmx50m」的參數來設置一下 Idea,它表示將程式運行的最大記憶體設置為 50m,如果程式的運行超過這個值就會出現記憶體溢出的問題,設置方法如下:
image.png
設置後的最終效果這樣的:
image.png

PS:因為我使用的 Idea 是社區版,所以可能和你的介面不一樣,你只需要點擊「Edit Configurations…」找到「VM options」選項,設置上「-Xmx50m」參數就可以了。

配置完 Idea 之後,接下來我們來實現一下業務程式碼。在程式碼中我們會創建一個大對象,這個對象中會有一個 10m 大的數組,然後我們將這個大對象存儲在 ThreadLocal 中,再使用執行緒池執行大於 5 次添加任務,因為設置了最大運行記憶體是 50m,所以理想的情況是執行 5 次添加操作之後,就會出現記憶體溢出的問題,實現程式碼如下:

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class ThreadLocalOOMExample {
    
    /**
     * 定義一個 10m 大的類
     */
    static class MyTask {
        // 創建一個 10m 的數組(單位轉換是 1M -> 1024KB -> 1024*1024B)
        private byte[] bytes = new byte[10 * 1024 * 1024];
    }
    
    // 定義 ThreadLocal
    private static ThreadLocal<MyTask> taskThreadLocal = new ThreadLocal<>();

    // 主測試程式碼
    public static void main(String[] args) throws InterruptedException {
        // 創建執行緒池
        ThreadPoolExecutor threadPoolExecutor =
                new ThreadPoolExecutor(5, 5, 60,
                        TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
        // 執行 10 次調用
        for (int i = 0; i < 10; i++) {
            // 執行任務
            executeTask(threadPoolExecutor);
            Thread.sleep(1000);
        }
    }

    /**
     * 執行緒池執行任務
     * @param threadPoolExecutor 執行緒池
     */
    private static void executeTask(ThreadPoolExecutor threadPoolExecutor) {
        // 執行任務
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("創建對象");
                // 創建對象(10M)
                MyTask myTask = new MyTask();
                // 存儲 ThreadLocal
                taskThreadLocal.set(myTask);
                // 將對象設置為 null,表示此對象不在使用了
                myTask = null;
            }
        });
    }
}

以上程式的執行結果如下:
image.png
從上述圖片可看出,當程式執行到第 5 次添加對象時就出現記憶體溢出的問題了,這是因為設置了最大的運行記憶體是 50m,每次循環會佔用 10m 的記憶體,加上程式啟動會佔用一定的記憶體,因此在執行到第 5 次添加任務時,就會出現記憶體溢出的問題。

原因分析

記憶體溢出的問題和解決方案比較簡單,重點在於「原因分析」,我們要通過記憶體溢出的問題搞清楚,為什麼 ThreadLocal 會這樣?是什麼原因導致了記憶體溢出?

要搞清楚這個問題(記憶體溢出的問題),我們需要從 ThreadLocal 源碼入手,所以我們首先打開 set 方法的源碼(在示例中使用到了 set 方法),如下所示:

public void set(T value) {
    // 得到當前執行緒
    Thread t = Thread.currentThread();
    // 根據執行緒獲取到 ThreadMap 變數
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value); // 將內容存儲到 map 中
    else
        createMap(t, value); // 創建 map 並將值存儲到 map 中
}

從上述程式碼我們可以看出 Thread、ThreadLocalMap 和 set 方法之間的關係:每個執行緒 Thread 都擁有一個數據存儲容器 ThreadLocalMap,當執行 ThreadLocal.set 方法執行時,會將要存儲的值放到 ThreadLocalMap 容器中,所以接下來我們再看一下 ThreadLocalMap 的源碼:

static class ThreadLocalMap {
    // 實際存儲數據的數組
    private Entry[] table;
    // 存數據的方法
    private void set(ThreadLocal<?> key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1);
        for (Entry e = tab[i];
                e != null;
                e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
            // 如果有對應的 key 直接更新 value 值
            if (k == key) {
                e.value = value;
                return;
            }
            // 發現空位插入 value
            if (k == null) {
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        // 新建一個 Entry 插入數組中
        tab[i] = new Entry(key, value);
        int sz = ++size;
        // 判斷是否需要進行擴容
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }
    // ... 忽略其他源碼
}

從上述源碼我們可以看出:ThreadMap 中有一個 Entry[] 數組用來存儲所有的數據,而 Entry 是一個包含 key 和 value 的鍵值對,其中 key 為 ThreadLocal 本身,而 value 則是要存儲在 ThreadLocal 中的值

根據上面的內容,我們可以得出 ThreadLocal 相關對象的關係圖,如下所示:
image.png
也就是說它們之間的引用關係是這樣的:Thread -> ThreadLocalMap -> Entry -> Key,Value,因此當我們使用執行緒池來存儲對象時,因為執行緒池有很長的生命周期,所以執行緒池會一直持有 value 值,那麼垃圾回收器就無法回收 value,所以就會導致記憶體一直被佔用,從而導致記憶體溢出問題的發生

解決方案

ThreadLocal 記憶體溢出的解決方案很簡單,我們只需要在使用完 ThreadLocal 之後,執行 remove 方法就可以避免記憶體溢出問題的發生了,比如以下程式碼:

import java.util.concurrent.LinkedBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class App {

    /**
     * 定義一個 10m 大的類
     */
    static class MyTask {
        // 創建一個 10m 的數組(單位轉換是 1M -> 1024KB -> 1024*1024B)
        private byte[] bytes = new byte[10 * 1024 * 1024];
    }

    // 定義 ThreadLocal
    private static ThreadLocal<MyTask> taskThreadLocal = new ThreadLocal<>();

    // 測試程式碼
    public static void main(String[] args) throws InterruptedException {
        // 創建執行緒池
        ThreadPoolExecutor threadPoolExecutor =
                new ThreadPoolExecutor(5, 5, 60,
                        TimeUnit.SECONDS, new LinkedBlockingQueue<>(100));
        // 執行 n 次調用
        for (int i = 0; i < 10; i++) {
            // 執行任務
            executeTask(threadPoolExecutor);
            Thread.sleep(1000);
        }
    }

    /**
     * 執行緒池執行任務
     * @param threadPoolExecutor 執行緒池
     */
    private static void executeTask(ThreadPoolExecutor threadPoolExecutor) {
        // 執行任務
        threadPoolExecutor.execute(new Runnable() {
            @Override
            public void run() {
                System.out.println("創建對象");
                try {
                    // 創建對象(10M)
                    MyTask myTask = new MyTask();
                    // 存儲 ThreadLocal
                    taskThreadLocal.set(myTask);
                    // 其他業務程式碼...
                } finally {
                    // 釋放記憶體
                    taskThreadLocal.remove();
                }
            }
        });
    }
}

以上程式的執行結果如下:
image.png
從上述結果可以看出我們只需要在 finally 中執行 ThreadLocal 的 remove 方法之後就不會在出現記憶體溢出的問題了。

remove的秘密

那 remove 方法為什麼會有這麼大的魔力呢?我們打開 remove 的源碼看一下:

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

從上述源碼中我們可以看出,當調用了 remove 方法之後,會直接將 Thread 中的 ThreadLocalMap 對象移除掉,這樣 Thread 就不再持有 ThreadLocalMap 對象了,所以即使 Thread 一直存活,也不會造成因為(ThreadLocalMap)記憶體佔用而導致的記憶體溢出問題了。

總結

本篇我們使用程式碼的方式演示了 ThreadLocal 記憶體溢出的問題,嚴格來講記憶體溢出並不是 ThreadLocal 的問題,而是因為沒有正確使用 ThreadLocal 所帶來的問題。想要避免 ThreadLocal 記憶體溢出的問題,只需要在使用完 ThreadLocal 後調用 remove 方法即可。不過通過 ThreadLocal 記憶體溢出的問題,讓我們搞清楚了 ThreadLocal 的具體實現,方便我們日後更好的使用 ThreadLocal,以及更好的應對面試。

關注公號「Java中文社群」查看更多有意思、漲知識的並發編程文章。

Tags: