ThreadLocal 夺命 11 连问
前言
前一段时间,有同事使用ThreadLocal
踩坑了,正好引起了我的兴趣。
所以近期,我抽空把 ThreadLocal 的源码再研究了一下,越看越有意思,发现里面的东西还真不少。
我把精华浓缩了一下,汇集成了下面 11 个问题,看看你能顶住第几个?
1. 为什么要用 ThreadLocal?
并发编程是一项非常重要的技术,它让我们的程序变得更加高效。
但在并发的场景中,如果有多个线程同时修改公共变量,可能会出现线程安全问题,即该变量最终结果可能出现异常。
为了解决线程安全问题,JDK
出现了很多技术手段,比如:使用synchronized
或Lock
,给访问公共资源的代码上锁,保证了代码的原子性
。
但在高并发的场景中,如果多个线程同时竞争一把锁,这时会存在大量的锁等待,可能会浪费很多时间,让系统的响应时间一下子变慢。
因此,JDK
还提供了另外一种用空间换时间的新思路:ThreadLocal
。
它的核心思想是:共享变量在每个线程
都有一个副本
,每个线程操作的都是自己的副本,对另外的线程没有影响。
例如:
2. ThreadLocal 的原理是什么?
为了搞清楚 ThreadLocal 的底层实现原理,我们不得不扒一下源码。
ThreadLocal
的内部有一个静态的内部类叫:ThreadLocalMap
。
ThreadLocal
的get
方法、set
方法和setInitialValue
方法,其实最终操作的都是ThreadLocalMap
类中的数据。
其中ThreadLocalMap
类的内部如下:
ThreadLocalMap
里面包含一个静态的内部类Entry
,该类继承于WeakReference
类,说明Entry
是一个弱引用。
ThreadLocalMap
内部还包含了一个Entry
数组,其中:Entry
= ThreadLocal
+ value
。
而ThreadLocalMap
被定义成了Thread
类的成员变量。
下面用一张图从宏观上,认识一下 ThreadLocal 的整体结构:
从上图中看出,在每个Thread
类中,都有一个ThreadLocalMap
的成员变量,该变量包含了一个Entry数组
,该数组真正保存了 ThreadLocal 类 set 的数据。
Entry
是由 threadLocal 和 value 组成,其中 threadLocal 对象是弱引用,在GC
的时候,会被自动回收。而 value 就是 ThreadLocal 类 set 的数据。
下面用一张图总结一下引用关系:
上图中除了 Entry 的 key 对 ThreadLocal 对象是弱引用
,其他的引用都是强引用
。
需要特别说明的是,上图中 ThreadLocal 对象我画到了堆上,其实在实际的业务场景中不一定在堆上。因为如果 ThreadLocal 被定义成了 static 的,ThreadLocal 的对象是类共用的,可能出现在方法区。
3. 为什么用 ThreadLocal 做 key?
不知道你有没有思考过这样一个问题:ThreadLocalMap
为什么要用ThreadLocal
做 key,而不是用Thread
做 key?
如果在你的应用中,一个线程中只使用了一个ThreadLocal
对象,那么使用Thread
做 key 也未尝不可。
但实际情况中,你的应用,一个线程中很有可能不只使用了一个 ThreadLocal 对象。这时使用Thread
做 key 不就出有问题?
假如使用Thread
做 key 时,你的代码中定义了 3 个 ThreadLocal 对象,那么,通过 Thread 对象,它怎么知道要获取哪个 ThreadLocal 对象呢?
如下图所示:
因此,不能使用Thread
做 key,而应该改成用ThreadLocal
对象做 key,这样才能通过具体 ThreadLocal 对象的get
方法,轻松获取到你想要的 ThreadLocal 对象。
如下图所示:
4. Entry 的 key 为什么设计成弱引用?
前面说过,Entry 的 key,传入的是 ThreadLocal 对象,使用了WeakReference
对象,即被设计成了弱引用。
那么,为什么要这样设计呢?
假如 key 对 ThreadLocal 对象的弱引用,改为强引用。
我们都知道 ThreadLocal 变量对 ThreadLocal 对象是有强引用存在的。
即使 ThreadLocal 变量生命周期完了,设置成 null 了,但由于 key 对 ThreadLocal 还是强引用。
此时,如果执行该代码的线程
使用了线程池
,一直长期存在,不会被销毁。
就会存在这样的强引用链
:Thread 变量 -> Thread 对象 -> ThreadLocalMap -> Entry -> key -> ThreadLocal 对象。
那么,ThreadLocal 对象和 ThreadLocalMap 都将不会被GC
回收,于是产生了内存泄露
问题。
为了解决这个问题,JDK 的开发者们把 Entry 的 key 设计成了弱引用
。
弱引用
的对象,在 GC 做垃圾清理的时候,就会被自动回收了。
如果 key 是弱引用,当 ThreadLocal 变量指向 null 之后,在 GC 做垃圾清理的时候,key 会被自动回收,其值也被设置成 null。
如下图所示:
接下来,最关键的地方来了。
由于当前的 ThreadLocal 变量已经被指向null
了,但如果直接调用它的get
、set
或remove
方法,很显然会出现空指针异常
。因为它的生命已经结束了,再调用它的方法也没啥意义。
此时,如果系统中还定义了另外一个 ThreadLocal 变量 b,调用了它的get
、set
或remove
,三个方法中的任何一个方法,都会自动触发清理机制,将 key 为 null 的 value 值清空。
如果 key 和 value 都是 null,那么 Entry 对象会被 GC 回收。如果所有的 Entry 对象都被回收了,ThreadLocalMap 也会被回收了。
这样就能最大程度的解决内存泄露
问题。
需要特别注意的地方是:
key 为 null 的条件是,ThreadLocal 变量指向
null
,并且 key 是弱引用。如果 ThreadLocal 变量没有断开对 ThreadLocal 的强引用,即 ThreadLocal 变量没有指向 null,GC 就贸然的把弱引用的 key 回收了,不就会影响正常用户的使用?如果当前 ThreadLocal 变量指向
null
了,并且 key 也为 null 了,但如果没有其他 ThreadLocal 变量触发get
、set
或remove
方法,也会造成内存泄露。
下面看看弱引用的例子:
打印结果:
传入 WeakReference 构造方法的是直接 new 处理的对象,没有其他引用,在调用 gc 方法后,弱引用对象会被自动回收。
但如果出现下面这种情况:
执行结果:
先定义了一个强引用 object 对象,在 WeakReference 构造方法中将 object 对象的引用作为参数传入。这时,调用 gc 后,弱引用对象不会被自动回收。
我们的 Entry 对象中的 key 不就是第二种情况吗?在 Entry 构造方法中传入的是 ThreadLocal 对象的引用。
如果将 object 强引用设置为 null:
执行结果:
第二次 gc 之后,弱引用能够被正常回收。
由此可见,如果强引用和弱引用同时关联一个对象,那么这个对象是不会被 GC 回收。也就是说这种情况下 Entry 的 key,一直都不会为 null,除非强引用主动断开关联。
此外,你可能还会问这样一个问题:Entry 的 value 为什么不设计成弱引用?
答:Entry 的 value 如果只是被 Entry 引用,有可能没被业务系统中的其他地方引用。如果将 value 改成了弱引用,被 GC 贸然回收了(数据突然没了),可能会导致业务系统出现异常。
而相比之下,Entry 的 key,管理的地方就非常明确了。
这就是 Entry 的 key 被设计成弱引用,而 value 没被设计成弱引用的原因。
5. ThreadLocal 真的会导致内存泄露?
通过上面的 Entry 对象中的 key 设置成弱引用,并且使用get
、set
或remove
方法清理 key 为 null 的 value 值,就能彻底解决内存泄露问题?
答案是否定的。
如下图所示:
假如 ThreadLocalMap 中存在很多 key 为 null 的 Entry,但后面的程序,一直都没有调用过有效的 ThreadLocal 的get
、set
或remove
方法。
那么,Entry 的 value 值一直都没被清空。
所以会存在这样一条强引用链
:Thread 变量 -> Thread 对象 -> ThreadLocalMap -> Entry -> value -> Object。
其结果就是:Entry 和 ThreadLocalMap 将会长期存在下去,会导致内存泄露
。
6. 如何解决内存泄露问题?
前面说过的 ThreadLocal 还是会导致内存泄露的问题,我们有没有解决办法呢?
答:有办法,调用 ThreadLocal 对象的remove
方法。
不是在一开始就调用 remove 方法,而是在使用完 ThreadLocal 对象之后。列如:
先创建一个 CurrentUser 类,其中包含了 ThreadLocal 的逻辑。
然后在业务代码中调用相关方法:
需要我们特别注意的地方是:一定要在finally
代码块中,调用remove
方法清理没用的数据。如果业务代码出现异常,也能及时清理没用的数据。
remove
方法中会把 Entry 中的 key 和 value 都设置成 null,这样就能被 GC 及时回收,无需触发额外的清理机制,所以它能解决内存泄露问题。
7. ThreadLocal 是如何定位数据的?
前面说过 ThreadLocalMap 对象底层是用 Entry 数组保存数据的。
那么问题来了,ThreadLocal 是如何定位 Entry 数组数据的?
在 ThreadLocal 的 get、set、remove 方法中都有这样一行代码:
通过 key 的 hashCode 值,与
数组的长度减 1。其中 key 就是 ThreadLocal 对象,与
数组的长度减 1,相当于除以数组的长度减 1,然后取模
。
这是一种 hash 算法。
接下来给大家举个例子:假设 len=16,key.threadLocalHashCode=31,
于是: int i = 31 & 15 = 15
相当于:int i = 31 % 16 = 15
计算的结果是一样的,但是使用与运算
效率跟高一些。
为什么与运算效率更高?
答:因为 ThreadLocal 的初始大小是16
,每次都是按2
倍扩容,数组的大小其实一直都是 2 的 n 次方。这种数据有个规律就是高位是 0,低位都是 1。在做与运算时,可以不用考虑高位,因为与运算的结果必定是 0。只需考虑低位的与运算,所以效率更高。
如果使用 hash 算法定位具体位置的话,就可能会出现hash冲突
的情况,即两个不同的 hashCode 取模后的值相同。
ThreadLocal 是如何解决 hash 冲突的呢?
我们看看getEntry
是怎么做的:
再看看getEntryAfterMiss
方法:
关键看看nextIndex
方法:
当通过 hash 算法计算出的下标小于数组大小,则将下标值加 1。否则,即下标大于等于数组大小,下标变成 0 了。下标变成 0 之后,则循环一次,下标又变成 1。。。
寻找的大致过程如下图所示:
如果找到最后一个,还是没有找到,则再从头开始找。
不知道你有没有发现,它构成了一个:环形
。
ThreadLocal 从数组中找数据的过程大致是这样的:
通过 key 的 hashCode 取余计算出一个下标。
通过下标,在数组中定位具体 Entry,如果 key 正好是我们所需要的 key,说明找到了,则直接返回数据。
如果第 2 步没有找到我们想要的数据,则从数组的下标位置,继续往后面找。
如果第 3 步中找 key 的正好是我们所需要的 key,说明找到了,则直接返回数据。
如果还是没有找到数据,再继续往后面找。如果找到最后一个位置,还是没有找到数据,则再从头,即下标为 0 的位置,继续从前往后找数据。
直到找到第一个 Entry 为空为止。
8. ThreadLocal 是如何扩容的?
从上面得知,ThreadLocal 的初始大小是16
。那么问题来了,ThreadLocal 是如何扩容的?
在set
方法中会调用rehash
方法:
注意一下,其中有个判断条件是:sz(之前的 size+1)如果大于或等于 threshold 的话,则调用 rehash 方法。
threshold 默认是 0,在创建 ThreadLocalMap 时,调用它的构造方法:
调用 setThreshold 方法给 threshold 设置一个值,而这个值 INITIAL_CAPACITY 是默认的大小 16。
也就是第一次设置的 threshold = 16 * 2 / 3, 取整后的值是:10。
换句话说当 sz 大于等于 10 时,就可以考虑扩容了。
rehash 代码如下:
在真正扩容之前,先尝试回收一次 key 为 null 的值,腾出一些空间。
如果回收之后的 size 大于等于 threshold 的 3/4 时,才需要真正的扩容。
计算公式如下:
也就是说添加数据后,新的 size 大于等于老 size 的1/2
时,才需要扩容。
resize 中每次都是按 2 倍的大小扩容。
扩容的过程如下图所示:
扩容的关键步骤如下:
老 size + 1 = 新 size
如果新 size 大于等于老 size 的 2/3 时,需要考虑扩容。
扩容前先尝试回收一次 key 为 null 的值,腾出一些空间。
如果回收之后发现 size 还是大于等于老 size 的 1/2 时,才需要真正的扩容。
每次都是按 2 倍的大小扩容。
9. 父子线程如何共享数据?
前面介绍的 ThreadLocal 都是在一个线程
中保存和获取数据的。
但在实际工作中,有可能是在父子线程中共享数据的。即在父线程中往 ThreadLocal 设置了值,在子线程中能够获取到。
例如:
执行结果:
你会发现,在这种情况下使用 ThreadLocal 是行不通的。main 方法是在主线程中执行的,相当于父线程。在 main 方法中开启了另外一个线程,相当于子线程。
显然通过 ThreadLocal,无法在父子线程中共享数据。
那么,该怎么办呢?
答:使用InheritableThreadLocal
,它是 JDK 自带的类,继承了 ThreadLocal 类。
修改代码之后:
执行结果:
果然,在换成 InheritableThreadLocal 之后,在子线程中能够正常获取父线程中设置的值。
其实,在 Thread 类中除了成员变量 threadLocals 之外,还有另一个成员变量:inheritableThreadLocals。
Thread 类的部分代码如下:
最关键的一点是,在它的init
方法中会将父线程中往 ThreadLocal 设置的值,拷贝一份到子线程中。
感兴趣的小伙伴,可以找我私聊。或者看看我后面的文章,后面还会有专栏。
10. 线程池中如何共享数据?
在真实的业务场景中,一般很少用单独的线程
,绝大多数,都是用的线程池
。
那么,在线程池中如何共享 ThreadLocal 对象生成的数据呢?
因为涉及到不同的线程,如果直接使用 ThreadLocal,显然是不合适的。
我们应该使用 InheritableThreadLocal,具体代码如下:
执行结果:
由于这个例子中使用了单例线程池,固定线程数是 1。
第一次 submit 任务的时候,该线程池会自动创建一个线程。因为使用了 InheritableThreadLocal,所以创建线程时,会调用它的 init 方法,将父线程中的 inheritableThreadLocals 数据复制到子线程中。所以我们看到,在主线程中将数据设置成 6,第一次从线程池中获取了正确的数据 6。
之后,在主线程中又将数据改成 7,但在第二次从线程池中获取数据却依然是 6。
因为第二次 submit 任务的时候,线程池中已经有一个线程了,就直接拿过来复用,不会再重新创建线程了。所以不会再调用线程的 init 方法,所以第二次其实没有获取到最新的数据 7,还是获取的老数据 6。
那么,这该怎么办呢?
答:使用TransmittableThreadLocal
,它并非 JDK 自带的类,而是阿里巴巴开源 jar 包中的类。
可以通过如下 pom 文件引入该 jar 包:
代码调整如下:
执行结果:
我们看到,使用了 TransmittableThreadLocal 之后,第二次从线程中也能正确获取最新的数据 7 了。
nice。
如果你仔细观察这个例子,你可能会发现,代码中除了使用TransmittableThreadLocal
类之外,还使用了TtlExecutors.getTtlExecutorService
方法,去创建ExecutorService
对象。
这是非常重要的地方,如果没有这一步,TransmittableThreadLocal
在线程池中共享数据将不会起作用。
创建ExecutorService
对象,底层的 submit 方法会TtlRunnable
或TtlCallable
对象。
以 TtlRunnable 类为例,它实现了Runnable
接口,同时还实现了它的 run 方法:
这段代码的主要逻辑如下:
把当时的 ThreadLocal 做个备份,然后将父类的 ThreadLocal 拷贝过来。
执行真正的 run 方法,可以获取到父类最新的 ThreadLocal 数据。
从备份的数据中,恢复当时的 ThreadLocal 数据。
11. ThreadLocal 有哪些用途?
最后,一起聊聊 ThreadLocal 有哪些用途?
老实说,使用 ThreadLocal 的场景挺多的。
下面列举几个常见的场景:
在 spring 事务中,保证一个线程下,一个事务的多个操作拿到的是一个 Connection。
在 hiberate 中管理 session。
在 JDK8 之前,为了解决 SimpleDateFormat 的线程安全问题。
获取当前登录用户上下文。
临时保存权限数据。
使用 MDC 保存日志信息。
等等,还有很多业务场景,这里就不一一列举了。
由于篇幅有限,今天的内容先分享到这里。希望你看了这篇文章,会有所收获。
接下来留几个问题给大家思考一下:
ThreadLocal 变量为什么建议要定义成 static 的?
Entry 数组为什么要通过 hash 算法计算下标,即直线寻址法,而不直接使用下标值?
强引用和弱引用有什么区别?
Entry 数组大小,为什么是 2 的 N 次方?
使用 InheritableThreadLocal 时,如果父线程中重新 set 值,在子线程中能够正确的获取修改后的新值吗?
敬请期待我的下一篇文章,谢谢。
评论