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

2,665 阅读11分钟

背景

  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的最佳实践 | 徐靖峰|个人博客

本文搬自我的博客,欢迎参观!