ThreadLocal原理及其正確用法

前言

當(dāng)多線程訪問共享且可變的數(shù)據(jù)時,涉及到線程間同步的問題,并不是所有時候,都要用到共享數(shù)據(jù),所以就需要ThreadLocal出場了。
ThreadLocal又稱線程本地變量,使用其能夠?qū)?shù)據(jù)封閉在各自的線程中,每一個ThreadLocal能夠存放一個線程級別的變量且它本身能夠被多個線程共享使用,并且又能達到線程安全的目的,且絕對線程安全,其用法如下所示:

public final static ThreadLocal<String> RESOURCE = new ThreadLocal<String>();

RESOURCE代表一個能夠存放String類型的ThreadLocal對象。此時不論什么一個線程能夠并發(fā)訪問這個變量,對它進行寫入、讀取操作,都是線程安全的。
除了線程安全之外,使用ThreadLocal也能夠作為一種“方便傳參”的工具,在業(yè)務(wù)邏輯冗長的代碼中,同一個參數(shù)需要傳入在多個方法之間層層傳遞,當(dāng)這種需要傳遞的參數(shù)過多時代碼會顯得十分臃腫、丑陋;之前我給公司做過企微會話存檔的功能,就是將企業(yè)微信聊天信息拉取下來保存,由于企業(yè)微信消息類型很多(至少有三十多種),為了后期便于維護在對消息解析、保存時根據(jù)消息類型封分別封裝了對應(yīng)的方法每個消息類型解析、保存時又會進一步細分拆分成多個方法(比如說文件資源的分片拉取、上傳到靜態(tài)資源服務(wù)器),這個時候麻煩的事情就來了,每個方法的入?yún)⒍夹枰笪挻鏅n的相關(guān)配置參數(shù)和封裝的對話信息參數(shù),導(dǎo)致入?yún)⒘斜矸浅iL,閱讀性比較差。實際上可以把企微會話存檔的相關(guān)配置參數(shù)存入到ThreadLocal中,各個方法內(nèi)需要使用直接從ThreadLocal中獲取就可以了,以后有時間了要把這塊代碼重構(gòu)一下。
后來我又做了公司的短信模塊的需求,主要是記錄短信發(fā)送記錄、發(fā)送統(tǒng)計及短信發(fā)送狀態(tài),短信發(fā)送的接口有多個(單條發(fā)送、批量發(fā)送、根據(jù)模板發(fā)送等等),需要記錄多個接口的調(diào)用情況,當(dāng)時就抽象出了短信上下文、模板上下文等實體,在調(diào)用方法時首先構(gòu)造對應(yīng)的上下文并將其保存到ThreadLocal中,在短信余額校驗、違禁詞過濾、余額不足提醒等業(yè)務(wù)處理方法中只需要從ThreadLocal中取出對應(yīng)的上下文即可,而且發(fā)送狀態(tài)是通過切面進行記錄的,在切入點記錄日志時也是直接從ThreadLocal中直接獲取的上下文信息,代碼簡潔、可讀性高。
說了不少廢話,現(xiàn)在就步入正題了,讓我們揭開ThreadLocal的廬山真面目。


原理

先看一下ThreadLocal的結(jié)構(gòu)


ThreadLocal.png

需要我們重點關(guān)注的方法有:

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

如果能夠搞懂這塊代碼,就能夠明白ThreadLocal到底是怎么實現(xiàn)的了。這塊代碼其實很有意思,我們發(fā)現(xiàn)在向ThreadLocal中存放值時需要先從當(dāng)前線程中獲取ThreadLocalMap,最后實際是要把當(dāng)前ThreadLocal對象作為key、要存入的值作為value存放到ThreadLocalMap中,那我們就不得不先看一下ThreadLocalMap的結(jié)構(gòu)。

static class ThreadLocalMap {
    /**
     * 鍵值對實體的存儲結(jié)構(gòu)
     */
    static class Entry extends WeakReference<ThreadLocal<?>> {
        // 當(dāng)前線程關(guān)聯(lián)的 value,這個 value 并沒有用弱引用追蹤
        Object value;

        /**
         * 構(gòu)造鍵值對
         *
         * @param k k 作 key,作為 key 的 ThreadLocal 會被包裝為一個弱引用
         * @param v v 作 value
         */
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }

    // 初始容量,必須為 2 的冪
    private static final int INITIAL_CAPACITY = 16;

    // 存儲 ThreadLocal 的鍵值對實體數(shù)組,長度必須為 2 的冪
    private Entry[] table;

    // ThreadLocalMap 元素數(shù)量
    private int size = 0;

    // 擴容的閾值,默認是數(shù)組大小的三分之二
    private int threshold;
}

ThreadLocalMapThreadLocal 的靜態(tài)內(nèi)部類,當(dāng)一個線程有多個 ThreadLocal 時,需要一個容器來管理多個 ThreadLocal,ThreadLocalMap 的作用就是管理線程中多個 ThreadLocal,從源碼中看到 ThreadLocalMap 其實就是一個簡單的 Map 結(jié)構(gòu),底層是數(shù)組,有初始化大小,也有擴容閾值大小,數(shù)組的元素是 Entry,Entry 的 key 就是 ThreadLocal 的引用,value 是 ThreadLocal內(nèi)存入 的值。ThreadLocalMap 解決 hash 沖突的方式采用的是「線性探測法」,如果發(fā)生沖突會繼續(xù)尋找下一個空的位置。
每個Thread內(nèi)部都持有一個ThreadLoalMap對象

 /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

至此,我們都能夠明白ThreadLocal存值的過程了,雖然我們是按照前言中的用法聲明了一個全局常量,但是這個常量在每次設(shè)置時實際都是向當(dāng)前線程的ThreadLocalMap內(nèi)存值,從而確保了數(shù)據(jù)在不同線程之間的隔離。


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

有了上面的鋪墊,這段代碼就不難理解了,獲取ThreadLocal內(nèi)的值時,實際上是從當(dāng)前線程的ThreadLocalMap中以當(dāng)前ThreadLocal對象作為key取出對應(yīng)的值,由于值在保存時時線程隔離的,所以現(xiàn)在取值時只會取得當(dāng)前線程中的值,所以是絕對線程安全的。

  • remove
private void remove(ThreadLocal<?> key) {
            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)]) {
                if (e.get() == key) {
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
            }
}

removeThreadLocal對象關(guān)聯(lián)的鍵值對從Entry中移除,正確執(zhí)行remove方法能夠避免使用ThreadLocal出現(xiàn)內(nèi)存泄漏的潛在風(fēng)險,int i = key.threadLocalHashCode & (len-1)這行代碼很有意思,從一個集合中找到一個元素存放位置的最簡單方法就是利用該元素的hashcode對這個集合的長度取余,如果我們能夠?qū)⒓系拈L度限制成2的整數(shù)次冪就能夠?qū)⑷∮噙\算轉(zhuǎn)換成hashcode與[集合長度-1]的與運算,這樣就能夠提高查找效率,HashMap中也是這樣處理的,這里就不再展開了。
下面的一張圖很好的解釋了ThreadLocal的原理

原理圖.png


ThreadLocal內(nèi)存泄漏及正確用法

在提及ThreadLocal使用的注意事項時,所有的文章都會指出內(nèi)存泄漏這一風(fēng)險,但是我發(fā)現(xiàn)很少有文章能夠真正的把這一部分講清楚,這里我就斗膽嘗試一下,由于ThreadLocalMap中的Entry的key持有的是ThreadLocal對象的弱引用,當(dāng)這個ThreadLocal對象當(dāng)且僅當(dāng)被ThreadLocalMap中的Entry引用時發(fā)生了GC,會導(dǎo)致當(dāng)前ThreadLocal對象被回收;那么 ThreadLocalMap 中保存的 key 值就變成了 null,而Entry 又被 ThreadLocalMap 對象引用,ThreadLocalMap 對象又被 Thread 對象所引用,那么當(dāng) Thread 一直不銷毀的話,value 對象就會一直存在于內(nèi)存中,也就導(dǎo)致了內(nèi)存泄漏,直至 Thread 被銷毀后,才會被回收。
下面我們就來驗證一下這個情景,我們在方法內(nèi)部聲明了一個ThreadLocal對象,為了更好的演示內(nèi)存泄漏的情景我們在使用這個對象存值后將方法內(nèi)取消對其的強引用,并且通過System.gc()觸發(fā)了一次垃圾回收(準(zhǔn)確的說是希望jvm執(zhí)行一次垃圾回收,不能保證垃圾回收一定會進行,而且具體什么時候進行是取決于具體的虛擬機的),這樣再垃圾回收時會將ThreadLocal對象回收,代碼如下所示:

@Test
    public void loop() throws Exception {

        for (int i = 0; i < 1; i++) {
            ThreadLocal<SysUser> threadLocal = new ThreadLocal<>();
            threadLocal.set(new SysUser(System.currentTimeMillis(), "李四"));
           // threadLocal = null;
            //System.gc();
            printEntryInfo();
        }

        //System.gc();

        //printEntryInfo();
    }

    private void printEntryInfo() throws Exception {
        Thread currentThread = Thread.currentThread();
        Class<? extends Thread> clz = currentThread.getClass();
        Field field = clz.getDeclaredField("threadLocals");
        field.setAccessible(true);
        Object threadLocalMap = field.get(currentThread);
        Class<?> tlmClass = threadLocalMap.getClass();
        Field tableField = tlmClass.getDeclaredField("table");
        tableField.setAccessible(true);
        Object[] arr = (Object[]) tableField.get(threadLocalMap);
        for (Object o : arr) {
            if (o != null) {
                Class<?> entryClass = o.getClass();
                Field valueField = entryClass.getDeclaredField("value");
                Field referenceField = entryClass.getSuperclass().getSuperclass().getDeclaredField("referent");
                valueField.setAccessible(true);
                referenceField.setAccessible(true);
                System.out.println(String.format("弱引用key:%s,值:%s", referenceField.get(o), valueField.get(o)));
            }
        }
    }

在不發(fā)生GC時,控制臺輸出如下:

image.png

,ThreadLocal對象并未被回收,將System.gc();放開,控制臺輸入如下:
image.png

可以看出key確實變成了null值,而Entry內(nèi)會一直持有對value的引用,導(dǎo)致value無法被回收,如果當(dāng)前線程一直在執(zhí)行未被銷毀,則確實會出現(xiàn)內(nèi)存泄漏(在使用線程池時更容易出現(xiàn)這樣的問題)。讓我們分析一下上面的為什么會出現(xiàn)內(nèi)存泄漏的原因,在上面的代碼里,我們在方法內(nèi)部聲明了一個ThreadLocal對象,該ThreadLocal對象僅有一個方法內(nèi)部的強引用且的生命周期很短,當(dāng)該方法執(zhí)行完成之后此ThreadLocal對象在下一次gc時就會被回收,當(dāng)然我們可以在方法結(jié)束前手動執(zhí)行一個該對象的remove方法,但是這樣就失去了使用ThreadLocal的意義。
由此,我們知道出現(xiàn)內(nèi)存泄漏的原因是失去了對ThreadLocal對象的強引用,避免內(nèi)存泄漏最簡單的方法就是始終保持對ThreadLocal對象的強引用,為每個線程聲明一個對ThreadLocal對象的強引用顯然是不合適的(太麻煩且缺乏聲明的時機),所以,我們可以將ThreadLocal對象聲明為一個全局常量,所有的線程均使用這一常量即可,例如:

private static final ThreadLocal<String> RESOURCE = new ThreadLocal<>();

    @Test
    public void multiThread() {
        Thread thread1 = new Thread(() -> {
            RESOURCE.set("thread1");
            System.gc();
            try {
                printEntryInfo();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        Thread thread2 = new Thread(() -> {
            RESOURCE.set("thread2");
            System.gc();
            try {
                printEntryInfo();
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        thread1.start();
        thread2.start();
    }

按照上面的方式聲明ThreadLocal對象后,所有的線程共用此對象,在使用此對象存值時會把此對象作為key然后把對應(yīng)的值作為value存入到當(dāng)前線程的ThreadLocalMap中,由于此對象始終存在著一個全局的強引用,所以其不會被垃圾回收,調(diào)用remove方法后就能夠?qū)⒋藢ο箨P(guān)聯(lián)的Entry清除。
驗證一下:

弱引用key:java.lang.ThreadLocal@10c6d8a7,值:thread1
弱引用key:java.lang.ThreadLocal@10c6d8a7,值:thread2

可以看出兩個線程內(nèi)對應(yīng)的Entry的key為同一個對象且即使發(fā)生了垃圾回收該對象也不會被回收。
那么是不是說將ThreadLocal對象聲明為一個全局常量后使用就沒有問題了呢,當(dāng)然不是,我們需要確保在每次使用完ThreadLocal對象后確保要執(zhí)行一下該對象的remove方法,清除當(dāng)前線程保存的信息,這樣當(dāng)此線程再被利用時不會取到錯誤的信息(使用線程池極易出現(xiàn));我們的項目之前就出現(xiàn)過這種場景,從線程池中獲取線程,并在每次請求時在當(dāng)前線程記錄下對應(yīng)的用戶信息,結(jié)果有一天出現(xiàn)了串號的問題,B用戶訪問時使用了A用戶的信息,這就是在每次請求結(jié)束后沒有執(zhí)行remove方法,線程復(fù)用時內(nèi)部還保存著上一個用戶的信息,貼上一份使用ThreadLocal的正確姿勢:

package com.cube.share.thread.config;

import com.cube.share.thread.entity.SysUser;

/**
 * @author poker.li
 * @date 2021/7/31 14:50
 * <p>
 * 線程當(dāng)前用戶信息
 */
public class CurrentUser {

    private static final ThreadLocal<SysUser> USER = new ThreadLocal<>();

    private static final ThreadLocal<Long> USER_ID = new ThreadLocal<>();

    private static final InheritableThreadLocal<SysUser> INHERITABLE_USER = new InheritableThreadLocal<>();

    private static final InheritableThreadLocal<Long> INHERITABLE_USER_ID = new InheritableThreadLocal<>();

    public static void setUser(SysUser sysUser) {
        USER.set(sysUser);
        INHERITABLE_USER.set(sysUser);
    }

    public static void setUserId(Long id) {
        USER_ID.set(id);
        INHERITABLE_USER_ID.set(id);
    }

    public static SysUser user() {
        return USER.get();
    }

    public static SysUser inheritableUser() {
        return INHERITABLE_USER.get();
    }

    public static Long inheritableUserId() {
        return INHERITABLE_USER_ID.get();
    }

    public static Long userId() {
        return USER_ID.get();
    }

    public static void removeAll() {
        USER.remove();
        USER_ID.remove();
        INHERITABLE_USER.remove();
        INHERITABLE_USER_ID.remove();
    }
}

我們可以通過切面或者請求監(jiān)聽器在請求結(jié)束時將當(dāng)前線程保存的ThreadLocal信息清除

/**
 * @author poker.li
 * @date 2021/7/31 15:12
 * <p>
 * ServletRequest請求監(jiān)聽器
 */
@Component
@Slf4j
public class ServletRequestHandledEventListener implements ApplicationListener<ServletRequestHandledEvent> {

    @Override
    public void onApplicationEvent(ServletRequestHandledEvent event) {
        CurrentUser.removeAll();
        log.debug("清除當(dāng)前線程用戶信息,uri = {},method = {},servletName = {},clientAddress = {}", event.getRequestUrl(),
                event.getMethod(), event.getServletName(), event.getClientAddress());
    }
}

可傳遞給子線程的InheritableThreadLocal

如果我們在當(dāng)前線程中開辟新的子線程并希望子線程獲取父線程保存的線程本地變量要怎么做呢,在子線程中聲明ThreadLocal對象并將父線程中對應(yīng)的值存入自然是可以的,但是大可不必如此繁瑣,jdk已經(jīng)為我們提供了一種可傳遞給子線程的InheritableThreadLocal,實現(xiàn)的原理也很簡單,可以在Thread中一窺究竟。


//持有了一個可傳遞給子線程的ThreadLocalMap
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

//線程創(chuàng)建時都會執(zhí)行這個初始化方法,inheritThreadLocals表示是否需要在構(gòu)造時從父線程中繼承thread-locals,默認為true
private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        //忽略了一部分代碼

        setPriority(priority);
        //從父線程中繼承thread-locals
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }


使用場景

ThreadLocal 的特性也導(dǎo)致了應(yīng)用場景比較廣泛,主要的應(yīng)用場景如下:

  • 線程間數(shù)據(jù)隔離,各線程的 ThreadLocal 互不影響
  • 方便同一個線程使用某一對象,避免不必要的參數(shù)傳遞
  • 全鏈路追蹤中的 traceId 或者流程引擎中上下文的傳遞一般采用 ThreadLocal
  • Spring 事務(wù)管理器采用了 ThreadLocal
  • Spring MVC 的 RequestContextHolder 的實現(xiàn)使用了 ThreadLocal

總結(jié)

本文主要從源碼的角度解析了 ThreadLocal,并分析了發(fā)生內(nèi)存泄漏的原因及正確用法,最后對它的應(yīng)用場景進行了簡單介紹。ThreadLocal還有其他變種例如FastThreadLocal和TransmittableThreadLocal,F(xiàn)astThreadLocal主要解決了偽共享的問題比ThreadLocal擁有更好的性能,TransmittableThreadLocal主要解決了線程池中線程復(fù)用導(dǎo)致后續(xù)提交的任務(wù)并不會繼承到父線程的線程變量的問題,這里限于篇幅就不展開了,以后另寫一篇介紹一下。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容