🔎【Java 源码探索】深入浅出的分析 ThreadLocal
每日一句
一个人的成功不取决于他的智慧,而是毅力。
基本介绍
ThreadLocal 是对 Thread 内部的局部变量 ThreadLocalMap 的维护类。当线程持有多个 ThreadLocal 的操作时,会在 ThreadLocalMap 中通过 key 进行寻找。
每个 Thread 里面维护了一个 ThreadLocal.ThreadLocalMap 变量,底层存储结构为 Entry[],ThreadLocal 实例作为 ThreadLocalMap 的 key,set/get 的值为 Map 的 value,其中,key 的引用为弱引用。
当执行 ThreadLocal.set 时,实际是将 ThreadLocal 对象和值通过 key-value 的形式放进了 Thread 中的 ThreadLocal.ThreadLocalMap 属性中,完成了线程隔离存储,保证了线程安全,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。
使用场景
ThreadLocal 即便你没有直接用到过,它也间接的出现在你使用过的框架里:
Spring 的事务管理。
Hibernate 的会话管理。
logback(和 log4j)中的 MDC 功能实现等。
比如用到的一些分页功能的实现往往也会借助于 ThreadLocal。
全链路追踪中的 traceId 或者流程引擎中上下文的传递一般采用 ThreadLocal;
Spring MVC 的 RequestContextHolder 的实现使用了 ThreadLocal;
总体概述
ThreadLocal 常用来做线程隔离,下面将对 ThreadLocal 的实现原理、设计理念、内部实现细节(Map、弱引用)、还有 ThreadLocal 存在的内存泄露问题进行讲解。
作用目的
提供一个线程内公共变量,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度,让线程的本地变量进行隔离。
原理概述
内部结构图
引用逻辑图(虚线表示弱引用)
原理分析
一个线程内可以存多个 ThreadLocal 对象,存储的位置位于 Thread 的 ThreadLocal.ThreadLocalMap 变量,在 Thread 中有如下变量:
ThreadLocalMap 是由 ThreadLocal 维护的静态内部类,正如代码中注解所说这个变量是由 ThreadLocal 维护的。
我们在使用 ThreadLocal 的 get()、set()方法时,其实都是调用了 ThreadLocalMap 类对应的 get()、set()方法。
Thread 中的这个变量的初始化通常是在首次调用 ThreadLocal 的 get()、set()方法时进行的。
上述 set 方法中,首先获取当前线程对象,然后通过 getMap 方法来获取当前线程中的 threadLocals:
如果 Thread 中的对应属性为 null,则创建一个 ThreadLocalMap 并赋值给 Thread:
如果已经存在,则通过 ThreadLocalMap 的 set 方法设置值,这里我们可以看到 set 中 key 为 this,也就是当前 ThreadLocal 对象,而 value 值则是我们要存的值。
对应的 get 方法源码如下:
可以看到同样通过当前线程,拿到当前线程的 threadLocals 属性,然后从中获取存储的值并返回。在 get 的时候,如果 Thread 中的 threadLocals 属性未进行初始化,则也会间接调用 createMap 方法进行初始化操作。
数据结构
ThreadLoalMap 是 ThreadLocal 中的一个静态内部类,类似 HashMap 的数据结构,但并没有实现 Map 接口。
ThreadLocalMap 中初始化了一个大小 16 的 Entry 数组,Entry 对象用来保存每一个 key-value 键值对。通过上面的 set 方法,我们已经知道其中的 key 永远都是 ThreadLocal 对象。
看一下相关的源码:
ThreadLoalMap 的类图结构如下:
这里需要留意的是,ThreadLocalMap 类中的 Entry 对象继承自 WeakReference,也就是说它是弱引用。
由于 ThreadLocalMaps 是延迟创建的,因此在构造时至少要创建一个 Entry 对象。这里可以从构造方法中看到:
上述构造方法,创建了一个默认长度为 16 的 Entry 数组,通过 hashCode 与 length 位运算确定索引值 i。而上面也提到,每个 Thread 都有一个 ThreadLocalMap 类型的变量。
至此,结合 Thread,我们可以看到整个数据模型如下:
hash 冲突及解决
我们留意到构造方法中 Entry 在 table 中存储位置是通过 hashcode 算法获得。每个 ThreadLocal 对象都有一个 hash 值 threadLocalHashCode,每初始化一个 ThreadLocal 对象,hash 值就增加一个固定的大小 0x61c88647。
在向 ThreadLocalMap 中的 Entry 数值存储 Entry 对象时,会根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置 i。
这里分三种情况:
如果当前位置为空的,直接将 Entry 存放在对应位置;
如果位置 i 已经有值且这个 Entry 对象的 key 正好是即将设置的 key,那么重新设置 Entry 中的 value;
如果位置 i 的 Entry 对象和即将设置的 key 没关系,则寻找一个空位置;
计算 hash 值便会有 hash 冲突出现,常见的解决方法有:再哈希法、开放地址法、建立公共溢出区、链式地址法等。
上面的流程可以看出这里采用的是开放地址方法,如果当前位置有值,就继续寻找下一个位置,注意 table[len-1]的下一个位置是 table[0],就像是一个环形数组,所以也叫闭散列法。
如果一直都找不到空位置就会出现死循环,发生内存溢出。当然有扩容机制,一般不会找不到空位置的。
内存泄露
ThreadLocal 使用不当可能会出现内存泄露,进而可能导致内存溢出。下面我们就来分析一下内存泄露的原因及相关设计思想。
内存引用链路
每个 Thread 维护一个 ThreadLocalMap,它 key 是 ThreadLocal 实例本身,value 是业务需要存储的 Object。
ThreadLocal 本身并不存储值,它只是作为一个 key 来让线程从 ThreadLocalMap 获取 value。
仔细观察 ThreadLocalMap,这个 map 是使用 ThreadLocal 的弱引用作为 Key 的,弱引用的对象在 GC 时会被回收。因此使用了 ThreadLocal 后,引用链如图所示:
泄露原因分析
正常来说,当 Thread 执行完会被销毁,Thread.threadLocals 指向的 ThreadLocalMap 实例也随之变为垃圾,它里面存放的 Entity 也会被回收。这种情况是不会发生内存泄漏的。
发生内存泄露的场景一般存在于线程池的情况下。此时,Thread 生命周期比较长(存在循环使用),threadLocals 引用一直存在,当其存放的 ThreadLocal 被回收(弱引用生命周期比较短)后,对应的 Entity 就成了 key 为 null 的实例,但 value 值不会被回收。
如果此 Entity 一直不被 get()、set()、remove(),就一直不会被回收,也就发生了内存泄漏。
所以,通常在使用完 ThreadLocal 后需要调用 remove()方法进行内存的清除。
为什么使用弱引用而不是强引用?
从表面上看内存泄漏的根源在于使用了弱引用,但为什么 JDK 采用了弱引用的实现而不是强引用呢?
先来看 ThreadLocalMap 类上的一段注释:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了协助处理数据比较大并且生命周期比较长的场景,hash table 的条目使用了 WeakReference 作为 key。
这跟我们想象的有些不同,弱引用反而是为了解决内存存储问题而专门使用的。
我们先来假设一下,如果 key 使用强引用,那么在其他持有 ThreadLocal 引用的对象都回收了,但 ThreadLocalMap 依旧持有 ThreadLocal 的强引用,这就导致 ThreadLocal 不会被回收,从而导致 Entry 内存泄露。
对照一下,弱引用的情况。持有 ThreadLocal 引用的对象都回收了,ThreadLocalMap 持有的是 ThreadLocal 的弱引用,会被自动回收。(防止用户获取一些不应该获取的数据,因为数据已经被回收了!)
只不过对应的 value 值,需要在下次调用 set/get/remove 方法时会被清除。
综合对比会发现,采用弱引用反而多了一层保障,ThreadLocal 被清理后 key 为 null,对应的 value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候可能会被清除。
所以,内存泄露的根本原因是是否手动清除操作,而不是弱引用。
常见问题
一个线程内可以存多个 ThreadLocal 对象,存储的位置位于 Thread 的 ThreadLocal.ThreadLocalMap 变量,在 Thread 中有如下变量:
为什么将 Map 放在每一个 Thread 里
应为如果将 Map 放在 ThreadLocal 中进行维护,即使使用 ConcurrentHashMap 减少并发竞争,但在形式上还是存在线程间的竞争,而放在各个线程中独立维护,就十分满足线程隔离的设计理念。
ThreadLocal.ThreadLocalMap 与 HashMap 有什么不同
解决 hash 冲突方法不同
HashMap 采用的是数组加链表的结构进行存储,当出现 hash 冲突时,进行链表追加。
ThreadLocal.ThreadLocalMap 采用的是开放定址法,即寻找下一个没有存储数据的位置。
拓展: 解决 hash 冲突的方式 1. 开放定址法 2. 再 hash 3. 链地址法 4. 公共溢出区。
扩容机制不同
当 ThreadLocal.ThreadLocalMap 的 size 大于数据 1/2 时,会扩容 2 倍。
为什么 Entry 的 key 存储采用弱引用
当 ThreadLocal 没有引用时,ThreadLocal.ThreadLocalMap 依旧存在于 Thread 中,而 ThreadLocal 对应的 Entry 永远不会被使用到,所以采用了弱引用,当 ThreadLocal 没有引用时,自动 key 就被 GC 回收。
为什么 Entry 的 value 存储没有采用弱引用
我们存储的对象除了 ThreadLocalMap 的 Value 就没有其他的引用了,value 一但是对象的弱引用,GC 的时候被回收,对象就无法访问了,这显然不是我们想要的。
如何解决
在 ThreadLocal 不使用时,调用 remove 方法,将 Entry 从 Map 中移除,即可解决。
对于 Java8 ThreadLocalMap 的 set 方法通过调用 replaceStaleEntry 方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏
get 方法会间接调用 expungeStaleEntry 方法将键和值为 null 的 Entry 设置为 null 从而使得该 Entry 可被回收
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/7dd8899da34c87032c541d745】。文章转载请联系作者。
评论