做者:小傅哥
博客:https://bugstack.cnhtml
沉澱、分享、成長,讓本身和他人都能有所收穫!😄
說到底,你真的會造火箭嗎?
java
常說面試造火箭,入職擰螺絲。但你真的有造火箭的本事嗎,大部分都是不敢認可本身的知識盲區和技術瓶頸以及經驗不足的自嘲。git
面試時:github
除法散列法
、平方散列法
、斐波那契(Fibonacci)散列法
等。因此,從不是CRUD選擇了你,也不是造螺絲讓你成爲工具人。而是你的技術能力決定你的眼界,眼界又決定了你寫出的代碼!面試
謝飛機,小記
尚未拿到 offer 的飛機,早早起了牀,吃完兩根油條,又跑到公司找面試官取經!算法
面試官:飛機,聽坦克說,你最近貪黑起早的學習呀。編程
謝飛機:嗯嗯,是的,最近頭髮都快掉沒了!設計模式
面試官:那今天咱們聊聊 ThreadLocal
,通常能夠用在什麼場景中?數組
謝飛機:嗯,ThreadLocal
要解決的是線程內資源共享 (This class provides thread-local variables.),因此通常會用在全鏈路監控中,或者是像日誌框架 MDC
這樣的組件裏。安全
面試官:飛機不錯哈,最近確實學習了。那你知道 ThreadLocal
是怎樣的數據結構嗎,採用的是什麼散列方式?
謝飛機:數組?嗯,怎麼散列的不清楚...
面試官:那 ThreadLocal
有內存泄漏的風險,是怎麼發生的呢?另外你瞭解在這個過程的,探測式清理和啓發式清理嗎?
謝飛機:這...,盲區了,盲區了,可樂
我放桌上了,我回家再看看書!
ThreadLocal
,做者:Josh Bloch
and Doug Lea
,兩位大神👍
若是僅是平常業務開發來看,這是一個比較冷門的類,使用頻率並不高。而且它提供的方法也很是簡單,一個功能只是潦潦數行代碼。但,若是深挖實現部分的源碼,就會發現事情並不那麼簡單。這裏涉及了太多的知識點,包括;數據結構
、拉鍊存儲
、斐波那契散列
、神奇的0x61c88647
、弱引用Reference
、過時key探測清理和啓發式清理
等等。
接下來,咱們就逐步學習這些盲區知識。本文涉及了較多的代碼和實踐驗證圖稿,歡迎關注公衆號:bugstack蟲洞棧
,回覆下載獲得一個連接打開後,找到ID:19🤫獲取!*
private SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public void seckillSku(){ String dateStr = f.format(new Date()); // 業務流程 }
你寫過這樣的代碼嗎?若是還在這麼寫,那就已經犯了一個線程安全的錯誤。SimpleDateFormat
,並非一個線程安全的類。
private static SimpleDateFormat f = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static void main(String[] args) { while (true) { new Thread(() -> { String dateStr = f.format(new Date()); try { Date parseDate = f.parse(dateStr); String dateStrCheck = f.format(parseDate); boolean equals = dateStr.equals(dateStrCheck); if (!equals) { System.out.println(equals + " " + dateStr + " " + dateStrCheck); } else { System.out.println(equals); } } catch (ParseException e) { System.out.println(e.getMessage()); } }).start(); } }
這是一個多線程下 SimpleDateFormat
的驗證代碼。當 equals 爲false
時,證實線程不安全。運行結果以下;
true true false 2020-09-23 11:40:42 2230-09-23 11:40:42 true true false 2020-09-23 11:40:42 2020-09-23 11:40:00 false 2020-09-23 11:40:42 2020-09-23 11:40:00 false 2020-09-23 11:40:00 2020-09-23 11:40:42 true false 2020-09-23 11:40:42 2020-08-31 11:40:42 true
爲了線程安全最直接的方式,就是每次調用都直接 new SimpleDateFormat
。但這樣的方式終究不是最好的,因此咱們使用 ThreadLocal
,來優化這段代碼。
private static ThreadLocal<SimpleDateFormat> threadLocal = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")); public static void main(String[] args) { while (true) { new Thread(() -> { String dateStr = threadLocal.get().format(new Date()); try { Date parseDate = threadLocal.get().parse(dateStr); String dateStrCheck = threadLocal.get().format(parseDate); boolean equals = dateStr.equals(dateStrCheck); if (!equals) { System.out.println(equals + " " + dateStr + " " + dateStrCheck); } else { System.out.println(equals); } } catch (ParseException e) { System.out.println(e.getMessage()); } }).start(); } }
如上咱們把 SimpleDateFormat
,放到 ThreadLocal
中進行使用,即不須要重複new對象,也避免了線程不安全問題。測試結果以下;
true true true true true true true ...
近幾年基於谷歌Dapper
論文實現非入侵全鏈路追蹤,使用的愈來愈廣了。簡單說這就是一套監控系統,但不須要你硬編碼的方式進行監控方法,而是基於它的設計方案採用 javaagent + 字節碼
插樁的方式,動態採集方法執行信息。若是你想了解字節碼插樁技術,能夠閱讀個人字節碼編程專欄:https://bugstack.cn/itstack-demo-agent/itstack-demo-agent.html
重點,動態採集方法執行信息。這塊是主要部分,跟 ThreadLocal
相關。字節碼插樁
解決的是非入侵式編程,那麼在一次服務調用時,在各個系統間以及系統內多個方法的調用,都須要進行採集。這個時候就須要使用 ThreadLocal
記錄方法執行ID,固然這裏還有跨線程調用使用的也是加強版本的 ThreadLocal
,但不管如何基本原理不變。
這裏舉例全鏈路方法調用鏈追蹤,部分代碼
public class TrackContext { private static final ThreadLocal<String> trackLocal = new ThreadLocal<>(); public static void clear(){ trackLocal.remove(); } public static String getLinkId(){ return trackLocal.get(); } public static void setLinkId(String linkId){ trackLocal.set(linkId); } }
@Advice.OnMethodEnter() public static void enter(@Advice.Origin("#t") String className, @Advice.Origin("#m") String methodName) { Span currentSpan = TrackManager.getCurrentSpan(); if (null == currentSpan) { String linkId = UUID.randomUUID().toString(); TrackContext.setLinkId(linkId); } TrackManager.createEntrySpan(); } @Advice.OnMethodExit() public static void exit(@Advice.Origin("#t") String className, @Advice.Origin("#m") String methodName) { Span exitSpan = TrackManager.getExitSpan(); if (null == exitSpan) return; System.out.println("鏈路追蹤(MQ):" + exitSpan.getLinkId() + " " + className + "." + methodName + " 耗時:" + (System.currentTimeMillis() - exitSpan.getEnterTime().getTime()) + "ms"); }
byte-buddy
,其實仍是使用,ASM
或者 Javassist
。測試方法
配置參數:-javaagent:E:\itstack\GIT\itstack.org\itstack-demo-agent\itstack-demo-agent-06\target\itstack-demo-agent-06-1.0.0-SNAPSHOT.jar=testargs
public void http_lt1(String name) { try { Thread.sleep((long) (Math.random() * 500)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("測試結果:hi1 " + name); http_lt2(name); } public void http_lt2(String name) { try { Thread.sleep((long) (Math.random() * 500)); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("測試結果:hi2 " + name); http_lt3(name); }
運行結果
onTransformation:class org.itstack.demo.test.ApiTest 測試結果:hi2 悟空 測試結果:hi3 悟空 鏈路追蹤(MQ):90c7d543-c7b8-4ec3-af4d-b4d4f5cff760 org.itstack.demo.test.ApiTest.http_lt3 耗時:104ms init: 256MB max: 3614MB used: 44MB committed: 245MB use rate: 18% init: 2MB max: 0MB used: 13MB committed: 14MB use rate: 95% name: PS Scavenge count:0 took:0 pool name:[PS Eden Space, PS Survivor Space] name: PS MarkSweep count:0 took:0 pool name:[PS Eden Space, PS Survivor Space, PS Old Gen] ------------------------------------------------------------------------------------------------- 鏈路追蹤(MQ):90c7d543-c7b8-4ec3-af4d-b4d4f5cff760 org.itstack.demo.test.ApiTest.http_lt2 耗時:233ms init: 256MB max: 3614MB used: 44MB committed: 245MB use rate: 18% init: 2MB max: 0MB used: 13MB committed: 14MB use rate: 96% name: PS Scavenge count:0 took:0 pool name:[PS Eden Space, PS Survivor Space] name: PS MarkSweep count:0 took:0 pool name:[PS Eden Space, PS Survivor Space, PS Old Gen]
90c7d543-c7b8-4ec3-af4d-b4d4f5cff760
。咳咳,除此以外全部須要活動方法調用鏈的,都須要使用到 ThreadLocal
,例如 MDC
日誌框架等。接下來咱們開始詳細分析 ThreadLocal
的實現。
瞭解一個功能前,先了解它的數據結構。這就至關於先看看它的地基,有了這個根本也就好日後理解了。如下是 ThreadLocal
的簡單使用以及部分源碼。
new ThreadLocal<String>().set("小傅哥");
private void set(ThreadLocal<?> key, Object value) { 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
底層採用的是數組結構存儲數據,同時還有哈希值計算下標,這說明它是一個散列表的數組結構,演示以下圖;
如上圖是 ThreadLocal
存放數據的底層數據結構,包括知識點以下;
Entry
,這裏沒用再打開,其實它是一個弱引用實現,static class Entry extends WeakReference<ThreadLocal<?>>
。這說明只要沒用強引用存在,發生GC時就會被垃圾回收。斐波那契(Fibonacci)散列法
,後面會具體分析。+1向後尋址
,直到找到空位置或垃圾回收位置進行存儲。既然 ThreadLocal
是基於數組結構的拉鍊法存儲,那就必定會有哈希的計算。但咱們翻閱源碼後,發現這個哈希計算與 HashMap
中的散列求數組下標計算的哈希方式不同。若是你忘記了HashMap,能夠翻閱文章《HashMap 源碼分析,插入、查找》、《HashMap 擾動函數、負載因子》
當咱們查看 ThreadLocal
執行設置元素時,有這麼一段計算哈希值的代碼;
private static final int HASH_INCREMENT = 0x61c88647; private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
看到這裏你必定會有這樣的疑問,這是什麼方式計算哈希?這個數字怎麼來的?
講到這裏,其實計算哈希的方式,毫不止是咱們日常看到 String 獲取哈希值的一種方式,還包括;除法散列法
、平方散列法
、斐波那契(Fibonacci)散列法
、隨機數法
等。
而 ThreadLocal
使用的就是 斐波那契(Fibonacci)散列法
+ 拉鍊法存儲數據到數組結構中。之因此使用斐波那契數列,是爲了讓數據更加散列,減小哈希碰撞。具體來自數學公式的計算求值,公式:f(k) = ((k * 2654435769) >> X) << Y對於常見的32位整數而言,也就是 f(k) = (k * 2654435769) >> 28
第二個問題,數字 0x61c88647
,是怎麼來的?
其實這是一個哈希值的黃金分割點,也就是 0.618
,你還記得你學過的數學嗎?計算方式以下;
// 黃金分割點:(√5 - 1) / 2 = 0.6180339887 1.618:1 == 1:0.618 System.out.println(BigDecimal.valueOf(Math.pow(2, 32) * 0.6180339887).intValue()); //-1640531527
(√5 - 1) / 2
,取10位近似 0.6180339887
。-1640531527
,也就是 16 進制的,0x61c88647。這個數呢也就是這麼來的*既然,Josh Bloch
和 Doug Lea
,兩位老爺子選擇使用斐波那契數列,計算哈希值。那必定有它的過人之處,也就是能更好的散列,減小哈希碰撞。
接下來咱們按照源碼中獲取哈希值和計算下標的方式,把這部分代碼提出出來作驗證。
private static AtomicInteger nextHashCode = new AtomicInteger(); private static final int HASH_INCREMENT = 0x61c88647; // 計算哈希 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); } // 獲取下標 int i = key.threadLocalHashCode & (len-1);
如上,源碼部分採用的是 AtomicInteger
,原子方法計算下標。咱們不須要保證線程安全,只須要簡單實現便可。另外 ThreadLocal
初始化數組長度是16,咱們也初始化這個長度。
@Test public void test_idx() { int hashCode = 0; for (int i = 0; i < 16; i++) { hashCode = i * HASH_INCREMENT + HASH_INCREMENT; int idx = hashCode & 15; System.out.println("斐波那契散列:" + idx + " 普通散列:" + (String.valueOf(i).hashCode() & 15)); } }
測試代碼部分,採用的就是斐波那契數列,同時咱們加入普通哈希算法進行比對散列效果。固然String 這個哈希並無像 HashMap 中進行擾動
測試結果:
斐波那契散列:7 普通散列:0 斐波那契散列:14 普通散列:1 斐波那契散列:5 普通散列:2 斐波那契散列:12 普通散列:3 斐波那契散列:3 普通散列:4 斐波那契散列:10 普通散列:5 斐波那契散列:1 普通散列:6 斐波那契散列:8 普通散列:7 斐波那契散列:15 普通散列:8 斐波那契散列:6 普通散列:9 斐波那契散列:13 普通散列:15 斐波那契散列:4 普通散列:0 斐波那契散列:11 普通散列:1 斐波那契散列:2 普通散列:2 斐波那契散列:9 普通散列:3 斐波那契散列:0 普通散列:4 Process finished with exit code 0
發現沒?,斐波那契散列的很是均勻,普通散列到15個之後已經開發生產碰撞。這也就是斐波那契散列的魅力,減小碰撞也就可讓數據存儲的更加分散,獲取數據的時間複雜度基本保持在O(1)。
new ThreadLocal<>()
初始化的過程也很簡單,能夠按照本身須要的泛型進行設置。但在 ThreadLocal
的源碼中有一點很是重要,就是獲取 threadLocal
的哈希值的獲取,threadLocalHashCode
。
private final int threadLocalHashCode = nextHashCode(); /** * Returns the next hash code. */ private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); }
如源碼中,只要實例化一個 ThreadLocal
,就會獲取一個相應的哈希值,則例咱們作一個例子。
@Test public void test_threadLocalHashCode() throws Exception { for (int i = 0; i < 5; i++) { ThreadLocal<Object> objectThreadLocal = new ThreadLocal<>(); Field threadLocalHashCode = objectThreadLocal.getClass().getDeclaredField("threadLocalHashCode"); threadLocalHashCode.setAccessible(true); System.out.println("objectThreadLocal:" + threadLocalHashCode.get(objectThreadLocal)); } }
由於 threadLocalHashCode
,是一個私有屬性,因此咱們實例化後經過上面的方式進行獲取哈希值。
objectThreadLocal:-1401181199 objectThreadLocal:239350328 objectThreadLocal:1879881855 objectThreadLocal:-774553914 objectThreadLocal:865977613 Process finished with exit code 0
這個值的獲取,也就是計算 ThreadLocalMap
,存儲數據時,ThreadLocal
的數組下標。只要是這同一個對象,在set
、get
時,就能夠設置和獲取對應的值。
new ThreadLocal<>().set("小傅哥");
設置元素的方法,也就這麼一句代碼。但設置元素的流程卻涉及的比較多,在詳細分析代碼前,咱們先來看一張設置元素的流程圖,從圖中先了解不一樣狀況的流程以後再對比着學習源碼。流程圖以下;
乍一看可能感受有點暈,咱們從左往右看,分別有以下知識點;
ThreadLocal
的數組結構,以後在設置元素時分爲四種不一樣的狀況,另外元素的插入是經過斐波那契散列計算下標值,進行存放的。ThreadLocal
會進行探測清理過時key,這部分清理內容後續講解。private void set(ThreadLocal<?> key, Object value) { 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<?> k = e.get(); if (k == key) { e.value = value; return; } if (k == null) { replaceStaleEntry(key, value, i); return; } } tab[i] = new Entry(key, value); int sz = ++size; if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }
在有了上面的圖解流程,再看代碼部分就比較容易理解了,與之對應的內容包括,以下;
key.threadLocalHashCode & (len-1);
,斐波那契散列,計算數組下標。Entry
,是一個弱引用對象的實現類,static class Entry extends WeakReference<ThreadLocal<?>>
,因此在沒有外部強引用下,會發生GC,刪除key。tab[i] = new Entry(key, value);
。if (k == key)
,相等則更新值。replaceStaleEntry
,也就是上圖說到的探測式清理過時元素。綜上,就是元素存放的所有過程,總體結構的設計方式很是贊👍,極大的利用了散列效果,也把弱引用使用的很是6!
只要使用到數組結構,就必定會有擴容
if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
在咱們閱讀設置元素時,有以上這麼一塊代碼,判斷是否擴容。
啓發式清理*cleanSomeSlots*
,把過時元素清理掉,看空間是否sz >= threshold
,其中 threshold = len * 2 / 3
,也就是說數組中天填充的元素,大於 len * 2 / 3
,就須要擴容了。rehash();
,擴容從新計算元素位置。探測式清理和校驗
private void rehash() { expungeStaleEntries(); // Use lower threshold for doubling to avoid hysteresis if (size >= threshold - threshold / 4) resize(); } 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); } }
rehash() 擴容
private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; for (int j = 0; j < oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } setThreshold(newLen); size = count; table = newTab; }
以上,代碼就是擴容的總體操做,具體包括以下步驟;
oldLen * 2
,實例化新數組。if (k == null)
,方便GC。new ThreadLocal<>().get();
一樣獲取元素也就這麼一句代碼,若是沒有分析源碼以前,你能考慮到它在不一樣的數據結構下,獲取元素時候都作了什麼操做嗎。咱們先來看下圖,分爲以下種狀況;
按照不一樣的數據元素存儲狀況,基本包括以下狀況;
private Entry getEntry(ThreadLocal<?> key) { int i = key.threadLocalHashCode & (table.length - 1); Entry e = table[i]; if (e != null && e.get() == key) return e; else return getEntryAfterMiss(key, i, e); } private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; int len = tab.length; while (e != null) { ThreadLocal<?> k = e.get(); if (k == key) return e; if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; }
好了,這部分就是獲取元素的源碼部分,和咱們圖中列舉的狀況是一致的。expungeStaleEntry
,是發現有 key == null
時,進行清理過時元素,並把後續位置的元素,前移。
探測式清理,是以當前遇到的 GC 元素開始,向後不斷的清理。直到遇到 null 爲止,才中止 rehash 計算Rehash until we encounter null
。
expungeStaleEntry
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 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; }
以上,探測式清理在獲取元素中使用到; new ThreadLocal<>().get() -> map.getEntry(this) -> getEntryAfterMiss(key, i, e) -> expungeStaleEntry(i)
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.
啓發式清理,有這麼一段註釋,大概意思是;試探的掃描一些單元格,尋找過時元素,也就是被垃圾回收的元素。當添加新元素或刪除另外一個過期元素時,將調用此函數。它執行對數掃描次數,做爲不掃描(快速但保留垃圾)和與元素數量成比例的掃描次數之間的平衡,這將找到全部垃圾,但會致使一些插入花費O(n)時間。
private boolean cleanSomeSlots(int i, int n) { boolean removed = false; Entry[] tab = table; int len = tab.length; do { i = nextIndex(i, len); Entry e = tab[i]; if (e != null && e.get() == null) { n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); return removed; }
while 循環中不斷的右移進行尋找須要被清理的過時元素,最終都會使用 expungeStaleEntry
進行處理,這裏還包括元素的移位。
ThreadLocal
知識點的一角分析完了,在 ThreadLocal
的家族裏還有 Netty
中用到的,FastThreadLocal
。在全鏈路跨服務線程間獲取調用鏈路,還有 TransmittableThreadLocal
,另外還有 JDK 自己自帶的一種線程傳遞解決方案 InheritableThreadLocal
。但站在本文的基礎上,瞭解了最基礎的原理,在理解其餘的拓展設計,就更容易接受了。new ThreadLocal<>().remove();
操做。避免弱引用發生GC後,致使內存泄漏的問題。