入坑ThreadLocal,这一篇文章就够了

一、前言

很多 Java 开发一般都是做中台较多,并发编程使用的不多。因此,对 ThreadLocal 不太熟悉,所以笔者这里想让大家了解它,知道它是用来干什么的。

二、ThreadLocal 是用来干什么的

ThreadLocal 是 Java 中一种线程封闭技术,它提供了一种线程本地变量的机制,使得每个线程都拥有一个独立的变量副本,这样可以避免多个线程访问同一个变量时产生的并发问题。

ThreadLocal 在工作中还是蛮常用的,笔者使用到的一些场景如下:

  1. 使用 zk 实现选举,采用单例 zkClient,但是对于里面一些全局变量就会存在线程安全问题,这时会希望这些特定的全局变量可以跟线程绑定。
  2. 项目UUC(统一认证中心),不同的用户登录,系统是如何确保当前用户的信息不会被张冠李戴的呢?其实都是通过 ThreadLocal 实现的(不过在 UUC 中,笔者使用的是 InheritableThreadLocal,这个会有点区别)。
  3. 参数传递,比如流水生成的方法里面的重试机制,假设限制重试 5 次,生成流水号的方法内部很多地方都可能失败需要重试(并发冲突或者 db 异常),最传统的方式就是将重试的次数传递。这种方式不够优雅,我们可以使用 ThreadLocal 来实现传递。

总的来说,当你需要和线程绑定的变量时,就可以考虑使用 ThreadLocal 啦!

至于线程安全问题,大家不妨想想我们平常说线程安全问题都是出现在什么场景?同一时间有两个或两个以上的线程对同一个变量进行修改,才有可能出现线程安全问题。但是使用 ThreadLocal,每个线程是独享自己的变量副本的,哪里还有线程安全问题呢?

三、ThreadLocal 如何使用

这个上网一搜一大堆,笔者就说下注意事项好了,用完后一定要释放,避免内存泄漏,提供几个点给大家参考:

  1. 及时清理: 确保在线程结束时,及时清理 ThreadLocal 中存储的数据。可以通过在使用完 ThreadLocal 后调用 remove() 方法来清理对应的数据。例如,可以使用 ThreadLocal.remove() 或在 finally 块中进行清理操作。
  2. 使用弱引用(WeakReference): 可以使用 ThreadLocal 的变体,如 InheritableThreadLocal 或 WeakThreadLocal,它们使用了弱引用来存储数据。这样,在没有其他强引用指向被存储的对象时,垃圾回收器可以自动清理该对象,避免内存泄漏。
  3. 避免长时间存储大量数据: 尽量避免在 ThreadLocal 中存储大量数据,特别是对于长时间运行的线程。因为 ThreadLocal 的值在线程的整个生命周期中都存在,如果存储大量数据,可能会导致内存占用过高。
  4. 及时释放资源: 如果你在 ThreadLocal 中存储了需要手动释放的资源,确保在不再需要时及时释放资源。可以通过在使用完资源后显式地调用资源的释放方法或使用 try-with-resources 语句来实现。
  5. 防止线程池中的内存泄漏: 当使用线程池时,要特别小心使用 ThreadLocal。确保在任务完成后清理 ThreadLocal 中的数据,以避免线程重用时的数据干扰和潜在的内存泄漏问题。可以在任务的开始和结束处使用 ThreadLocal 进行数据绑定和解绑。

总之,要正确使用 ThreadLocal 并避免内存泄漏问题,需要注意适时清理、使用弱引用、避免存储过多数据、及时释放资源,并在使用线程池时特别小心。

四、ThreadLocal 的实现原理

下面是一个简单的示例代码:

typescript 复制代码
public class ThreadLocalExample {
 private static final ThreadLocal<Object> threadLocal = new ThreadLocal<>();

 public static void main(String[] args) {
 Thread workerThread = new Thread(() -> {
 try {
 // 在线程中设置ThreadLocal值
 threadLocal.set(new Object());

 // 执行业务逻辑
 // ... 

 } finally {
 // 在线程结束时清理ThreadLocal值
 threadLocal.remove();
 }
 });

 workerThread.start();
 // 等待线程结束
 try {
 workerThread.join();
 } catch (InterruptedException e) {
 e.printStackTrace();
 }
 }
}

在示例代码中,线程 workerThread 和 ThreadLocal 实例是一个怎样的关系呢?set 方法和 remove 方法都做了什么呢?为什么会有内存泄漏的情况呢?我们带着疑问一起往下看。

4.1 java.lang.ThreadLocal#set

我们直接从源码开始分析 ThreadLocal。

scss 复制代码
public void set(T value) {
 // 获取当前线程
 Thread t = Thread.currentThread();
 // 通过当前线程获取ThreadLocalMap 
 ThreadLocalMap map = getMap(t);
 if (map != null)
 map.set(this, value);
 else 
 createMap(t, value);
 }

 ThreadLocalMap getMap(Thread t) {
 return t.threadLocals;
 }

 void createMap(Thread t, T firstValue) {
 t.threadLocals = new ThreadLocalMap(this, firstValue);
 }

 ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
 table = new Entry[INITIAL_CAPACITY];
 int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
 table[i] = new Entry(firstKey, firstValue);
 size = 1;
 setThreshold(INITIAL_CAPACITY);
 }

 static class Entry extends WeakReference<ThreadLocal<?>> {
 /** The value associated with this ThreadLocal. */
 Object value;

 Entry(ThreadLocal<?> k, Object v) {
 super(k);
 value = v;
 }
 }

结合示例代码来看,这里是当前线程A在 main 方法中通过 threadLocal 实例调用 threadLocal.set 方法,而 set 方法会给当前线程创建一个 ThreadLocalMap(如果没有的话),并使用 threadLocal 实例作为 key。

它们的关系如下图:

4.2 内存泄漏问题

这里应该分成两种情况看:无线程复用和有线程复用。

  1. 无线程复用
    当 workerThread 结束后,没有强引用的 ThreadLocalMap 自然而然也会被垃圾回收器回收,不会出现内存泄漏。
  2. 有线程复用
    这里也要分开看,有释放和无释放的情况。如果发生内存泄漏,当然就是我们没有释放导致的(释放可以通过调用 set、get、remove方法释放)。当我们使用线程池,线程会被复用时,ThreadLocalMap 的生命周期与它绑定的线程是一样的,所以不会被回收。如果这时发生了 gc,那么 Entry 的 key 是弱引用,key 会变成 null,而 value 将继续存活。如果该线程一直不调用 set/get/remove 方法,那么 value 一直得不到释放,就会发生内存泄漏的现象。

那为什么使用 set/get/remove 可以避免内存泄漏呢?因为 set/get 在根据当前线程找到对应 Entry 元素后(这里是刚好是碰到了 key==null 的 entry[i],碰不到是不会顺手释放旧 value 的。因此,最好还是使用完后调用 remove 释放),发现 key == null,就会调用java.lang.ThreadLocal.ThreadLocalMap#expungeStaleEntry 释放引用,所以就不会发生内存泄漏了。这里就不再展示源码了,有兴趣的可以自己去看下。

五、哈希冲突问题

上面看到 ThreadLocalMap 使用了 Hash,是不是马上就想到了哈希冲突呢?HashMap 遇到哈希冲突,在 key 不相同的情况下,会使用链表解决。但是 ThreadLocalMap 的 Entry 没有 next 指针,因此它明显不会采用链表,那么它是如何解决哈希冲突的呢?

请看 java.lang.ThreadLocal.ThreadLocalMap#set 源码,笔者添加了注释,可以看到是怎么解决哈希冲突的。

ini 复制代码
private void set(ThreadLocal<?> key, Object value) {

 // We don't use a fast path as with get() because it is at 
 // least as common to use set() to create new entries as 
 // it is to replace existing ones, in which case, a fast 
 // path would fail more often than not. 

 Entry[] tab = table;
 int len = tab.length;
 int i = key.threadLocalHashCode & (len-1);

 for (Entry e = tab[i];
 e != null;
 // 存在哈希冲突的话,会往下走,如果超过数组长度,就会回到0 
 e = tab[i = nextIndex(i, len)]) {
 ThreadLocal<?> k = e.get();

 if (k == key) {
 // 找到存储自己的entry,更新value 
 e.value = value;
 return;
 }

 if (k == null) {
 // 因为 gc 导致 key 被回收了,这个 Entry 会被新的 Entry 取代(新的Entry的key和value就是这里的传参),旧的会被释放
 replaceStaleEntry(key, value, i);
 return;
 }
 }

 tab[i] = new Entry(key, value);
 int sz = ++size;
 if (!cleanSomeSlots(i, sz) && sz >= threshold)
 rehash();
 }

总结

到这里相信大家对 ThreadLocal 都有了一定的了解。有什么想交流可以留言或私信笔者。

最后,介绍一款软件开发工具。JNPF开发平台,很多人都用过它,它是功能的集大成者,任何信息化系统都可以基于它开发出来。原理是将开发过程中某些重复出现的场景、流程,具象化成一个个组件、api、数据库接口,避免了重复造轮子。因而极大的提高了程序员的生产效率。

官网:www.jnpfsoft.com/?juejin,如果你有闲暇时间,可以做个知识拓展。

这是一个基于Java Boot/.Net Core构建的简单、跨平台快速开发框架。前后端封装了上千个常用类,方便扩展;集成了代码生成器,支持前后端业务代码生成,满足快速开发,提升工作效率;框架集成了表单、报表、图表、大屏等各种常用的Demo方便直接使用;后端框架支持Vue2、Vue3。

为了支撑更高技术要求的应用开发,从数据库建模、Web API构建到页面设计,与传统软件开发几乎没有差异,只是通过低代码可视化模式,减少了构建"增删改查"功能的重复劳动。

相关推荐
崔庆才丨静觅12 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
曹牧13 小时前
Spring Boot:如何测试Java Controller中的POST请求?
java·开发语言
passerby606113 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了13 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅13 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅13 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
爬山算法13 小时前
Hibernate(90)如何在故障注入测试中使用Hibernate?
java·后端·hibernate
kfyty72514 小时前
集成 spring-ai 2.x 实践中遇到的一些问题及解决方案
java·人工智能·spring-ai
猫头虎14 小时前
如何排查并解决项目启动时报错Error encountered while processing: java.io.IOException: closed 的问题
java·开发语言·jvm·spring boot·python·开源·maven
李少兄14 小时前
在 IntelliJ IDEA 中修改 Git 远程仓库地址
java·git·intellij-idea