CreateArtTechnology
/ Blog
Login
最新文章
Java
语言相关
库相关
虚拟机相关
CreateArtTechnology
项目搭建
使用的工具
自研的工具
开源工具
ELK
ElasticSearch
Jenkins
Markdown
GraphQL
Arthas
生产工具
Linux
Nginx
VersionControl
Subversion
Git
Redis
Archiva
Maven
Zookeeper
Spring
SpringBoot
MySql
HBase
Cassandra
容器化
Docker
Kubernetes
服务容器化从零开始
未分类笔记
算法相关
概念相关
豆知识
机器学习
机器学习从零开始
Doug Lea写的ThreadLocal怎么还是会产生内存泄漏?
29
2019-09-09 12:55:59
Java
语言相关
## 背景 1. 某次在查看一个计时工具类时,发现这个工具类的实例被频繁创建和回收 2. 虽然这个类很轻,但考虑到是个基础工具类且这个功能需要频繁调用,希望尽量减轻这个工具对系统的影响 3. 优化目标是在线程安全的基础上池化某个类的对象,以复用这个对象 于是,初步方案是使用ThreadLocal为每个线程保存一个计时类对象。 然而重构这个工具类之后,发现阿里规约插件提示“应该至少调用一次remove()方法”,还提示可能造成内存泄漏问题。 奇怪了,记得之前看WeakReference时明确地看到ThreadLocal有用到弱引用,按理说不是GC的时候会自动回收吗?这还是Doug Lea写的呢。 ## 源码探究 带着如下问题分析一下源码: 1. ThreadLocal是如何实现每个线程保存一份独有变量的 2. ThreadLocal使用了WeakReference,为什么阿里规约提示至少需要调用一次remove方法,真的会造成内存泄漏吗 ### ThreadLocal的实现思路 ThreadLocal的实现非常巧妙,在每个线程增加了一个独有的“类似HashMap的结构”`ThreadLocalMap`,所有的ThreadLocal变量保存在这个ThreadLocalMap中。 ThreadLocalMap是这样设计的: 1. ThreadLocalMap对象保存在对应的线程即Thread对象,根据Java内存模型,每个线程有自己对应的工作内存,线程无法访问其他线程的工作内存(这也就是线程安全问题的根本原因,也是volatile变量存在的意义) 2. ThreadLocalMap结构类似HashMap,有一个Entry数组,也会在threshold扩容,也有哈希碰撞和解决方案 3. 与HashMap最大的不同是,这个Map的Entry并非常规的包含key和value两个属性 - Entry继承`WeakReference
>`即弱引用,将弱引用的真正引用对象即ThreadLocal对象当作普通Entry中的key,也就是说使用时通过`entry.get()即获取弱引用指向的对象,并计算equals的结果 - Entry包含一个`Object value`属性,保存对应的变量 ThreadLocal通过包装这个ThreadLocalMap,为线程开辟一块变量存放区的功能,实现了变量在线程间隔离,GC时回收掉“Entry的key”这样的功能。此时,仅key被回收,entry和value都未被回收。 ### 几个关键方法 **哈希算法** ThreadLocalMap的哈希算法是取模哈希,即key(即ThreadLocal)的哈希值对容量取模,其中容量保证是2的幂;冲突解决方案是线型探测法,查看下一相邻位置的entry,在“可以写入”的情况下将值赋入。 什么情况下是可用的位置呢? 1. entry为null,这个entry还没被使用,显然可以写入 2. entry的key为null,说明这个entry已过期,key已经被GC回收,可以将其key和value都替换掉 要注意的是,ThreadLocalMap没有使用拉链法/红黑树等解决冲突的方式。 **ThreadLocal.nextHashCode()** 由于ThreadLocal要作为key使用,而且使用了特殊的哈希算法,因此重写了哈希值的生成方法。 每个ThreadLocal的哈希值是通过步长0x61c88647累加生成的,为什么是这个数?我个人的看法是,这是一个素数(1640531527),即使通过累加计算,对2的幂取模后的冲突也比较少。[参考资料](https://juejin.im/post/5cced289f265da03804380f2)和[参考资料](https://juejin.im/post/5b5ecf9de51d45190a434308)对这个值对取模哈希结果的分散表现有说明,虽然其中的“黄金分割点”理论我不是很赞同就是了。 **ThreadLocalMap.expungeStaleEntry(int staleSlot)** 对某个过期的entry进行清空操作,这是个private方法,无法直接调用。 由于使用线性探测法解决冲突,其后的一批entry都有可能是由于哈希冲突才插入到当前slot的。这个entry虽然过期了,但如果清空后不做处理,可能导致因哈希冲突而产生的一批slot连续且哈希结果相同的entry出现“断裂”,之后再通过哈希查找这批entry时由于断裂而在线性探测时找不到对应的结果,副作用还有size对不上等。 因此,在清空该特定位置的数据后,还对其后连续的所有entry进行了rehash,直白地说可能就像在数组中删除元素后把后边连续的元素前移,保证逻辑上不出错。 不过我个人认为这部分的处理不够到位,没有检查需要rehash的entry是否过期,过期的entry本可以直接清理掉。极端情况下后边的多个entry都过期了,就得进行多次rehash,就像冒泡排序的极端情况一样。好在哈希算法足够简单(计算快),而entry个数和线程数大致对应(数组不会特别大),还因为哈希算法的原因分布较均匀(难以出现很长的连续非空entry),这种极端情况应该也可以忽略。 在get、set、remove方法中,遇到已经过期被回收的entry key时都会直接或间接调用这个方法,这能够确保在没有进行remove操作的情况下即使key被回收也能够定期清理很多已过期的entry和entry value。当然,有些特殊情况下也无法清理就是了,比如位于当前过期entry之前的过期entry,rehash过程可能检查不到。 **总结** 可以说ThreadLocal仅仅是包含一个int型的Map key,并封装了通过key从各自线程查value的工具。 ## 回头看问题 ### 最初的疑问 **如何实现每个线程变量隔离** 因为get方法的第一步就是从`Thread.currentThread()`中获取该线程的ThreadLocalMap,再从ThreadLocalMap中获取value的,隔离性显然是可以保证的(有特例)。 **使用了WeakReference还会造成内存泄漏吗** 只有entry中的key是弱引用,entry本身和其中的value仍然是强引用,如果引用没有释放,还是可能出现内存泄漏的问题。 内存泄漏的具体原因下文会分析。 ### 新的问题 在查找资料时发现,最初的问题引发了一些其他的问题。 **不调remove()方法除了内存泄漏还会有什么样的影响** 由于ThreadLocalMap保存在Thread对象中,而现在很多主流框架里线程池的广泛应用,导致复用Thread对象同时也就复用了其绑定的ThreadLocalMap,那么以下的代码就会出现问题: ```java Object v = threadLocal.get(); // 由于线程复用,可能该线程上个执行过程中的数据没清理,本次拿到了上次的数据 if (v == null) { v = genFromSomePlace(); threadLocal.set(v); } ``` 另外,要谨慎使用`ThreadLocal.withInitial(Supplier extends S> supplier)`这个工厂方法创建ThreadLocal对象,一旦不同线程的ThreadLocal使用了同一个Supplier对象,那么隔离也就无从谈起了,比如这样: ```java // ... // 反例,这实际上是不同线程共享同一个变量 private static ThreadLocal
threadLocal = ThreadLocal.withIntitial(() -> order); // ... ``` 要使用这种方式: ```java // ... private static ThreadLocal
threadLocal = ThreadLocal.withIntitial(Order::new); // ... ``` **为什么不把Entry或value定义为弱引用** ![](/img/pic/2019090912543982400_png_852_335_92639) > ThreadLocal在内存中的引用情况 Entry定义为弱引用:当GC回收后,无法区分是原本就没有写入还是被回收了,后续线性探测的修补也无法完成。 value定义为弱引用:我到觉得这是个不错的方法,为啥没这么做?因为这么做和将key定义为弱引用基本没区别,而且通常在我们的使用中不会持有value的强引用,只会持有key即ThreadLocal对象的强引用,而value没有强引用的情况下会被GC回收,与我们期望的功能不符。 让我们换个问题:为什么key要用弱引用而不是直接用强引用? 1. 一般我们是可以同时持有ThreadLocal对象强引用和Thread对象强引用的 2. 某些情况下key的强引用断了,此时key就仅存在弱引用,在下次GC时key就会被回收 3. 在key被回收后,set、get等方法就有可能触发expungeStaleEntry方法,将这个entry给清空 一般网上的资料到这也就结束了,但我想再继续深入探究一下:什么情况下key的强引用会断? 强引用是对应的子线程或主线程中某个对象持有的,对象生命周期结束或对象替换指向这个key的引用后,key的强引用也就断了。 我们综合看一下这个过期回收的过程: 1. 子线程中使用A类的对象a,包含非静态ThreadLocal变量即key ```java public class A { private ThreadLocal
local = new ThreadLocal<>(); public void doSth() { // Context ctx = ... local.set(ctx); } } ``` 2. 子线程终止,或者下次子线程使用了A类的对象a',其中a'的ThreadLocal也使用了新的哈希值,成了key' 3. 原对象a不可达,GC回收 4. key被回收,但是key对应的entry和value有Thread.threadLocalMap强引用指向,都没被回收 5. 可能在某些情况下,通过expungeStaleEntry方法,这个entry和value都被清空回收 在这种情况下,如果使用弱引用,还可能通过expungeStaleEntry机制清理ThreadLocalMap; 而通过强引用,根本无法清理,因为仅ThreadLocalMap不可能知晓key持有者a是否还存活,而key本身是被entry强引用的。 **ThreadLocal的最佳实践应该是怎样的** 上文提到,当使用某个中间类A持有非静态ThreadLocal对象即key时,会通过弱引用机制及自身策略自动清理部分无效的entry。 但是在ThreadLocal类的注释文档中提到,通常应该将ThreadLocal声明为private static变量。 ***我个人认为ThreadLocal的弱引用回收机制只是作者Josh Bloch和Doug Lea为避免错误使用而进行的防范措施,因为如果将ThreadLocal声明为private static,那么基本就不存在需要弱引用回收的情况不是吗?*** 但是声明为静态变量又会引入新的问题。 首先我们看一下在static情况下ThreadLocal的结构示意: ![](/img/pic/2019090912553210001_png_1058_960_360302) > threadLocal实际上就是个key,在不同线程中通过这个key取value 一旦ThreadLocal声明为静态,那么多个线程都会将同一个ThreadLocal对象作为key,那么可能在多个线程中都会出现这批key的value。 想象一下,当某些线程不再需要更新/使用一些threadLocal时,就出现了内存泄漏:其threadLocalMap中的很多value已经处于不需要且可清理的状态,但由于对应的threadLocal即key还有一些线程在用,不会被回收,就导致这部分过期value也无法回收,即便使用了弱引用也无法解决这类问题。 拿上图举个例子: 1. 线程1和线程2都用了threadLocal1和threadLocal2,且设置了value 2. 线程1使用完毕归还线程池,但没有调用threadLocal1.remove() 3. 之后线程1不再使用threadLocal1了,仅使用threadLocal2 4. 线程1的threadLocalMap中仍然保存了threadLocal1和obj1 5. 由于线程2仍然在使用threadLocal1,导致threadLocal1不会被回收,线程1无法触发expungeStaleEntry机制,threadLocal1对应的entry和value无法回收,造成了内存泄漏 所以用private static修饰之后,好处就是仅使用有限的ThreadLocal对象以节约创建对象和后续自动回收的开销,坏处是需要我们手动调用remove方法清理使用完的slot,否则会有内存泄漏问题。 **使用弱引用后,存放在ThreadLocal中的数据会在GC时回收导致后续使用过程中NPE吗** 如果使用static修饰,那么肯定不会被回收,可以放心使用。 如果不使用static修饰,那么得自行分析一下会不会出现强引用断开的情况。 ## 总结 总的来说,阿里编码规约没有问题,ThreadLocal使用不当的确会有内存泄漏的风险。常规使用应当遵照以下几点: 1. 使用private static修饰ThreadLocal对象 2. 调用ThreadLocal.withInitial时要谨慎,不要传入同一个对象造成假隔离 3. 在流程开始前将上下文保存到threadLocal中 4. 在流程结束后调用remove去除threadLocal中的数据,避免内存泄漏及线程复用的问题 对于ThreadLocal内存泄漏问题以及解决方案,网上的很多资料说得其实并不清楚,大多数没说到点上甚至还有误。 尽管Josh Bloch和Doug Lea为ThreadLocal内存泄漏问题增加了很多防范措施,但终究因为一些原因而无法完全避免,非常遗憾。 我想对于平常可能经常要用到的工具,不光要做到知其然,还应当知其所以然。 ## 补充 **什么情况下适合使用ThreadLocal** 1. 某些在整个流程中都需要用到的上下文信息,比如RpcContext,很多框架中都是保存在ThreadLocal中 2. 一些线程不安全但每次创建代价又比较高的对象,比如SimpleDateFormat、JDBC连接,保存在ThreadLocal中可以有效节约开销 ## 参考资料 [ThreadLocal的hash算法(关于 0x61c88647)- 掘金](https://juejin.im/post/5cced289f265da03804380f2) [为什么使用0x61c88647 - 掘金](https://juejin.im/post/5b5ecf9de51d45190a434308) [将ThreadLocal变量设置为private static的好处是啥? - 知乎](https://www.zhihu.com/question/35250439) [ThreadLocal的最佳实践 | 徐靖峰|个人博客](https://www.cnkirito.moe/threadLocal/)
发布文章 101
文章被阅读 1816
最近修改
什么是“丝滑”的曲线
2021-12-08 15:19:20
高效空间数据索引R树及其批量加载方法STR简介
2021-09-29 20:33:37
关于分库分表的一些事儿
2021-06-25 11:51:25
获得诺奖的稳定匹配理论之TTC算法与GS算法
2021-03-14 23:04:48
算法小白的机器学习入门实践,从零到上线
2021-01-13 14:28:27
分站宗旨
一站式资料平台,减少重复检索,减少重复采坑。