ThreadLocal 理解與應用 ThreadLocal 理解與應用

ThreadLocal 理解與應用

在併發編程中,我們主要考慮的問題是多個線程對於共享數據的訪問,並在訪問共享數據時保證線程安全。如果我們希望每個線程都有一個共享變量的副本,並且對這個副本進行讀寫時不影響其他的線程該如何做呢?

JDK 爲我們提供了ThreadLocal類來解決線程與數據綁定的需求。如果說synchronizedvolatile關鍵字保證了線程間的數據共享(可見性),那麼ThraedLocal類就是保證線程間的數據隔離。爲什麼這麼說呢?

volatilesynchronized保證共享數據在不同的線程中是可見的,一個線程對共享數據的改變其他線程也能觀察到,通過同步機制來保證線程安全。而ThreadLocal類則提供了另一個保證線程安全的處理思路:

每個線程持有共享變量的一個副本,並且與線程綁定,這樣每個線程對這個變量的讀寫都在自己線程內部,對線程來說,這個變量是屬於線程私有的,不會對其他線程有影響,避免出現數據不一致的情況,也就是線程間的數據是隔離的,互不相干的。

什麼是線程局部變量(thread-local variable)?

線程局部變量就是爲每一個使用該變量的線程都提供一個變量值的副本,該副本是線程私有的,不同的線程持有不同的副本,每個線程都可以獨立的改變這個副本並且不會和其他的線程起衝突。這是 Java 中較爲特殊的線程綁定機制,從而爲多線程環境中常出現的併發訪問問題提供了一種數據隔離機制。

如何使用 ThreadLocal

  1. 創建 ThreadLocal 實例

        public static ThreadLocal<Integer> threadLocalData = new ThreadLocal<>();
        public static void main(String[] args) {
        System.out.println(threadLocalData.get());//輸出爲Null
    }
    

    一般將 ThreadLocal 變量聲明爲公開的靜態字段,方便線程對其進行訪問,需要注意的是:直接使用構造方法聲明 ThreadLocal 對象後,對象的初始值爲 null。

  2. ThreadLocal 對象初始化值
    ThreadLocal類提供了初始化接口

    protected T initialValue()
    

    我們可以通過繼承 ThreadLocal 類並複寫initialValue()方法提供初始值,提供的初始值對所有線程都是可見的。

     public static ThreadLocal<Integer> threadLocalData = new ThreadLocal<Integer>() {
        @Override
        protected Integer initialValue() {
            return 1;
        }
    };
    
    public static void main(String[] args) {
        System.out.println(threadLocalData.get());//輸出爲1
    }
    
    

    實例化 ThreadLocal 對象並將其初始值設置爲 1。對所有線程來講,它們拿到的初始值都是 1。

  3. 讀取 ThreadLocal 值

    get()方法用於獲取當前線程的副本變量值

    public T get()
    
  4. 寫入 ThreadLocal 值

    set()方法用於寫入當前線程的副本變量值

    public void set(T value)
    
  5. 刪除 ThreadLocal 值

    remove()方法移除當前前程的副本變量值。每次 remove()之後都會對副本變量應用一次 initialValue(),恢復副本的初始值。所以 remove()之後再 get()得到的是初始值。

    public void remove()
    
  6. example

    我們用一個簡單的例子來說明這幾個接口的使用方式。

     //初始化ThreadLocal對象,並將其初始值設置爲1,在Java8中使用Lambda構造方式
    public static ThreadLocal<Integer> threadLocalData = ThreadLocal.withInitial(() -> 1);
    
    public static void main(String[] args) {
    
         //先使用remove()移除值,再獲取值
         new Thread(() -> {
             threadLocalData.remove();
             System.out.println("remove()&get()" + threadLocalData.get());//輸出remove()&get()1
         }).start();
         //直接使用get()獲取值
         new Thread(() -> System.out.println("get()" + threadLocalData.get())).start();//輸出get()1
         //先使用set()設置值,再獲取
         new Thread(() -> {
             threadLocalData.set(2);
             System.out.println("set()&get()" + threadLocalData.get());//輸出set()&get()2
         }).start();
     }
    

ThreadLocal 使用場景

什麼場景適合使用ThreadLocal類呢?

ThreadLocal 主要使用在多線程多實例(並且每個線程對應一個實例狀態)的對象訪問,並且不想顯式的爲每個多線程對象以參數傳遞的形式來傳遞這個共享變量。總結起來還是比較繞口,分開來看場景應該滿足以下幾點:

  1. 有一個共享變量,這個共享變量在應用的全局域一般來說只有一個,也就是單例的。一般體現爲使用static final修飾。
  2. 這個共享變量是持有狀態的,也就是說這個共享變量自身有一個初始值,但是又被多線程訪問,每個線程都會對這個共享值進行讀取操作,但是又希望每個線程有自己的獨立的副本值。
  3. 要滿足前兩個條件,可以在 Thread 對象中設置一個字段,用來存儲這個線程私有的狀態,但是又不會採取線程類成員變量的方式來實現,那麼就使用ThreadLocal類來隱式的持有這個共享變量的副本。

我們來舉個常用的場景來說明下。在 Web 開發中,經常需要對用戶的請求時間進行格式化,一般服務端會生成一個當前時間對象,然後將這個時間對象格式化爲字符串類型記錄到日誌中,而格式化方法往往又是一個工具類,所有的線程都調用這個工具類的格式化方法,就像下面這樣。

import javax.annotation.concurrent.NotThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@NotThreadSafe
public class ErrorDateUtils {

    private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static String format(Date date) {
        return FMT.format(date);
    }

    public static Date parse(String dateStr) throws ParseException {
        return FMT.parse(dateStr);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now = new Date();
                String format = format(now);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}
/**
 * 輸出:
 *  ........
 * Exception in thread "Thread-98" java.lang.NumberFormatException: For input string: ""
 *  at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)
 *  at java.lang.Long.parseLong(Long.java:601)
 *  ........
 *  at java.text.DateFormat.parse(DateFormat.java:364)
 *  ........
 */


由於SimpleDateFormat類本身不是線程安全的,在多線程訪問的情況下產生了異常,那麼我們可以自然的想到使用同步對parse()format()方法進行處理。

import javax.annotation.concurrent.ThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@ThreadSafe
public class SafeButSlowDateUtils {

    private static final SimpleDateFormat FMT = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static synchronized String format(Date date) {
        return FMT.format(date);
    }

    public static synchronized Date parse(String dateStr) throws ParseException {
        return FMT.parse(dateStr);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now = new Date();
                String format = format(now);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}



這樣經過同步處理後確實不會發生線程安全問題,但是在大規模併發訪問時由於同步的存在每個用戶發起的請求都可能存在鎖競爭的情況,拖慢系統的處理速度。當然我們可以使用棧封閉,在每個線程中實例化SimpleDateFormat對象,這樣就一勞永逸的解決了線程安全問題,例如:

public class SafeButNotGraceParseDateDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SimpleDateFormat formater = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
                Date now = new Date();
                String format = formater.format(now);
                try {
                    Date parse = formater.parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}

嗯,看起來不錯,但是這樣做就不再需要DateUtils工具類了,並且這個日期格式轉換器每次都要初始化,無法複用。

現在看看我們遇到的情況:

  1. 我們需要全局有一個共享的SimpleDateFormat對象(共享)
  2. 這個全局共享的SimpleDateFormat對象是持有狀態的,也就是格式化的格式字符串yyyy-MM-dd HH:mm:ss,並且會對每個線程的 Date 對象應用這個格式化模式字符串進行格式化。每個線程都會對這個共享對象進行讀取操作。(共享對象有狀態,且線程間狀態可能不一致)
  3. 我們爲了滿足複用性,不希望在每個線程中實例化,SimpleDateFormat對象(不希望每個線程顯式持有狀態)

滿足了以上的條件,我們就可以認爲,目前的場景非常適合使用ThreadLocal類來解決問題,共享變量SimpleDateFormat對象不變,只需要使用ThreadLocal來做線程綁定,這樣每個線程都持有SimpleDateFormat對象的副本,每個線程都持有私有的狀態,是獨立互不干擾的。


import javax.annotation.concurrent.ThreadSafe;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

@ThreadSafe
public class SafeAndGraceDateUtils {

    private static final ThreadLocal<SimpleDateFormat> FMT = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy" +
            "-MM-dd HH:mm:ss"));


    public static String format(Date date) {
        return FMT.get().format(date);
    }

    public static Date parse(String str) throws ParseException {
        return FMT.get().parse(str);
    }

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

        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                Date now1 = new Date();
                String format = format(now1);
                try {
                    Date parse = parse(format);
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }).start();

        }
    }
}


使用ThreadLocalSimpleDateFormat封裝後,每個線程都有一個獨立的SimpleDateFormat副本,狀態隔離,這樣就不會出現線程安全問題了。

ThreadLocal 在 MyBatis 中的應用

ThreadLocal 的多線程隔離數據副本的特性非常適合在管理數據庫連接中應用。例如在 Mybatis 中SqlSessionManager中就使用了ThreadLocal進行 session 管理。

我們知道SqlSessionManager負責維護管理SqlSession,SqlSessionManager本身是線程安全的,但是 DefaultSqlSession 卻並不是線程安全的。如果多個併發線程同時從SqlSessionManager獲取到同一個SqlSession實例,由於SqlSession實例中包含了數據庫操作相關的狀態信息,多個併發線程同時使用一個SqlSession實例對數據庫進行讀寫操作則會引起數據不一致錯誤。所以 Mybatis 選擇了使用 ThreadLocal 來維護 session,對每個線程存儲一個 session 副本,這樣進行了數據的隔離,防止出現線程安全問題。

注意註釋標明瞭Note that this class is not Thread-Safe.,DefaultSqlSession本身並不是線程安全的。



/**
 *
 * The default implementation for {@link SqlSession}.
 * Note that this class is not Thread-Safe.
 *
 * @author Clinton Begin
 */
public class DefaultSqlSession implements SqlSession {

  private final Configuration configuration;
  private final Executor executor;

  private final boolean autoCommit;
  private boolean dirty;
  private List<Cursor<?>> cursorList;

  public DefaultSqlSession(Configuration configuration, Executor executor, boolean autoCommit) {
    this.configuration = configuration;
    this.executor = executor;
    this.dirty = false;
    this.autoCommit = autoCommit;
  }

SqlSessionManager使用成員變量localSqlSession來維護數據庫會話。



/**
 * @author Larry Meadors
 */
public class SqlSessionManager implements SqlSessionFactory, SqlSession {

  private final SqlSessionFactory sqlSessionFactory;
  private final SqlSession sqlSessionProxy;

  private final ThreadLocal<SqlSession> localSqlSession = new ThreadLocal<SqlSession>();

 // ......

    @Override
  public Connection getConnection() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot get connection.  No managed session is started.");
    }
    return sqlSession.getConnection();
  }

  @Override
  public void clearCache() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot clear the cache.  No managed session is started.");
    }
    sqlSession.clearCache();
  }

  @Override
  public void commit() {
    final SqlSession sqlSession = localSqlSession.get();
    if (sqlSession == null) {
      throw new SqlSessionException("Error:  Cannot commit.  No managed session is started.");
    }
    sqlSession.commit();
  }



在獲取連接getConnection()等方法中,即使多線程訪問,也是使用localSqlSession.get()來獲取線程本地綁定的localSqlSession對象副本。

ThreadLocal 使用總結

概括起來說,對於多線程資源共享的問題,同步機制採用了“以時間換空間”的方式,而 ThreadLocal另闢蹊徑採用了“以空間換時間”的方式來實現了數據的隔離。
前者僅提供一份變量,讓不同的線程排隊訪問,而後者爲每一個線程都提供了一份變量,因此可以同時訪問而互不影響。

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