前言
ThreadLocal 本身不存储值,访问的是当前线程 ThreadLocalMap 里存储的数据副本,实现了线程间的数据隔离。只有当前线程能访问,也就不存在并发访问时的安全问题了。
ThreadLocal 的核心是 ThreadLocalMap,它和 HashMap 不同的是:面对哈希冲突时,后者用的是链表法,而前者用的是线性探测法,为什么呢???
理解线性探测
哈希表的实现,一般是创建一个数组,然后根据 key 计算哈希值,再根据数组长度计算一个下标,把 key value 封装成一个 Entry 写入当前下标位置即可。一旦发生哈希冲突,问题就来了,一块地址不能同时存储两个元素啊,这时就出现了几种常见的哈希冲突的解决方案。
链表法是 HashMap 的解决方案,把哈希冲突的 Entry 构建成链表即可,查找时得遍历整个链表。
线性探测是 ThreadLocalMap 的解决方案,它的思路是:一旦发生哈希冲突,就继续往后找(环形),找到第一个空节点的位置,再把当前 Entry 放进去。查找的过程也是一样的,先根据哈希值计算下标,再从这个位置开始往后找,如果找到第一个空节点还没找到,就认为 key 不存在。
所以,使用线性探测法有一个前提,数组必须能容纳所有的元素,否则就会出现死循环。一般情况下,使用线性探测法的哈希表,每次放入一个 Entry 后都要判断是否要扩容,确保有足够的容量存储下一个 Entry。
如下图所示,元素8、9分别占用了下标2、3的位置,此时元素14要放进来,下标计算也是2,但是因为已经有元素8了,所以只能往后继续找,直到发现下标4的地方是空的,元素就可以放进去了。
ThreadLocalMap
要知道为啥 ThreadLocalMap 用线性探测法,必然和它的某些特性相关,那就深入源码一探究竟。
ThreadLocalMap 虽然叫Map,但是并没有实现Map接口,只提供了简单的get、set方法。内部节点类 Entry 对 Key Value 进行了封装,且继承自 WeakReference,也就是说 Entry 对 ThreadLocal 是弱引用,这主要是为了清理过期节点,避免内存泄漏。
static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}}
}
ThreadLocal#set
其实是把 ThreadLocal 作为 key,参数作为 value 写入到 ThreadLocalMap,方法是ThreadLocalMap#set
。在set新值时,有几种情况:
- 如果发现key已经存在,直接替换value即可
- 否则线性探测向后查找,如果发现了过期节点,替换过期节点。
- 如果以上都没发生,那么肯定会找到一个空节点,直接插入即可,插入后判断是否扩容
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) {// key相同,直接替换Valuee.value = value;return;}if (k == null) {// 过程中找到过期节点,有两种情况要处理:// 1. key不存在,直接替换掉过期节点即可,线性探测也不会中断// 2. key存在(在后面),接着往后找,把目标节点替换到当前过期节点replaceStaleEntry(key, value, i);return;}}// 找到了空节点,插入tab[i] = new Entry(key, value);int sz = ++size;// 因为插入一个新节点,所以要判断是否需要扩容// 如果尝可以清理掉部分过期节点,那就无需扩容// 如果数量没有超过阈值,也无需扩容if (!cleanSomeSlots(i, sz) && sz >= threshold)rehash();
}
上述第2步要特别注意:并不能单纯的替换过期节点,因为相同的key可能在过期节点的后面,如果直接替换就会导致相同的key被插入两次,程序就出bug了,所以专门写了个replaceStaleEntry()
用来替换过期节点。
替换过期节点时,还捎带做了一些其他事:
- 从当前过期节点往前找,看看能否扫描到一些过期节点,捎带清理一下
- 从当前过期节点往后找,如果发现相同key,就替换指定节点。找不到相同key,就替换过期节点
- 最后把整个过程中扫描到的过期节点做清理
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {Entry[] tab = table;int len = tab.length;Entry e;// 往前查找最久的过期节点,稍后清理掉int slotToExpunge = staleSlot;for (int i = prevIndex(staleSlot, len);(e = tab[i]) != null;i = prevIndex(i, len))if (e.get() == null)slotToExpunge = i;// 线性探测往后找,找到key就替换,找不到就直接插入到当前过期节点for (int i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {ThreadLocal<?> k = e.get();// 找到了keyif (k == key) {// 替换valuee.value = value;// 替换节点tab[i] = tab[staleSlot];tab[staleSlot] = e;if (slotToExpunge == staleSlot)// 前面没有过期节点,从当前节点开始往后清理&rehashslotToExpunge = i;// 从slotToExpunge开始往后清理&rehashcleanSomeSlots(expungeStaleEntry(slotToExpunge), len);return;}// 往前没找到过期节点,但是往后找到了,那就从当前位置开始清理if (k == null && slotToExpunge == staleSlot)slotToExpunge = i;}// key没找到,直接写入当前过期节点tab[staleSlot].value = null;tab[staleSlot] = new Entry(key, value);// 往前/往后扫描到了过期节点,清理它if (slotToExpunge != staleSlot)cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
清理过期节点并不容易,不是把它从数组里移除那么简单。因为线性探测的原因,如果过期节点后面还有节点的话,单纯的把过期节点移除掉,会导致整个探测的链路断掉,程序就出bug了。
expungeStaleEntry()
用来清理过期节点,主要做了两件事:
- 将当前过期节点设为null
- 向后查找,扫描到过期节点就清理掉,正常节点就rehash操作,重新放进哈希表,以此来保证探测的链路完整
private int expungeStaleEntry(int staleSlot) {Entry[] tab = table;int len = tab.length;// 删除过期节点,本质是置为nulltab[staleSlot].value = null;tab[staleSlot] = null;size--;// rehash后续不为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 {// 重新计算下标,如果不一样就rehashint h = k.threadLocalHashCode & (len - 1);if (h != i) {tab[i] = null;// rehash:线性探测,直到发现一个空节点插入while (tab[h] != null)h = nextIndex(h, len);tab[h] = e;}}}return i;
}
尾巴
回到问题本身,为什么 ThreadLocalMap 用线性探测来解决哈希冲突而不是链表法?本质上它和 HashMap 的定位是不一样的,HashMap 是性能优先,尽可能的保证元素的高效访问。ThreadLocalMap 性能不是第一要素,看完源码你会发现,如果数组元素比较密集的话,ThreadLocalMap 不管是 set 还是 get 都会不可避免地扫描很多节点,这肯定会影响性能。但是换来的收益,就是 ThreadLocalMap 可以在扫描节点时主动发现过期节点且清理掉,尽可能的避免内存泄漏,这比牺牲一点性能更加值得。