1. 程式人生 > 其它 >【586】Terminal 使用 for 語句

【586】Terminal 使用 for 語句

什麼是ThreadLocal?

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one has its own, independently initialized copy of the variable.

threadLocal是用於執行緒內部儲存的類,通過threadLocal可以實現執行緒獨享的儲存空間。不同於執行緒同步的概念,threadLocal是讓每個執行緒用於獨立的資料。

使用示例

示例程式碼:

public class ThreadLocalExample {
    static final ThreadLocal<Human> holder = new ThreadLocal<>();

    public static void main(String[] args) {
        Thread t1 = new Thread(()->{
            holder.set(new Human("張三", 21, 0));
            System.out.println(Thread.currentThread().getName() + ":" + holder.get());
        }, "t1");

        Thread t2 = new Thread(()->{
            holder.set(new Human("李四", 22, 0));
            System.out.println(Thread.currentThread().getName() + ":" + holder.get());
        }, "t2");

        t1.start();
        t2.start();

        try{
            t1.join();
            t2.join();
            System.out.println(Thread.currentThread().getName() + ":" + holder.get());
        }catch (Exception e){
            e.printStackTrace();
        }
    }
}

執行結果:

t2:Human{name='李四', age=22, gender=0}
t1:Human{name='張三', age=21, gender=0}
main:null

可以發現不同的執行緒使用同一個threadLocal物件的get方法獲得的結果是不一樣的。這就表示這是資料是線上程中單獨儲存的。

實現原理

set(T value)

public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
}

從set方法可以發現value實際上是儲存在一個叫ThreadLocalMap的物件中的,而這個物件是與當前執行緒關聯的。set方法會先拿到這個map,然後將this和value作為鍵值對儲存。注意,這裡的鍵值對的key是threadLocal物件本身。

如果map為null的話,會先建立map並新增第一個鍵值對。

getMap(Thread t)

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

threadLocal是從執行緒物件獲取到的map物件。

在Thread類中這個map物件定義如下:

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

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

與set相同,get同樣是從執行緒物件獲取到的map。然後用threadLocal物件作為key來獲取entry物件,這裡的entry物件與HashMap的entry類似,記錄了一個鍵值對。

可以發現,如果執行緒的map為null時,會進行一次初始化。

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

建立map

通過set和get的分析,threadLocalMap採用了一種懶漢式的初始化方法,threadLocalMap的建立其實是在第一次set或get的時候完成的。

ThreadLocalMap

通過對threadLocal的get、set分析,我們發現這兩個方法都是用threadLocal物件作為key線上程物件所擁有的ThreadLocalMap物件上做鍵值對操作。接下來就來看看ThreadLocalMap是什麼。

資料結構之 Hash表

Hash表是使用hashcode作為陣列下標來存放資料的資料結構。它會先計算出物件的hashcode,然後根據hashcode找到槽位(slot)放入。訪問時會計算hashcode然後直接根據下標找到物件。這樣使得訪問的時間複雜度變成了O(1)。

因為hash演算法,可能會出現不同物件擁有相同hashcode 的場景,明顯衝突時你不能直接替換槽位裡現有的物件。所以需要解決hash衝突的方法:

  1. 拉鍊法:拉鍊法是在槽位中構建連結串列,這樣一個槽位就可以存放多個hash衝突的物件。不過因為連結串列訪問時間複雜度位O(N),這種方式降低了訪問效率。java中的HashMap等都採用了拉鍊法。
  2. 開放地址法:開放地址法是在衝突後,從衝突位置向後尋找空位放入。這種方式實現簡單,但是使訪問和新增效率都降低了。ThreadLocalMap使用的是這種方式。

Entry

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

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

Entry繼承了WeakReference,代表Entry物件是一個弱引用。弱引用不管記憶體空間是否充足,只要發生垃圾回收就會被清除。至於使用弱引用的原因,我會在後面關於記憶體洩漏的解決中解釋。

  • 強引用:使用new建立的物件都是強引用,強引用不會被垃圾回收
  • 軟引用:使用SoftReference建立軟引用,如果一個物件只有軟引用指向,在記憶體不足時觸發的垃圾回收中會被回收
  • 弱引用:使用WeakReference建立弱引用,只有弱引用的物件只要發生垃圾回收就會被回收
  • 虛引用:虛引用主要用來跟蹤物件被垃圾回收器回收的活動。虛引用與軟引用和弱引用的一個區別在於:虛引用必須和引用佇列 (ReferenceQueue)聯合使用。

除了弱引用,還可以發現entry不同於HashMap的Entry,它只有value屬性,沒有key屬性。這是因為ThreadLocalMap是使用threadLocal作為key,這裡的key-value關係體現在弱引用是引用的ThreadLocal型別。即這個弱引用本身就是key。

set(ThreadLocal key, T value)

private void set(ThreadLocal<?> key, Object value) {

        // We don't use a fast path as with get() because it is at
        // least as common to use set() to create new entries as
        // it is to replace existing ones, in which case, a fast
        // path would fail more often than not.

        Entry[] tab = table;
        int len = tab.length;
    	// 用threadLocal物件的hashcode計算陣列槽位
        int i = key.threadLocalHashCode & (len-1);
    
		// 從i位置開始,尋找空槽位
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            
            ThreadLocal<?> k = e.get();
			// key相同,替換值
            if (k == key) {
                e.value = value;
                return;
            }
			// key為null,是髒entry
            if (k == null) {
                // 替換髒entry
                replaceStaleEntry(key, value, i);
                return;
            }
        }
		
        tab[i] = new Entry(key, value);
        nt sz = ++size;
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
}

ThreadLocalMap的set方法不同於HashMap的set方法,它不涉及連結串列或紅黑樹,因為它是使用開放地址法解決的hash衝突。

之前說過ThreadLocalMap使用ThreadLocal物件作為key,所以第一步會從ThreadLocal物件獲取到hashcode並計算出在陣列中的下標。

因為使用的是開放地址發解決hash衝突,所以會從這個計算出的下標開始遍歷陣列。

nextIndex(int i, int len)

private static int nextIndex(int i, int len) {
	return ((i + 1 < len) ? i + 1 : 0);
}

當下標超過len的時候回到0從頭開始

getEntry(ThreadLocal<?> key)

private Entry getEntry(ThreadLocal<?> key) {
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
    	// key命中
        if (e != null && e.get() == key)
            return e;
        else
            // 沒有命中,通過線性探測法向後尋找
            return getEntryAfterMiss(key, i, e);
    }

getEntry首先會用ThreadLocal物件的hashcode計算出陣列下標,然後判斷該下標的entry是否命中。沒有命中的話會採用線性探測法向後尋找。

getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e)

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;
		// 從i位置開始線性探測
        while (e != null) {
            ThreadLocal<?> k = e.get();
            // key命中
            if (k == key)
                return e;
            // 發現k為null的髒entry
            if (k == null)
                expungeStaleEntry(i);
            // 向後遍歷
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }

getEntryAfterMiss是getEntry中沒有命中後的線性探測。它會從i位置開始向後搜尋,如果有命中則返回value,如果找到key為null的值會視其為髒entry而清理掉。

記憶體洩漏

什麼是記憶體洩漏

用人話說,記憶體洩漏就是一塊記憶體無法被訪問到,但是又沒有被回收清除。比如以下程式碼中HashMap的記憶體洩漏:

static HashMap<Object, Object> map = new HashMap<>(16);
    public static void main(String[] args) {
        memoryLeak();
        // 現在如何訪問到v1?
    }

    private static void memoryLeak(){
        Object k1 = new Object();
        Object v1 = new Object();
        map.put(k1, v1);
    }

因為k1的作用域只有memoryLeak這個方法,方法結束後我們將無法訪問到v1物件,但是v1物件因為HashMap的引用並沒有被回收,這就導致了一個無法訪問的記憶體空間,也就是記憶體洩漏。

ThreadLocalMap如何解決記憶體洩漏

ThreadLocal的記憶體洩漏

ThreadLocal的整個引用關係如圖,可以發現ThreadLocal的物件是具有一個強引用的,我們通過這個引用去訪問entry。如果這個引用斷開了,這個entry將無法被訪問到,但是有因為它被ThreadLocalMap強引用,所以沒被回收,導致了記憶體洩漏。

弱引用

static class Entry extends WeakReference<ThreadLocal<?>>

之前有提到ThreadLocalMap的Entry繼承了WeakReference。之所以使用WeakReference就是在一定程度上解決記憶體洩漏。

  • WeakReference通過get()方法獲取它引用的物件,如果該物件已經被回收了就會返回null
  • ThreadLocalMap通過ThreadLocal物件訪問value,如果我們沒有ThreadLocal物件就訪問不到value,也就導致了記憶體洩漏
  • 可以知道弱引用被回收後get()返回null,那麼當我們沒有ThreadLocal物件的引用時,ThreadLocal物件只有弱引用,它將被回收,此時get()返回null

綜上,如果ThreadLocal物件被回收了,那麼Entry的弱引用會返回null。這樣只要發現null,我們就將這個Entry稱為髒entry(StaleEntry),即發生了記憶體洩漏的entry,需要即時清理。接下來就介紹何時發現null,又如何清理。

expungeStaleEntry

getEntry時的清理

ThreadLocal<?> k = e.get();
// key命中
if (k == key)
	return e;
// 發現k為null的髒entry
if (k == null)
    // 清理髒entry
	expungeStaleEntry(i);

getEntry的時候,當發現e.get()為null是會呼叫expungeStaleEntry清理髒entry。具體清理過程如下:

private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
			
    		// part I
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // part II
            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;
        }

我們將清理的程式碼分為Part I,II兩個部分。

  • Part I:這一部分程式碼將該下標的entry的value設定成了null,這樣使得value物件沒有引用指向可以被回收。最後將該下標設為null,變成了空位。
  • Part II:這部分程式碼是在將後面的entry前移,因為現在清楚了髒entry就多出了一個空位,所以要將後面的非空entry向前移動。這樣做的好處是避免了用hashcode沒有命中entry時向後線性探測遇到null結束。

replaceStaleEntry

set()時清理髒entry

ThreadLocal<?> k = e.get();
// key相同,替換值
if (k == key) {
	e.value = value;
	return;
}
// key為null,是髒entry
if (k == null) {
	// 替換髒entry
	replaceStaleEntry(key, value, i);
	return;
}

與getEntry時相似,也是線上性探測的過程中發現了k==null觸發的清理。

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;
	
    // ---------------- Part I ----------------------------------- 
    
    // Back up to check for prior stale entry in current run.
    // We clean out whole runs at a time to avoid continual
    // incremental rehashing due to garbage collector freeing
    // up refs in bunches (i.e., whenever the collector runs).
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;
    
	// ---------------- Part II ----------------------------------- 
    // Find either the key or trailing null slot of run, whichever
    // occurs first
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // If we find key, then we need to swap it
        // with the stale entry to maintain hash table order.
        // The newly stale slot, or any other stale slot
        // encountered above it, can then be sent to expungeStaleEntry
        // to remove or rehash all of the other entries in run.
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // Start expunge at preceding stale entry if it exists
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // If we didn't find stale entry on backward scan, the
        // first stale entry seen while scanning for key is the
        // first still present in the run.
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
	
    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

同樣,我們把程式碼分為兩部分。

  • Part I:這一部分程式碼的作用是從當前髒entry位置向前找到最靠前的髒entry,至於具體的原因這裡涉及到了垃圾回收器的工作原理,所以不多介紹
  • Part II:依然是使用線性探測法向後遍歷,當找到目標的key後會把目標key和髒entry交換

總結(*)

ThreadLocalMap使用了弱引用和線性探測的方法避免記憶體洩漏。不過這並不能完全避免記憶體洩漏,因為只有在使用了get和set,並且遍歷到了發生記憶體洩漏的下標才會清理,如果發生洩露後我們沒有呼叫相關方法去解決,那麼記憶體洩漏依然存在。

要完全解決記憶體洩漏有兩種方式:

  • 結束執行緒,釋放掉ThreadLocalMap的記憶體:也就是打斷之前圖中的Thread到entry的強引用鏈,使垃圾回收清除entry。
  • 手動呼叫remove方法:在使用完後使用remove移出entry