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

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

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

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

鎖的實現方案是在多執行緒寫入全域性變數時,通過排隊一個一個來寫入全域性變數,從而就可以避免執行緒不安全的問題了。比如當我們使用執行緒不安全的 SimpleDateFormat 對時間進行格式化時,如果使用鎖來解決執行緒不安全的問題,實現的流程就是這樣的:



從上述圖片可以看出,通過加鎖的方式雖然可以解決執行緒不安全的問題,但同時帶來了新的問題,使用鎖時執行緒需要排隊執行,因此會帶來一定的效能開銷。然而,如果使用的是 ThreadLocal 的方式,則是給每個執行緒建立一個 SimpleDateFormat 物件,這樣就可以避免排隊執行的問題了,它的實現流程如下圖所示:

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

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

什麼是記憶體溢位?

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

記憶體溢位程式碼演示

在開始演示 ThreadLocal 記憶體溢位的問題之前,我們先使用“-Xmx50m”的引數來設定一下 Idea,它表示將程式執行的最大記憶體設定為 50m,如果程式的執行超過這個值就會出現記憶體溢位的問題,設定方法如下:



設定後的最終效果這樣的:

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;
}
});
}
}

以上程式的執行結果如下:



從上述圖片可看出,當程式執行到第 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 相關物件的關係圖,如下所示:



也就是說它們之間的引用關係是這樣的: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();
}
}
});
}
}

以上程式的執行結果如下:



從上述結果可以看出我們只需要在 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中文社群」檢視更多有意思、漲知識的併發程式設計文章。