ThreadLocal学习
ThreadLocal简介
ThreadLocal顾名思义,就是线程局部变量,也可以叫线程变量。可以让不同的线程访问自己独有的变量,而不会影响其他线程变量,实现了线程隔离级别的变量存储。
常见的使用场景包括:
- 会话管理:在web应用中,可以使用线程变量来存储请求中的相关信息,这样在后续处理请求的过程中都可以方便的获取到请求相关的信息,比如用户的相关信息。
- 数据库连接管理:在并发访问数据库的场景下,使用线程变量管理数据库的连接,这样每个线程都有自己独立的数据库连接,避免线程之间数据不一致和连接冲突的问题。
工作原理
Thread
类中有一个threadLocals
的属性,这是一个ThreadLocalMap
对象。ThreadLocalMap
可以被看做是一个集合,其内部维护一个Entry[]
数组,用来保存ThreadLocal
的引用。
1 | static class Entry extends WeakReference<ThreadLocal<?>> { |
当调用ThreadLocal
的get方法时,其实是从Thread
类中去取threadLocals
的值,getMap
方法就是从当前的thread
对象里面取出threadLocals
,如果取出来为null
就说说明当前线程还没有创建过ThreadLocalMap
就会调用setInitialValue
方法来初始化一个ThreadLocalMap
。
1 | public T get() { |
总的来说,ThreadLocal
是通过在每个线程对象的实例中都维护一个threadLocalMap
,然后将ThreadLocal
作为map的key(准确来说是ThreadLocal
对象的哈希值和此时Entry
数组的长度按位与),value就是我们set的对应值,这样每个线程都会有自己的一个map,我们也可以在这一个线程中的任意地方来获取这个map中的值,并且不同线程之间不会互相影响。
内存泄漏问题
1. 不会存在内存泄漏的情况
不使用线程池创建而单独创建线程时,这种情况下因为ThreadLocalMap
是Thread
类的一个成员变量,会随着线程的消亡而消亡,即使我们不手动将value移除,ThreadLocalMap
也不会存在,其中的Entry数组也不会存在。
在实际项目中一般也不会这样去使用ThreadLocal
,这样使用也毫无意义,并且通常情况下我们都是用线程池来创建对象,线程池中的核心线程基本会伴随着应用程序的整个生命周期。
另一种情况是我们实例化的ThreadLocal
对象是一个长生命周期的,通常就是static修饰的静态类变量,这种情况下ThreadLocal
对象的生命周期和应用程序一样长,一般不将其人为赋值为null
,就不会出现弱引用的情况,GC也就不会将其给回收掉。但是在我们使用完变量之后还是要将其remove掉,否则另一个用户发起会话可能会错误的读取到上一个用户的值。
2.存在内存泄漏的情况
当我们创建的ThreadLocal
对象是短生命周期的,随着ThreadLocal
对象变成null
之后,GC会回收ThreadLocalMap
中弱引用的ThreadLocal
对象,而它对应的value由于是一个强引用的对象,无法被回收,这个时候value和线程的生命周期一样长,就会一直占着内存造成内存泄漏问题。
为什么key是弱引用
弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
简单理解就是当垃圾回收时,该对象只被WeakReference
对象的弱引用字段(T reference)所引用,而未被任何强类型的对象引用,那么,该弱引用的对象就会被回收。
- 假设 Entry 的 key 是对
ThreadLocal
对象的强引用:这个 Entry 又持有ThreadLocal
对象和 value 对象的强引用。如果在其他地方都没有对这个ThreadLocla
对象的引用了,然后在使用 ThreadLocalMap 的过程中又没有正确地在用完后就调用 remove 方法,所以这个ThreadLocal
对象和所关联的 value 对象就会跟随着线程一直存在,这样就会可能会造成内存泄漏问题。
特别是在使用线程池的时候,核心线程是会一直存在直到程序结束,如果这些线程中的ThreadLocalMap
中的数据没有被及时清理,就会一直占用内存,而且在线程复用时可能会导致数据错乱的危险。 - Entry 的 key 是对
ThreadLocal
对象的弱引用:弱引用就意味着,如果没有其他引用对象的强引用关系,那么这个仅被弱引用引用着的对象在下次 GC 时就会被回收掉,这样在一定程度上降低内存泄漏的风险。但同时也引入了新的问题,key 虽然被回收了,但是 value 对象还在,我们无法获取,也无法删除,这样也会存在内存泄漏的风险。虽然ThreadLocalMap
中在进行 set 和 get 操作时会进行启发式清理和探测式清理,清理一部分 key 为 null 的 Entry 对象,但是这也只是一种后备选择方案,最重要的还是开发人员在编写代码时记得在使用完数据后及时调用 remove() 方法手动清理。
清理策略
ThreadLocalMap
一共有两种清理策略,分别是探测式清理和启发式清理。
- 探测式清理:源码中的
expungeStaleEntry()
方法 - 启发式清理:源码中的
cleanSomeSlots()
方法
1. 探测式清理
探测式清理会从指定的位置(也就是staleSlot
)开始向后探测清理过期的数据,将过期的数据,也就是key==null
的Entry
设置为null,沿途中碰到未过期的数据,就将此数据rehash
后重新在table数组中定位,如果定位到的位置已经有数据了,则会依次向后遍历,将未过期的数据放到最靠近此位置并且Entry==null
的位置,是的rehash后的Entry数据里正确的桶的位置更近一些。
简单总结就是探测式清理会将key==null
位置的Entry也置为null,不为空的则会重新计算哈希值分配位置,如果重新分配的位置上有元素了,就往后延续。
1 | private int expungeStaleEntry(int staleSlot) { |
2. 启发式清理
启发式清理需要接受两个参数,分别是:
- 探测式清理后返回的下标
- 数组的总长度
从源码可以看出,启发式清理从传入的下标i
开始向后遍历,如果发现过期的Entry就会再次触发探测式清理,并将n重置为table长度,经过数组长度的2的整数次幂的梁旭遍历之后如果没有发现过期的Entry,就人为数组中没有过期的Entry了。
1 | private boolean cleanSomeSlots(int i, int n) { |
3. 哪些地方会触发这两种清理方式
- set() 方法中,遇到
key==null
的情况会触发一轮探测式清理 - set()方法最后会执行一次启发式清理
- rehash()方法会调用一次探测式清理
- get()方法中入到key过期的时候也会触发一次探测式清理
- 启发式清理过程中遇到
key==null
时会触发探测式清理