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中文社羣」查看更多有意思、漲知識的併發編程文章。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章