阅读 3769

图解分析ThreadLocal的原理与应用场景

ThreadLocal的介绍

ThreadLocal这个类想必大家都不陌生,直接翻译为线程本地(变量),我们经常会使用到它来保存一些线程隔离的全局的变量信息。使用ThreadLocal维护变量时,每个线程都会获得该线程独享一份变量副本。
ThreadLocal比较像是DNF中的一个地下城副本,而每个线程像是每个进入DNF副本中的玩家。各个线程进入副本后都是比较隔离的,不会互相干扰,这一特性在多线程的某些场景下十分适用。 龙龙的奇妙比喻--ThreadLocal

ThreadLocal介于全局变量与局部变量之间的生命周期

ThreadLocal将变量的使用范围恰当的保存到了全局变量和局部变量之间。

  • 全局变量

静态变量static Object val = new Object() 或 对象的成员变量Object val = new Object(),前者保存在JVM的方法区,后者保存在堆区且随着对象的GC而回收,所有线程都可以访问

  • 局部临时变量

在某个方法中或代码块中声明创建的对象,也保存在堆区,随着代码块的结束因没有引用而被回收,线程独享,但生命周期仅存在于该方法块中

  • ThreadLocal变量

private static ThreadLocal<Object> store = new ThreadLocal<>();线程独享,且线程执行的任何阶段都可以得到

ThreadLocal常见的使用场景

笔者经常使用ThreadLocal的场景有:

  • 微服务请求的requestId(或traceId),在微服务模块中用于唯一标记一次请求的全局唯一UUID,在不同模块中传递相同的traceid是通过RPC框架封装并且序列化/反序列化实现的,而RPC框架反序列化出traceid后就会将其放入到ThreadLocal中,这样在模块的各个阶段记录log时都可以通过该ID标识出该请求。并且随着微服务框架的完成,也可以通过一个服务将traceid串联起来,分析一次请求中各个阶段的状态以及耗时。

requestid

  • 作为db的本地缓存,通过ThreadLocal-redis-MySQL三级关系,ThreadLocal作为生命周期最短的缓存,缓存查询的结果,对于在同一个线程中的同样的查询,能够快速返回

ThreadLocal的实现原理

ThreadLocal.get()

ThreadLocal实现结构以及执行的过程如下图所示。 ThreadLocal的几个关键词。

  1. 哈希,每个线程内部独立维护着一个ThreadLocalMap,这是一个Entry[]数组,通过对ThreadLocal进行hash(具体细节读者可以从源码了解)获取到Entry的下标
  2. 哈希冲突的解决办法采用了开放地址法,对于如图所示hash冲突的情况则下标挪一位再找(哈希冲突的三种解决办法:HashMap常用的拉链法、开放地址法、再哈希法,感兴趣的读者可以自行搜索:哈希冲突的解决办法)。ThreadLocal通常存放的数据量不会特别大,并且使用开放地址法(或叫开放寻址法)相对于拉链法而言节省了存储指针的空间
  3. WeakReference弱引用,ThreadLocalMap中对于ThreadLocal的引用使用了弱引用,弱引用的作用是当该引用是该对象的唯一一个引用时,不阻碍GC的回收,下面将展开讨论下ThreadLocal中弱引用与内存泄漏的问题

ThreadLocalMap中的弱引用与使用注意

如前文所述,ThreadLocalMap其实是一个ThreadLocal --> value的映射,具体的实现关系如下图 ThreaLocal清理的过程 当线程中使用的ThreadLocal置为null的时候,ThreadLocalMap中的弱引用作为最后一个指向ThreadLocal的引用,发生GC的时候直接被回收掉,但是这时Entry中的value不会被回收
ThreadLocal的set/get/remove方法中在遇到key==null的节点时(被称为stale腐烂节点),会进行清理等处理逻辑。

  1. 如果Thread1执行完销毁了,那么ThreadLocalMap会整个销毁,也就不会有内存泄漏的问题了
  2. 如果Thread1长期存在,并且一直在创建新的ThreadLocal,并且从来没有执行过set/get/remove方法是有一定可能导致内存泄漏的
  3. 一般情况下我们会使用线程池,这样会在执行完后表现为线程结束,实际上线程只是回到了池子中等待下次调度的时候再次使用,这种情况时ThreadLocal是会被复用的,假如前面的使用场景中我们使用ThreadLocal保存了traceId,如果线程执行完没有进行回收并且下次执行的时候没有重新设置traceId的话,那么在打印日志的时候又会打印前一次的traceId,这样也会导致很多逻辑上的错误

因此,必须在使用了ThreadLocal的线程执行完后finally中调用threadLocal.remove(),或者如果ThreadLocal<HashMap>的话则调用threadlocal.get().remove()清空HashMap

ThreadLocal的复制

在ThreadLocal的使用中,我们经常会需要创建子线程,希望子线程能够继承父线程的ThreadLocal,还是以traceid的使用场景为例,我们创建了子线程来并发处理耗时的逻辑,并且希望子线程中也能如实的打印当前请求的traceid,但是普通的ThreadLocal在创建新线程后信息会完全丢失,笔者曾经在这里踩到过坑。

所以就需要一种方案来复制ThreadLocal到子线程:

  1. 先将ThreadLocal的内容保存在堆中,再子线程中将堆中的内容复制过来


2. InheritableThreadLocal(线程池无效),原理是子线程是通过在父线程中通过调用new Thread()方法来创建子线程,Thread#init方法在Thread的构造方法中被调用。在init方法中拷贝父线程数据到子线程中。但是注意!我们现在一般都会使用线程池创建新线程,这种时候所谓的创建新线程只是复用了线程池中已有的线程,并不会调用new Thread()方法,因此使用InheritableThreadLocal往往是没效果的
3. 阿里巴巴开源了TransmittableThreadLocal,据说可以解决2中的问题,这个后面我们可以再看下,笔者一般只是使用1的方法基本就可以解决子线程ThreadLocal复制的问题

reference

[1] ThreadLocal-hash冲突与内存泄漏
[2] ThreadLocal面试攻略:吃透它的每一个细节和设计原理
[3] 面试官:小伙子,听说你看过ThreadLocal源码?(万字图文深度解析ThreadLocal)

文章分类
后端
文章标签