Doug Lea写的ThreadLocal怎么还是会产生内存泄漏?

背景

  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内存模型,每个线程有自己对应的工作内存,线程无法访问其他线程的工作内存
  2. ThreadLocalMap结构类似HashMap,有一个Entry数组,也会在threshold扩容,也有哈希碰撞和解决方案
  3. 与HashMap最大的不同是,这个Map的Entry并非常规的包含key和value两个属性
    • Entry继承WeakReference<ThreadLocal<?>>即弱引用,将弱引用的真正引用对象即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的幂取模后的冲突也比较少。一些资料中对这个值对取模哈希结果的分散表现有说明,虽然其中的“黄金分割点”理论我不是很赞同就是了。

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,那么以下的代码就可能会出现问题:

    Object v = threadLocal.get();
    // 由于线程复用,可能该线程上个执行过程中的数据没清理,本次拿到了上次的数据
    if (v == null) {
        v = genFromSomePlace();
        threadLocal.set(v);
    }

另外,要谨慎使用ThreadLocal.withInitial(Supplier<? extends S> supplier)这个工厂方法创建ThreadLocal对象,一旦不同线程的ThreadLocal使用了同一个Supplier对象,那么隔离也就无从谈起了,比如这样:

// ...
// 反例,这实际上是不同线程共享同一个变量
private static ThreadLocal<Obj> threadLocal = ThreadLocal.withIntitial(() -> obj);
// ...

要使用这种方式:

// ...
private static ThreadLocal<Obj> threadLocal = ThreadLocal.withIntitial(Obj::new);
// ...

为什么不把Entry或value定义为弱引用

image.png

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
public class A {
    private ThreadLocal<Context> local = new ThreadLocal<>();

    public void doSth() {
        // Context ctx = ...
        local.set(ctx);
    }
}
  1. 子线程终止,或者下次子线程使用了A类的对象a',其中a'的ThreadLocal也使用了新的哈希值,成了key'
  2. 原对象a不可达,GC回收
  3. key被回收,但是key对应的entry和value有Thread.threadLocalMap强引用指向,都没被回收
  4. 可能在某些情况下,通过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的结构示意:

image.png

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中仍然保存了obj1
  5. 由于静态变量threadLocal1引用仍然可达,不会被回收,线程1无法触发expungeStaleEntry机制,threadLocal1对应的entry和value无法回收,造成了内存泄漏

所以用private static修饰之后,好处就是仅使用有限的ThreadLocal对象以节约创建对象和后续自动回收的开销,坏处是需要我们手动调用remove方法清理使用完的slot,否则会有内存泄漏问题。

使用弱引用后,存放在ThreadLocal中的数据会在GC时回收导致后续使用过程中NPE吗?

如果使用static修饰,那么只要static引用没有变化就肯定不会被回收,可以放心使用。

如果不使用static修饰,那么得自行分析一下,正常使用(持有threadLocal强引用)是不会被回收的。

ps.使用private static final修饰也许是个更好的选择。

总结

总的来说,ThreadLocal使用不当的确会有内存泄漏的风险。常规使用应当遵照以下几点:

  1. 使用private static修饰ThreadLocal对象
  2. 调用ThreadLocal.withInitial时要谨慎,不要传入同一个对象造成假隔离
  3. 在流程开始前将上下文保存到threadLocal中
  4. 最好不要修改ThreadLocal的引用
  5. 在流程结束后调用remove去除threadLocal中的数据,避免内存泄漏及线程复用的问题

对于ThreadLocal内存泄漏问题以及解决方案,网上的很多资料说得其实并不清楚,大多数没说到点上甚至还有误。

尽管Josh Bloch和Doug Lea为ThreadLocal内存泄漏问题增加了很多防范措施,但终究因为一些原因而无法完全避免,非常遗憾。

补充

什么情况下适合使用ThreadLocal

  1. 某些在整个流程中都需要用到的上下文信息,比如RpcContext,很多框架中都是保存在ThreadLocal中
  2. 一些线程不安全但每次创建代价又比较高的对象,比如SimpleDateFormat、JDBC连接,保存在ThreadLocal中可以有效节约开销

参考资料

ThreadLocal的hash算法(关于 0x61c88647)- 掘金

为什么使用0x61c88647 - 掘金

将ThreadLocal变量设置为private static的好处是啥? - 知乎

ThreadLocal的最佳实践 | 徐靖峰|个人博客

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,012评论 4 359
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,589评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 106,819评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,652评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 51,954评论 3 285
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,381评论 1 210
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,687评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,404评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,082评论 1 238
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,355评论 2 241
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,880评论 1 255
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,249评论 2 250
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,864评论 3 232
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,007评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,760评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,394评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,281评论 2 259

推荐阅读更多精彩内容