我正在参加「掘金·启航计划」
1、概述
ThreadLocal叫做“线程变量”,其中填充的变量属于当前线程,对其他线程是隔离的,不会被别的线程读取或修改。这种思路叫线程封闭。
原理是,每个线程内部都有一个ThreadLocalMap,它用ThreadLocal作为键,能够存储值。
ThreadLocal的适用场景:
- 每个线程需要有自己的实例,且该实例需要在多个方法间传递,但不希望线程间共享。
- 实例需要在多个方法间传递,就可以保存在当前线程的ThreadLocal中,就不需要通过参数传递了。需要时直接get()取出。
2、ThreadLocal与sync的区别
它们都用于解决多线程的并发访问,区别是:
-
synchronized用于线程间的数据共享,而ThreadLocal用于线程内的数据共享,线程间的数据隔离
-
synchronized利用锁机制,让变量或代码块在同一时刻只能被一个线程访问。
而ThreadLocal不涉及到锁,它在每个线程内都提供了变量的副本,每个线程只能操作自己内部的副本,避免了共享问题。
3、使用方法
ThreadLocal的变量通常用private static修饰
public class ThreadLocalDemo {
private static ThreadLocal<String> localVar = new ThreadLocal<>();
//打印出本线程内的localVar值
public static void printLocalVar(String str){
System.out.println(str + " " + localVar.get());
//清除本地内存中的本地变量
localVar.remove();
}
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
ThreadLocalDemo.localVar.set("t1的localVar");
printLocalVar("t1");
}, "t1");
t1.start();
Thread.sleep(1000);
Thread t2 = new Thread(() -> {
ThreadLocalDemo.localVar.set("t2的localVar");
printLocalVar("t2");
}, "t2");
t2.start();
}
}
注意:
- ThreadLocal的泛型是Object的,可以定义成map、set、list等等
- 一个类中可以定义多个ThreadLocal属性
4、原理分析
1、set方法
赋值过程:
- 首先获取当前线程对象,并获取当前线程的ThreadLocalMap
- 如果这个map为null,就调用createMap()方法初始化map,初始化需要传入当前线程对象和要存储的值
- 如果map不为null,就调用ThreadLocalMap的set方法,它会将当前线程对象作为键,存储值。
createMap()
这里可以看出,线程对象Thread对ThreadLocalMap是强引用。
2、ThreadLocalMap的set()
1、概述
这个方法本身非常简单,但包含很多优化,导致最终的方法逻辑其实非常复杂。
这个方法要做的事情是,往ThreadLocalMap自身中插入一个键值对,Key为ThreadLocal类型,Value为指定泛型。
-
首先,获取当前ThreadLocalMap的Entry数组,然后得到数组长度
-
获取当前Key的threadLocalHashCode属性,然后和 当前数组长度-1 做与运算,得到当前Key对应的数组下标。从这里可以看出,ThreadLocalMap的数组长度应该也是和HashMap一样,固定为2的幂次。
-
之后,
获取该下标对应的数组元素-
如果不为null就进行开放定址,做法是:如果下标没有超过数组长度,就+1;如果超过了就置为0 -
对于不为null的数组元素,也就是Entry,会进行两个判断:-
检
查它的Key是否等于当前Key,等于就说明是替换操作,将Entry的Value置为当前的Value,插入成功。 -
检查它的Key是否为null,如果为null,说明Key已经被GC,调用replaceStaleEntry()方法把Key和Value存放进去,插入成功所以,在刚遇到被淘汰的Key时就会把新的键值对给替换进去,而不是仅仅把过期Entry占用的空间释放掉。
-
-
-
如果两个条件一直都不符合,就一直往后查找,找到目标数组元素后,把Key和Value存入
-
理论上当前的数组容量为之前的size+1,可以直接检查是否大于扩容阈值。
但是由于可能存在被GC掉的Key,所以先调用
cleanSomeSlots()来判断是否存在过期的Entry,如果没有再去判断是否大于扩容阈值如果需要扩容,就调用
rehash()进行扩容操作。
2、什么是threadLocalHashCode
这是ThreadLocal类的成员属性,用于充当Key的Hash值,仅用于ThreadLocalMap。
可见,在ThreadLocal每次实例化时,会调用nextHashCode()方法
这里的nextHashCode是ThreadLocal类的静态属性,它是一个原子整数:
所以:
- threadLocalHashCode其实就是一个原子整数,它是ThreadLocal的静态属性,不同对象间是共享的。
- 每次new一个ThreadLocal,会在原先nextHashCode的基础上,增加HASH_INCREMENT,作为当前ThreadLocal的threadLocalHashCode
为什么设计threadLocalHashCode,为啥不用Object的hashcode()方法
由于threadLocalHashCode是个静态的原子整数,所以在同一个线程内创建多个ThreadLocal,它们的threadLocalHashCode肯定不会重复,而且差值就是固定好的HASH_INCREMENT。而在不同的线程之间,显然不会影响。
官方的说法是,这样做是为了消除同一个线程下连续创建多个ThreadLocal对象可能存在的哈希冲突问题。
可以这么理解:
-
连续创建多个ThreadLocal对象,它们的内存地址就很可能是连续的,导致算出的hashcode也是连续的。因为ThreadLocal没有重写hashcode()方法,Object的hashcode()方法是,把对象的内存地址转换成整数来作为hashcode。
-
重点来了,由于ThreadLocalMap想要进行防止内存泄露的优化,所以
使用开放定址法来解决哈希冲突,这就要求Key最好不要连续存放,以免连续访问好几个Entry都有数据,降低插入和查找的效率。 -
因此,ThreadLocal使用了自定义的threadLocalHashCode作为hashcode,每个值的间隔是HASH_INCREMENT。据官方说,在长度为2的幂次的表上,这是最适合开放定址法效率的间隔大小。
3、replaceStaleEntry()
这个方法的作用是,用指定的键值对来替换目标索引下的Entry内容。
它被调用时,是出现了哈希冲突,然后往后查找,遇到的第一个过期Entry,将它的下标传入了方法。
但是这个方法的设计思想并不是对号入座,而是一次性检查所有失效的Entry,然后顺带把新的键值对放到第一个过期的Entry中。
所以方法内部的逻辑是:
-
当前找到的过期Entry的下标是staleSlot,它作为参数传入,含义是“过期的槽”。
-
首先要找到slotToExpunge,含义是“准备删除的槽”。
- 它最开始就是staleSlot
- 先从staleSlot开始,往左遍历数组,找到数组从左往右的第一个过期Entry,记录它的下标,作为slotToExpunge。
-
之后,从staleSlot的下一个下标开始,往右遍历数组,检查每个Entry的Key。
-
如果当前Entry的Key等于当前的Key,说明是一次更新操作,把当前找到的过期Entry的Value置为新的Value,然后把该过期Entry和当前Entry的存储位置调换一下。
并且,如果slotToExpunge等于staleSlot,就把slotToExpunge置为当前下标,因为发生了交换,此时当前下标对应的是原先staleSlot位置的过期Entry。
-
4、cleanSomeSlots()
2、ThreadLocalMap
createMap()
其实线程对象的threadLocals属性,就是用来存储ThreadLocalMap的。
查看ThreadLocalMap源码,很长,截取一部分
ThreadLocalMap是ThreadLocal的静态内部类。它用Entry保存数据,而且继承了弱引用。
在构造器中,调用父类构造,来弱引用连接Key。
Entry内部使用ThreadLocal类型的变量作为键,保存传入的值。
ThreadLocalMap如何工作
这个Map使用哈希确定下标,将值保存在数组中,类似于HashMap。但没有实现Map接口,也没有链表结构。
一个线程只有一个ThreadLocalMap,但是可以创建多个ThreadLocal字段,所以需要使用数组存储每个Entry。
不使用链表,它解决哈希冲突的方式是,找空隙:
- 要插入一组数据,根据ThreadLocal对象的哈希值,计算出一个下标
- 如果该下标对应的位置是空的,就初始化一个Entry,存入数据
- 如果不为空,就检查它的key,如果正好和要存入的key一样,此次是覆盖操作,直接替换Value
- 如果不为空且key不符,说明
出现了哈希冲突,就找下一个空的位置,继续判断,直到成功插入。 - 在get的时候也是,如果下标中的key不符,说明插入时有哈希冲突,就找下一个位置,直到找到key
3、get方法
获取流程:
- 获取当前线程对象
- 如果map不为null,就通过ThreadLocal对象,取出对应的Entry
- 如果entry不为空,就获取Entry中的Value,返回。
- 如果前一步中map为空,就调用setInitialValue()方法
setInitialValue()
这个方法是给ThreadLocal设置初始值
4、remove方法
将ThreadLocal的值,从当前线程的ThreadLocalMap中删除。
5、总结
ThreadLocal的值,存储在当前线程对象的threadLocals属性中,这个属性对应一个ThreadLocalMap对象,在第一次调用ThreadLocal的set方法时被初始化。
ThreadLocalMap保存对象的策略是,以ThreadLocal为键,映射存储值。
这个ThreadLocal是多线程共享的,而ThreadLocalMap是线程私有的,所以每个线程都可以根据ThreadLocal存储不同的值,别的线程也无法获取到。
5、使用场景
1、Spring实现事务隔离级别
Spring使用ThreadLocal的方式,保证一个线程中的数据库操作都是使用的同一个连接对象
private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
……
2、解决日期的线程安全
项目中有部分用户的时间出错,发现是多个线程共享一个SimpleDataFormat的问题。
使用SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),然后调用Calendar.add()。
如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。
但是每个线程内部都new一个SimpleDataFormat对象也不太好,所以使用ThreadLocal包装SimpleDataFormat,解决了线程安全的问题。
3、多个方法调用
一个线程经常需要横跨多个方法调用,那么它的参数就必须层层传递,给每个方法都加上相同的参数不太优雅。
而且,如果中间遇到第三方类库,参数就无法传递了。可以使用ThreadLocal,开始时把参数存进去,需要时直接get取出即可。
4、JDBC的数据库连接
从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。
数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题
6、ThreadLocal 的内存泄漏问题
1、为什么存在内存泄漏问题
在“阅后即焚”的线程中,在Thread中使用了ThreadLocal不会有内存泄漏的问题,因为线程执行完就会被销毁,释放它占用的所有资源。
但是在线程池中,线程会重用,线程对象持有ThreadLocalMap,这是强引用的,所以每个线程的ThreadLocalMap都会存活到线程销毁为止。
由于无法确认线程池的具体用途,比如可能有的业务需要使用ThreadLocal,有的不需要,而且线程使用哪个ThreadLocal作为Key也是未知。所以,如果一直不remove,持续添加ThreadLocal,就会引发OOM。
ThreadLocal内存泄漏隐患的本质
ThreadLocalMap 的生命周期和 线程对象Thread 一样长,如果没有手动删除,就会导致每个Value占用的value一直存在。
2、Key和Value的释放时机
Key会被GC掉
ThreadLocalMap中,Entry的Key是ThreadLocal对象的弱引用。如果一个对象只存在弱引用,那它在下一次GC中一定会被清理。
所以,如果ThreadLocal对象没有外部的强引用,在垃圾回收时,Entry的key会被清理掉,所以key变为null。
但是Entry的Value是强引用,不会被GC回收掉。
为什么ThreadLocal对象可能没有外部的强引用?因为如果线程被重用,之前的代码会给Thread类的ThreadLocalMap里面存放数据,当前线程不一定需要使用对应的ThreadLocal,所以可能不存在强引用。
Value不会GC掉
在currentThread 存在,并且没有手动remove掉这个entry时,存在一条强引用链:currentThread -> ThreadLocalMap -> entry -> value
因为线程对象通过强引用指向ThreadLocalMap,而ThreadLocalMap也是通过强引用指向Entry,所以Entry的value也是强引用。
key不再使用,被清理掉了,就没有任何途径能访问到这个value,所以value属于垃圾。
所以,只是将Key弄成弱引用,并不能保证ThreadLocal肯定不会发生内存泄漏,因为Value是强引用的,不会被自动GC。
Value什么时候被释放
官方建议,使用完ThreadLocal的值后,就手动调用remove()方法,把对应的值清理掉,这样value就置为null,能被垃圾回收了。
但是,如果不去remove,久而久之就肯定会内存泄漏,ThreadLocalMap在实现时考虑了这种情况。因此调用set()、get()时,会清理掉Key为null的Entry对象。
能这么做的原因就是Key是弱引用,所以它被GC掉之后就会变为null,所以可以根据key是否为null来判断Value是否需要移除。
这属于多了一层保障,不手动remove,Value占用的空间也有机会被释放。
调用set、get清理对象具体的流程是:
- 调用ThreadLocal的get(),它会先获取当前线程对象的ThreadLocalMap
- 调用ThreadLocalMap的getEntry(),它会调用哈希函数,计算出一个数组下标。
- 如果发生了哈希冲突(下标的key不等于所需的key),就调用getEntryAfterMiss()
- ThreadLocalMap使用开放定址法解决哈希冲突,即向后寻找所需的key值。
- 期间如果遇到key为null的Entry,就会调用expungeStaleEntry(),将key为null的Entry的value也设置为null
3、Key和Value引用的设计思想
为什么Key弄成弱引用
因为ThreadLocal的设计思想其实有些暴力,直接往线程对象中塞数据,如果考虑不周到就很容易OOM。
这属于官方的巧妙优化手段,目的是尽量减少内存泄漏。
这个优化思路做了两件事情:
- 把Key定义成弱引用,当ThreadLocal没有外部强引用时,说明当前线程的代码不需要线程中的这个数据,下次GC它就会被垃圾回收,对应的Entry的Key将置为null。
- 每次get()或set(),如果发生哈希冲突就会进行开放定址,期间会把遇到的Key为null的Entry的Value置为null,这样这个Entry就能被回收了。
这样,即使不去调用remove()方法,也有机会从线程对象中清理掉无用数据。
但是,把Key做成弱引用可无法完全避免内存泄漏,最靠谱的当然还是手动remove()
为什么Value不弄成弱引用
如果把Value设置成弱引用,在不存在其他引用的时候,Value就会被GC掉,变成null。
但是假如Key所引用的 ThreadLocal 对象还被其他的引用对象强引用着,说明当前线程需要使用到这个ThreadLocal ,那么这个 ThreadLocal 对象就不会被 GC 回收。但是此时如果再去获取Value,就会获取到null,产生空指针问题。
显然这个逻辑是有问题的。因为我们引用了Key,肯定是希望通过Key来获取到Value,所以Value不能被无缘无故GC掉。所以Value弄成了强引用。
7、常见问题
1、ThreadLocal对象存放在哪里
Java中,栈内存是线程私有的,堆内存是线程共享的。
ThreadLocal对象存放在堆上。
2、如何共享ThreadLocal数据
使用InheritableThreadLocal,可以实现主线程和子线程共享ThreadLocal数据。
在主线程new一个InheritableThreadLocal的实例,子线程就可以获取到它的值。它也是ThreadLocal类型。
final ThreadLocal threadLocal = new InheritableThreadLocal();
如何传递的?
在线程类中,有这样一条属性:
线程初始化时,有这样一个逻辑:
只要它自己的inheritThreadLocals和父线程的inheritThreadLocals都不为null,就把父线程的inheritThreadLocals给到子线程。
这里的parent,是获取的当前线程。因为这个子线程还没有创建,那么调用它初始化方法的线程就是它的父线程。
3、ThreadLocal与Thread、ThreadLocalMap
Thread有一个threadLocals属性,存放一个线程私有的ThreadLocalMap类型变量。
ThreadLocalMap是ThreadLocal的静态内部类。它类似Map,使用Entry存放数据,key为ThreadLocal对象。
ThreadLocal相当于ThreadLocalMap的工具类。调用ThreadLocal对象的get、set方法,底层是在调用ThreadLocalMap的get、set方法
ThreadLocal帮助ThreadLocalMap初始化。
4、请求线程中清理ThreadLocal
请求线程是跑在线程池中的,所以有些线程会被复用。
所以请求的末尾去清理ThreadLocal,一方面是避免内存泄漏,一方面也是为了避免由于线程复用导致信息错乱。