发布于2021-05-29 18:48 阅读(737) 评论(0) 点赞(8) 收藏(0)
欢迎大家关注我的微信公众号【老周聊架构】,Java后端主流技术栈的原理、源码分析、架构以及各种互联网高并发、高性能、高可用的解决方案。
写这篇文章的目的是因为现在网上很多关于 ThreadLocal 的文章,很大一部分都不太准确。
比如说:
都是不准确的哈,太误导人了。这里老周先来点开胃小菜,先说一下第一个问题。
1.1 ThreadLocal 的内部 ThreadLocalMap,键为 ThreadLocal。
那些说键为 Thread 对象的,它们不看源码的吗?
大家产生错误的认知,我猜测是 ThreadLocal 的 set 方法引起的。下面判断 map 为 null 的话,不是把当前线程 t 当作 key 设置进去吗?
事实真的是这样吗?源码也要看仔细点吧,继续跟 createMap 方法,你会发现:
t.threadLocals 其实是 Thread 内部的 ThreadLocalMap,这里正在给 Thread 的 ThreadLocalMap 赋值呢,而且 ThreadLocalMap 的 key 是 this,也就是当前 ThreadLocal,而不是 Thread。
好了第一个错误我们搞清楚了,我们继续来搞清楚第二个错误。
1.2 ThreadLocal 的数据结构是个环形数组
说 ThreadLocal 的数据结构是个数组也是没有仔细看源码的,好多文章画的图说 ThreadLocal 的数据结构是数组,太误导人了。
这里老周给出正确的 ThreadLocal 的数据结构
1.2.1 ThreadLocal 的数据结构
ThreadLocalMap 维护了 Entry 环形数组,数组中元素 Entry 的逻辑上的 key 为某个 ThreadLocal 对象(实际上是指向该 ThreadLocal 对象的弱引用),value 为代码中该线程往该 ThreadLoacl 变量实际塞入的值。
1.2.2 ThreadLocal 类结构
1.2.3 Thread、ThreadLocal、ThreadLocalMap 的关系
1.3 ThreadLocal 里的 get、set 以及 remove 方法能保证不内存泄露吗?
这个问题使我们本文重点分析的问题,这里老周先说下结论。
get,set 两个方法都不能完全防止内存泄漏,还是每次用完 ThreadLocal 都勤奋的 remove 一下靠谱。
再详细分析这个问题之前,我们下面会来看下所需的前置知识。
2.1 什么是内存泄露?
首先你得知道什么叫内存泄露吧,不然后面分析会很吃力。
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
站在 Java 的角度来说,就是 JVM 创建的对象永远都无法访问到,但是 GC 又不能回收对象所占用的内存。少量的内存泄漏并不会出现什么严重问题,无非是浪费了一些内存资源罢了,但是随着时间的积累,内存泄漏的越来越多就会导致内存溢出,程序崩溃。
2.2 Java 四中引用类型
在 JDK1.2 之前,“引用”的概念过于狭隘,如果 Reference 类型的数据存储的是另外一块内存的起始地址,就称该 Reference 数据是某块地址、对象的引用,对象只有两种状态:被引用、未被引用。
这样的描述未免过于僵硬,对于这一类对象则无法描述:内存足够时暂不回收,内存吃紧时进行回收。例如:缓存数据。
在 JDK1.2 之后,Java 对引用的概念做了一些扩充,将引用分为四种,由强到弱依次为:
强引用
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
软引用
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
弱引用
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
虚引用
虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
JDK 中大多数的类都是采用了链地址法来解决 hash 冲突,为什么 ThreadLocalMap 采用开放地址法来解决哈希冲突呢?首先我们来看看这两种不同的方式:
3.1 链地址法
这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。列如对于关键字集合{28, 93, 90, 3, 21, 11, 19, 31, 18},我们假如数组的长度为 8,那我们用 8 为除数,进行除留余数法:
3.2 开放地址法
这种方法的基本思想是一旦发生了冲突,就去寻找下一个空的散列地址(这非常重要,源码都是根据这个特性,必须理解这里才能往下走),只要散列表足够大,空的散列地址总能找到,并将记录存入。
我们还是以上面的关键字集合 {28, 93, 90, 3, 21, 11, 19, 31, 18} 来演示,我们用散列函数 f(key) = key mod 16。 当计算前 S 个数 {28, 93, 90, 3, 21, 11} 时,都是没有冲突的散列地址,直接存入(蓝色下标代表已存入了数据,空白的下标可以存放数据):
当计算到集合中的 19 的时候,发现 f(19) = 3,此时就与 3 所在的位置冲突。
于是我们应用上面的公式f(19) = (f(19)+1) mod 10 = 4。于是将 19 存入下标为 4 的位置。这其实就是房子被人买了于是买下一间的作法,以此类推。
3.3 链地址法和开放地址法的优缺点
3.3.1 链地址
优点:
缺点:
3.3.2 开放地址法
优点:
缺点:
3.4 ThreadLocalMap 采用开放地址法原因
上面我们已经说了内存泄露的概念,这里我还是再说下 ThreadLocal 的内存泄露是怎么回事。
根据 ThreadLocal 的源码,可以画出一些对象之间的引用关系图,实线表示强引用,虚线表示弱引用:
如上图,ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用它,那么系统 gc 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永远无法回收,造成内存泄露。
那 Java 源码团队就没有啥解决方案吗?当然有,前面已经说过,由于 key 是弱引用,因此 ThreadLocal 可以通过 key.get()==null 来判断 key 是否已经被回收,如果 key 被回收,就说明当前 Entry 是一个废弃的过期节点,ThreadLocal 会自发的将其清理掉。
ThreadLocal 会在以下过程中清理过期节点:
那么正好回到我们前言的第三个问题:
还有说 ThreadLocal 存在内存泄露,但里面的 get、set 以及 remove 方法能防止 ThreadLocal 内存泄露问题。
那么老周的问题是:get、set 以及 remove 方法真的能防止 ThreadLocal 内存泄露吗?
这里你自己可以翻看源码思考下再来看我接下写的,这样有思考收获才更大。
这里分界线假装你思考完了哈,那我们就来讲本文的最重要的一部分了。
事先约定:
4.1 remove 方法能否防止内存泄露?
一开始都是有效的 entry,并且每个 entry 的 key 通过散列算法后算出的位置都是自己所在的位置(都在自己的位置上的话之后的线性清扫中不会造成搬移,因为 ThreadLocalMap 的散列表用的是开放地址法,所以 entry 可能因为 hash 冲突而不在自己位置上)
要达成下面的效果,就要一直没有失效的 entry 出现,并且一直实现插入,也就是一直执行 set 方法。
假设 entry 循环数组有 16 个槽位
如果执行一次 remove,把图中的某个 entry 无效化。
然后我们来看下 ThreadLocal#remove 方法的实现:
因为每个 entry 都在自己的位置上,所以下图的 if (e.get() == key)
会在第一个循环就成立,也就是 remove 会执行 e.clear()
来把弱引用置空,无效化,并且执行一次线性清扫后返回。
我们跟着 expungeStaleEntry 方法进去看:
上面画红框的部分把要清扫的位置给置空了(黑色下标 entry 的槽位置空):
向后遍历整个数组,直到遇到空槽为止,并且第一种情况 (k == null)
为真的情况下,会把无效 entry 置空,防止内存泄漏。
其实就是向后扫描,遇到无效的就顺带干掉,直到遇到空槽为止。
接着再看第二种情况 (k != null)
的分支的(h != i)
场景:
也就是说遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 冲突到其它位置上了,则把它们搬去离原本位置最近的后边空槽上。这样在 get 的时候,会最快找到这个 entry,减少开放地址法遍历数组的时间。
小结:
e.clear()
来把弱引用置空,无效化。结论:
remove 方法能防止内存泄露
4.2 set 方法能否防止内存泄露?
看完 remove 方法后,我们再来看下 set 方法能否防止内存泄漏。
因为每个 entry 都在自己的位置上,并且没有遇到无效的 entry,最终的效果只是把 remove 的位置置为空槽。所以上面 remove 方法得到的效果图:
同理,假设再经历 4 次 remove,可以得出下面的效果图:
如果此时,这时候正好有两个 entry 的 key,也即是 ThreadLocal 的所有强应用被置空,于是这两个 entry 无效。
如果之后只执行 set 方法,是否会内存泄漏呢?是否任意调用 set 之后就保证内存不会泄漏了呢?
带着这两个问题我们来看下 ThreadLocal#set 方法
4.2.1 代码块①
遇到 key 和我们当前调用 set 的 ThreadLocal 相等的 entry,则只用直接把 entry 的 value 设置一下就好了,和 HashMap 的 put(key, A); put(key, B); 中 A 被替换成 B 同理。
4.2.2 代码块②
遇到无效 entry,是我们重点关注的地方。
4.2.3 代码块③
遇到空槽,直接插入,并且尝试指数清扫,如果指数清扫不成功并且当前 entry 的使用槽数到达阈值则重散列。
我们重点来关注代码块②
这里方便演示,我假设下标 9 为有效 entry,也正好是 set 方法的位置。
我们接着上面的 代码块② 分析,要调用到 replaceStaleEntry 方法。
下标 9 为有效 entry 的话,我们在 remove 方法中说过,set 不是在自己原本的位置上,而是被 hash 冲突到其它位置上了,则把它们搬去离原本位置最近的后边空槽上。即下标 12 为 hash 冲突的有效 entry,我们标为绿色下标 12。
第一个 for 循环中,开始向前找,找到最靠前的无效 entry,直到遇到空槽为止,当然可能会绕循环数组一圈绕回来,但是因为使用的槽数如果到达阈值,就会 rehash,不可能所有槽都用完,所以会遇到空槽的。
如下图:
因为没有找到,所以 slotToExpunge = staleSlot
,也就是上图红色下标 10 位置的 entry。
接着往下看:
我们重点关注 k == key
的情况,也就是 i 遍历到图中绿色下标12槽位的情况。 这种情况下会执行一次线性清扫,然后执行对数清扫,最后返回。
如下图:
从 slotToExpunge 位置开始,先进行一轮线性清扫:
对数清扫:清扫次数 = log2(N) ,N 是循环数组大小,本例中是 16,所以要清扫 4 次,每次清扫是通过调用线性清扫实现的。
这里你可能会问了,为啥这里是对数清扫了?而且清扫次数为啥是 log2(N)啊?
别着急,源码是个好东西,源码写的很清楚了。
老周你不要骗我,这里明明是循环数组的长度啊。
哈哈,没错,确实是循环数组的长度,作者想表达的是循环数组的长度是扫描控制的参数,具体的清扫参数是 log2(N)。等等,那清扫参数 log2(N)怎么来的呢?不着急,源码往下看,看到 while 循环了吧,while ( (n >>>= 1) != 0)
,没错就是这个得到清扫参数。我们例子的循环数组长度为 16,代入到里面去,无符号向右移动一位,直到等于 0 跳出循环。16->8->4->2->1,共循环 4 次,并且只有遇到无效entry时才执行线性清扫。
注:老周这里为了严谨,还是要再提一嘴。这里的 n 是循环数组的长度,只是 replaceStaleEntry 方法调用时,但当从插入调用时,这个参数 n 是元素的数量。
显然,4 次扫描中都没有无效 entry。
这里 removed 返回 false,接着 cleanSomeSlots 返回,一直返回到 replaceStaleEntry,并且继续返回,最后从 set 方法返回。
结果显而易见,红色下标 5 位置的无效 entry 未被清除,导致内存泄露。
结论:
set 方法的清扫程度不够深,set 方法并不能防止内存泄漏。
4.3 get 方法能否防止内存泄露?
直接跟进 getEntry 方法:
get 方法相对来说简单点,在原本属于当前 key 的位置上找不到当前 key 的 entry 的话,就会根据开放地址法线性遍历找到 key 对应的 entry。
k == null
的话,执行线性清扫 expungeStaleEntry 方法,顺便把路上无效的 entry 清除掉。
还是看我们上面的例子:
根据前面说的遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 冲突到其它位置上了,则把它们搬去离原本位置最近的后边空槽上。这样在 get 的时候,会最快找到这个 entry,减少开放地址法遍历数组的时间。所以蓝色下标 12 的位置会搬移到 黑色下标 10 号位置。
搬移后的图:
因为是直接取线性清扫开始的位置,所以 k = key 是 true,所以返回绿色 10 号位置的 entry,查找成功。
结果显而易见,红色下标 5 位置的无效 entry 未被清除,导致内存泄露。
结论:
get 方法的清扫程度不够深,get 方法并不能防止内存泄漏。
本文主要以市面上关于 ThreadLocal 都不太准确的文章进行了一番论证并给出正确的结论。特别重点介绍了 ThreadLocal 中 ThreadLocalMap 的大致实现原理以及 ThreadLocal 内存泄露的问题。
ThreadLocalMap 的 Entry 的 key 是弱引用,如果外部没有强引用指向 key,key 就会被回收,而 value 由于 Entry 强引用指向了它,导致无法被回收,但是 value 又无法被访问,因此发生内存泄漏。
关于内存泄漏,我们重点从源码层面分析了 get、set、remove 方法,并图文并茂的演示了 get、set 方法不能防止内存泄漏,而 remove 方法能防止内存泄漏的结论。
所以,开发者应该尽量在使用完毕后及时调用 remove 删除节点,这里老周建议用 Spring 的 AOP 思想对 remove 方法进行切面,省的使用完毕后忘记调用 remove 方法清除。
原文链接:https://blog.csdn.net/riemann_/article/details/117198792
作者:coding
链接:http://www.javaheidong.com/blog/article/207043/06fd654dfc0e49105cc5/
来源:java黑洞网
任何形式的转载都请注明出处,如有侵权 一经发现 必将追究其法律责任
昵称:
评论内容:(最多支持255个字符)
---无人问津也好,技不如人也罢,你都要试着安静下来,去做自己该做的事,而不是让内心的烦躁、焦虑,坏掉你本来就不多的热情和定力
Copyright © 2018-2021 java黑洞网 All Rights Reserved 版权所有,并保留所有权利。京ICP备18063182号-2
投诉与举报,广告合作请联系vgs_info@163.com或QQ3083709327
免责声明:网站文章均由用户上传,仅供读者学习交流使用,禁止用做商业用途。若文章涉及色情,反动,侵权等违法信息,请向我们举报,一经核实我们会立即删除!