摘要ThreadLocal 是 Java 多线程面试的高频考点。本文从「线程隔离」的设计理念出发,深入剖析 ThreadLocalMap 数据结构、源码级 set/get/remove、线性探测解决哈希冲突,重点讲清弱引用 key 与强引用 value 的内存模型与设计动机、数据存储位置、一个线程存多个值的姿势、完整生命周期,以及内存泄漏的根因与最佳实践;并延伸至 InheritableThreadLocalTransmittableThreadLocal 的跨线程传递,结尾以总结收束。每个章节均穿插面试口述与记忆钩子。

目录


一、作用与定位

一句话ThreadLocal 的设计理念是线程隔离——为每个线程提供变量的独立副本,从而解决多线程下共享变量的线程安全问题。

它解决的不是「多线程同时改一个共享变量」,而是「让每个线程用自己的副本,互不干扰」。

🔑 记忆钩子:ThreadLocal = 给每个线程一份独立副本,各用各的

二、线程隔离演示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class test {
static ThreadLocal threadLocal = new ThreadLocal();

public static void main(String[] args) {
Thread thread0 = new Thread(() -> {
System.out.println(threadLocal.get()); // ① null
threadLocal.set(0);
System.out.println(threadLocal.get()); // ② 0
});
Thread thread1 = new Thread(() -> {
System.out.println(threadLocal.get()); // ③ null(隔离!)
threadLocal.set(1);
System.out.println(threadLocal.get()); // ④ 1
});
thread0.start();
thread1.start();
}
}

线程隔离演示输出

现象解析thread0thread1 用的是同一个 ThreadLocal 对象(静态字段),但 thread1 在 ③ 处 get() 仍是 null——证明两个线程各自持有独立副本,互不可见。

💡 面试口述:用一句话即可点出隔离——「同一个 ThreadLocal 对象,两个线程各自 get() 都先拿到 null,说明副本是按线程隔离的,互不干扰。」

三、注意事项

  1. ThreadLocal 的核心是线程隔离,不是「线程安全地修改共享变量」。如果你想让多线程安全地改同一个变量,应该用 synchronized/Lock/Atomic*,而不是 ThreadLocal
  2. 局限性:只适用于单机多线程环境;分布式系统中不同节点的 ThreadLocal 不共享(跨 JVM 无效)。

四、实现原理:数据结构

理解 ThreadLocal 的关键,是看清「数据存在哪」和「结构长啥样」。

(1)每个 Thread 内部持有一个 ThreadLocalMap

1
2
3
// Thread 类源码片段
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
  • 每个 Thread 对象内部都维护了一个 ThreadLocalMap 实例。
  • 这个 Map 是延迟初始化的:只有在第一次调用 ThreadLocalset()/get() 时才会创建(详见第十章 生命周期)。
  • ThreadLocalMap 本身不是 ThreadLocalThreadLocal 只是把「自己」当作 key 去操作这个 Map。

(2)ThreadLocalMapEntry 结构

1
2
3
4
5
6
7
8
9
10
11
12
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是【弱引用】的 ThreadLocal 对象
value = v; // value 是【强引用】的实际值(变量副本)
}
}
private Entry[] table; // 桶数组
private int size;
private int threshold; // 扩容阈值(通常为容量的 2/3)
}

这是一个为 ThreadLocal 定制的哈希表,不是 HashMapEntry 继承自 WeakReference,这正是内存模型的核心(见第七章)。

⚠️ 容易混淆的三个「静态」

  • ThreadLocal 本身:普通顶级类,不是静态类
  • static class ThreadLocalMap:写在 ThreadLocal 内部的静态嵌套类(储物柜),加 static 只是表示它不依赖外部 ThreadLocal 实例即可 new
  • static ThreadLocal xxx:这是静态字段(最常见写法),表示引用属于类、全局一份。它持有的正是强引用,所以 xxx 指向的 ThreadLocal 对象通常不会被 GC(见第七章)。

🎤 面试口述版(30 秒讲清架构)
「ThreadLocal 的原理可以概括为:每个线程内部持有一个 ThreadLocalMap,而 ThreadLocal 对象本身作为这个 Map 的 key。具体来说:① Thread A 和 Thread B 各自有一份独立的 ThreadLocalMap(首次 set/get 时才创建,延迟初始化);② ThreadLocal 对象作为 key,被多个线程的 Map 共同引用;③ 每个 Entry 里,key 是对 ThreadLocal 对象的弱引用,value 是变量副本的强引用;④ 当某线程调 threadLocalA.set(值) 时,实际把值存进了自己那个 Map 里,以 threadLocalA 为 key——跟别的线程完全隔离。」

记忆比喻:每个人的笔记本 = ThreadLocalMap(各自独立);笔记本上的科目名 = ThreadLocal 对象(作为 key);写下的分数 = value(强引用)。「人手一本账,同一科目名记各自的数。」

五、核心操作源码解析(set / get / remove)

set() 方法

1
2
3
4
5
6
7
8
9
public void set(T value) {
Thread t = Thread.currentThread(); // ① 拿到当前线程
ThreadLocalMap map = getMap(t); // ② 取当前线程的 ThreadLocalMap
if (map != null) {
map.set(this, value); // ③ 以「当前 ThreadLocal 对象」为 key 存值
} else {
createMap(t, value); // ④ 首次使用 → 创建 Map 再存
}
}

get() 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T) e.value;
return result;
}
}
return setInitialValue(); // 找不到 → 返回初始值(默认 null 或重写的 initialValue)
}

remove() 方法

1
2
3
4
5
6
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
m.remove(this); // 移除当前 ThreadLocal 对应的整条 Entry(key + value 一起清)
}
}

💡 面试提示set/get/remove 三步套路完全一致——先拿当前线程 → 拿它的 Map → 以 this(当前 ThreadLocal)为 key 操作。这正是「线程隔离」的实现机制:每个线程操作的是自己的 Map。

六、哈希冲突:线性探测

ThreadLocalMap 不用 HashMap 的「拉链法/红黑树」,而是用开放寻址法中的线性探测

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len - 1); // 计算初始位置

// 线性探测:从 i 开始往后找,直到遇到空槽
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) { // key 已存在 → 更新 value
e.value = value;
return;
}
if (k == null) { // 遇到「过期 Entry」→ 替换并清理
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value); // 找到空槽 → 新建 Entry
// ... 后续判断是否需要扩容
}

冲突时顺序往后挪找空槽(而不是挂链表)。这种方式在 Entry 数量少时更省内存,也是 ThreadLocalMap 选择它的原因。

💡 面试提示:被问「为什么不用 HashMap 的拉链法?」——ThreadLocalMap 里 Entry 数量通常很少(一个线程就几个),开放寻址的数组更紧凑、缓存友好;而 HashMap 面对海量数据才用拉链/红黑树。取舍基于使用场景。

七、强弱引用模型(面试核心)

这是全文最该懂、也最容易被问倒的点。回到 Entry

1
2
3
4
5
6
7
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是对 ThreadLocal 对象的【弱引用】
value = v; // value 是【强引用】
}
}

为什么这样设计(而非反过来)?

这是一道取舍题,要分别看两个字段的约束:

(1)value 为什么必须是强引用(刚需,没得选)

value 就是你 set(xxx) 存进去、之后还要 get() 取出来的业务数据。它必须被强引用抓着——否则你刚 set() 完、代码里又没别的地方引用这个对象,下一次 GC 就把 value 收走了,get() 只能返回 null,ThreadLocal 就彻底失去意义。所以 value 强引用是硬性要求,不存在「设计选择」的余地。

(2)key 为什么设计成弱引用(真正的取舍)

先看反面假设:如果 key 也是强引用会怎样? 设想你用完一个 ThreadLocal(比如它是某个方法里的局部变量,方法结束后不再需要),希望它能随业务代码一起被 GC。但由于 Entry 里强引用着这个 ThreadLocal 对象,只要线程还活着(尤其线程池),这个 Entry 就一直 pin 住 key → key 永远回收不了,value 也永远回收不了。更糟的是:因为 key 还「活着」,系统根本无法判断这条 Entry 是否已无人使用,只能靠手写 remove() 才能清掉——强引用 key 会让泄漏既更严重(连 ThreadLocal 对象本身都泄漏)又更隐蔽(没有「过期」标记可供自动清理)。

弱引用 key 的妙处,正在于此:它牺牲了「key 的持久性」,换来了一个自动清理的信号。当业务代码不再持有 ThreadLocal 对象的强引用时(局部变量出作用域、或手动置 null),GC 会回收该对象,Entry 的 key 变成 null——这条 Entry 就被打上「过期条目(stale entry)」的标记。后续只要再调用 set()/get()/remove(),Map 检测到 k == null,就会顺手把这个孤儿 value 一起清掉(源码里的 replaceStaleEntry / expungeStaleEntry,即「探测式清理 / 启发式清理」)。

一句话:弱引用 key 是一道「自我修复」的安全网——它不能杜绝泄漏(value 仍是强引用),但能在你忘了 remove() 时,至少让那些「key 已无人使用」的条目变得可被自动识别和回收,而不是永远钉死在内存里。

(3)那为什么弱引用还是没彻底解决泄漏?

因为 value 是强引用。key 被 GC 成 null 后,value 仍被 Entry 强引用抓着:它既取不到了(key 没了,按 key 查不到),又回收不了(强引用还在)——这就是「悬挂的孤儿 value」,正是内存泄漏的本质。所以弱引用只是把泄漏从「完全不可见」变成「可见且可被部分清理」,真正的根治手段始终是 remove()

(4)现实补充:常见 static 写法下这道安全网常失效

最常见的 private static final ThreadLocal<...> 写法,静态字段本身就是一条强引用且贯穿程序生命周期,key 永远不会被 GC 成 null——意味着弱引用这道安全网在常见用法下几乎不触发。这再次说明:无论 key 会不会被 GC,用完 remove() 都不可省略。

「失强引用即被 GC」到底指谁?(澄清)

被 GC 的是 Entry 里作为 key 的那个 ThreadLocal 对象——因为它被弱引用抱着。但它会不会真被 GC,取决于还有没有别的强引用

  • static ThreadLocal xxx = new ThreadLocal()(最常见写法):这个静态字段就是一条强引用,且贯穿整个程序生命周期 → ThreadLocal 对象永远不会被 GC → key 永远是活的,不会产生 null key。
  • ThreadLocal 只是方法局部变量 / 被手动置 null:方法结束或置空后强引用消失 → GC 回收 ThreadLocal 对象 → Entry 的 key 变 null → 成为源码里反复处理的 stale entry(过期条目)

准确说法:ThreadLocal 对象在没有强引用时会被 GC(因为 key 是弱引用);但最常见的 static 写法下,它一直有强引用,不会被 GC。

super(k) 调用链(key 是怎么变成弱引用的)

1
2
3
4
你写:  super(k)
└─> WeakReference(ThreadLocal<?> referent) // 父类构造
└─> Reference(referent) // 祖父类构造
└─> this.referent = k; // 把 k 存为「被弱引用的对象」

正因如此,Entry 兼职成了弱引用,它继承的 get() 方法可以取出 key:

1
2
3
ThreadLocal<?> k = e.get();   // 取出抽屉上的标签;若 k 已被 GC,返回 null
if (k == key) { /* 命中,更新 */ }
if (k == null) { /* 过期 Entry,清理 */ }

🔑 记忆钩子:key 弱引用(可被 GC 清掉)、value 强引用(不自动释放);弱引用 key 只是安全网,根治泄漏靠 remove()

八、业务数据到底存在哪

直接回答:业务数据存在「当前线程对象(Thread 自己)」身上,不在 ThreadLocal 对象里。

1
2
3
4
Thread 实例(JVM 创建)
└─ threadLocals : ThreadLocalMap
└─ table : Entry[]
└─ Entry.value ← 业务数据在这里
  • ThreadLocal 对象只是钥匙:当 key、提供哈希值,但它肚子里不装任何业务数据
  • 真正装数据的柜子(ThreadLocalMap)挂在每个线程身上。

这一设计带来三个直接后果:

  1. 天然线程隔离:每个线程有自己的柜子,你在线程 A 存的东西,线程 B 的柜子里根本没有。
  2. 线程死了数据就没:柜子是 Thread 的字段,线程结束 → Thread 被回收 → 柜子和 value 一起 GC(被动清理)。
  3. 线程池的坑:线程池线程长期存活、不销毁,柜子一直在;任务里 set 了却不 remove,value 会一直占内存 → 泄漏(见第十一章)。

🔑 速记:数据在线程里,不在 ThreadLocal 里;柜子随线程生灭,线程不死数据不灭。

九、一个线程如何存多个值

一个 ThreadLocal 对象 = 一个 key = 一个值槽。 想在一个线程里存多个值,就建多个 ThreadLocal 对象,它们作为不同 key,落在同一个线程的 ThreadLocalMap 里,各占一条 Entry

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class test {
static ThreadLocal threadLocalA = new ThreadLocal(); // key A
static ThreadLocal threadLocalB = new ThreadLocal(); // key B

public static void main(String[] args) {
Thread threadA = new Thread(() -> {
threadLocalA.set("线程A的变量副本A");
threadLocalB.set("线程A的变量副本B");
System.out.println(threadLocalA.get() + " " + threadLocalB.get());
});
Thread threadB = new Thread(() -> {
threadLocalA.set("线程B的变量副本A");
threadLocalB.set("线程B的变量副本B");
System.out.println(threadLocalA.get() + " " + threadLocalB.get());
});
threadA.start();
threadB.start();
}
}

完整工作流程输出

此时 threadA 这一个线程 的 Map 里长这样(threadB 同理,但值不同):

1
2
3
4
5
threadA 的 ThreadLocalMap.table
┌──────────────────────────────────────────────┐
│ Entry[key=threadLocalA, value="线程A的副本A"] │
│ Entry[key=threadLocalB, value="线程B的副本B"] │
└──────────────────────────────────────────────┘

🔑 速记多值 = 多 key。每个 ThreadLocal 是独立 key,同线程多 key 各占一条 Entry;取时按 key 取回自己的那份。哈希冲突只影响 Entry 在数组中的位置(线性探测往后挪),不影响「多值」逻辑。

十、完整生命周期

把前面所有知识点串成一条线:

1
2
3
4
5
6
7
8
9
10
11
12
① new ThreadLocal()        → 只是造了一把「钥匙」,不存任何数据

② new Thread().start() → 线程诞生,threadLocals 仍为 null(延迟初始化,省内存)

③ 首次 set()/get() → createMap() 给当前线程挂上 ThreadLocalMap,塞入第一条 Entry

④ 使用期 → 反复按 key 读写(多个 ThreadLocal = 多 key = 多条 Entry)

┌───────────────────┴───────────────────┐
安全出口 危险出口
remove() 删整条 Entry 线程池复用 + 忘 remove
线程自然结束 → 整体 GC → value 强引用残留 → 内存泄漏
阶段 触发动作 发生了什么
① 创建 new ThreadLocal() 只创建 key 对象,无数据存储
② 线程诞生 new Thread().start() threadLocals 仍为 null
③ 首次访问 第一次 set()/get() createMap() 建柜子并塞第一条 Entry
④ 使用期 反复 set()/get() 按 key 哈希定位 → 线性探测 → 读写 value
⑤ 安全退出 remove() / 线程结束 删 Entry / 整体 GC
⑥ 泄漏分支 线程池复用 + 忘 remove() value 强引用残留,内存只增不减

💡 面试提示threadLocals 何时创建? 不在 new Thread() 时,而在「该线程第一次触碰 set()/get()」时(延迟初始化)。get() 也会建 Map(存一个 null 初始值),并非只有 set() 才创建。

十一、内存泄漏与最佳实践

泄漏根因

1
2
3
4
5
6
7
8
9
// 线程池场景:线程长期存活,但任务结束后忘了 remove
ExecutorService executor = Executors.newFixedThreadPool(5);
ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();

executor.execute(() -> {
threadLocal.set(new BigObject()); // 大对象存入当前线程的 Map
// 业务逻辑...
// 但忘了 threadLocal.remove()
});
  • 线程池线程长期存活,业务结束后 ThreadLocalMap 中的 Entry 依然存在。
  • 下次该线程执行别的任务时,旧 BigObject 仍占用内存 → 内存只增不减,直至泄漏

为什么弱引用 key 救不了你? 即使你把 threadLocal = null,key 被 GC 成 nullvalue 仍是强引用残留,成为 stale entry,只能等 set/get 时探测式清理或扩容时启发式清理——不可靠。真正的解法永远是 remove()

四种清理机制

机制 触发时机 可靠性
remove() 手动调用 ✅ 最可靠,推荐使用
探测式清理 set/get 时遇到过期 Entry 部分
启发式清理 扩容时扫描全表 部分
被动清理 线程终止 → 整个 Map 被 GC 仅限非池化短命线程

最佳实践

  1. 用完务必 remove():把 Entry(key + value)整体清掉,从根上避免泄漏。
  2. 线程池场景用 afterExecute 兜底:即使业务代码忘了 remove,也在钩子里统一清理。
1
2
3
4
5
6
executor = new ThreadPoolExecutor(...) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
threadLocal.remove(); // 兜底清理,防止任务异常路径漏掉 remove
}
};
  1. 优先用 private static final ThreadLocal<...>static final 保证 key 全局唯一、类型安全;但仍要 remove(),因为 static 只保 key 不被 GC,不保 value 不被泄漏
  2. 考虑 try-finally 包裹
1
2
3
4
5
6
threadLocal.set(value);
try {
// 业务逻辑
} finally {
threadLocal.remove(); // 无论成功失败都清理
}

🔑 最佳实践速记:用完 remove();线程池用 afterExecute / try-finally 兜底;static final 保证类型安全但不替代 remove()


十二、InheritableThreadLocal:父子线程的值传递

前面十一节讲的都是「线程自己用自己的副本」。但实际开发中常有一个需求:父线程里设的值,希望子线程也能拿到(比如链路追踪的 traceId、用户登录态)。ThreadLocal 默认做不到——它只隔离,不传递。

问题演示:子线程拿不到父线程的 ThreadLocal 值

1
2
3
4
5
6
7
8
static ThreadLocal<String> tl = new ThreadLocal<>();

public static void main(String[] args) {
tl.set("parent-value");
new Thread(() -> {
System.out.println(tl.get()); // null!子线程完全隔离,拿不到父线程的值
}).start();
}

解决方案:InheritableThreadLocal(ITL)

ITL 是 ThreadLocal 的子类,API 完全一致,只是多了一个能力:子线程在创建时会拷贝父线程的 ITL 值。

1
2
3
4
5
6
7
8
static InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

public static void main(String[] args) {
itl.set("parent-value");
new Thread(() -> {
System.out.println(itl.get()); // parent-value!子线程继承了父线程的值
}).start();
}

实现原理

回看第四章 Thread 类的字段,除了 threadLocals 还有第二个字段 inheritableThreadLocals

1
2
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null; // ← ITL 用的柜子

new Thread() 创建子线程的 init() 阶段,JVM 会把父线程的 inheritableThreadLocals 拷贝一份给子线程:

1
2
3
4
// Thread.init() 片段
if (parent.inheritableThreadLocals != null)
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

所以子线程拿到的是创建那一刻的快照——之后父子各自修改互不影响。

🔑 速记:ITL 让值「父传子」,但只在 new Thread() 创建子线程那一刻拷贝一次(快照);ThreadLocal 本身不支持任何传递。

致命局限:线程池场景完全失效

ITL 的拷贝发生在「线程创建」时,而线程池的线程是预先创建、长期复用的——提交任务的线程(比如处理 HTTP 请求的线程)并不是池线程的「父线程」,池线程在创建时也没有你的任务值。所以:

1
2
3
4
5
6
7
static InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);

public static void main(String[] args) {
itl.set("main-value");
pool.submit(() -> System.out.println(itl.get())); // null!池线程不是 main 的孩子
}

💡 面试提示:被问「ITL 有什么坑?」——它不支持线程池。现代 Java 应用几乎离不开线程池,所以 ITL 在真实生产里很少直接用,引出下一个主角 TransmittableThreadLocal

十三、TransmittableThreadLocal:线程池场景的跨线程传递

ITL 在「线程创建」时拷贝,解决不了「任务提交时把值带给池线程」的问题。TransmittableThreadLocal(TTL,阿里巴巴开源)正是为线程池而生的。

核心思路:把拷贝时机从「线程创建」移到「任务提交 / 执行」

TTL 通过包装任务实现:

  • 提交任务时,把当前线程的所有 TTL 值捕获并绑到包装后的任务上;
  • 池线程执行任务前,把捕获到的值还原到自己的 TTL 中;
  • 任务执行完后,清理还原(避免污染同一个池线程的下一次任务)。

用法一:包装 Runnable / Callable

1
2
3
4
5
6
7
TransmittableThreadLocal<String> ttl = new TransmittableThreadLocal<>();
ExecutorService pool = Executors.newFixedThreadPool(1);

ttl.set("main-value");
Runnable task = () -> System.out.println(ttl.get()); // 期望拿到 main-value

pool.submit(TtlRunnable.get(task)); // 用 TtlRunnable 包装 → 输出 main-value ✅

用法二:包装线程池(更省心,推荐)

1
2
3
4
5
ExecutorService pool = Executors.newFixedThreadPool(1);
ExecutorService ttlPool = TtlExecutors.getTtlExecutorService(pool); // 包装线程池

ttl.set("main-value");
ttlPool.submit(() -> System.out.println(ttl.get())); // main-value ✅

底层机制(简述)

  • TransmittableThreadLocal 继承自 InheritableThreadLocal,并维护一个全局 holderInheritableThreadLocal<WeakHashMap<TransmittableThreadLocal, Object>>)记录所有存活的 TTL 实例。
  • TtlRunnable.get() 在构造时调用 capture() 把父线程的 TTL 值快照保存进任务对象;run() 内部先 replay() 还原到当前(池)线程,执行完 restore() 恢复现场。

典型应用场景

  • 链路追踪 traceId 跨线程传递(SkyWalking、Spring Sleuth);
  • 日志 MDC(Mapped Diagnostic Context)在异步场景不丢上下文;
  • 用户登录态 / 租户 ID 在异步任务中透传。

依赖引入

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.14.5</version>
</dependency>

💡 面试提示:TTL 与 ITL 的本质区别——ITL 在线程创建时拷贝一次,TTL 在每次任务提交/执行时拷贝并还原。因此只有 TTL 能正确工作在线程池环境。


总结

一句话ThreadLocal 解决「线程内隔离」,InheritableThreadLocal 解决「父子线程(new Thread)传递」,TransmittableThreadLocal 解决「线程池环境下的跨线程传递」。三者层层递进,覆盖从隔离到传递的全部场景;而无论用哪一个,用完都要 remove()

一、三者能力对比

维度 ThreadLocal InheritableThreadLocal TransmittableThreadLocal
跨线程可见 仅本线程 子线程继承(创建时快照) 任意线程(含池线程)传递
值拷贝时机 父线程 new Thread() 任务提交时捕获、执行时还原
线程池支持
底层字段 / 机制 threadLocals inheritableThreadLocals(继承拷贝) 包装 Runnable/Callable + 全局 holder
典型用途 线程内隔离(如 SimpleDateFormat、DB Connection) 父子线程传值(已少用) 链路追踪、MDC、登录态透传

二、核心要点速记

  • ThreadLocal:各线程独立副本;数据存在线程的 threadLocals;key 弱引用 / value 强引用;用完 remove()
  • InheritableThreadLocal:只比 ThreadLocal 多了「new Thread() 时拷贝父线程值」;线程池无效。
  • TransmittableThreadLocal:解决线程池场景,提交时捕获、执行时还原、完事清理;需引入阿里巴巴依赖。

三、最佳实践 Checklist

  1. 用完 remove()try-finallyafterExecute 兜底,杜绝泄漏。
  2. 声明为 private static final:保证 key 全局唯一、类型安全。
  3. 按场景选型:线程内隔离用 ThreadLocalnew Thread 父子传值用 InheritableThreadLocal;线程池传值用 TransmittableThreadLocal
  4. 别跨 JVMThreadLocal 体系仅限单 JVM 内,分布式共享得用 Redis / 分布式上下文。

四、高频面试题速答

  • Q:ThreadLocal 怎么实现线程隔离?→ 每个线程一个 ThreadLocalMap,以 ThreadLocal 为 key。
  • Q:为什么 key 用弱引用?→ 安全网,让无强引用的 key 可被 GC、过期条目可被自动清理;value 强引用是刚需。
  • Q:内存泄漏根因?→ 线程池复用 + value 强引用 + 忘 remove()
  • Q:ThreadLocal 和 InheritableThreadLocal 区别?→ ITL 父子继承,且仅 new Thread() 生效,线程池无效。
  • Q:线程池里怎么把父线程的值带给子任务?→ 用 TransmittableThreadLocalTtlRunnable / TtlExecutors)。