Java進階(七)正確理解Thread Local的原理與適用場景

郭俊JasonGuo發表於2017-12-21

原創文章,轉載請務必將下面這段話置於文章開頭處(保留超連結)。
本文轉發自技術世界原文連結http://www.jasongj.com/java/threadlocal/

1. ThreadLocal解決什麼問題

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

1.1 不恰當的理解

寫這篇文章的一個原因在於,網上很多部落格關於 ThreadLocal 的適用場景以及解決的問題,描述的並不清楚,甚至是錯的。下面是常見的對於 ThreadLocal的介紹

ThreadLocal為解決多執行緒程式的併發問題提供了一種新的思路
ThreadLocal的目的是為了解決多執行緒訪問資源時的共享問題

還有很多文章在對比 ThreadLocal 與 synchronize 的異同。既然是作比較,那應該是認為這兩者解決相同或類似的問題。

上面的描述,問題在於,ThreadLocal 並不解決多執行緒 共享 變數的問題。既然變數不共享,那就更談不上同步的問題。

1.2 合理的理解

ThreadLoal 變數,它的基本原理是,同一個 ThreadLocal 所包含的物件(對ThreadLocal< String >而言即為 String 型別變數),在不同的 Thread 中有不同的副本(實際是不同的例項,後文會詳細闡述)。這裡有幾點需要注意
- 因為每個 Thread 內有自己的例項副本,且該副本只能由當前 Thread 使用。這是也是 ThreadLocal 命名的由來
- 既然每個 Thread 有自己的例項副本,且其它 Thread 不可訪問,那就不存在多執行緒間共享的問題
- 既無共享,何來同步問題,又何來解決同步問題一說?

那 ThreadLocal 到底解決了什麼問題,又適用於什麼樣的場景?

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable. ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).
Each thread holds an implicit reference to its copy of a thread-local variable as long as the thread is alive and the ThreadLocal instance is accessible; after a thread goes away, all of its copies of thread-local instances are subject to garbage collection (unless other references to these copies exist).

核心意思是

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

總的來說,ThreadLocal 適用於每個執行緒需要自己獨立的例項且該例項需要在多個方法中被使用,也即變數線上程間隔離而在方法或類間共享的場景。後文會通過例項詳細闡述該觀點。另外,該場景下,並非必須使用 ThreadLocal ,其它方式完全可以實現同樣的效果,只是 ThreadLocal 使得實現更簡潔。

2. ThreadLocal用法

2.1 例項程式碼

下面通過如下程式碼說明 ThreadLocal 的使用方式

public class ThreadLocalDemo {

  public static void main(String[] args) throws InterruptedException {

    int threads = 3;
    CountDownLatch countDownLatch = new CountDownLatch(threads);
    InnerClass innerClass = new InnerClass();
    for(int i = 1; i <= threads; i++) {
      new Thread(() -> {
        for(int j = 0; j < 4; j++) {
          innerClass.add(String.valueOf(j));
          innerClass.print();
        }
        innerClass.set("hello world");
        countDownLatch.countDown();
      }, "thread - " + i).start();
    }
    countDownLatch.await();

  }

  private static class InnerClass {

    public void add(String newStr) {
      StringBuilder str = Counter.counter.get();
      Counter.counter.set(str.append(newStr));
    }

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

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

  private static class Counter {

    private static ThreadLocal<StringBuilder> counter = new ThreadLocal<StringBuilder>() {
      @Override
      protected StringBuilder initialValue() {
        return new StringBuilder();
      }
    };

  }

}

2.2 例項分析

ThreadLocal本身支援範型。該例使用了 StringBuilder 型別的 ThreadLocal 變數。可通過 ThreadLocal 的 get() 方法讀取 StringBuidler 例項,也可通過 set(T t) 方法設定 StringBuilder。

上述程式碼執行結果如下

Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:0
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:0
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:0
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:01
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:01
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:012
Thread name:thread - 3 , ThreadLocal hashcode:372282300, Instance hashcode:1609588821, Value:0123
Set, Thread name:thread - 3 , ThreadLocal hashcode:372282300,  Instance hashcode:1362597339, Value:hello world
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:01
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:012
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:012
Thread name:thread - 1 , ThreadLocal hashcode:372282300, Instance hashcode:418873098, Value:0123
Thread name:thread - 2 , ThreadLocal hashcode:372282300, Instance hashcode:1780437710, Value:0123
Set, Thread name:thread - 1 , ThreadLocal hashcode:372282300,  Instance hashcode:482932940, Value:hello world
Set, Thread name:thread - 2 , ThreadLocal hashcode:372282300,  Instance hashcode:1691922941, Value:hello world

從上面的輸出可看出

  • 從第1-3行輸出可見,每個執行緒通過 ThreadLocal 的 get() 方法拿到的是不同的 StringBuilder 例項
  • 第1-3行輸出表明,每個執行緒所訪問到的是同一個 ThreadLocal 變數
  • 從7、12、13行輸出以及第30行程式碼可見,雖然從程式碼上都是對 Counter 類的靜態 counter 欄位進行 get() 得到 StringBuilder 例項並追加字串,但是這並不會將所有執行緒追加的字串都放進同一個 StringBuilder 中,而是每個執行緒將字串追加進各自的 StringBuidler 例項內
  • 對比第1行與第15行輸出並結合第38行程式碼可知,使用 set(T t) 方法後,ThreadLocal 變數所指向的 StringBuilder 例項被替換

3. ThreadLocal原理

3.1 ThreadLocal維護執行緒與例項的對映

既然每個訪問 ThreadLocal 變數的執行緒都有自己的一個“本地”例項副本。一個可能的方案是 ThreadLocal 維護一個 Map,鍵是 Thread,值是它在該 Thread 內的例項。執行緒通過該 ThreadLocal 的 get() 方案獲取例項時,只需要以執行緒為鍵,從 Map 中找出對應的例項即可。該方案如下圖所示

ThreadLocal side Map

該方案可滿足上文提到的每個執行緒內一個獨立備份的要求。每個新執行緒訪問該 ThreadLocal 時,需要向 Map 中新增一個對映,而每個執行緒結束時,應該清除該對映。這裡就有兩個問題:
- 增加執行緒與減少執行緒均需要寫 Map,故需保證該 Map 執行緒安全。雖然從ConcurrentHashMap的演進看Java多執行緒核心技術一文介紹了幾種實現執行緒安全 Map 的方式,但它或多或少都需要鎖來保證執行緒的安全性
- 執行緒結束時,需要保證它所訪問的所有 ThreadLocal 中對應的對映均刪除,否則可能會引起記憶體洩漏。(後文會介紹避免記憶體洩漏的方法)

其中鎖的問題,是 JDK 未採用該方案的一個原因。

3.2 Thread維護ThreadLocal與例項的對映

上述方案中,出現鎖的問題,原因在於多執行緒訪問同一個 Map。如果該 Map 由 Thread 維護,從而使得每個 Thread 只訪問自己的 Map,那就不存在多執行緒寫的問題,也就不需要鎖。該方案如下圖所示。

ThreadLocal side Map

該方案雖然沒有鎖的問題,但是由於每個執行緒訪問某 ThreadLocal 變數後,都會在自己的 Map 內維護該 ThreadLocal 變數與具體例項的對映,如果不刪除這些引用(對映),則這些 ThreadLocal 不能被回收,可能會造成記憶體洩漏。後文會介紹 JDK 如何解決該問題。

3.3 ThreadLocal 在 JDK 8 中的實現

3.3.1 ThreadLocalMap與記憶體洩漏

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

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 不能被回收而造成的記憶體洩漏的問題。

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

注:***Entry雖然是弱引用,但它是 ThreadLocal 型別的弱引用(也即上文所述它是對 * 的弱引用),而非具體例項的的弱引用,所以無法避免具體例項相關的記憶體洩漏。

3.3.2 讀取例項

讀取例項方法如下所示

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 變數在該執行緒中對應的具體例項的初始值。

3.3.3 設定初始值

設定初始值方法如下

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,也即該物件不會在多個執行緒中共享,也就不存線上程安全的問題。

3.3.4 設定例項

除了通過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 物件。

3.3.5 防止記憶體洩漏

對於已經不再被使用且已被回收的 ThreadLocal 物件,它在每個執行緒內對應的例項由於被執行緒的 ThreadLocalMap 的 Entry 強引用,無法被回收,可能會造成記憶體洩漏。

針對該問題,ThreadLocalMap 的 set 方法中,通過 replaceStaleEntry 方法將所有鍵為 null 的 Entry 的值設定為 null,從而使得該值可被回收。另外,會在 rehash 方法中通過 expungeStaleEntry 方法將鍵和值為 null 的 Entry 設定為 null 從而使得該 Entry 可被回收。通過這種方式,ThreadLocal 可防止記憶體洩漏。

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

4. 適用場景

如上文所述,ThreadLocal 適用於如下兩種場景

  • 每個執行緒需要有自己單獨的例項
  • 例項需要在多個方法中共享,但不希望被多執行緒共享

對於第一點,每個執行緒擁有自己例項,實現它的方式很多。例如可以線上程內部構建一個單獨的例項。ThreadLoca 可以以非常方便的形式滿足該需求。

對於第二點,可以在滿足第一點(每個執行緒有自己的例項)的條件下,通過方法間引用傳遞的形式實現。ThreadLocal 使得程式碼耦合度更低,且實現更優雅。

5. 案例

對於 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 = new ThreadLocal<Session>();

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

  public void createSession() {
    session.set(new Session());
  }

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

使用 ThreadLocal 改造後的程式碼,不再需要在各個方法間傳遞 Session 物件,並且也非常輕鬆的保證了每個執行緒擁有自己獨立的例項。

如果單看其中某一點,替代方法很多。比如可通過線上程內建立區域性變數可實現每個執行緒有自己的例項,使用靜態變數可實現變數在方法間的共享。但如果要同時滿足變數線上程間的隔離與方法間的共享,ThreadLocal再合適不過。

6. 總結

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

7. Java進階系列

相關文章