ThreadLocal原理及魔數0x61c88647


ThreadLocal結構

下圖是本文介紹到的一些對象之間的引用關系圖,實線表示強引用,虛線表示弱引用:

ThreadLocal的hashcode

ThreadLocalMap中都需要根據索引i來get,set

 int i = key.threadLocalHashCode & (len-1);

這里關鍵的threadLocalHashCode

下面仿照ThreadLocal來跑threadLocalHashCode

單線程,多實例化

public class ThreadLocalMapDemo {

	private final int threadLocalHashCode = nextHashCode();

	private static AtomicInteger nextHashCode =
			new AtomicInteger();

	private static final int HASH_INCREMENT = 0x61c88647;

	private static int nextHashCode() {
		return nextHashCode.getAndAdd(HASH_INCREMENT);
	}

	public static void main(String[] args) {
		System.out.println(new ThreadLocalMapDemo().threadLocalHashCode);
		System.out.println(new ThreadLocalMapDemo().threadLocalHashCode);
		System.out.println(new ThreadLocalMapDemo().threadLocalHashCode);
		System.out.println(new ThreadLocalMapDemo().threadLocalHashCode);
	}
}

Output:

0
1640531527
-1013904242
626627285

多線程,單實例化

public class ThreadLocalMapDemo {

	private final int threadLocalHashCode = nextHashCode();

	private static AtomicInteger nextHashCode =
			new AtomicInteger();

	private static final int HASH_INCREMENT = 0x61c88647;

	private static int nextHashCode() {
		return nextHashCode.getAndAdd(HASH_INCREMENT);
	}

	public static void main(String[] args) {
		for(int i=0;i<5;i++){
			new Thread(() -> {
				System.out.println("threadName:"+Thread.currentThread().getName()+":"+new ThreadLocalMapDemo().threadLocalHashCode);
			}).start();
		}
	}
}

Output:

threadName:Thread-0:0
threadName:Thread-1:1640531527
threadName:Thread-2:-1013904242
threadName:Thread-3:626627285
threadName:Thread-4:-2027808484

將ThreadLocal對象和局部變量作為key-value初始化一個Entry實例並存儲到數組里之前哈希映射到的位置里

每次實例化ThreadLocal,那么就會生成不同的threadLocalHashCode,從而將Entry均勻的分布到數組table中。

設置初始值

設置初始值方法如下

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

該方法為 private 方法,無法被重載。

首先,通過initialValue()方法獲取初始值。該方法為 public 方法,且默認返回 null。所以典型用法中常常重載該方法。上例中即在內部匿名類中將其重載。

然后拿到該線程對應的 ThreadLocalMap 對象,若該對象不為 null,則直接將該 ThreadLocal 對象與對應實例初始值的映射添加進該線程的 ThreadLocalMap中。若為 null,則先創建該 ThreadLocalMap 對象再將映射添加其中。

這里並不需要考慮 ThreadLocalMap 的線程安全問題。因為每個線程有且只有一個 ThreadLocalMap 對象,並且只有該線程自己可以訪問它,其它線程不會訪問該 ThreadLocalMap,也即該對象不會在多個線程中共享,也就不存在線程安全的問題。

 private static ThreadLocal<StringBuilder> counter = new ThreadLocal<StringBuilder>() {
      @Override
      protected StringBuilder initialValue() {
        return new StringBuilder();
      }
    };

set方法

ThreadLocal內部code:

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

ThreadLocalMap內部code:

 ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            setThreshold(INITIAL_CAPACITY);
        }

ThreadLocalMap--set

/**
  * Set the value associated with key.
  *     存儲鍵值對,比較有趣的是Entry並不是鏈表,這意味着ThreadLocalMap底層只是數組
  *     其解決沖突(或者說散列優化)的關鍵在於神奇的0x61c88647
  *     若遇到過期槽,就占用該過期槽(會涉及位移和槽清除操作)
  *     當清理成功同時到達閾值,需要擴容
  * @param key the thread local object
  * @param value the value to be set
  */
private void set(ThreadLocal key, Object value) {
    Entry[] tab = table;
    int len = tab.length;//數組容量
    //計算數組下標 跟HashMap的 index = key.hashCode() & (cap -1) 保持一致(即取模運算優化版) 
    int i = key.threadLocalHashCode & (len-1);
    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;
        }
        //若當前槽為過期槽,就清除和占用該過期槽
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
        //否則繼續往后 直到找到key相等或第一個過期槽為止
    }
    tab[i] = new Entry(key, value);
    int sz = ++size;
    //當清理成功同時到達閾值,需要擴容
    //cleanSomeSlots要處理的量是已有元素數量
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
/**
  * Increment i modulo len. 不超過長度就自增1
  */
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

通過i取出的entry對象取出的key為null(思考:為什么會有存儲在ThreadLocalMap對象中的entry實體中的key為null?)

對於已經不再被使用且已被回收的 ThreadLocal 對象,它在每個線程內對應的實例由於被線程的ThreadLocalMapEntry 強引用,無法被回收,可能會造成內存泄漏。

針對該問題,ThreadLocalMapset方法中,通過 replaceStaleEntry 方法將所有鍵為 null Entry 的值設置為 null,從而使得該值可被回收。另外,會在 rehash 方法中通過 expungeStaleEntry 方法將鍵和值為null的 Entry 設置為 null 從而使得該 Entry 可被回收。通過這種方式,ThreadLocal 可防止內存泄漏。

replaceStaleEntry(清除和占用該過期槽)

 /**
 * Replace a stale entry encountered during a set operation with an entry 
 * for the specified key.  The value passed in the value parameter is stored in
 * the entry, whether or not an entry already exists for the specified key.
 *   在set時用新元素替換掉一個過期元素(也就是占用過期元素的所在槽)
 * As a side effect, this method expunges all stale entries in the
 * "run" containing the stale entry.  (A run is a sequence of entries
 * between two null slots.)
 *  該方法的副作用是將當前過期槽前后兩個空槽之間的所有過期元素全部移除
 * @param  key the key
 * @param  value the value to be associated with key
 * @param  staleSlot index of the first stale entry encountered while searching for key. 
 *      !!重點:過期槽:這里指的都是key為null的槽,由於key(ThreadLocal)是弱引用類型,
 *      !!所以可能被GC自動回收,從而導致key為null,但槽對應的Entry並不一定被回收,value不一定被回收
 */
private void replaceStaleEntry(ThreadLocal key, Object value, int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;
    int slotToExpunge = staleSlot;//先備份一個要處理的過期槽下標
    //1和2 的綜合作用是將當前過期槽前后兩個空槽之間的所有過期元素全部移除
    //1.從當前過期槽開始往前找,一旦找到一個空槽就停止,記錄前一個空槽下標
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;i = prevIndex(i, len))
        //找到前一個空槽並記錄其下標
        if (e.get() == null) 
            slotToExpunge = i;
    //Find either the key or trailing null slot of run, whichever occurs first
    //2.從當前過期槽開始往后找,一旦找到當前key 或 之后的第一個空槽 就停止
    for (int i = nextIndex(staleSlot, len);
            (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal k = e.get();
        //一旦先找到key,就替換
        if (k == key) {
            e.value = value;
            tab[i] = tab[staleSlot];//原槽點對應entry移動到新的槽點上
            tab[staleSlot] = e;//當前entry占領原槽點
            // Start expunge at preceding stale entry if it exists
            //當第一次掃描找到,slotToExpunge要變成i,作為后續清除操作的新的起始槽點
            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.
        //當第一次掃描的時候就碰到過期槽點(或空槽點),slotToExpunge要變成i
        //作為后續清除操作的起始槽點
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }
    // If key not found, put new entry in stale slot
    // 若key不存在,直接用新元素占據該過期槽點
    tab[staleSlot].value = null;//先把過期槽點的value清除,防止泄露
    tab[staleSlot] = new Entry(key, value);//占領
    // If there are any other stale entries in run, expunge them
    //若還有過期元素,清除他們
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

cleanSomeSlots

/**
  * Heuristically scan some cells looking for stale entries.
  * This is invoked when either a new element is added, or
  * another stale one has been expunged. It performs a
  * logarithmic number of scans, as a balance between no
  * scanning (fast but retains garbage) and a number of scans
  * proportional to number of elements, that would find all
  * garbage but would cause some insertions to take O(n) time.
  *     當添加一個新元素或一個過期元素被移除時,該方法會被調用,用來掃描一些槽的過期元素並清洗
  *     為了取得無掃描和全掃描之間的一個平衡,該方法使用對數掃描(也就是log)
  *     它將發現需要回收的元素同時可能導致插入操作的性能降低為O(n)
  * @param i a position known NOT to hold a stale entry. The
  *     scan starts at the element after i. 從該槽點之后開始掃描(已知該槽點沒有存儲過期元素)
  * @param n scan control: <tt>log2(n)</tt> cells are scanned,
  * unless a stale entry is found, in which case <tt>log2(table.length)-1</tt>
  * additional cells are scanned.When called from insertions,this parameter is the number
  * of elements, but when from replaceStaleEntry, it is the table length.
  *     log2(n)個槽點將被掃描,當插入時被調用,這指的是已有元素數量,當替換時被調用,指的是數組容量
  * But this version is simple, fast, and seems to work well.
  *     官方說這種寫法簡單、快速同時工作良好,讀者可自行測試一番(主要跟n的權重有關)
  * @return true if any stale entries have been removed.
  *     一旦有過期元素被移除,就返回true,表示至少有一個過期元素被清除成功
  */
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    //這里跟skipList的跳躍思想有點類似,區別是跳躍表是空間換時間,這是就是簡單的跳躍
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        //找到一個過期槽點(可能也是空槽點)
        if (e != null && e.get() == null) {
            n = len;
            removed = true;//找到一個過期槽點就標志成功
            //但有個疑問就是此時並沒有完成清洗操作,但文檔描述稱 have been removed
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);//2進制往右移動一位:即log2(n)
    //簡單回顧一下數學知識:2^n 的逆運算就是 log2(n),不理解的讀者請心中愧對中學數學老師3秒鍾
    return removed;
}

expungeStaleEntry

/**
  * Expunge a stale entry by rehashing any possibly colliding entries
  * lying between staleSlot and the next null slot. This also expunges
  * any other stale entries encountered before the trailing null.  
  *     在當前過期槽點和下一個空槽點之間,移除過期元素
  *     該方法主要干了兩個事情:
  *         1.清理當前過期槽
  *         2.從下一個槽開始遍歷數組,移除過期槽,一旦遇到空槽就停止:
  *             2.1 當key為空時,移除過期槽
  *             2.2 當key非空但rehash之后rehash之后下標變化則移除原槽,元素搬遷新空槽
  * @param staleSlot index of slot known to have null key
  * @return the index of the next null slot after staleSlot 返回過期槽后面第一個空槽下標
  * (all between staleSlot and this slot will have been checked for expunging).
  *     在當前過期槽點和下一個空槽點之間所有過期元素都會被移除
  */
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;//注意是數組容量
    // expunge entry at staleSlot 移除過期槽中的過期元素 加速GC
    tab[staleSlot].value = null;//1.value help gc
    tab[staleSlot] = null;//2.slot help gc
    size--;
    // Rehash until we encounter null 遍歷數組並Rehash,直到遇到null時停止
    Entry e;
    int i;
    //從當前過期槽的下一個槽開始遍歷數組
    for (i = nextIndex(staleSlot, len);
        //根據(e = tab[i]) != null可知,一旦遇到空槽就停止
         (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal k = e.get();
        //key是空就清除元素,防止內存泄露,help gc
        if (k == null) {
            //為了防止內存泄露,當ThreadLocal已過期失效時,通過主動移除value和slot幫助加速GC
            //同時還可以空出一個空槽供后面使用,不浪費空間
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            //當key已存在,則需要重新計算下標(為什么不叫index而叫h?)
            int h = k.threadLocalHashCode & (len - 1);
            //當前后坐標不一致時(可能是擴容導致的 - 總之就是len變動導致下標變化)
            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;
}

魔數0x61c88647與碰撞解決

  • 機智的讀者肯定發現ThreadLocalMap並沒有使用鏈表或紅黑樹去解決hash沖突的問題,而僅僅只是使用了數組來維護整個哈希表,那么重中之重的散列性要如何保證就是一個很大的考驗
  • ThreadLocalMap通過結合三個巧妙的設計去解決這個問題:
    • 1.Entry的key設計成弱引用,因此key隨時可能被GC(也就是失效快),盡量多的面對空槽
    • 2.(單個ThreadLocal時)當遇到碰撞時,通過線性探測的開放地址法解決沖突問題
    • 3.(多個ThreadLocal時)引入了神奇的0x61c88647,增強其的散列性,大大減少碰撞幾率
  • 之所以不用累加而用該值,筆者認為可能跟其找最近的空槽有關(跳躍查找比自增1查找用來找空槽可能更有效一些,因為有了更多可選擇的空間spreading out),同時也跟其良好的散列性有關
  • 0x61c88647與黃金比例、Fibonacci 數有關,讀者可參見What is the meaning of 0x61C88647 constant in ThreadLocal.java
private static final int HASH_INCREMENT = 0x61c88647;
/**
 * Returns the next hash code.
 *  每個ThreadLocal的hashCode每次累加HASH_INCREMENT
 */
private static int nextHashCode() {
    //the previous id + our magic number
    return nextHashCode.getAndAdd(HASH_INCREMENT); 
}

ThreadLocal與內存泄露

ThreadLocal導致內存泄露的錯誤行為

  • 1.使用static的ThreadLocal,延長了ThreadLocal的生命周期,可能導致內存泄漏
  • 2.分配使用了ThreadLocal又不再調用get()set()remove()方法 就會導致內存泄漏
  • 3.當使用線程池時,即當前線程不一定會退出(比如固定大小的線程池),這樣將一些大對象設置到ThreadLocal中,可能會導致系統出現內存泄露(當對象不再使用時,因為引用存在,無法被回收)

ThreadLocal導致內存泄露的根源

  • 首先需要明確一點:ThreadLocal本身的設計是不會導致內存泄露的,原因更多是使用不當導致的!
  • ThreadLocalMap對象被Thread對象所持有,當線程退出時,Thread類執行清理操作,比如清理ThreadLocalMap;否則該ThreadLocalMap對象的引用並不會被回收。
//先回顧一下:Thread的exit方法
/**
  * This method is called by the system to give a Thread
  * a chance to clean up before it actually exits.
  */
private void exit() {
    if (group != null) {
        group.threadTerminated(this);
        group = null;
    }
    /* Aggressively null out all reference fields: see bug 4006245 */
    target = null;
    /* Speed the release of some of these resources */
    threadLocals = null;//清空threadLocalMap的引用
    inheritableThreadLocals = null;
    inheritedAccessControlContext = null;
    blocker = null;
    uncaughtExceptionHandler = null;
}
  • 根源:由於Entry的key弱引用特性(見注意),當每次GC時JVM會主動將無用的弱引用回收掉,因此當ThreadLocal外部沒有強引用依賴時,就會被自動回收,這樣就可能造成當ThreadLocal被回收時,相當於將Map中的key設置為null,但問題是該key對應的entry和value並不會主動被GC回收
  • 當Entry和value未被主動回收時,除非當前線程死亡,否則線程對於Entry的強引用會一直存在,從而導致內存泄露
  • 建議: 當希望回收對象,最好使用ThreadLocal.remove()方法將該變量主動移除,告知JVM執行GC回收
  • 注意: ThreadLocal本身不是弱引用的,Entry繼承了WeakReference,同時Entry又將自身的key封裝成弱引用,所有真正的弱引用是Entry的key,只不過恰好Entry的key是ThreadLocal!!
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        //這里才是真正的弱引用!!
        super(k);//將key變成了弱引用!而key恰好又是ThreadLocal!
        value = v;
    }
}
public class WeakReference<T> extends Reference<T> {
    public WeakReference(T referent) {
        super(referent);
    }
    public WeakReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
    }
}

仿ThreadLocalMap結構測試

public class AnalogyThreadLocalDemo {

	public static void main(String[] args) {
		HashMap map = new HashMap();
		Obj o1 = new Obj();
		Obj o2 = new Obj();
		map.put(o1, "o1");
		map.put(o2, "o2");
		o1 = null;
		System.gc();
		System.out.println("##########o1 gc:" + map);
		o2 = null;
		System.gc();
		System.out.println("##########o2 gc:" + map);
		map.clear();
		System.gc();
		System.out.println("##########GC after map clear:" + map);
	}
}

class Obj {
	private final String DESC = "obj exists";
	@Override
	public String toString() {
		return DESC;
	}
	@Override
	protected void finalize() throws Throwable {
		System.out.println("##########gc over");
	}
}

設置VM options:

-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintTenuringDistribution
-XX:+PrintGCTimeStamps

Output:

0.316: [GC (System.gc()) 
Desired survivor size 11010048 bytes, new threshold 7 (max 15)
[PSYoungGen: 7911K->1290K(76288K)] 7911K->1298K(251392K), 0.0025504 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.319: [Full GC (System.gc()) [PSYoungGen: 1290K->0K(76288K)] [ParOldGen: 8K->1194K(175104K)] 1298K->1194K(251392K), [Metaspace: 3310K->3310K(1056768K)], 0.0215288 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
##########o1 gc:{obj exists=o1, obj exists=o2}
0.342: [GC (System.gc()) 
Desired survivor size 11010048 bytes, new threshold 7 (max 15)
[PSYoungGen: 1310K->64K(76288K)] 2504K->1258K(251392K), 0.0002418 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.342: [Full GC (System.gc()) [PSYoungGen: 64K->0K(76288K)] [ParOldGen: 1194K->964K(175104K)] 1258K->964K(251392K), [Metaspace: 3322K->3322K(1056768K)], 0.0058113 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
##########o2 gc:{obj exists=o1, obj exists=o2}
0.348: [GC (System.gc()) 
Desired survivor size 11010048 bytes, new threshold 7 (max 15)
[PSYoungGen: 1310K->32K(76288K)] 2275K->996K(251392K), 0.0002203 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
0.349: [Full GC (System.gc()) [PSYoungGen: 32K->0K(76288K)] [ParOldGen: 964K->964K(175104K)] 996K->964K(251392K), [Metaspace: 3322K->3322K(1056768K)], 0.0055209 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
##########gc over
##########gc over
##########GC after map clear:{}
Heap
 PSYoungGen      total 76288K, used 3932K [0x000000076af00000, 0x0000000770400000, 0x00000007c0000000)
  eden space 65536K, 6% used [0x000000076af00000,0x000000076b2d7248,0x000000076ef00000)
  from space 10752K, 0% used [0x000000076ef00000,0x000000076ef00000,0x000000076f980000)
  to   space 10752K, 0% used [0x000000076f980000,0x000000076f980000,0x0000000770400000)
 ParOldGen       total 175104K, used 964K [0x00000006c0c00000, 0x00000006cb700000, 0x000000076af00000)
  object space 175104K, 0% used [0x00000006c0c00000,0x00000006c0cf1240,0x00000006cb700000)
 Metaspace       used 3328K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 355K, capacity 388K, committed 512K, reserved 1048576K

可以看出,當map.clear()以后,Obj對象才被finalize回收

總結

  • ThreadLocal 並不解決線程間共享數據的問題
  • ThreadLocal 通過隱式的在不同線程內創建獨立實例副本避免了實例線程安全的問題
  • 每個線程持有一個 Map 並維護了 ThreadLocal 對象與具體實例的映射,該 Map 由於只被持有它的線程訪問,故不存在線程安全以及鎖的問題
  • ThreadLocalMap 的 Entry 對 ThreadLocal 的引用為弱引用,避免了 ThreadLocal 對象無法被回收的問題
  • ThreadLocalMap 的 set 方法通過調用 replaceStaleEntry 方法回收鍵為 null 的 Entry 對象的值(即為具體實例)以及 Entry 對象本身從而防止內存泄漏
  • ThreadLocal 適用於變量在線程間隔離且在方法間共享的場景

參考:

Java進階(七)正確理解Thread Local的原理與適用場景

ThreadLocal源碼詳細解析、魔數0x61c88647

ThreadLocal的hash算法(關於 0x61c88647)

徹底理解ThreadLocal

並發番@ThreadLocal一文通(1.7版)

並發之線程封閉與ThreadLocal解析


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM