基於ThreadLocal 的用法及內存泄露(內存溢出)

ThreadLocal 看名字 就可以看出一點頭緒來,線程本地。

來看一下java對他的描述:

該類提供線程本地變量。這些變量與它們的正常對應變量的不同之處在於,每個線程(通過ThreadLocal的 get 或 set方法)訪問自己的、獨立初始化的變量副本。 ThreadLocal實例通常是類中的私有靜態字段。

上面這段話呢,一個重點就是 每個線程都有自己的專屬變量,這個專屬變量呢,是不會被其他線程影響的。

使用

public class ThreadLocalTwo {
    //靜態的 延長生命周期。final  不可改變
    private static final ThreadLocal<Integer> threalLocal = ThreadLocal.withInitial(() -> {
        return 0;
    });
    public static void main(String[] args) {
        new Thread(() -> {
            while (true) {
                //取出來
                int inner = threalLocal.get();
                //使用
                System.out.println(Thread.currentThread().getName() + "   " + inner);
                LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
                //更新值存入
                threalLocal.set(++inner);
            }
        }, "three").start();
        new Thread(() -> {
            while (true) {
                //取出來
                int inner = threalLocal.get();
                //使用
                System.out.println(Thread.currentThread().getName() + "   " + inner);
                LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1));
                //更新值存入
                threalLocal.set(++inner);
            }
        }, "four").start();
    }
}

使用這個我隻是隨便寫一個demo,具體的邏輯有很多種,隻要你想,就會有很多種寫法。具體看業務需求。

個人理解

ThreadLocal 類似於一個工具,通過這個工具,來為當前線程設定修改移除本地副本。,如果 你查看Thread的源碼會發現下面這段代碼

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

這是靜態內部類構造的一個字段,那麼我們看一下 ThreadLocal.ThreadLocalMap的源碼.

     static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        
        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;

上面代碼我們可以發現 ThreadLocal.ThreadLocalMap這個內部靜態類,裡面還包含這一個內部靜態類Entry。

這個Entry 繼承瞭WeakReference,並且將ThreadLocal作為弱引用類型。這表明 ThreadLocal如果沒有其他的強引用時候,說不定 有可能不知道啥時候就被回收瞭。

那麼至於 value呢? 我可以肯定的告訴你 value不會被回收,即便 傳進來的v是個匿名類。

value持有著線程的本地副本的引用

Entry[] table 這個持有 entry的引用

現在 ,隻需要知道

1 弱引用對象,會持有引用對象的引用,弱引用對象並不能決定 引用對象是否回收。

2 弱引用的子類的 如果有自己的字段的話, 那麼那個字段是強引用,不會被回收

3 弱引用對象,如果是new出來的,那麼弱引用對象本身也是一個強引用。弱引用對象自己不會被回收。

構造方法

一個默認的無參構造方法 ,沒啥好講的,,

public ThreadLocal() {
    }

使用

  private static final ThreadLocal<String> construct  = new ThreadLocal<>(){
        //如果 不重寫這個方法的話,默認返回null
        @Override
        protected String initialValue() {
            return "默認值";
        }
    };

靜態方法

note Java8新增的方法

 public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
        return new SuppliedThreadLocal<>(supplier);
    }

上面的這個靜態方法呢,生成一個ThreadLocal對象,參數是一個Supplier函數接口。

下面展示一個代碼

private static final ThreadLocal<String> local = ThreadLocal.withInitial(() -> "默認值");

上面這段代碼使用瞭Lambda表達式, 比起上面 new 並且重寫方法的寫法,代碼會少很多,顯得很有逼格對不。

如果你對java8的Lambda不清楚的話,可以看這篇文章:java Lambda表達式的使用

公共方法

//返回當前線程本地副本的值。如果本地副本為null,則返回初始化為調用{@link #initialValue}方法返回的值。
public T get()
//將當前線程的本地副本 設為 value
public void set(T value)
//將當前線程的本地副本移除,如果後面調用get()方法的話,會返回T initialValue()的值
public void remove()

內存泄露

接下來講一下,ThreadLocal配合線程池時候 會出現內存泄漏的原理。按照我的個人理解 ,是因為內存溢出造成的。內存泄露指的是 原本應該回收的對象,現在由於種種原因,無法被回收。

為什麼上面會強調 配合線程池的時候,因為單獨線程的時候,當線程任務運行完以後,線程資源會被回收,自然 本地副本也被回收瞭。而線程池裡面的線程不全被回收(有的不會被回收,也有的會被回收)。

現在來看一下上面的Entry這個最終存儲本地副本的靜態內部類,

   static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

下面內容需要你對 java 內存管理關系瞭解,否則 你肯定會一臉蒙蔽。

如果 你不會 可以看我這篇文章java內存管理關系及內存泄露的原理

由於它是WeakReference的子類,所以 作為引用對象的 ThreadLocal,就有可能會被Entry清除引用。如果這時候 ThreadLocal沒有其他的引用,那麼它肯定就會被GC回收瞭。

但是value 是強引用,而Entry 又被Entry[]持有,Entry[]又被ThreadLocalMap持有,ThreadLocalMap又被線程持有。隻要線程不死或者 你不調用set,remove這兩個方法之中任何一個,那麼value指向的這個對象就始終 不會被回收。因為 不符合GC回收的兩個條件的任何一個。

試想一下如果線程池裡面的線程足夠的多,並且 你傳給線程的本地副本內存占用又很大。毫無疑問 會內存溢出。

解決方法

隻要調用remove 這個方法會擦出 上一個value的引用,這樣線程就不會持有上一個value指向對象的引用。就不會有內存露出瞭。

有讀者會有疑問瞭,上面不是說兩個放過會使value對象可以回收麼,怎麼上面沒有set方法呢?

這個是因為,set方法確實可以是value指向的對象 這個引用斷開,但同時它又強引用瞭一個內存空間給value。即使上一個對象被回收瞭,但是新對象也產生瞭。

至於 get方法,隻有在ThreadLocalMap 被GC後,調用get方法 才會將value對應的引用切斷。

首先,我們看get源碼

  public T get() {
        Thread t = Thread.currentThread();//當前線程的引用
        //得到當前線程的ThreadLocalMap,如果沒有返回null
        ThreadLocalMap map = getMap(t);
        //存在時候走這個
        if (map != null) {
             //與鍵關聯的項,如果沒有鍵則為null  
             //如果ThreadLocalMap的entry 清除瞭ThreadLocal 對象的引用,那麼這個會清除對應的value 引用
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //當前線程 沒有設置ThreadLocalMap,那麼返回initialValue()的值
        return setInitialValue();
    }

上面這段代碼,調用瞭getEntry,這個方法內部調用瞭 另一個方法,實現瞭當ThreadLocal被清除引用後,也清除對應的value引用,

    private Entry getEntry(ThreadLocal<?> key) {
            //得到位置  table數組 的容量是16
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
             //key沒有被回收後
            if (e != null && e.get() == key)
                return e;
            else
                 //這個key被回收 調用,將對應的value 釋放引用
                return getEntryAfterMiss(key, i, e);
        }

我們看見最後調用 getEntryAfterMiss(key, i, e),這個方法 也不是最終的擦除value引用的方法,我們接著往下看

 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            Entry[] tab = table;
            int len = tab.length;
            while (e != null) {
                 //得到弱引用對象 持有的引用對象的引用
                ThreadLocal<?> k = e.get();
                //ThreadLocal沒有被回收
                if (k == key)
                    return e;
                
                if (k == null)
                    //entry 清除ThreadLocal的引用 
                   //通過entry[]數組的元素entry 清除entry的value引用
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }

這上面呢,我們要關註expungeStaleEntry(i),這個才是最終的擦除entry的value對象的引用。 看一下 expungeStaleEntry(i)的源碼

 private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;//得到table引用
            int len = tab.length;//得到table的長度,不出意外 應該是16
            // expunge entry at staleSlot
           //下面兩句代碼 是關鍵。
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;
            // Rehash until we encounter null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;
                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

上面這段代碼很長,我們不必細看個,關註下面這兩行代碼就行

            tab[staleSlot].value = null;//清除引用  這樣 GC就可以回收瞭
            tab[staleSlot] = null;//清除自身的引用

通過entry[staleSlot]得到存儲的entry ,通過entry清除entry的value引用。

這樣大傢明白瞭吧,get也是可以起到和remove一樣的效果的。

我們再看一下remove的源碼

 public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

上面這段代碼沒什麼說的,直接看ThreadLocalMap的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)]) {
                 //這裡有可能會發生 ThreadLocal 被entry清除引用,那麼value就被線程引用瞭,如果不調用set,get方法的話,隻能等待線程銷毀。
                if (e.get() == key) {
                    //調用弱引用的方法 , 將引用對象的引用清除
                    e.clear();
                    //擦出ThreadLocal 對應的value
                    expungeStaleEntry(i);
                    return;
                }
            }
        }

上面調用瞭 expungeStaleEntry 擦除。

set

我們關註這個方法

  private void expungeStaleEntries() {
            Entry[] tab = table;
            int len = tab.length;
            for (int j = 0; j < len; j++) {
                Entry e = tab[j];
                if (e != null && e.get() == null)
                    //擦除
                    expungeStaleEntry(j);
            }
        }

這個呢 循環調用瞭expungeStaleEntry(j)方法 ,也是擦除瞭value的對象引用。

為什麼要將ThreadLocal 定義成 static 變量

延長生命周期,之所以是static 是因為,ThreadLocal 我們更應該將他看成是 工具。

對ThreadLocal內存泄漏引起的思考

概述

最近在對一個項目進行重構,用到瞭ThreadLocal。

場景如下:

外圍系統會調用接口上傳數據,在接口中要記錄數據的變化Id,在上傳數據完後需要集中在一個地方把這些Id以消息形式發送出去。

使用場景樣例代碼

    public Result<Void> uploadOrder(TotalPayInfoVo totalPayInfoVo) {
        try {
            saveTotalPayInfoVo(totalPayInfoVo);
            //發送消息
            UnitWork.getCurrent().pushMessage();
        } catch (Exception e) {
            cashLogger.error("uploadOrder error,data: {}, error: {}", JSON.toJSONString(totalPayInfoVo), e);
            throw new RuntimeException("保存失敗", e);
        } finally {
            UnitWork.clean();//
        }
        return ResultUtil.successResult();避免內存泄漏
    }

ThreadLocal使用源碼

/**
 * 工作單元,在同一個線程中負責記錄一個事件或者一個方法或者一個事務過程中產生的變化,等操作結束後再處理這種變化。
 */
public class UnitWork {
    private UnitWork() {
    }
    private static ThreadLocal<UnitWork> current = new ThreadLocal<UnitWork>() {
        protected UnitWork initialValue() {
            return new UnitWork();
        }
    };
    /**
     * 狀態變化的instance
     */
    private Set<String> statusChangedInstances = new HashSet<>();
    public void addStatusChangedInstance(String instance) {
        statusChangedInstances.add(instance);
    }
    /**
     * 推送消息
     */
    public void pushMessage() {
       for(String id : statusChangedInstances){
            //異步發消息
       }
    }
    public static UnitWork getCurrent() {
        return current.get();
    }
    /**
     * 刪除當前線程的工作單元,建議放在finally中調用,避免內存泄漏
     */
    public static void clean() {
        current.remove();
    }
}

思考問題

為瞭避免內存泄漏,每次用完做一下clean清理操作。發送消息的過程是異步的,意味著clean的時候可能和發送消息同時進行。那麼會不會把這些Id清理掉?那麼可能造成消息發送少瞭。要回答這個問題,首先要搞懂ThreadLocal的引用關系,remove操作做瞭什麼?

ThreadLocal解讀

ThreadLocal可以分別在各個線程保存變量獨立副本。每個線程都有ThreadLocalMap,顧名思義,類似Map容器,不過是用數組Entry[]來模擬的。那麼既然類似Map,肯定會存在Key。其實Key是ThreadLocal類型,Key的值是ThreadLocal的HashCode,即通過threadLocalHashCode計算出來的值。

這個Map的Entry並不是ThreadLocal,而是一個帶有弱引用的Entry。既然是弱引用,每次GC的時候都會回收。

        static class Entry extends WeakReference<ThreadLocal> {
            /** The value associated with this ThreadLocal. */
            Object value;
            Entry(ThreadLocal k, Object v) {
                super(k);
                value = v;
            }
        }

而Key對應的value就是要保存在線程副本Object,這裡指的就是UnitWork的實例。調用ThreadLocal的get方法時,首先找到當前線程的ThreadLocalMap,然後根據這個ThreadLocal算出來的hashCode找到保存線程副本Object。

他們的關系對應如下:

這裡寫圖片描述

ThreadLocal在remove的時候,會調用Entry的clear,即弱引用的clear方法。把Key->ThreadLocal的引用去掉。接下來的expungeStaleEntry會把entry中value引用設置為null。

        /**
         * Remove the entry for key.
         */
        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;
                }
            }
        }

現在可以回答之前提前的問題。雖然ThreadLocal和當前線程都會與Object脫離瞭引用的關系,但是最重要一點就是異步的線程仍然存在一條強引用路徑到Object,即到UnitWork實例的強引用。因此GC然後不會回收UnitWork的實例,發消息還是不會少發或者出現空指針情況。

以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。

推薦閱讀: