ThreadLocal 解析

HuDu發表於2022-08-26

一、ThreadLocal介紹

1.1 官方介紹

從Java官方文件中的描述:ThreadLocal類用來提供執行緒內部的區域性變數。這種變數在多執行緒環境下訪問(透過get和set方法訪問)時能保證各個執行緒的變數相對獨立於其他執行緒內的變數。ThreadLocal 例項通常來說都是 private static 型別的,用於關聯執行緒和執行緒上下文。

我們可以得知 ThreadLocal 的作用是:提供執行緒內的區域性變數,不同的執行緒之間不會相互干擾,這種變數線上程的生命週期內起作用,減少同一個執行緒內多個函式或元件之間一些公共變數傳遞的複雜度。

總結:

執行緒併發: 在多執行緒併發的場景下
傳遞資料: 我們可以透過ThreadLocal在同一執行緒,不同元件中傳遞公共變數
執行緒隔離: 每個執行緒的變數都是獨立的,不會互相影響

1.2、Java 中的引用型別(強、軟、弱、虛)

定義一個物件,重寫 finalize() 方法

public class M {
    @Override
    protected void finalize() throws Throwable {
        // 當物件被回收,finalize 會被列印
        System.out.println("finalize");;
    }
}

強引用

在 Java 中最常見的就是強引用,把一個物件賦給一個引用變數,這個引用變數就是一個強引用。當一個物件被強引用變數引用時,它處於可達狀態,它是不可能被垃圾回收機制回收的,即使該物件以後永遠都不會被用到 JVM 也不會回收。

public class NormalReference {
    public static void main(String[] args) throws IOException {
        // 當沒有任何引用指向該物件,記憶體會被回收
        M m = new M();
        m = null;
        System.gc(); // DisableExplicitGC
        System.out.println(m);
        System.in.read(); // 阻塞 main 執行緒,給垃圾回收執行緒時間執行
    }
}

執行結果

null
finalize

軟引用

軟引用需要用 SoftReference 類來實現,對於只有軟引用的物件來說,當系統記憶體足夠時它不會被回
收,當系統記憶體空間不足時它會被回收。軟引用通常用在對記憶體敏感的程式中。

public class T02_SoftReference {
    public static void main(String[] args) {
        // 10 MB 位元組陣列
        // sr(強引用)指向 -> SoftReference(軟引用)-> 位元組陣列
        SoftReference<byte[]> sr = new SoftReference<>(new byte[1024 * 1024 * 10]);
        // 拿到位元組陣列
        System.out.println(sr.get());
        System.gc();
        try {
            TimeUnit.MILLISECONDS.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(sr.get());

        // 再分配一個陣列,heap 將裝不下,這時候系統會垃圾回收,先回收一次,如果不夠,會把軟引用幹掉
        byte[] b = new byte[1024 * 1024 * 10];
        System.out.println(sr.get());
    }
}

正常執行結果如下,發現記憶體並沒有被回收,因為 jvm 記憶體足夠,不會被主動回收

[B@60e53b93
[B@60e53b93
[B@60e53b93

設定虛擬機器記憶體大小為 20M

ThreadLocal 解析

此時執行結果為

[B@60e53b93
[B@60e53b93
null

弱引用

弱引用需要用 WeakReference 類來實現,它比軟引用的生存期更短,對於只有弱引用的物件來說,只
要垃圾回收機制一執行,不管 JVM 的記憶體空間是否足夠,總會回收該物件佔用的記憶體.【ThreadLocal】

public class T03_WeakReference {
    public static void main(String[] args) throws InterruptedException {
        // wr(強引用)-> WeakReference(弱引用)-> M
        WeakReference<M> wr = new WeakReference<>(new M());
        System.out.println(wr.get());
        System.gc();
        TimeUnit.SECONDS.sleep(1);
        System.out.println(wr.get());
    }
}

執行結果如下

om.hudu.threadlocal.M@60e53b93
finalize
null

虛引用

虛引用需要 PhantomReference 類來實現,它不能單獨使用,必須和引用佇列聯合使用。虛引用的主要作用是跟蹤物件被垃圾回收的狀態。【NIO】

public class T04_PhantomReference {
    private static final List<Object> LIST = new LinkedList<>();
    public static final ReferenceQueue<M> QUEUE = new ReferenceQueue<>();

    public static void main(String[] args) {
        PhantomReference<M> phantomReference = new PhantomReference<>(new M(), QUEUE);
        System.out.println(phantomReference.get());
        ByteBuffer b = ByteBuffer.allocateDirect(1024);

        new Thread(() -> {
            while (true) {
                LIST.add(new byte[1024 * 1024]);
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(phantomReference.get());
            }
        }).start();

        // 垃圾回收執行緒
        new Thread(() -> {
            while (true) {
                Reference<? extends M> poll = QUEUE.poll();
                if (poll != null) {
                    System.out.println("虛引用物件愛過你被 jvm 回收了" + poll);
                }
            }
        }).start();
    }
}

1.3、基本使用

1.3.1、常用方法

在使用之前,我們先來認識幾個 ThreadLocal 的常用方法

方法宣告 描述
ThreadLocal() 建立 ThreadLocal 物件
public void set(T value) 設定當前執行緒繫結的區域性變數
public T get() 獲取當前執行緒繫結的區域性變數
public void remove() 移除當前執行緒繫結的區域性變數

1.3.2 使用案例

我們來看下面這個案例 , 感受一下ThreadLocal 執行緒隔離的特點:

public class MyDemo {
    private String content;

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public static void main(String[] args) {
        MyDemo demo = new MyDemo();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                demo.setContent(Thread.currentThread().getName() + "的資料");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
            });
            thread.setName("執行緒" + i);
            thread.start();
        }
    }
}

ThreadLocal 解析

從結果可以看出多個執行緒在訪問同一個變數的時候出現的異常,執行緒間的資料沒有隔離。下面我們來看下采用 ThreadLocal 的方式來解決這個問題的例子。

public class MyDemo1 {
    private static ThreadLocal<String> tl = new ThreadLocal<>();

    public String getContent() {
        return tl.get();
    }

    public void setContent(String content) {
        tl.set(content);
    }

    public static void main(String[] args) {
        MyDemo1 demo = new MyDemo1();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                demo.setContent(Thread.currentThread().getName() + "的資料");
                try {
                    TimeUnit.MILLISECONDS.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
            });
            thread.setName("執行緒" + i);
            thread.start();
        }
    }
}

ThreadLocal 解析

從結果來看,這樣很好的解決了多執行緒之間資料隔離的問題,十分方便。

1.4、ThreadLocal 類與 synchronized 關鍵字

1.4.1 synchronized同步方式

這裡可能有的朋友會覺得在上述例子中我們完全可以透過加鎖來實現這個功能。我們首先來看一下用synchronized程式碼塊實現的效果:

public class MyDemo2 {
    private static ThreadLocal<String> tl = new ThreadLocal<>();

    public String getContent() {
        return tl.get();
    }

    public void setContent(String content) {
        tl.set(content);
    }

    public static void main(String[] args) {
        MyDemo2 demo = new MyDemo2();
        for (int i = 0; i < 5; i++) {
            Thread thread = new Thread(() -> {
                synchronized (MyDemo2.class) {
                    demo.setContent(Thread.currentThread().getName() + "的資料");
                    try {
                        TimeUnit.MILLISECONDS.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
                }
            });
            thread.setName("執行緒" + i);
            thread.start();
        }
    }
}

從結果可以發現, 加鎖確實可以解決這個問題,但是在這裡我們強調的是執行緒資料隔離的問題,並不是多執行緒共享資料的問題, 在這個案例中使用synchronized關鍵字是不合適的,而且實現的效率大大降低。

ThreadLocal 解析

1.4.2 ThreadLocal與synchronized的區別

synchronized
原理 同步機制採用’以時間換空間’的方式, 只提供了一份變數,讓不同的執行緒排隊訪問 ThreadLocal採用’以空間換時間’的方式, 為每一個執行緒都提供了一份變數的副本,從而實現同時訪問而相不干擾
側重點 多個執行緒之間訪問資源的同步 多執行緒中讓每個執行緒之間的資料相互隔離

總結:
在剛剛的案例中,雖然使用ThreadLocal和synchronized都能解決問題,但是使用ThreadLocal更為合適,因為這樣可以使程式擁有更高的併發性。

二、實際運用場景

透過事務操作案例
這裡我們先構建一個簡單的轉賬場景: 有一個資料表account,裡面有兩個使用者Jack和Rose,使用者Jack 給使用者Rose 轉賬。
案例的實現主要用mysql資料庫,JDBC 和 C3P0 框架。以下是詳細程式碼 :

ThreadLocal 解析

ThreadLocal 解析

引入依賴

<dependency>
  <groupId>com.mchange</groupId>
 <artifactId>c3p0</artifactId>
 <version>0.9.5.5</version>
</dependency>
<dependency>
  <groupId>mysql</groupId>
 <artifactId>mysql-connector-java</artifactId>
 <version>8.0.29</version>
</dependency>

resource 目錄下新增 c3p0-config.xml

<c3p0-config>
    <!--使用預設的配置讀取連線池物件-->
    <default-config>
        <!--連線引數-->
        <property name="driverClass">com.mysql.cj.jdbc.Driver</property>
        <property name="jdbcUrl">jdbc:mysql://localhost:3306/test?characterEncoding=utf-8&amp;serverTimeZone=Asia/shanghai</property>
        <property name="user">root</property>
        <property name="password">123456</property>

        <!--連線池-->
        <property name="initialPoolSize">5</property>
        <property name="maxPoolSize">10</property>
        <property name="checkoutTimeout">3000</property>
    </default-config>
</c3p0-config>

案例中的轉賬涉及兩個DML操作: 一個轉出,一個轉入。這些操作是需要具備原子性的,不可分割。不然就有可能出現資料修改異常情況。所以這裡就需要操作事務,來保證轉出和轉入操作具備原子性,要麼同時成功,要麼同時失敗。

(1) JDBC中關於事務的操作的api

Connection介面的方法 作用
void setAutoCommit(false) 禁用事務自動提交(改為手動)
void commit(); 提交事務
void rollback(); 回滾事務

(2) 開啟事務的注意點:

  • 為了保證所有的操作在一個事務中,案例中使用的連線必須是同一個: service層開啟事務的connection需要跟dao層訪問資料庫的connection保持一致
  • 執行緒併發情況下, 每個執行緒只能操作各自的 connection

基於上面給出的前提, 大家通常想到的解決方案是 :

  • 傳參: 從service層將connection物件向dao層傳遞

但是仔細觀察,會發現這樣實現的弊端:

  • 直接從service層傳遞connection到dao層, 造成程式碼耦合度提高

像這種需要在專案中進行資料傳遞執行緒隔離的場景,我們不妨用ThreadLocal來解決:

AccountWeb

public class AccountWeb {
    public static void main(String[] args) {
        // 模擬 HuDu 給 Alex 轉賬 100
        String outUser = "HuDu";
        String inUser = "Alex";
        int money = 100;

        AccountService as = new AccountService();
        boolean result = as.transfer(outUser, inUser, money);

        if (result) {
            System.out.println("轉賬成功!");
        } else {
            System.out.println("轉賬失敗!");
        }
    }
}

JdbcUtils

public class JdbcUtils {
    // ThreadLocal 物件:將 connection 繫結在當前執行緒中
    public static final ThreadLocal<Connection> tl = new ThreadLocal<>();

    // c3p0 資料庫連線池物件屬性
    private static final ComboPooledDataSource ds = new ComboPooledDataSource();

    // 獲取連線
    public static Connection getConnection() throws SQLException {
        // 取出當前執行緒繫結的 connection 物件
        Connection conn = tl.get();
        if (conn == null) {
            // 如果沒有,則從連線池中取出
            conn = ds.getConnection();
            // 再將 connection 物件繫結到當前執行緒中
            tl.set(conn);
        }
        return conn;
    }

    // 釋放資源
    public static void release(AutoCloseable... ios) {
        for (AutoCloseable io : ios) {
            if (io != null) {
                try {
                    io.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void commitAndClose() {
        try {
            Connection connection = getConnection();
            // 提交事務
            connection.commit();
            // 解除繫結
            tl.remove();
            // 釋放連線
            connection.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }

    public static void rollbackAndClose() {
        try {
            Connection connection = getConnection();
            // 回滾事務
            connection.rollback();
            // 解除繫結
            tl.remove();
            // 釋放連線
            connection.close();
        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}

AccountService

public class AccountService {
    public boolean transfer(String outUser, String inUser, int money) {
        AccountDao ad = new AccountDao();
        try {
            Connection conn = JdbcUtils.getConnection();
            // 開啟事務
            conn.setAutoCommit(false);
            // 轉出
            ad.out(outUser, money);
            // 模擬中間操作出現異常
            int i = 1 / 0;
            // 轉入
            ad.in(inUser, money);
            // 事務提交
            JdbcUtils.commitAndClose();
        } catch (Exception e) {
            e.printStackTrace();
            // 事務回滾
            JdbcUtils.rollbackAndClose();
            return false;
        }
        return true;
    }
}

AccountDao

public class AccountDao {
    // public void out(Connection conn, String outUser, int money) throws SQLException{
    public void out(String outUser, int money) throws SQLException {
        String sql = "update account set money = money - ? where name = ?";
        //註釋從連線池獲取連線的程式碼,使用從service中傳遞過來的connection
        // Connection conn = JdbcUtils.getConnection();
        Connection conn = JdbcUtils.getConnection();
        PreparedStatement pstm = conn.prepareStatement(sql);
        pstm.setInt(1,money);
        pstm.setString(2,outUser);
        pstm.executeUpdate();
        //連線不能在這裡釋放,service層中還需要使用
        // JdbcUtils.release(pstm,conn);
        JdbcUtils.release(pstm);
    }

    // public void in(Connection conn, String outUser, int money) throws SQLException{
    public void in(String inUser, int money) throws SQLException {
        String sql = "update account set money = money + ? where name = ?";
       // Connection conn = JdbcUtils.getConnection();
        Connection conn = JdbcUtils.getConnection();
        PreparedStatement pstm = conn.prepareStatement(sql);
        pstm.setInt(1,money);
        pstm.setString(2,inUser);
        pstm.executeUpdate();
        // JdbcUtils.release(pstm,conn);
        JdbcUtils.release(pstm);
    }
}

執行

ThreadLocal 解析

ThreadLocal 解析

從上述的案例中我們可以看到, 在一些特定場景下,ThreadLocal方案有兩個突出的優勢:

  • 傳遞資料 : 儲存每個執行緒繫結的資料,在需要的地方可以直接獲取, 避免引數直接傳遞帶來的程式碼耦合問題
  • 執行緒隔離 : 各執行緒之間的資料相互隔離卻又具備併發性,避免同步方式帶來的效能損失

三、ThreadLocal 的內部結構

透過以上的學習,我們對ThreadLocal的作用有了一定的認識。現在我們一起來看一下ThreadLocal的內部結構,探究它能夠實現執行緒資料隔離的原理。

3.1 常見的誤解

如果我們不去看原始碼的話,可能會猜測ThreadLocal是這樣子設計的:每個ThreadLocal都建立一個Map,然後用執行緒作為Map的key,要儲存的區域性變數作為Map的value,這樣就能達到各個執行緒的區域性變數隔離的效果。這是最簡單的設計方法,JDK最早期的ThreadLocal 確實是這樣設計的,但現在早已不是了。

ThreadLocal 解析

3.2 現在的設計

但是,JDK後面最佳化了設計方案,在JDK8中 ThreadLocal的設計是:每個Thread維護一個ThreadLocalMap,這個Map的key是ThreadLocal例項本身,value才是真正要儲存的值Object。

具體的過程是這樣的:

  • 每個Thread執行緒內部都有一個Map (ThreadLocalMap)
  • Map裡面儲存ThreadLocal物件(key)和執行緒的變數副本(value)
  • Thread內部的Map是由ThreadLocal維護的,由ThreadLocal負責向map獲取和設定執行緒的變數值。
  • 對於不同的執行緒,每次獲取副本值時,別的執行緒並不能獲取到當前執行緒的副本值,形成了副本的隔離,互不干擾。

ThreadLocal 解析

3.3 這樣設計的好處

這個設計與我們一開始說的設計剛好相反,這樣設計有如下兩個優勢:

  • 這樣設計之後每個Map儲存的Entry數量就會變少。因為之前的儲存數量由Thread的數量決定,現在是由ThreadLocal的數量決定。在實際運用當中,往往ThreadLocal的數量要少於Thread的數量。
  • 當Thread銷燬之後,對應的 ThreadLocalMap 也會隨之銷燬,能減少記憶體的使用。

四、ThreadLocal 核心方法原始碼

基於ThreadLocal的內部結構,我們繼續分析它的核心方法原始碼,更深入的瞭解其操作原理。

除了構造方法之外, ThreadLocal對外暴露的方法有以下4個:

方法宣告 描述
protected T initialValue() 返回當前執行緒區域性變數的初始值
public void set( T value) 設定當前執行緒繫結的區域性變數
public T get() 獲取當前執行緒繫結的區域性變數
public void remove() 移除當前執行緒繫結的區域性變數

4.1、set 方法

(1 ) 原始碼和對應的中文註釋

  /**
     * 設定當前執行緒對應的ThreadLocal的值
     *
     * @param value 將要儲存在當前執行緒對應的ThreadLocal的值
     */
    public void set(T value) {
        // 獲取當前執行緒物件
        Thread t = Thread.currentThread();
        // 獲取此執行緒物件中維護的ThreadLocalMap物件
        ThreadLocalMap map = getMap(t);
        // 判斷map是否存在
        if (map != null)
            // 存在則呼叫map.set設定此實體entry
            map.set(this, value);
        else
            // 1)當前執行緒Thread 不存在ThreadLocalMap物件
            // 2)則呼叫createMap進行ThreadLocalMap物件的初始化
            // 3)並將 t(當前執行緒)和value(t對應的值)作為第一個entry存放至ThreadLocalMap中
            createMap(t, value);
    }

 /**
     * 獲取當前執行緒Thread對應維護的ThreadLocalMap 
     * 
     * @param  t the current thread 當前執行緒
     * @return the map 對應維護的ThreadLocalMap 
     */
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    /**
     *建立當前執行緒Thread對應維護的ThreadLocalMap 
     *
     * @param t 當前執行緒
     * @param firstValue 存放到map中第一個entry的值
     */
    void createMap(Thread t, T firstValue) {
        //這裡的this是呼叫此方法的threadLocal
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

(2 ) 程式碼執行流程

  • A. 首先獲取當前執行緒,並根據當前執行緒獲取一個Map
  • B. 如果獲取的Map不為空,則將引數設定到Map中(當前ThreadLocal的引用作為key)
  • C. 如果Map為空,則給該執行緒建立 Map,並設定初始值

4.2、get 方法

(1 ) 原始碼和對應的中文註釋

    /**
     * 返回當前執行緒中儲存ThreadLocal的值
     * 如果當前執行緒沒有此ThreadLocal變數,
     * 則它會透過呼叫{@link #initialValue} 方法進行初始化值
     *
     * @return 返回當前執行緒對應此ThreadLocal的值
     */
    public T get() {
        // 獲取當前執行緒物件
        Thread t = Thread.currentThread();
        // 獲取此執行緒物件中維護的ThreadLocalMap物件
        ThreadLocalMap map = getMap(t);
        // 如果此map存在
        if (map != null) {
            // 以當前的ThreadLocal 為 key,呼叫getEntry獲取對應的儲存實體e
            ThreadLocalMap.Entry e = map.getEntry(this);
            // 對e進行判空 
            if (e != null) {
                @SuppressWarnings("unchecked")
                // 獲取儲存實體 e 對應的 value值
                // 即為我們想要的當前執行緒對應此ThreadLocal的值
                T result = (T)e.value;
                return result;
            }
        }
        /*
            初始化 : 有兩種情況有執行當前程式碼
            第一種情況: map不存在,表示此執行緒沒有維護的ThreadLocalMap物件
            第二種情況: map存在, 但是沒有與當前ThreadLocal關聯的entry
         */
        return setInitialValue();
    }

    /**
     * 初始化
     *
     * @return the initial value 初始化後的值
     */
    private T setInitialValue() {
        // 呼叫initialValue獲取初始化的值
        // 此方法可以被子類重寫, 如果不重寫預設返回null
        T value = initialValue();
        // 獲取當前執行緒物件
        Thread t = Thread.currentThread();
        // 獲取此執行緒物件中維護的ThreadLocalMap物件
        ThreadLocalMap map = getMap(t);
        // 判斷map是否存在
        if (map != null)
            // 存在則呼叫map.set設定此實體entry
            map.set(this, value);
        else
            // 1)當前執行緒Thread 不存在ThreadLocalMap物件
            // 2)則呼叫createMap進行ThreadLocalMap物件的初始化
            // 3)並將 t(當前執行緒)和value(t對應的值)作為第一個entry存放至ThreadLocalMap中
            createMap(t, value);
        // 返回設定的值value
        return value;
    }

(2 ) 程式碼執行流程

  • A. 首先獲取當前執行緒, 根據當前執行緒獲取一個Map
  • B. 如果獲取的Map不為空,則在Map中以ThreadLocal的引用作為key來在Map中獲取對應的Entry e,否則轉到D
  • C. 如果e不為null,則返回e.value,否則轉到D
  • D. Map為空或者e為空,則透過initialValue函式獲取初始值value,然後用ThreadLocal的引用和value作為firstKey和firstValue建立一個新的Map

總結: 先獲取當前執行緒的 ThreadLocalMap 變數,如果存在則返回值,不存在則建立並返回初始值。

4.3 remove方法

(1 ) 原始碼和對應的中文註釋

 /**
     * 刪除當前執行緒中儲存的ThreadLocal對應的實體entry
     */
     public void remove() {
        // 獲取當前執行緒物件中維護的ThreadLocalMap物件
         ThreadLocalMap m = getMap(Thread.currentThread());
        // 如果此map存在
         if (m != null)
            // 存在則呼叫map.remove
            // 以當前ThreadLocal為key刪除對應的實體entry
             m.remove(this);
     }

(2) 程式碼執行流程

  • A. 首先獲取當前執行緒,並根據當前執行緒獲取一個Map
  • B. 如果獲取的Map不為空,則移除當前ThreadLocal物件對應的entry

4.4 initialValue方法

/**
  * 返回當前執行緒對應的ThreadLocal的初始值

  * 此方法的第一次呼叫發生在,當執行緒透過get方法訪問此執行緒的ThreadLocal值時
  * 除非執行緒先呼叫了set方法,在這種情況下,initialValue 才不會被這個執行緒呼叫。
  * 通常情況下,每個執行緒最多呼叫一次這個方法。
  *
  * <p>這個方法僅僅簡單的返回null {@code null};
  * 如果程式設計師想ThreadLocal執行緒區域性變數有一個除null以外的初始值,
  * 必須透過子類繼承{@code ThreadLocal} 的方式去重寫此方法
  * 通常, 可以透過匿名內部類的方式實現
  *
  * @return 當前ThreadLocal的初始值
  */
protected T initialValue() {
    return null;
}

此方法的作用是 返回該執行緒區域性變數的初始值。

  • (1) 這個方法是一個延遲呼叫方法,從上面的程式碼我們得知,在set方法還未呼叫而先呼叫了get方法時才執行,並且僅執行1次。
  • (2)這個方法預設實現直接返回一個null。
  • (3)如果想要一個除null之外的初始值,可以重寫此方法。(備註: 該方法是一個protected的方法,顯然是為了讓子類覆蓋而設計的)

五、ThreadLocalMap原始碼分析

在分析ThreadLocal方法的時候,我們瞭解到ThreadLocal的操作實際上是圍繞ThreadLocalMap展開的。ThreadLocalMap的原始碼相對比較複雜, 我們從以下三個方面進行討論。

5.1 基本結構

ThreadLocalMap是ThreadLocal的內部類,沒有實現Map介面,用獨立的方式實現了Map的功能,其內部的Entry也是獨立實現。

ThreadLocal 解析

ThreadLocal 解析

(1) 成員變數

    /**
     * 初始容量 —— 必須是2的整次冪
     */
    private static final int INITIAL_CAPACITY = 16;

    /**
     * 存放資料的table,Entry類的定義在下面分析
     * 同樣,陣列長度必須是2的整次冪。
     */
    private Entry[] table;

    /**
     * 陣列裡面entrys的個數,可以用於判斷table當前使用量是否超過閾值。
     */
    private int size = 0;

    /**
     * 進行擴容的閾值,表使用量大於它的時候進行擴容。
     */
    private int threshold; // Default to 0

跟HashMap類似,INITIAL_CAPACITY代表這個Map的初始容量;table 是一個Entry 型別的陣列,用於儲存資料;size 代表表中的儲存數目; threshold 代表需要擴容時對應 size 的閾值。

(2) 儲存結構 - Entry

/*
 * Entry繼承WeakReference,並且用ThreadLocal作為key.
 * 如果key為null(entry.get() == null),意味著key不再被引用,
 * 因此這時候entry也可以從table中清除。
 */
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

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

在ThreadLocalMap中,也是用Entry來儲存K-V結構資料的。不過Entry中的key只能是ThreadLocal物件,這點在構造方法中已經限定死了。

另外,Entry繼承WeakReference,也就是key(ThreadLocal)是弱引用,其目的是將ThreadLocal物件的生命週期和執行緒生命週期解綁。

5.2 弱引用和記憶體洩漏

有些程式設計師在使用ThreadLocal的過程中會發現有記憶體洩漏的情況發生,就猜測這個記憶體洩漏跟Entry中使用了弱引用的key有關係。這個理解其實是不對的。

(1) 如果key使用強引用

假設ThreadLocalMap中的key使用了強引用,那麼會出現記憶體洩漏嗎?
此時ThreadLocal的記憶體圖(實線表示強引用)如下:

ThreadLocal 解析

假設在業務程式碼中使用完ThreadLocal ,threadLocal Ref被回收了。

但是因為threadLocalMap的Entry強引用了threadLocal,造成threadLocal無法被回收。

在沒有手動刪除這個Entry以及CurrentThread依然執行的前提下,始終有強引用鏈 threadRef->currentThread->threadLocalMap->entry,Entry就不會被回收(Entry中包括了ThreadLocal例項和value),導致Entry記憶體洩漏。

也就是說,ThreadLocalMap中的key使用了強引用, 是無法完全避免記憶體洩漏的。

(2)如果key使用弱引用

那麼ThreadLocalMap中的key使用了弱引用,會出現記憶體洩漏嗎?
此時ThreadLocal的記憶體圖(實線表示強引用,虛線表示弱引用)如下:

ThreadLocal 解析

同樣假設在業務程式碼中使用完ThreadLocal ,threadLocal Ref被回收了。

由於ThreadLocalMap只持有ThreadLocal的弱引用,沒有任何強引用指向threadlocal例項, 所以threadlocal就可以順利被gc回收,此時Entry中的key=null。

但是在沒有手動刪除這個Entry以及CurrentThread依然執行的前提下,也存在有強引用鏈 threadRef->currentThread->threadLocalMap->entry -> value ,value不會被回收, 而這塊value永遠不會被訪問到了,導致value記憶體洩漏。

也就是說,ThreadLocalMap中的key使用了弱引用, 也有可能記憶體洩漏。

(3)出現記憶體洩漏的真實原因

比較以上兩種情況,我們就會發現,記憶體洩漏的發生跟ThreadLocalMap中的key是否使用弱引用是沒有關係的。那麼記憶體洩漏的的真正原因是什麼呢?

細心的同學會發現,在以上兩種記憶體洩漏的情況中,都有兩個前提:

1.沒有手動刪除這個Entry
2.CurrentThread依然執行

第一點很好理解,只要在使用完ThreadLocal,呼叫其remove方法刪除對應的Entry,就能避免記憶體洩漏。

第二點稍微複雜一點, 由於ThreadLocalMap是Thread的一個屬性,被當前執行緒所引用,所以它的生命週期跟Thread一樣長。那麼在使用完ThreadLocal之後,如果當前Thread也隨之執行結束,ThreadLocalMap自然也會被gc回收,從根源上避免了記憶體洩漏。

綜上,ThreadLocal記憶體洩漏的根源是:由於ThreadLocalMap的生命週期跟Thread一樣長,如果沒有手動刪除對應key就會導致記憶體洩漏。

(4)為什麼使用弱引用

根據剛才的分析, 我們知道了: 無論ThreadLocalMap中的key使用哪種型別引用都無法完全避免記憶體洩漏,跟使用弱引用沒有關係。

要避免記憶體洩漏有兩種方式:

  1. 使用完ThreadLocal,呼叫其remove方法刪除對應的Entry
  2. 使用完ThreadLocal,當前Thread也隨之執行結束

相對第一種方式,第二種方式顯然更不好控制,特別是使用執行緒池的時候,執行緒結束是不會銷燬的。

也就是說,只要記得在使用完ThreadLocal及時的呼叫remove,無論key是強引用還是弱引用都不會有問題。那麼為什麼key要用弱引用呢?

事實上,在ThreadLocalMap中的set/getEntry方法中,會對key為null(也即是ThreadLocal為null)進行判斷,如果為null的話,那麼是會對value置為null的。

這就意味著使用完ThreadLocal,CurrentThread依然執行的前提下,就算忘記呼叫remove方法,弱引用比強引用可以多一層保障:弱引用的ThreadLocal會被回收,對應的value在下一次ThreadLocalMap呼叫set,get,remove中的任一方法的時候會被清除,從而避免記憶體洩漏。

5.3 hash衝突的解決

hash衝突的解決是Map中的一個重要內容。我們以hash衝突的解決為線索,來研究一下ThreadLocalMap的核心原始碼。

(1) 首先從ThreadLocal的set() 方法入手

  public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocal.ThreadLocalMap map = getMap(t);
        if (map != null)
            //呼叫了ThreadLocalMap的set方法
            map.set(this, value);
        else
            createMap(t, value);
    }

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

    void createMap(Thread t, T firstValue) {
            //呼叫了ThreadLocalMap的構造方法
        t.threadLocals = new ThreadLocal.ThreadLocalMap(this, firstValue);
    }

這個方法我們剛才分析過, 其作用是設定當前執行緒繫結的區域性變數 :

A. 首先獲取當前執行緒,並根據當前執行緒獲取一個Map

B. 如果獲取的Map不為空,則將引數設定到Map中(當前ThreadLocal的引用作為key)

(這裡呼叫了ThreadLocalMap的set方法)

C. 如果Map為空,則給該執行緒建立 Map,並設定初始值

(這裡呼叫了ThreadLocalMap的構造方法)

(2)構造方法ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)

 /*
  * firstKey : 本ThreadLocal例項(this)
  * firstValue : 要儲存的執行緒本地變數
  */
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //初始化table
        table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY];
        //計算索引(重點程式碼)
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        //設定值
        table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue);
        size = 1;
        //設定閾值
        setThreshold(INITIAL_CAPACITY);
    }

建構函式首先建立一個長度為16的Entry陣列,然後計算出firstKey對應的索引,然後儲存到table中,並設定size和threshold。

重點分析: int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)。

a. 關於firstKey.threadLocalHashCode

     private final int threadLocalHashCode = nextHashCode();

    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
//AtomicInteger是一個提供原子操作的Integer類,透過執行緒安全的方式操作加減,適合高併發情況下的使用
    private static AtomicInteger nextHashCode =  new AtomicInteger();
     //特殊的hash值
    private static final int HASH_INCREMENT = 0x61c88647;

這裡定義了一個AtomicInteger型別,每次獲取當前值並加上HASH_INCREMENT,HASH_INCREMENT = 0x61c88647,這個值跟斐波那契數列(黃金分割數)有關,其主要目的就是為了讓雜湊碼能均勻的分佈在2的n次方的陣列裡, 也就是Entry[] table中,這樣做可以儘量避免hash衝突。

b. 關於& (INITIAL_CAPACITY - 1)

計算hash的時候裡面採用了hashCode & (size - 1)的演算法,這相當於取模運算hashCode % size的一個更高效的實現。正是因為這種演算法,我們要求size必須是2的整次冪,這也能保證在索引不越界的前提下,使得hash發生衝突的次數減小。

(3) ThreadLocalMap中的set方法

private void set(ThreadLocal<?> key, Object value) {
        ThreadLocal.ThreadLocalMap.Entry[] tab = table;
        int len = tab.length;
        //計算索引(重點程式碼,剛才分析過了)
        int i = key.threadLocalHashCode & (len-1);
        /**
         * 使用線性探測法查詢元素(重點程式碼)
         */
        for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
            //ThreadLocal 對應的 key 存在,直接覆蓋之前的值
            if (k == key) {
                e.value = value;
                return;
            }
            // key為 null,但是值不為 null,說明之前的 ThreadLocal 物件已經被回收了,
           // 當前陣列中的 Entry 是一個陳舊(stale)的元素
            if (k == null) {
                //用新元素替換陳舊的元素,這個方法進行了不少的垃圾清理動作,防止記憶體洩漏
                replaceStaleEntry(key, value, i);
                return;
            }
        }

        //ThreadLocal對應的key不存在並且沒有找到陳舊的元素,則在空元素的位置建立一個新的Entry。
            tab[i] = new Entry(key, value);
            int sz = ++size;
            /**
             * cleanSomeSlots用於清除那些e.get()==null的元素,
             * 這種資料key關聯的物件已經被回收,所以這個Entry(table[index])可以被置null。
             * 如果沒有清除任何entry,並且當前使用量達到了負載因子所定義(長度的2/3),那麼進行                 * rehash(執行一次全表的掃描清理工作)
             */
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
}

 /**
     * 獲取環形陣列的下一個索引
     */
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }

程式碼執行流程:

  • A. 首先還是根據key計算出索引 i,然後查詢i位置上的Entry,

  • B. 若是Entry已經存在並且key等於傳入的key,那麼這時候直接給這個Entry賦新的value值,

  • C. 若是Entry存在,但是key為null,則呼叫replaceStaleEntry來更換這個key為空的Entry,

  • D. 不斷迴圈檢測,直到遇到為null的地方,這時候要是還沒在迴圈過程中return,那麼就在這個null的位置新建一個Entry,並且插入,同時size增加1。

最後呼叫cleanSomeSlots,清理key為null的Entry,最後返回是否清理了Entry,接下來再判斷sz 是否>= thresgold達到了rehash的條件,達到的話就會呼叫rehash函式執行一次全表的掃描清理。

重點分析 : ThreadLocalMap使用線性探測法來解決雜湊衝突的。

該方法一次探測下一個地址,直到有空的地址後插入,若整個空間都找不到空餘的地址,則產生溢位。

舉個例子,假設當前table長度為16,也就是說如果計算出來key的hash值為14,如果table[14]上已經有值,並且其key與當前key不一致,那麼就發生了hash衝突,這個時候將14加1得到15,取table[15]進行判斷,這個時候如果還是衝突會回到0,取table[0],以此類推,直到可以插入。

按照上面的描述,可以把Entry[] table看成一個環形陣列。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章