本文以JDK21为例子,其实大致方法和JDK8都一样。
1.基本介绍
ThreadLocal
是一个在多线程编程中常用的概念,不同编程语言中实现方式不同,但核心思想一致:为每个使用该变量的线程都提供一个独立的变量副本,每个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。
主要作用
- 线程安全:避免多线程共享变量时需要进行同步操作(如加锁),从而简化并发编程。
- 传递上下文:在同一个线程的不同方法中传递数据,避免显式传递参数。
它的几个API:
方法声明 | 描述 |
ThreadLocal() | 创建ThreadLocal对象 |
public void set(T value) | 设置当前线程绑定的局部变量 |
public T get() | 获取当前线程绑定的局部变量 |
public void remove() | 移除当前线程绑定的局部变量 |
下面来简单使用一下:
2.对比Synchronized
ThreadLocal
和 Synchronized
(或其他同步机制)都用于处理多线程环境下的并发问题,但它们的核心思路和应用场景完全不同。
ThreadLocal | Synchronized |
避免共享:为每个线程创建独立的变量副本,线程之间互不干扰。 | 控制共享:通过锁机制保证多线程对共享资源的有序访问。 |
空间换时间:每个线程单独存储数据,牺牲内存换取无锁的高效。 | 时间换空间:通过阻塞其他线程的访问,保证共享资源的安全性。 |
每个线程内部通过 | 基于 JVM 内置锁(Monitor)实现,通过锁的获取和释放控制代码块或方法的访问权限。 |
通过 | 锁竞争时,未获取锁的线程会进入阻塞状态(或自旋),直到锁释放。 |
线程隔离:每个线程需要独立操作变量(如用户会话、数据库连接)。 | 共享资源保护:多个线程需要操作同一资源(如计数器、缓存)。 |
避免线程安全问题:通过隔离变量副本,无需同步(如 | 原子性保证:确保一段代码的原子执行(如余额扣减)。 |
性能敏感场景:避免锁竞争的开销(如线程池中的上下文传递)。 | 临界区保护:保护共享数据的读写一致性。 |
内存泄漏风险:若未调用 | 性能开销:锁竞争激烈时,线程阻塞和唤醒会带来性能损耗(尤其是重量级锁)。 |
无锁操作: | 锁优化:JVM 对 |
- 两者可以结合使用:例如用
ThreadLocal
保存线程私有数据【数据隔离】,用Synchronized
保护共享状态【数据共享】。 - 现代框架中的典型应用:Spring 的事务管理通过
ThreadLocal
保存数据库连接
3.原理分析
首先从上面的ThreadLocal简单使用案例的方法来看看。
①set
经过上面的源码分析我们可以得出以下信息:
- 每个Thread对象里面都有一个threadLocals变量,它的类型是ThreadLocalMap;
- ThreadLocalMap是ThreadLocal的静态内部类
下面来简单看一下ThreadLocalMap(ThreadLocal的静态内部类)
可以把它看作为一个Map。到这里,set方法算是知道了,然后还大致知道了他们之间的关系,如下图:【不同线程之间的ThreadLocal他们的value是不一样的,达到了线程隔离的效果】
②get
get方法就是首先拿到执行方法的线程是哪一个,然后从该线程的threadLocals(ThreadLocalMap)上以该Thread对象为key,拿到Entry的value值。
③remove
直接将ThrealLocal 对应的值从当前的Thread中的ThreadLocalMap中删除
④再说ThreadLocalMap
我们对于ThreadLocal的get、set或者是remove,本质上都是在操作ThreadLocaMap里面的Entry数组
Entry将ThreadLocal作为Key【为弱引用】,值作为value保存,它继承自WeakReference. 这个弱引用是啥?前面还说了,可以把它看作为一个Map(ThreadLocalMap并没有实现Map接口),但是我们熟知的HashMap之类的,key可能是会冲突的,这里的ThreadLocalMap里面的key冲突了咋办呢?本节就来分析一下。
1) 冲突
发生冲突的时候,那么肯定是在set/get的时候把,我们看一下set方法
根据上面的源码分析,我们得知,set的时候,根据ThreadLocal对象的hash值,定位到table中的位置i,然后判断该位置是否为空;如果key相同,直接覆盖旧值;如果是空的,初始化一个Entry对象放在位置i上;否则,就在i的位置上,往后一个一个找。【线性探测法】
再来看一下get方法:
上面的源码很简单吧。可以知道,ThreadLocal在hash冲突严重的时候,他的效率其实是不高的。
2) 弱引用
那么这个弱引用呢?说起这个,肯定会提到ThreadLocal老生常谈的内存泄漏问题了。
内存泄漏:【不会再被使用的对象或者变量占用的内存不能被回收,就是内存泄露。】
Memory overflow:内存溢出,没有足够的内存提供申请者使用。
Memory leak:内存泄漏是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统溃等严重后果。内存泄漏的堆积终将导致内存溢出。
在 Java 中,弱引用(Weak Reference) 是一种特殊的引用类型,它的核心特点是:当垃圾回收(GC)发生时,无论内存是否充足,弱引用指向的对象【对象必须仅被弱引用指向(没有任何强引用)】都会被回收。这与其他引用类型(如强引用、软引用、虚引用)有显著区别。下面通过对比不同引用类型,详细解释弱引用的特性及用途。
引用类型 | GC 行为 | 典型应用场景 | 实现类 |
强引用 | 对象有强引用时,永远不会被回收(如果想取消强引用和某个对象之间的关联,可以显式地将引用赋值为null,这样可以使JVM在合适的时间就会回收该对象。) | 日常对象的使用(如 | 默认引用类型 |
弱引用 | 只要发生 GC,就会被回收 | 缓存、 |
|
软引用 | 内存不足时才会被回收 | 缓存(如图片缓存) |
|
虚引用 | 无法通过虚引用访问对象,GC 后会收到通知 | 资源清理跟踪(如堆外内存释放) |
|
弱引用的回收时机由 GC 决定,无法精确控制。
在弄清楚上述的内存泄漏和弱引用问题之前,我们需要先知道ThreadLocal体系的对象存在哪里的,如下图:
Thread ThreadRef = new Thread(xx);就是强引用,下图中用实线连接起来的,ThreadLocal同理。
在 ThreadLocal
的使用中,内存泄漏的核心原因是 ThreadLocalMap
的 Entry 对 value 是强引用,而 Entry 的 key 是对 ThreadLocal
实例的弱引用。当 ThreadLocal
实例失去外部强引用时,GC 会回收 key(弱引用),key就为null了,但 value 仍被强引用保留在 Entry 中,若线程长期存活(如线程池线程),value 将无法被回收,导致内存泄漏。那么,什么时候ThreadLocal会失去外部的强引用呢?下面给出两个例子
总结一下,何时出现 “无外部强引用”?
- 当
ThreadLocal
实例不再被任何强引用直接或间接指向的时候:
- 局部变量超出作用域。
- 所属对象实例被回收。
- 但线程仍存活(如线程池线程长期复用)。
我们平时开发都是使用的线程池,线程有的可能会一直存活。
为了避免出现上述情况,我们平时使用完成之后,尽量在业务结束并且不需要该线程本地变量的时候,给它remove掉。
通过上述分析,我们可以看到一个非常致命的条件,那就是线程存活的时间 大于了 ThreadLocal的强引用存活时间。如果说,ThreadLocal 和 Thread的生命周期一样长,即时我们不remove,一样不会内存泄漏的。( 如下图 )
ThreadLocal其实它有兜底措施的:【就是上面的remove方法里面调用的expungeStaleEntry
】
当 ThreadLocal
实例被垃圾回收(GC)后,其对应的 Entry
的 key
会变为 null
(弱引用特性),但 value
仍被强引用保留。expungeStaleEntry
会遍历哈希数组,将这些 key
为 null
的 Entry
的 value
置为 null
,并释放 Entry
对象本身,从而切断 value
的强引用链,帮助 GC 回收内存
删除的时候:
- 从指定位置开始向后遍历哈希数组,若发现
Entry
的key
为null
,则清除其value
并释放Entry
对象。 - 若
Entry
的key
有效,则重新计算其哈希值(k.threadLocalHashCode & (len - 1)
),检查是否需要调整位置以优化哈希分布
从上述分析可以看出:expungeStaleEntry
仅清理从指定位置开始的连续过期 Entry
,而非整个哈希表,因此无法完全避免内存泄漏;清理效果取决于 set
、get
等方法的调用频率,若线程长期不操作 ThreadLocal
,残留的 value
仍可能堆积。
那如果key是强引用呢?会出现上述内存泄漏问题吗?
为什么key设计成弱引用呢?
当 ThreadLocalMap
的键是 弱引用 时:
- 外部强引用
threadLocal
被置为null
后,键(弱引用)指向的ThreadLocal
对象仅被弱引用持有。 - GC 会回收
ThreadLocal
对象,并将对应的弱引用放入引用队列,key就为null了。 ThreadLocalMap
在下次操作(如get()
、set()
)时,会清理引用队列中的失效条目,释放值对象的内存。【就是我们说的兜底措施嘛】
如果 ThreadLocalMap
的键是 强引用,会发生:
threadLocal
变量被置为null
,但ThreadLocalMap
中的键仍强引用着ThreadLocal
对象。ThreadLocal
对象无法被 GC 回收,导致其对应的值(BigObject
)也无法被回收,即使线程可能长期存活(如线程池中的线程)。- 内存泄漏:无用的键值对会一直存在于
ThreadLocalMap
中
5.框架中的应用
这一节只看一下ThreadLocal在Spring事务中的应用。
在事务中,需要做到如下保证:
- 每个事务的执行需要通过数据源连接池获取到数据库的connetion。
- 为了保证所有的数据库操作都属于同一个事务,事务使用的连接必须是同一个,也就是在一个线程里面需要操作同一个连接
- 线程隔离:在多线程并发的情况下,每个线程都只能操作各自的connetion,不能使用其他线程的连接
在Spring事务的源码中:
DataSourceTransactionManager.java
TransactionSynchronizationManager.java
:下面就把线程和ThreadLocal绑定起来了。
在上面源码中,每个线程里面的本地变量是一个map,map是以DateSource为key,ConnectionHolder为value。在一个系统可以有多个DataSource,connection又是由相应的DataSource得到的。所以ThreadLocal维护的是以DataSource作为key, 以ConnectionHolder为value的一个Map
总结一下,在开启事务的时候,绑定资源,Spring 事务管理器(如 DataSourceTransactionManager
)会调用 doBegin()
,获取数据库连接并绑定到 ThreadLocal
。
在执行sql的时候,MyBatis 通过 SpringManagedTransaction
获取当前事务的连接
最后提交事务,相关清理工作.... 就是remove那些了。
总流程如下所示:
end.参考
留一个问题:ThreadLocal还有一个很经典的问题,那就是在父子线程中通信的问题了。
1. https://blog.youkuaiyun.com/qq_35190492/article/details/107599875
2. https://blog.youkuaiyun.com/u010445301/article/details/111322569
3. https://zhuanlan.zhihu.com/p/102571059