所以说,如果通过 key 的哈希值得到的下标无法直接命中,则会将下标 +1,即继续往后遍历数组查找 Entry ,直到找到或者返回 null。
可以看到,这种 hash 冲突的解决效率其实不高,但是一般 ThreadLocal 也不会太多,所以用这种简单的办法解决即可。
至于代码中的expungeStaleEntry
我们等下再分析,先来看下 ThreadLocalMap#set
方法,看看写入的怎样实现的,来看看 hash 冲突的解决方法是否和上面说的一致。
可以看到 set 的逻辑也很清晰,先通过 key 的 hash 值计算出一个数组下标,然后看看这个下标是否被占用了,如果被占了看看是否就是要找的 Entry ,如果是则进行更新,如果不是则下标++,即往后遍历数组,查找下一个位置,找到空位就 new 个 Entry 然后把坑给占用了。
当然,这种数组操作一般免不了阈值的判断,如果超过阈值则需要进行扩容。
上面的清理操作和 key 为空的情况,下面再做分析,这里先略过。
至此,我们已经分析了 ThreadLocalMap 的**核心操作 get 和 set **,想必你对 ThreadLocalMap 的原理已经从源码层面清晰了!
可能有些小伙伴对 key 的哈希值的来源有点疑惑,所以我再来补充一下 key.threadLocalHashCode
的分析。
可以看到key.threadLocalHashCode
其实就是调用 nextHashCode 进行一个原子类的累加。
注意看上面都是静态变量和静态方法,所以在 ThreadLocal 对象之间是共享的,然后通过固定累加一个奇怪的数字0x61c88647
来分配 hash 值。
这个数字当然不是乱写的,是实验证明的一个值,即通过 0x61c88647
累加生成的值与 2 的幂取模的结果,可以较为均匀地分布在 2 的幂长度的数组中,这样可以减少 hash 冲突。
有兴趣的小伙伴可以深入研究一下,反正我没啥兴趣。
ThreadLocal 内存泄露之为什么要用弱引用
接下来就是要解决上面挖的坑了,即 key 的弱引用、Entry 的 key 为什么可能为 null、还有清理 Entry 的操作。
之前提到过,Entry 对 key 是弱引用,那为什么要弱引用呢?
我们知道,如果一个对象没有强引用,只有弱引用的话,这个对象是活不过一次 GC 的,所以这样的设计就是为了让当外部没有对 ThreadLocal 对象有强引用的时候,可以将 ThreadLocal 对象给清理掉。
那为什么要这样设计呢?
假设 Entry 对 key 的引用是强引用,那么来看一下这个引用链:
从这条引用链可以得知,如果线程一直在,那么相关的 ThreadLocal 对象肯定会一直在,因为它一直被强引用着。
看到这里,可能有人会说那线程被回收之后就好了呀。
重点来了!线程在我们应用中,常常是以线程池的方式来使用的,比如 Tomcat 的线程池处理了一堆请求,而线程池中的线程一般是不会被清理掉的,所以这个引用链就会一直在,那么 ThreadLocal 对象即使没有用了,也会随着线程的存在,而一直存在着!
所以这条引用链需要弱化一下,而能操作的只有 Entry 和 key 之间的引用,所以它们之间用弱引用来实现。
与之对应的还有一个条引用链,我结合着上面的线程引用链都画出来:
另一条引用链就是栈上的 ThreadLocal 引用指向堆中的 ThreadLocal 对象,这个引用是强引用。
如果有这条强引用存在,那说明此时的 ThreadLocal 是有用的,此时如果发生 GC 则 ThreadLocal 对象不会被清除,因为有个强引用存在。
当随着方法的执行完毕,相应的栈帧也出栈了,此时这条强引用链就没了,如果没有别的栈有对 ThreadLocal 对象的引用,那么说明 ThreadLocal 对象无法再被访问到(定义成静态变量的另说)。
那此时 ThreadLocal 只存在与 Entry 之间的弱引用,那此时发生 GC 它就可以被清除了,因为它无法被外部使用了,那就等于没用了,是个垃圾,应该被处理来节省空间。
至此,想必你已经明白为什么 Entry 和 key 之间要设计为弱引用,就是因为平日线程的使用方式基本上都是线程池,所以线程的生命周期就很长,可能从你部署上线后一直存在,而 ThreadLocal 对象的生命周期可能没这么长。
所以为了能让已经没用 ThreadLocal 对象得以回收,所以 Entry 和 key 要设计成弱引用,不然 Entry 和 key是强引用的话,ThreadLocal 对象就会一直在内存中存在。
但是这样设计就可能产生内存泄漏。
那什么叫内存泄漏?就是指:程序中已经无用的内存无法被释放,造成系统内存的浪费。
当 Entry 中的 key 即 ThreadLocal 对象被回收了之后,会发生 Entry 中 key 为 null 的情况,其实这个 Entry 就已经没用了,但是又无法被回收,因为有 Thread->ThreadLocalMap ->Entry 这条强引用在,这样没用的内存无法被回收就是内存泄露。
那既然会有内存泄漏还这样实现?
这里就要填一填上面的坑了,也就是涉及到的关于 expungeStaleEntry
即清理过期的 Entry 的操作。
设计者当然知道会出现这种情况,所以在多个地方都做了清理无用 Entry ,即 key 已经被回收的 Entry 的操作。
比如通过 key 查找 Entry 的时候,如果下标无法直接命中,那么就会向后遍历数组,此时遇到 key 为 null 的 Entry 就会清理掉,再贴一下这个方法:
这个方法也很简单,我们来看一下它的实现:
所以在查找 Entry 的时候,就会顺道清理无用的 Entry ,这样就能防止一部分的内存泄露啦!
还有像扩容的时候也会清理无用的 Entry:
其它还有,我就不贴了,反正知晓设计者是做了一些操作来回收无用的 Entry 的即可。
ThreadLocal 的最佳实践
当然,等着这些操作被动回收不是最好的方法,假设后面没人调用 get 或者调用 get 都直接命中或者不会发生扩容,那无用的 Entry 岂不是一直存在了吗?所以上面说只能防止一部分的内存泄露。
所以,最佳实践是用完了之后,调用一下 remove 方法,手工把 Entry 清理掉,这样就不会发生内存泄漏了!
void yesDosth { threadlocal.set(xxx); try { // do sth } finally { threadlocal.remove(); } }
这就是使用 Threadlocal 的一个正确姿势啦,即不需要的时候,显示的 remove 掉。
当然,如果不是线程池使用方式的话,其实不用关系内存泄漏,反正线程执行完了就都回收了,但是一般我们都是使用线程池的,可能只是你没感觉到。
比如你用了 tomcat ,其实请求的执行用的就是 tomcat 的线程池,这就是隐式使用。
还有一个问题,关于 withInitial 也就是初始化值的方法。
由于类似 tomcat 这种隐式线程池的存在,即线程第一次调用执行 Threadlocal 之后,如果没有显示调用 remove 方法,则这个 Entry 还是存在的,那么下次这个线程再执行任务的时候,不会再调用 withInitial 方法,也就是说会拿到上一次执行的值。
但是你以为执行任务的是新线程,会初始化值,然而它是线程池里面的老线程,这就和预期不一致了,所以这里需要注意。
InheritableThreadLocal
这个其实之前文章写过了,不过这次竟然写了 threadlocal 就再拿出来。
这玩意可以理解为就是可以把父线程的 threadlocal 传递给子线程,所以如果要这样传递就用 InheritableThreadLocal ,不要用 threadlocal。
原理其实很简单,在 Thread 中已经包含了这个成员:
这里要注意,只会在线程创建的时会拷贝 InheritableThreadLocal 的值,之后父线程如何更改,子线程都不会受其影响。
最后
至此有关 ThreadLocal 的知识点就差不多了。
想必你已经清楚 ThreadLocal 的原理,包括如何实现 ThreadLocal ,为什么 key 要设计成弱引用,并且关于线程池中使用 ThreadLocal 的注意点等等。
其实本没打算写 ThreadLocal 的,因为最近在看 Netty ,所以想写一下 FastThreadLocal ,但是前置知识点是 ThreadLocal ,所以就干了一篇 ThreadLocal 。
消化了这篇之后,出去面试 ThreadLocal 算是没问题了吧,最后再留个小小的思考题。
那为什么 Entry 中的 value 不弱引用?
这个题目来自群友的一个面试题哈,想必看完这篇文章之后,这个题目难不倒你,欢迎留言区写出答案!
可以关注我,等我下篇的 ThreadLocal 进阶版哈