当前位置: 首页 > news >正文

Day57 | 一文详解ThreadLocal

在整个Java多线程模块的讲解中,我们都是用的时间换空间这个思路。像我们之前讲的synchronized和Lock都是这样的思路。

他们的核心思想都是在任何时刻,只能有一个线程访问被保护的共享变量。

从空间效率上看,不管有多少线程,共享变量在内存里通常只有一份拷贝,内存开销是恒定的O(1)。

从时间效率上看,当多个线程尝试同时访问该变量的时候,他们必须排队等待。没有获得锁的线程会被阻塞,直到锁被释放。在高并发场景下,这种线程间的上下文切换和等待肯定会增加执行时间,成为性能瓶颈 。

本质就是通过序列化访问来换取数据的一致性。

其实当多个线程需要访问共享状态的时候,还有另外一种解决思路,空间换时间。

这种思路不会尝试去管理对共享资源的访问,而是给每个线程都提供一个独立的、私有的变量副本。

从时间效率上看,由于每个线程操作的都是自己的私有副本,线程之间不存在任何竞争,也就不需要加锁和等待。那么在并发读写场景下性能肯定好,几乎没什么并发开销。

从空间成本来看,天下没有免费的午餐。这种高性能的代价就是内存消耗。如果有N个线程访问同一个 ThreadLocal变量,那么内存中就会存在N份这个变量的副本,内存开销和线程数量成正比,达到了O(N)。

这种思路的本质就是线程封闭,通过分配独立内存空间来消除线程之间的同步开销。

我想在正式讲ThreadLocal之前,先说明两个事情,免得大家混淆了概念。

首先,ThreadLocal他不是线程安全容器,ThreadLocal本身不解决一个共享对象(比如ArrayList)的线程安全问题。他只是提供了一种机制,让每个线程都拥有这个对象的独立副本,从而绕过了共享访问的问题。如果 ThreadLocal存储的对象本身是共享的,那么对这个对象的操作还是需要同步的 。

再一个就是ThreadLocal不等于是跨线程通信,ThreadLocal的设计初衷是数据隔离,他的值对其他线程是不可见的。不要尝试用他来在线程之间传递数据,这违背了他的设计原则。

举个简单的例子,synchronized这样的机制就像公共厕所, 他只有一个隔间,所有上厕所的人都必须在门口排队等待。这种方式肯定节省空间,因为只要一个卫生间,但高峰期大家肯定等的时间比较长。

而ThreadLocal就像是每个人都配了一个私人厕所。每个人都可以随时使用自己的厕所,不用等待,体验肯定更高。但代价是需要给每个人都造个独立厕所,会占用大量空间。

一、核心API及基本使用

1.1. 核心API

ThreadLocal的API还是比较简单的,他的核心操作其实都是围绕下面这个四个方法展开的:

void set(T value)

 

这个方法会把指定的值设置到当前线程的存储副本里。这是往ThreadLocal存数据的入口。

T get()

 

这个方法可以获取当前线程存储的副本值。如果当前线程是首次调用get()而且之前没调用过set(),这个方法会触发一个初始化流程 。

void remove()

 

这个方法很重要,他是用来移除当前线程的存储副本值的。这个操作是防止内存泄漏的关键,尤其是在使用线程池的环境中。

withInitial(Supplier<? extends S> supplier)

 

这个方法是Java8后引入的静态工厂方法,可以使用Supplier函数式接口来提供初始值。

1.2. 基础使用

下面我们用一段简单的代码,演示一下ThreadLocal的基本使用方法。

package com.lazy.snail.day57;import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;/*** @ClassName ThreadLocalDemo1* @Description TODO* @Author lazysnail* @Date 2025/11/4 10:25* @Version 1.0*/
public class ThreadLocalDemo1 {private static final ThreadLocal<String> userContext = new ThreadLocal<>();public static void main(String[] args) throws InterruptedException {ExecutorService executor = Executors.newFixedThreadPool(5);for (int i = 0; i < 5; i++) {final int threadId = i;executor.submit(() -> {String threadName = "Thread-" + threadId;System.out.println(threadName + " 设值: " + threadName);userContext.set(threadName);try {Thread.sleep(100);} catch (InterruptedException e) {Thread.currentThread().interrupt();}String value = userContext.get();System.out.println(threadName + " 取值: " + value);userContext.remove();System.out.println(threadName + " 清除值. 值是: " + userContext.get());});}executor.shutdown();executor.awaitTermination(1, TimeUnit.SECONDS);}
}

我们在示例代码中创建了一个ThreadLocal实例userContext,然后启动了5个线程,每个线程都往这个ThreadLocal里设置自己的线程名,然后读取打印出来。

大家是不是觉得很神奇,各个线程的数据好像都是存储在了userContext这个实例里面,但是又能通过同一个实例的get取出对应的值。

其实数据不是存储在ThreadLocal里的,userContext只不过是个访问入口。

后面我们会细讲这个数据到底存哪儿了。

示例代码里我们还使用了remove方法,针对ThreadLocal的使用,忘记remove其实是个巨大的坑。

通常情况下,我们都会认为当一个任务执行完毕了,线程生命周期就结束了,那ThreadLocal变量也会被垃圾回收。

但是实际的场景中,在线程池,比如ExecutorService、Web服务器的请求处理线程里面,线程是被复用的。

一个任务执行完了之后,线程不会被销毁,而是被归还到池子里等待下一个任务。这个线程对象的生命周期一般是比单个任务要长的 。

如果上一个任务设置了ThreadLocal值但没有remove(),下一个任务被分配到同一个线程的时候,他会读取到上一个任务残留的脏数据,后续引发的逻辑错误也是在所难免了。

如果ThreadLocal存储的是一个大对象,只要这个线程不销毁,这个大对象就永远不会被垃圾回收,因为他被线程的内部ThreadLocalMap强引用着。

线程池执行的任务越来越多,这些没处理的对象就会不断累积,等着我们的就是OutOfMemoryError了 。

二、底层原理

接下来我们来看看ThreadLocal的内部,了解一下他的设计和运行机制。了解完,像数据存储、内存泄露这些问题也会迎刃而解。

2.1. 存储结构

首先就是数据的存储位置,我最开始学习的时候也觉得有点反直觉。后来才知道ThreadLocal实例本身并不存储任何值。他充当的更像是一把钥匙或者入口,真正存储键值对的保险箱是每个java.lang.Thread对象内部的一个实例变量:

 

当在一个线程里第一次对一个ThreadLocal变量进行set或get操作的时候,JVM会检查当前线程的 threadLocals字段是不是null。

如果是,就会给这个线程创建一个新的ThreadLocalMap实例然后赋值给threadLocals。

后续所有对ThreadLocal的操作,都是在这个线程专属的ThreadLocalMap上进行的。

我们接着看一下ThreadLocalMap的结构:

 

ThreadLocalMap内部的存储结构是一个Entry数组。每个Entry包含一个键值对。

最核心的设计就是键(ThreadLocal实例)被包装在一个弱引用(WeakReference)里面,而值(value)是强引用。

 

这里提到了一个弱引用的概念,其实在Java里有强、软、弱、虚四种引用类型,这里不展开讲,我列一下四种引用的差异:

引用类型

被GC回收时间

用途

生存时间

强引用

永远不会主动回收

普通对象引用

JVM停止前

软引用

内存不足时回收

实现内存敏感缓存

内存不足前

弱引用

GC时立即回收

实现规范映射、ThreadLocalMap

下次GC前

虚引用

GC时立即回收

对象回收跟踪

不确定

我们暂时记住一点,通过WeakReference类实现的弱引用,不管内存是不是充足,只要发生垃圾回收,就会被回收。

想象一下这个场景,假设我们在一个可以动态加载和卸载类的环境,比如Web容器里使用ThreadLocal。

如果ThreadLocal实例本身由于类的卸载变得不可达了,垃圾回收器应该能够回收他。

如果这里的键是强引用,那么只要线程还活着,ThreadLocalMap就会一直持有对ThreadLocal实例的强引用,导致这个实例及其所属的类都没办法被卸载,就会引发类加载器内存泄漏 。

如果使用了弱引用,当ThreadLocal实例在外部没有强引用时,GC会在下一次回收时自动回收他,这个时候Entry里的的弱引用get()方法返回的是null。

这样的Entry就被称为陈旧条目。这就解决了key的泄漏。

2.2. 哈希机制

ThreadLocalMap不是java.util.HashMap的简单复用。他是一个给ThreadLocal场景定制的、基于开放地址法和线性探测的哈希表实现。

首先是他的哈希和槽位计算,每个ThreadLocal实例在创建的时候都会被赋予一个唯一的 threadLocalHashCode。

 

这个哈希码通过一个魔数HASH_INCREMENT = 0x61c88647进行递增,这样就可以把连续创建的ThreadLocal 实例均匀地散布到哈希表里。

 

计算键在Entry数组中的索引跟HashMap思路是一样的。也是利用了数组长度必须是2的幂这个特性,把取模运算替代成了位运算。

 

既然是Hash,肯定就要处理冲突问题。还记得HashMap怎么处理的吗?HashMap中冲突的时候,会把元素挂到同一个桶的链表,冲突太多了还会转化成红黑树。

查找或者更新的时候只需要再同一桶里走链或树,不用线性探测整个数组。

而ThreadLocalMap遇到冲突的时候,会把元素放到数组的下一个槽位。

 

当计算出的初始槽位已经被其他Entry占用的时候,ThreadLocalMap采用的是线性探测法解决哈希冲突。

这个线性探测就是简单的检查下一个相邻的槽位,直到找到一个空的槽位来插入新的Entry,或者找到匹配的key 来更新值 。

 

2.3. set流程

下面简单的梳理一下set方法的流程:

首先就是计算key的初始哈希槽位i。

然后就从槽位i开始线性的向后探测。

探测循环里的逻辑是这样的:

如果当前槽位的Entry的key跟要设置的key相同,就直接更新value然后返回。

如果当前槽位是null,说明key不存在,就在这个位置创建一个新的Entry,增加size,然后检查下要不要扩容。

如果当前槽位的Entry是一个陈旧条目,也就是entry.get() == null,就会触发replaceStaleEntry方法。这个方法会用新的Entry替换掉陈旧的条目,而且在这个过程中会调用expungeStaleEntry来清理该陈旧条目周围的一段区域。

上面都做完了,如果探测完一圈还没找到空位或匹配的,就会触发rehash,他会进行一次全表的扫描清理,然后再尝试set。

三、内存泄露

首先我们要明确的是,ThreadLocal本身是不会导致内存泄露的。真正导致内存泄露的是没有使用remove再加上线程复用这种组合情况。

3.1. 内存泄漏本质

看一下这段伪代码,模拟了一个典型的内存泄漏场景:

static ThreadLocal<BigObject> tl = new ThreadLocal<>();void process() {tl.set(new BigObject(10MB));  // 任务1:放入10MB对象// ...执行业务逻辑// 忘了 tl.remove();
}

在线程池里,任务1执行完之后,线程就归还到了线程池里。

BigObject没有被remove(),还是被ThreadLocalMap强引用。

任务2分配到同一个线程,tl.get()就可能拿到脏数据。

任务3、4、5... 不断累积,就直接OOM了。

ThreadLocalMap.Entry.value是强引用,只要线程活着,value就永远不会被GC。

3.2. 为什么key是弱引用,value却是强引用?

我一开始也有这样的疑问,把value搞成弱引用不就完事儿了。后来想想自己太天真的了,完全就没理解弱引用和ThreadLocal。

我举个例子,假设ThreadLocal是酒店的房卡系统,这个系统里面,key是房卡本身,是弱引用。

value是我们放在房间的行李,是强引用。Thread我们把他比作房间。

现在房卡是弱引用,假设我们把房卡弄丢了,也就是ThreadLocal失去引用,那酒店前台重新帮我们配个卡就行了,类似GC回收掉了key。但是我们房间里的行李还是在的。

那如果我们的行李是弱引用的话,我们才把行李放到房间去,保洁就把我们的行李当垃圾清理了。那不GG了。

来看一下这段代码的说明:

package com.lazy.snail.day57;/*** @ClassName ThreadLocalDemo2* @Description TODO* @Author lazysnail* @Date 2025/11/4 13:46* @Version 1.0*/
public class ThreadLocalDemo2 {// 假设ThreadLocal的Value是弱引用// private static ThreadLocal<Object> weakValueContext = ... public static void main(String[] args) {Object importantData = new Object();System.out.println("重要数据: " + importantData);// 假设设置到弱引用的ThreadLocal// weakValueContext.set(importantData);// 此时没有其他强引用指向importantDataimportantData = null;// GC发生!System.gc();Thread.sleep(100);// 数据消失了!// Object retrieved = weakValueContext.get(); // null!// System.out.println("取回的数据: " + retrieved); // 业务中断!}
}

这就是假设我们的value是弱引用的情况。

所以ThreadLocalMap保证的是在整个线程生命周期内不会丢掉数据,但是我们得负责用完之后清理掉。

3.3. 避免内存泄露

既然我们都知道了内存泄露产生的原因,为了避免内存泄漏也就变得简单了。

记住一个原则ThreadLocal值的生命周期应该跟任务边界绑定,而不是线程生命周期。

一个最基础,也是最可靠的模版就是基础的try-finally。

private static final ThreadLocal<UserContext> USER_CTX = new ThreadLocal<>();public void processRequest(Request request) {try {// 1. 设置上下文USER_CTX.set(new UserContext(request.getUserId()));// 2. 执行核心业务businessService.process();anotherService.validate();} finally {// 3. 不管怎么样都要清理。USER_CTX.remove();// 即使你觉得这里不会异常,最好也包一下try {USER_CTX.remove();} catch (Exception e) {log.warn("ThreadLocal清理失败", e);}}
}

这其实就是我们所说的防御性编程。

结语

今天,我们深入了ThreadLocal的核心。

他通过空间换时间的隔离策略,给并发编程提供了另一种思路。

我们需要注意的是数据是存在Thread自己的ThreadLocalMap里的,ThreadLocal实例只是入口。

在线程池复用的场景下,try-finally块中的remove()是防止内存泄漏的核心。

下一篇预告

待定

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

http://www.dtcms.com/a/569846.html

相关文章:

  • 快速判断地图上的点是否在多边形内部
  • 网站文章的作用邵阳市今天新闻
  • C#设计模式 单例模式实现方式
  • 网站是怎么搭建的简单个人博客模板网站
  • 【题解】洛谷 P10083 [GDKOI2024 提高组] 不休陀螺 [思维 + 树状数组 + st 表]
  • C语言字符串操作:手写strlen+常用库函数解析
  • 自己可以创建公司网站吗赣州网站制作培训
  • 百度优化排名软件seo交流
  • 链表相关的算法题(1)
  • 速成网站建设有哪些专业做饰品的网站app
  • 服务器负载过高的多维度诊断与性能瓶颈定位指南
  • 超云发布R2425存储服务器:以全栈自研引领国产存储新方向
  • 网站域名快速备案做网站没有高清图片怎么办
  • 【Python基础】f-string用法
  • 前端高频面试手写题——扁平化数组转树
  • 网站建设合同通用范本免费推广引流怎么做
  • 上海怎么建设网站网站建设网站制作公司
  • Flink 多流转换
  • Redis_5_单线程模型
  • 做简单网站用什么软件有哪些洛阳网站建设设计公司
  • CTF WEB入门 命令执行篇29-49
  • IDEA自定义类注释、方法注释
  • Grafana12安装部署[特殊字符]
  • 网站建设报价流程河南建设工程信息网站
  • 苍穹外卖(第五天)
  • NFC与RFID防伪标签:构筑产品信任的科技防线
  • 深圳网站建设 设计首选成都展示型网页设计公司
  • 网站三层结构示意图网站建设资讯
  • WithAnyone: Towards Controllable and ID Consistent Image Generation论文阅读
  • 无人机远距离无线通信模块:突破空中通信的未来之钥