作者:小傅哥
博客:https://bugstack.cn
沉淀、分享、成长,让自己和他人都能有所收获!😄
说到底,你真的会造火箭吗?
常说面试造火箭,入职拧螺丝。但你真的有造火箭的本事吗,大部分都是不敢承认自己的知识盲区和技术瓶颈以及经验不足的自嘲。
面试时:
除法散列法
、平方散列法
、斐波那契(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后,导致内存泄漏的问题。