上一篇文章中说明了ThreadLocal的使用,部分源码实现以及Thread,ThreadLocal,ThreadLocalMap三者之间的关联关系,其中核心实现ThreadLocalMap将在本篇文章中进行讲解,让咱们一块儿来探究下jdk中的ThreadLocalMap是如何实现的java
JDK版本号:1.8.0_171
在源码正式解读以前有些知识须要提早了解的,以便更好的理解源码实现。以前文章中的关联关系是理解ThreadLocal内部实现结构的重点,这里回顾下:程序员
在图里咱们能够看到threadLocals变量就是ThreadLocalMap实现的一个实例对象,每一个线程对应一个ThreadLocalMap,其实现是kv结构,在第一次看到这个名字你们就会想到Map结构吧,而其底层实现与Map是相似的,若是你曾经看过HashMap源码,能够回想下HashMap的实现原理,固然也能够参考我之前对HashMap源码文章的分析。在这里你们须要提早去了解下散列表这种数据结构,不然下面的知识理解起来比较困难算法
在注释上咱们能够看到做者对其进行了一些解释:ThreadLocalMap是一个定制的hashmap,仅用于维护线程的本地变量值,只有ThreadLocal有操做权限,是Thread的私有属性。哈希表中的key是弱引用WeakReference实现,当GC时会进行清理未被引用的entry。有些人可能会比较疑惑,请你们继续往下阅读就好,咱们一步一步来理解其内部实现编程
既然都是散列表结构,那么ThreadLocalMap和HashMap的实现有什么区别呢?数组
这里只以jdk8版本源码进行比较,最重要的区别在于解决hash冲突的方式,在HashMap中使用链表法解决冲突,使得其底层数据结构的实现使用了链表,固然,为了提高效率,在达到阈值时转化为了红黑树。然而,在ThreadLocalMap中并未使用这种方式,而采用了开放寻址法解决冲突,于是并不须要链表这种数据结构安全
那么这两种解决冲突的方式有什么不一样呢?数据结构
这里借用王争老师的《数据结构和算法之美》中的结论,你们能够思考下理解理解:并发
使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。致使这种方法比链表法更浪费内存空间,因此当数据量比较小、装载因子小的时候,适合采用开放寻址法。
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,并且,比起开放寻址法,它更加灵活,支持更多的优化策略,好比用红黑树代替链表
再次提醒下,在源码的学习中须要先去了解散列表(也就是哈希表)这种数据结构,散列冲突的缘由以及其解决散列冲突的方式,若是未了解过,建议先去学习下,不然下面的讲解理解起来相对比较困难。这里我只说明下开放寻址法中使用线性探测解决冲突的方式,由于这种方式也就是ThreadLocalMap中解决散列冲突的方式,下面经过插入,查找,删除操做了解其使用,便于后续ThreadLocalMap的源码实现理解数据结构和算法
在插入数据时若是出现了散列冲突,就从新探测一个空闲位置,将其插入。当咱们往散列表中插入数据时,若是某个数据通过散列函数散列以后,存储位置已经被占用了,那么就从当前位置开始,依次日后查找,直至查找到空闲位置为止。举个例子,以下图,咱们散列表大小为14,咱们输入一个数x在通过散列函数hash()散列后应该存放到数组中散列槽(即数组索引)为2的位置上,可是2已经被占用了,这样就形成了散列冲突,那么咱们就须要依次向后查找空闲位置,最终查找到5的空闲位置,将数x保存在5的位置上函数
散列表元素查找和插入过程相似。当咱们查找时散列到对应的散列槽上,比较对应散列槽上的数据与咱们查找的数据是否相等,相等则表示查找成功了,不等则说明冲突了,咱们须要与插入元素相似的方式依次向后进行查找比较,找到正确的元素便可。若是遍历到数组中的空闲位置,尚未找到,就说明要查找的元素并无在散列表中。一般比较的是键值对中的键,就如同map结构
散列表中的元素删除操做就有些不太同样了,你能够想一想,若是咱们仅仅将散列表中对应元素删除后什么都不作会有什么问题?看下查找操做,你就应该明白,若是不处理的话查找操做会被破坏,仍是继续元素插入的例子,在5的位置已经放了对应的数据x,此时执行删除操做,将3位置上保存的数据删除,若是不作任何处理,咱们再次查找散列表中是否存在x,在查找操做进行到3位置上时发现空闲,就会认为查找的元素不在散列表中,这样就形成了查找错误,由于x明明在5的位置上
因此你应该明白的是,删除操做须要保证查找操做的正确性,那么如何解决呢?其中一种就是在删除元素以后,将以后不为null的数据rehash操做,这样就能保证查找的正确性(参考下图),固然,这也就是ThreadLocalMap删除操做实现的方式,然而,实际上并不止这一种方式,好比还能够逻辑删除,即不是真正删除,只是打个标识,说明这个数据被删除了,查询时发现这个标识就跳过
提醒:请先去学习了解下java中的强,软,弱,虚引用,要不下列知识可能难以理解!
因为WeakReference在ThreadLocalMap中的使用不得不先来了解下内存泄漏这个知识点,常常有人说ThreadLocal使用时要注意不要内存泄漏,那么,什么是内存泄漏?
建立的对象再也不被其余应用程序使用,但由于被其余对象所引用着(即经过可达性分析,从GC Roots具备到该对象的链路),所以垃圾回收器没办法回收它们,此时将在内存中一直被占用,形成浪费
简单来讲,就是咱们其实已经使用完毕了,可是因为还具备有效的引用致使GC没法回收,对象在内存中一直占用致使浪费,其实这是一个相对的概念,也就是说这些建立的对象是否是咱们想要保留还要使用的,若是不是,那就算内存泄漏,若是是,那就不算。内存泄漏和WeakReference自己是没有关系的,基本上都是使用不当致使的
ThreadLocalMap中很明显的部分在于key是弱引用,在key的生命周期完成后发生GC必然会被回收,而value自己就是被Entry强引用,可是并非说必定会形成内存泄漏,若是value只在线程中被定义,初始化,使用,那么在线程生命周期结束以后,经过可达性算法分析,value一样会被回收,若是value在线程外被定义,初始化,也就是在线程外经过可达性算法还能够找到链路,那么即便线程结束,value依旧不会被回收,可是这里请注意,若是你这里的value还有用,那么也不算内存泄漏,只有当这个value无用了没被回收才能算内存泄漏。也就是说内存泄漏并非ThreadLocalMap的专属,ThreadLocalMap不背这个锅,就像使用集合类也有可能形成内存泄漏同样,最终仍是须要理解并正确的使用才能避免这种情况
那么什么状况下使用ThreadLocalMap会形成内存泄漏呢?最多见的就是在线程池中了,线程池中的线程存活时间基本都是与程序同生共死的,这样就致使Thread持有的ThreadLocalMap一直都不会被回收,再加上ThreadLocalMap中的Entry对ThreadLocal是弱引用(WeakReference),因此只要 ThreadLocal 结束了本身的生命周期是能够被回收掉的。可是Entry中的Value倒是被Entry强引用的,因此即使Value的生命周期结束了,Value 也是没法被回收的,从而致使内存泄露。因此ThreadLocal使用时推荐手动remove操做避免可能出现的内存泄漏风险
Entry继承WeakReference,ThreadLocal做为key使用弱引用,在ThreadLocalMap的数组中存放的也就是这个Entry,这里使用WeakReference也就是不想让ThreadLocal在生命周期结束后因为这里的引用而致使其使用的内存没法被回收
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
与HashMap实现相似,经过数组实现散列表,只不过HashMap使用的是Node链表节点,而这里Entry仅仅保存kv值,不须要next指针,固然,形成这种不一样结果的缘由就是由于解决hash冲突的方式是不一样的
/** * 初始化容量,必须为2的幂,默认16 */ private static final int INITIAL_CAPACITY = 16; /** * table,Entry数组 */ private Entry[] table; /** * table中元素实际个数 */ private int size = 0; /** * 扩容阈值大小,默认为0 */ private int threshold; // Default to 0
ThreadLocalMap只有当被使用到时才会进行初始化操做,也就是懒加载模式,其中散列槽位的计算经过ThreadLocal中生成的hashcode和(数组长度-1)进行与操做进行定位,很是方便,若是须要继承父线程中的ThreadLocalMap变量,则须要使用复制父线程的ThreadLocalMap构造方法,经过ThreadLocal.createInheritedMap方法进行操做
/* 懒加载,只有须要保存键值时才初始化 */ ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) { // 初始化table数组 table = new Entry[INITIAL_CAPACITY]; // hashcode和(容量-1)与操做做为该数据存放到数组中的索引位置 int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); // 在对应的数组索引位置建立Entry table[i] = new Entry(firstKey, firstValue); // 数组中元素数量加1 size = 1; // 设置阈值 setThreshold(INITIAL_CAPACITY); } /* 参数传入父线程相关的ThreadLocalMap */ private ThreadLocalMap(ThreadLocalMap parentMap) { Entry[] parentTable = parentMap.table; int len = parentTable.length; setThreshold(len); // 把父线程的ThreadLocalMap中的数据复制到当前线程的ThreadLocalMap table = new Entry[len]; // 循环遍历复制 for (int j = 0; j < len; j++) { Entry e = parentTable[j]; if (e != null) { @SuppressWarnings("unchecked") ThreadLocal<Object> key = (ThreadLocal<Object>) e.get(); // key非空的部分entry if (key != null) { Object value = key.childValue(e.value); // 建立Entry Entry c = new Entry(key, value); // 肯定数组中的索引位置 int h = key.threadLocalHashCode & (len - 1); // 若索引位置已经被使用,则使用开放寻址法解决hash冲突,从新计算索引位置 while (table[h] != null) h = nextIndex(h, len); // 将建立的Entry保存到对应的table数组索引中 table[h] = c; // 数组中实际元素个数加1 size++; } } } }
首先为了方便讲解作个说明,key(也就是ThreadLocal)为null的,可是value非空的entry这里称为无用entry,并非null值,只是key为null,也就是被ThreadLocal被回收了的entry
调整阈值时使用数组容量的2/3做为新的阈值,同时在rehash方法中能够看到在数组中元素个数达到阈值的3/4,也就是容量的1/2时进行扩容操做,能够看出这种解决hash冲突的方式仍是比较浪费内存空间的
private void setThreshold(int len) { threshold = len * 2 / 3; }
prevIndex查找索引i的前一个值,若是当前索引为0,则直接取数组最大索引值len - 1。nextIndex查找索引i的后一个值,若是为最大索引值len - 1,则直接取0。至关于在一个环形数组中查找i的先后索引,这里封装好便于使用
private static int prevIndex(int i, int len) { return ((i - 1 >= 0) ? i - 1 : len - 1); } private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); }
获取ThreadLocal对应的数组table中的Entry对象,因为其使用开放寻址法解决hash冲突,故在未直接命中时须要进一步的查找处理
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 // 未命中则经过getEntryAfterMiss继续查找 return getEntryAfterMiss(key, i, e); }
在首次经过threadLocalHashCode计算索引槽位时,未直接命中,即上面的getEntry方法,则经过此方法进一步继续查找
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) { Entry[] tab = table; // table容量 int len = tab.length; // e为null说明数组中无对应的entry,直接返回null结束查找 while (e != null) { // 获取对应entry的ThreadLocal ThreadLocal<?> k = e.get(); // 数组槽位上的key相等,则命中返回 if (k == key) return e; if (k == null) // 键为null说明ThreadLocal已经被回收 // 这里进行清除无用entry操做并对其以后的entry进行rehash操做以保证查找的正确性 // 直到遍历到null的数组槽位中止 expungeStaleEntry(i); else // 非空且key不等则继续查找下一个索引位置的entry // 也就是继续向后查找匹配 i = nextIndex(i, len); e = tab[i]; } return null; }
清除ThreadLocal已经被回收的无用entry,同时进行rehash操做,因为删除了数组中的entry,为了保证开放寻址法查找匹配entry的正确性,必然要对删除元素这个操做进行后续的处理,而这里是经过对其以后到第一个null元素之间的全部元素进行rehash操做,能够参考我上面讲解的开放寻址法删除操做的处理。能够看到expungeStaleEntry是对哈希槽中删除entry所处的这一段数据进行处理以保证查找的正确性
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // expunge entry at staleSlot // 删除无用entry,置空操做,标识gc可回收 tab[staleSlot].value = null; tab[staleSlot] = null; // 记录减1 size--; // Rehash until we encounter null // 遍历删除entry以后的entry // 清除数组中无用的entry // 同时非null时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) { // 无用entry处理 e.value = null; tab[i] = null; size--; } else { int h = k.threadLocalHashCode & (len - 1); // 非i位置表示须要从新设置该entry所处的槽位 // 也就是进行rehash操做 if (h != i) { // 先将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); // entry赋值 tab[h] = e; } } } return i; }
以ThreadLocal为key构建Entry添加到table对应的散列槽上,在ThreadLocal.set和ThreadLocal.setInitialValue中被调用
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; // 定位散列槽位 int i = key.threadLocalHashCode & (len-1); // hash冲突使用开放寻址法解决 for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) { ThreadLocal<?> k = e.get(); // 对应key相同,更新value if (k == key) { e.value = value; return; } // ThreadLocal被回收,可是这个槽位上的entry还在 // 能够理解为使用当前的新的key和value取代无用的entry // 可是须要注意replaceStaleEntry内部处理并无这么简单,可参考下面的方法说明 if (k == null) { replaceStaleEntry(key, value, i); return; } } // 执行到这里说明循环中未处理,i处为null,没有entry占用,可被使用 tab[i] = new Entry(key, value); int sz = ++size; // 尝试清理部分无效entry,可参考下面的方法解释 if (!cleanSomeSlots(i, sz) && sz >= threshold) // 若是未清理任何数据同时使用长度已经达到阈值限制则执行rehash方法 rehash(); }
使用入参取代无用的hash槽位上的entry,即staleSlot上的key已经被回收了,可是entry还在占用,使用key和value来进行替换。同时会经过expungeStaleEntry清理这一段数组数据,cleanSomeSlots来尝试检查清理整个数组中一些无用的entry
这里有个很是关键的地方须要理解,这个方法是在set中被调用处理碰见无用entry的操做,咱们须要保存新的键值对,同时对无用entry进行处理,清理无用entry有个重要的地方在于,从哪一个点开始进行处理,回想上面的expungeStaleEntry方法,咱们在清除无用的entry时还会对其后的部分entry进行rehash迁移操做,那么咱们就须要找到当前无用entry这一段数组中第一个无用entry所在的位置,有点绕人,也不是很难理解,首先,这一段数组也就是在这个staleSlot位置的无用entry所在的,向前查找第一个非null的entry和向后查找第一个非null的entry,咱们须要处理这段数组中无用的entry,而expungeStaleEntry是从无用entry向后进行处理的,因此咱们须要找到这段数组中第一个无用entry所在的位置,参考下图理解下
图中3,5,8的位置为无用的entry,此时执行replaceStaleEntry操做的是5的位置,那么最终会从3的位置执行expungeStaleEntry对这一段数组数据进行处理,其余状况相似,你能够本身画图理解,这里就不一一进行说明了
private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // 待清除的槽位 // 这里实际上是找到这段非null数组中第一个无用entry的位置 int slotToExpunge = staleSlot; // 遍历staleSlot前的槽位上的entry,找到其以前一个槽位非null,key为null的entry // 固然是最靠近null的那个无用entry的位置 for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; // 遍历staleSlot以后的非null的entry // 这里和向前遍历处理不同 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); // 对应槽位上的entry的key相等,则替换value if (k == key) { e.value = value; // 与staleSlot位置交换 // 由于staleSlot位置是无用的entry,直接替换掉,这样再次散列时其能够直接从staleSlot位置获取了 tab[i] = tab[staleSlot]; tab[staleSlot] = e; // 若是上面的向前遍历未找到无用entry则根据这里的i处已经被更换为key为null的entry,从i处开始清理 // 若是向前遍历已经找到无用entry则不更新slotToExpunge,从向前遍历获得的slotToExpunge开始处理 if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // 当前entry为无用entry同时向前遍历未找到无用entry则更新这里向后遍历中得到的第一个无用entry的位置 // 最后的代码会从这个位置开始清理 if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } // 未找到匹配的key则直接替换掉staleSlot位置的entry便可 tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 不相等则说明其余位置有无用的entry 须要进行清理操做 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }
扫描数组中部分槽位,清理无用entry,经过n >>>= 1
控制扫描次数,若是找到无用entry则重置扫描次数,若是未扫描到无用entry则每次调用扫描次数为log(n)
。cleanSomeSlots是尝试查找部分无用entry并进行清理操做,为了下降扫描次数,并不一一进行检查操做
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]; // 扫描到无用entry if (e != null && e.get() == null) { // 重置n n = len; removed = true; // 调用expungeStaleEntry处理 i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); return removed; }
移除对应key的entry操做,同时经过expungeStaleEntry进行删除操做的后续处理
private void remove(ThreadLocal<?> key) { 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)]) { if (e.get() == key) { // 找到匹配entry则进行清理 // clear清理的是key,先变为无用entry再使用expungeStaleEntry清理 e.clear(); expungeStaleEntry(i); return; } } }
对整个table哈希槽进行rehash操做,同时判断是否须要进行扩容操做
private void rehash() { // 清理table中全部的无用entry expungeStaleEntries(); // 判断是否进行扩容操做 if (size >= threshold - threshold / 4) resize(); }
经过expungeStaleEntry清理table中的全部无用entry
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); } }
扩容操做,固定扩容为原数组容量的2倍,这里迁移entry也比较暴力,直接计算对应槽位,而后使用开放寻址法解决冲突,将entry放入对应的新数组中的槽位上
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) { // ThreadLocal为空则value也置空便于GC回收 e.value = null; // Help the GC } else { // 在新table中设置到对应的槽位上 int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } // 设置阈值 setThreshold(newLen); size = count; table = newTab; }
为何Thread中的变量定义是ThreadLocalMap而不是ThreadLocal?
借用在《Java并发编程实战》中的话能够解释下这个缘由:
在Java的实现方案里面,ThreadLocal仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,全部和线程相关的数据都存储在Thread里面,这样的设计容易理解。而从数据的亲缘性上来说,ThreadLocalMap属于Thread也更加合理。固然还有一个更加深层次的缘由,那就是不容易产生内存泄露。假如ThreadLocal持有的Map会持有Thread对象的引用,这就意味着,只要ThreadLocal对象存在,那么Map中的 Thread对象就永远不会被回收。ThreadLocal的生命周期每每都比线程要长,因此这种设计方案很容易致使内存泄露。而Java的实现中Thread持有ThreadLocalMap,并且ThreadLocalMap里对ThreadLocal 的引用仍是弱引用(WeakReference),因此只要 Thread 对象能够被回收,那么ThreadLocalMap就能被回收。Java的这种实现方案虽然看上去复杂一些,可是更加安全。Java 的ThreadLocal实现应该称得上深思熟虑了,不过即使如此深思熟虑,仍是不能百分百地让程序员避免内存泄露,例如在线程池中使用 ThreadLocal,若是不谨慎就可能致使内存泄露。
到此关于ThreadLocal的源码基本讲解完毕,整体来讲有些地方仍是比较难理解的,你能够多思考思考,相信有不同的收获
本文讲解了Hash冲突的解决方式之一开放寻址法是为了帮助你们更好的理解源码实现,同时说明了内存泄漏的风险,而后对源码部分进行了详细的说明,正确理解其内部数据结构是理解源码实现的关键,同时结合上篇文章,理清Thread,ThreadLocal,ThreadLocalMap三者之间的关联关系,相信对于ThreadLocal的使用便会了然于心。对于实现中最重要的在于经过expungeStaleEntry,cleanSomeSlots,expungeStaleEntries完成无用entry的清理,同时进行数据的rehash操做便于开放寻址法查找数据的正确性,相信对你们而言不是过于复杂
以上内容若有问题欢迎指出,笔者验证后将及时修正,谢谢