12.15 ThreadLocal到底是什麼?它解決了什麼問題?


ThreadLocal到底是什麼?它解決了什麼問題?

功能迭代,在代碼層面小編有1w種實現方法(吹牛的),一起來看看這次小編如何使用ThreadLocal優雅地完成本次迭代吧!

由於 ThreadLocal 支持範型,如 ThreadLocal< StringBuilder >,為表述方便,後文用 變量 代表 ThreadLocal 本身,而用 實例 代表具體類型(如 StringBuidler )的實例。

理解誤區

寫這篇文章之前,小編就在網上看了很多博客關於 ThreadLocal 的適用場景以及解決的問題,描述的都並不是很清楚,甚至誤人子弟的。比如下面是常見對於 ThreadLocal的介紹(wrong

ThreadLocal為解決多線程程序的併發問題提供了一種新的思路;
ThreadLocal的目的是為了解決多線程訪問資源時的共享問題。

在小編大量閱讀和動手實驗後得出結論:ThreadLocal 並不是像上面所說為了解決多線程 共享變量的問題。

正確理解

ThreadLoal 變量,它的基本原理是,同一個 ThreadLocal 所包含的對象(對ThreadLocal< StringBuilder >而言即為 StringBuilder 類型變量),在不同的 Thread 中有不同的副本(實際上是不同的實例):

  • 因為每個 Thread 內有自己的實例副本,且該副本只能由當前 Thread 使用;
  • 既然其它 Thread 不可訪問,那就不存在多線程間共享的問題。

官方文檔是這樣描述的:

ThreadLocal到底是什麼?它解決了什麼問題?

我看完之後,得出這樣的結論

ThreadLocal 提供了線程本地的實例。它與普通變量的區別在於,每個使用該變量的線程都會初始化一個完全獨立的實例副本。ThreadLocal 變量通常被private static修飾。當一個線程結束時,它所使用的所有 ThreadLocal 相對的實例副本都會被回收。

因此ThreadLocal 非常適用於這樣的場景:每個線程需要自己獨立的實例且該實例需要在多個方法中使用。當然,使用其它方式也可以實現同樣的效果,但是看完這篇文章,你會發現 ThreadLocal 會讓實現更簡潔、更優雅!

ThreadLocal用法

實例代碼

我們通過下面的代碼,先做個示例,然後分析一下現象,得出一個結論:

public class ThreadLocalDemo {

public static void main(String[] args) throws InterruptedException {
int threadNum = 3;
CountDownLatch countDownLatch = new CountDownLatch(threadNum);
for (int i = 1; i <= threadNum; i++) {
new Thread(() -> {
for (int j = 0; j <= 2; j++) {
MyUtil.add(String.valueOf(j));
MyUtil.print();
}
MyUtil.set("hello world");

countDownLatch.countDown();
}, "thread - " + i).start();
}
countDownLatch.await();
}

private static class MyUtil {

public static void add(String newStr) {
StringBuilder str = StringBuilderUtil.stringBuilderThreadLocal.get();
StringBuilderUtil.stringBuilderThreadLocal.set(str.append(newStr));
}

public static void print() {
System.out.printf("Thread name:%s , ThreadLocal hashcode:%s, Instance hashcode:%s, Value:%s\\n",
Thread.currentThread().getName(),
StringBuilderUtil.stringBuilderThreadLocal.hashCode(),
StringBuilderUtil.stringBuilderThreadLocal.get().hashCode(),
StringBuilderUtil.stringBuilderThreadLocal.get().toString());
}

public static void set(String words) {
StringBuilderUtil.stringBuilderThreadLocal.set(new StringBuilder(words));
System.out.printf("Set, Thread name:%s , ThreadLocal hashcode:%s, Instance hashcode:%s, Value:%s\\n",
Thread.currentThread().getName(),
StringBuilderUtil.stringBuilderThreadLocal.hashCode(),
StringBuilderUtil.stringBuilderThreadLocal.get().hashCode(),
StringBuilderUtil.stringBuilderThreadLocal.get().toString());
}
}

private static class StringBuilderUtil {
// ThreadLocal 變量通常被 private static 修飾
private static ThreadLocal<stringbuilder> stringBuilderThreadLocal = ThreadLocal.withInitial(() -> new StringBuilder());
}

}/<stringbuilder>

實例分析

ThreadLocal本身支持範型,比如該例使用了 StringBuilder 類型的 ThreadLocal 變量。可通過 ThreadLocal 的 get() 方法讀取 StringBuidler 實例,也可通過 set(T t) 方法設置 StringBuilder。

tips:CountDownLatch類位於java.util.concurrent包下,利用它可以實現類似計數器的功能。比如有一個場景:任務A,它要等待其他4個任務執行完畢之後才能執行,此時就可以利用CountDownLatch來實現這種功能了。下次,我們可以單獨聊聊這一個功能。

點擊運行,控制檯輸出結果

ThreadLocal到底是什麼?它解決了什麼問題?

我們可以發現:

  • 每個線程訪問的是同一個 ThreadLocal 變量,而通過 ThreadLocal 的 get() 方法拿到的是不同的 StringBuilder 實例;
  • 雖然從代碼上都是對 StringBuilderUtil 類的靜態 stringBuilderThreadLocal 字段進行 get() 得到 StringBuilder 實例並追加字符串,但是這並不會將所有線程追加的字符串都放進同一個 StringBuilder 中,而是每個線程將字符串追加進各自的 StringBuidler 實例內
  • 使用 set(T t) 方法後,ThreadLocal 變量所指向的 StringBuilder 實例被替換

ThreadLocal原理

方案一

我們大膽猜想一下,既然每個訪問 ThreadLocal 變量的線程都有自己的一個“本地”實例副本。一個可能的方案是 ThreadLocal 維護一個 Map,Key 是當前線程,Value是ThreadLocal在當前線程內的實例。這樣,線程通過該 ThreadLocal 的 get() 方案獲取實例時,只需要以線程為鍵,從 Map 中找出對應的實例即可。該方案如下圖所示

ThreadLocal到底是什麼?它解決了什麼問題?

這個方案可以滿足上文提到的每個線程內部都有一個ThreadLocal 實例備份的要求。每個新線程訪問該 ThreadLocal 時,都會向 Map 中添加一個新的映射,而當每個線程結束時再清除該線程對應的映射。But,這樣就存在兩個問題:

  • 開啟線程與結束線程時我們都需要及時更新 Map,因此必需保證 Map 的線程安全。
  • 當線程結束時,需要保證它所訪問的所有 ThreadLocal 中對應的映射均刪除,否則可能會引起內存洩漏。

線程安全問題是JDK 未採用該方案的一個主要原因。

方案二

上面這個方案,存在多線程訪問同一個 Map時可能會出現的同步問題。如果該 Map 由 Thread 維護,從而使得每個 Thread 只訪問自己的 Map,就不存在這個問題。該方案如下圖所示。

ThreadLocal到底是什麼?它解決了什麼問題?

該方案雖然沒有鎖的問題,但是由於每個線程在訪問ThreadLocal 變量後,都會在自己的 Map 內維護該 ThreadLocal 變量與具體實例的映射,如果不刪除這些引用(映射),就有可能會造成內存洩漏的問題。我們一起來看一下Jdk8是如何解決這個問題的。

ThreadLocal 在 JDK 8 中的實現

ThreadLocalMap與內存洩漏

在該方案中,Map 由 ThreadLocal 類的靜態內部類 ThreadLocalMap 提供。該類的實例維護某個 ThreadLocal 與具體實例的映射。與 HashMap 不同的是,ThreadLocalMap 的每個 Entry 都是一個對 Key 的弱引用,這一點我們可以從super(k)可看出。另外,每個 Entry 中都包含了一個對 Value 的強引用。

static class Entry extends WeakReference<threadlocal>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal> k, Object v) {
super(k);
value = v;
}
}/<threadlocal>

之所以使用弱引用,是因為當沒有強引用指向 ThreadLocal 變量時,這個變量就可以被回收,就避免ThreadLocal 因為不能被回收而造成的內存洩漏的問題。

但是,這裡又可能出現另外一種內存洩漏的問題。ThreadLocalMap 維護 ThreadLocal 變量與具體實例的映射,當 ThreadLocal 變量被回收後,該映射的鍵變為 null,該 Entry 無法被移除。從而使得實例被該 Entry 引用而無法被回收造成內存洩漏。

注意:Entry是對 ThreadLocal 類型的弱引用,並不是具體實例的弱引用,因此還存在具體實例相關的內存洩漏的問題。

讀取實例

我們來看一下ThreadLocal獲取實例的方法

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

當線程獲取實例時,首先會通過getMap(t)方法獲取自身的 ThreadLocalMap。從如下該方法的定義可見,該 ThreadLocalMap 的實例是 Thread 類的一個字段,即由 Thread 維護 ThreadLocal 對象與具體實例的映射,這一點與上文分析一致。

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;

}

獲取到 ThreadLocalMap 後,通過map.getEntry(this)方法獲取該 ThreadLocal 在當前線程的 ThreadLocalMap 中對應的 Entry。該方法中的 this 即當前訪問的 ThreadLocal 對象。

如果獲取到的 Entry 不為 null,從 Entry 中取出值即為所需訪問的本線程對應的實例。如果獲取到的 Entry 為 null,則通過setInitialValue()方法設置該 ThreadLocal 變量在該線程中對應的具體實例的初始值。

設置初始值

設置初始值方法如下

private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

該方法為 private 方法,無法被重載。

首先,通過initialValue()方法獲取初始值。該方法為 public 方法,且默認返回 null。所以典型用法中常常重載該方法。上例中即在內部匿名類中將其重載。

然後拿到該線程對應的 ThreadLocalMap 對象,若該對象不為 null,則直接將該 ThreadLocal 對象與對應實例初始值的映射添加進該線程的 ThreadLocalMap中。若為 null,則先創建該 ThreadLocalMap 對象再將映射添加其中。

這裡並不需要考慮 ThreadLocalMap 的線程安全問題。因為每個線程有且只有一個 ThreadLocalMap 對象,並且只有該線程自己可以訪問它,其它線程不會訪問該 ThreadLocalMap,也即該對象不會在多個線程中共享,也就不存在線程安全的問題。

設置實例

除了通過initialValue()方法設置實例的初始值,還可通過 set 方法設置線程內實例的值,如下所示。

public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

該方法先獲取該線程的 ThreadLocalMap 對象,然後直接將 ThreadLocal 對象(即代碼中的 this)與目標實例的映射添加進 ThreadLocalMap 中。當然,如果映射已經存在,就直接覆蓋。另外,如果獲取到的 ThreadLocalMap 為 null,則先創建該 ThreadLocalMap 對象。

防止內存洩漏

對於已經不再被使用且已被回收的 ThreadLocal 對象,它在每個線程內對應的實例由於被線程的 ThreadLocalMap 的 Entry 強引用,無法被回收,可能會造成內存洩漏。

針對該問題,ThreadLocalMap 的 set 方法中,通過 replaceStaleEntry 方法將所有鍵為 null 的 Entry 的值設置為 null,從而使得該值可被回收。另外,會在 rehash 方法中通過 expungeStaleEntry 方法將鍵和值為 null 的 Entry 設置為 null 從而使得該 Entry 可被回收。

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();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}

案例

對於 Java Web 應用而言,Session 保存了很多信息。很多時候需要通過 Session 獲取信息,有些時候又需要修改 Session 的信息。一方面,需要保證每個線程有自己單獨的 Session 實例。另一方面,由於很多地方都需要操作 Session,存在多方法共享 Session 的需求。如果不使用 ThreadLocal,可以在每個線程內構建一個 Session實例,並將該實例在多個方法間傳遞,如下所示。

public class SessionHandler {

@Data
public static class Session {
private String id;
private String user;
private String status;
}

public Session createSession() {

return new Session();
}

public String getUser(Session session) {
return session.getUser();
}

public String getStatus(Session session) {
return session.getStatus();
}

public void setStatus(Session session, String status) {
session.setStatus(status);
}

public static void main(String[] args) {
new Thread(() -> {
SessionHandler handler = new SessionHandler();
Session session = handler.createSession();
handler.getStatus(session);
handler.getUser(session);
handler.setStatus(session, "close");
handler.getStatus(session);
}).start();
}
}

該方法是可以實現需求的。但是每個需要使用 Session 的地方,都需要顯式傳遞 Session 對象,方法間耦合度較高,給人的感覺並不優雅。

這裡使用 ThreadLocal 重新實現該功能如下所示。

public class SessionHandler {

public static ThreadLocal<session> session = ThreadLocal.<session>withInitial(() -> new Session());

@Data
public static class Session {
private String id;
private String user;
private String status;
}

public String getUser() {
return session.get().getUser();

}

public String getStatus() {
return session.get().getStatus();
}

public void setStatus(String status) {
session.get().setStatus(status);
}

public static void main(String[] args) {
new Thread(() -> {
SessionHandler handler = new SessionHandler();
handler.getStatus();
handler.getUser();
handler.setStatus("close");
handler.getStatus();
}).start();
}
}/<session>/<session>

可以看到,改造過後的代碼,不再需要在各個方法間來回傳遞 Session 對象,並且不費吹灰之力保證了每個線程都能夠擁有自己獨立的實例。雖然單看其中某一點,備選方案還很多。比如還可以通過在線程內創建局部變量保證每個線程有自己的實例,通過靜態變量可實現變量在方法間的共享。但如果還需要同時滿足變量在線程間的隔離與方法間的共享,ThreadLocal再合適不過。

總結

  • ThreadLocal 並不解決線程間共享數據的問題
  • ThreadLocal 通過隱式的在不同線程內創建獨立實例副本避免了實例線程安全的問題
  • 每個線程持有一個 Map 並維護了 ThreadLocal 對象與具體實例的映射,該 Map 由於只被持有它的線程訪問,故不存在線程安全以及鎖的問題
  • ThreadLocalMap 的 Entry 對 ThreadLocal 的引用為弱引用,避免了 ThreadLocal 對象無法被回收的問題
  • ThreadLocalMap 的 set 方法通過調用 replaceStaleEntry 方法回收鍵為 null 的 Entry 對象的值(即為具體實例)以及 Entry 對象本身從而防止內存洩漏
  • ThreadLocal 適用於變量在線程間隔離且在方法間共享的場景

關注我,後續更多幹貨奉上!


分享到:


相關文章: