ThreadLocal 到底会不会内存泄漏?源码级拆解 + 线程池陷阱

面试常考:ThreadLocalMap 结构、弱引用与内存泄漏、InheritableThreadLocal 的局限、TransmittableThreadLocal

一、从一次线上 OOM 开始

线上服务运行 3 天后突然 OOM,堆 dump 分析发现大量 byte[]ThreadLocal 引用无法回收。线程池中的线程是复用的,ThreadLocal 的值随线程一直活着。ThreadLocal 到底怎么导致内存泄漏?正确用法是什么?

二、ThreadLocal 的数据结构

scss 复制代码
┌──────────────────────────────────────────────────────────────────┐
│                     ThreadLocal 内存结构                         │
│                                                                  │
│   Thread 对象                                                     │
│   ┌──────────────────────────────────────────┐                   │
│   │  ...                                     │                   │
│   │  ThreadLocal.ThreadLocalMap threadLocals │  ← 每个线程一个   │
│   └──────────────┬───────────────────────────┘                   │
│                  │                                               │
│                  ▼                                               │
│   ThreadLocalMap                                                 │
│   ┌─────────────────────────────────────┐                       │
│   │  Entry[] table                      │                       │
│   │  ┌───────┐ ┌───────┐ ┌───────┐     │                       │
│   │  │Entry 0│ │Entry 1│ │Entry 2│ ... │                       │
│   │  └───┬───┘ └───┬───┘ └─┬─────┘     │                       │
│   └──────┼──────────┼───────┼──────────┘                       │
│          │          │       │                                   │
│     ┌────▼────┐ ┌───▼────┐ ┌▼─────────────────┐                │
│     │ WeakRef │ │WeakRef │ │   WeakRef        │                │
│     │   ↓     │ │  ↓     │ │     ↓            │                │
│     │ThreadLocal│ThreadLocal│ ThreadLocal     │  ← Key (弱引用) │
│     │         │ │        │ │                  │                │
│     │ value   │ │ value  │ │ value            │  ← Value(强引用) │
│     │ (byte[])│ │(User)  │ │ (Connection)     │                │
│     └─────────┘ └────────┘ └──────────────────┘                │
│                                                                  │
│   ThreadLocal 对象本身在外部被强引用 (static field)              │
└──────────────────────────────────────────────────────────────────┘

核心关系:Thread 持有 ThreadLocalMap,ThreadLocalMap 的 Entry 以 ThreadLocal 为 key、业务对象为 value。

三、源码拆解

Thread

java 复制代码
public class Thread {
    // 每个线程都有自己的 ThreadLocalMap
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

ThreadLocal

java 复制代码
public class ThreadLocal<T> {
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null)
                return (T) e.value;
        }
        return setInitialValue();
    }

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null)
            m.remove(this);
    }

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

ThreadLocalMap:自定义的弱引用哈希表

java 复制代码
static class ThreadLocalMap {
    static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    private Entry[] table;
    private int size = 0;
    private int threshold;
}

关键设计:Entry 的 key 是弱引用,value 是强引用。

四、内存泄漏分析

正常用法(不泄漏)

ini 复制代码
外部: static ThreadLocal<User> tl = new ThreadLocal<>();
Thread.threadLocals:
  Entry(key=WeakRef(tl), value=User对象)

tl = null;
  → key 被 GC 回收
  → Entry 变成: key=null, value=User对象
  → value 仍被 Entry 强引用,无法回收!

内存泄漏的完整链路

scss 复制代码
Thread (线程池中,永生)
  └→ ThreadLocalMap
       └→ Entry[]
            └→ Entry
                 ├→ key: WeakRef(ThreadLocal) ← GC后变null
                 └→ value: BigObject ← 强引用,无法回收!

JDK 的补救措施

java 复制代码
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    // 顺带清理其他 key=null 的 Entry
    ...
}

但这只是补救,不是根治! 如果你 set 后不再 get/set/remove,那个 Entry 永远不会被清理。

正确用法:务必 remove

java 复制代码
private static final ThreadLocal<Connection> connHolder = new ThreadLocal<>();

public void executeQuery(String sql) {
    try {
        Connection conn = getConnection();
        // 使用 conn
    } finally {
        connHolder.remove();  // 必须 finally 中 remove!
    }
}

五、ThreadLocalMap 的哈希冲突处理:开放寻址法

java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        if (k == key) { e.value = value; return; }
        if (k == null) { replaceStaleEntry(key, value, i); return; }
    }
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

六、InheritableThreadLocal:父子线程传递

java 复制代码
public class InheritableThreadLocalDemo {
    private static InheritableThreadLocal<String> context = new InheritableThreadLocal<>();
    public static void main(String[] args) {
        context.set("main-data");
        new Thread(() -> {
            System.out.println(context.get()); // "main-data"
        }).start();
    }
}

InheritableThreadLocal 的致命局限

csharp 复制代码
InheritableThreadLocal 的传递发生在 Thread 构造时:
  新线程创建时复制父线程的数据 → 一次性快照
线程池场景:
  worker线程早已创建 → 不会触发 init → 不会复制!

七、TransmittableThreadLocal:线程池场景的终极方案

java 复制代码
public class TTLDemo {
    private static TransmittableThreadLocal<String> requestId = new TransmittableThreadLocal<>();
    public static void main(String[] args) {
        ExecutorService pool = Executors.newFixedThreadPool(2);
        pool = TtlExecutors.getTtlExecutor(pool);
        requestId.set("REQ-001");
        pool.submit(() -> System.out.println(requestId.get()));
    }
}

TTL 原理

markdown 复制代码
主线程:
  TTL.set("REQ-001")
  提交任务 → TtlRunnable.get(runnable, ttl)
    1. 捕获当前线程所有 TTL 的值 (快照)
    2. 包装成 TtlRunnable
线程池执行:
    1. 回放: 把快照设置到当前 worker 的 TTL
    2. 执行原任务
    3. 恢复: 恢复到执行前状态

八、典型应用场景

1. 请求级上下文传递

java 复制代码
public class RequestContext {
    private static final ThreadLocal<RequestContext> CONTEXT = new ThreadLocal<>();
    public static RequestContext current() {
        RequestContext ctx = CONTEXT.get();
        if (ctx == null) { ctx = new RequestContext(); CONTEXT.set(ctx); }
        return ctx;
    }
    public static void clear() { CONTEXT.remove(); }
}

2. 数据库连接管理

java 复制代码
public class ConnectionManager {
    private static final ThreadLocal<Connection> holder = new ThreadLocal<>();
    public static Connection getConnection() throws SQLException {
        Connection conn = holder.get();
        if (conn == null || conn.isClosed()) {
            conn = DriverManager.getConnection(URL, USER, PASS);
            holder.set(conn);
        }
        return conn;
    }
    public static void close() {
        Connection conn = holder.get();
        if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} holder.remove(); }
    }
}

3. SimpleDateFormat 线程安全

java 复制代码
public class DateUtils {
    private static final ThreadLocal<SimpleDateFormat> FORMATTER =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
    public static String format(Date date) { return FORMATTER.get().format(date); }
    public static Date parse(String str) throws ParseException { return FORMATTER.get().parse(str); }
}

九、面试高频问题速答

Q1: ThreadLocal 的 key 为什么用弱引用?

如果 key 是强引用:外部不再使用 ThreadLocal(tl=null)后,Thread→ThreadLocalMap→Entry→key(强引用)→ThreadLocal,ThreadLocal 无法被 GC。用弱引用后,外部无强引用时 ThreadLocal 可被 GC,key 变 null,降低泄漏风险。

Q2: key 用弱引用就能避免内存泄漏吗?

不能。key 回收后 Entry 变成 {null, value},value 仍被强引用。如果线程不死亡(线程池场景),这些 value 永远不会被回收。必须手动 remove() 才能彻底避免泄漏。

Q3: ThreadLocal 和 synchronized 有什么区别?

维度 ThreadLocal synchronized
目的 线程隔离 线程同步
方式 每个线程一份副本 多线程共享同一份
竞争 无竞争 有竞争
适用 线程上下文、避免共享 保护共享数据

Q4: InheritableThreadLocal 为什么在线程池中无效?

InheritableThreadLocal 在子线程创建时复制父线程数据。线程池中 worker 线程预先创建,提交任务时不会创建新线程,因此不会触发复制。需要用 TransmittableThreadLocal + TtlExecutors 解决。

Q5: ThreadLocalMap 为什么用开放寻址而不是链表法?

ThreadLocal 的数量通常不多(几十个以内),开放寻址法在小数据量下缓存友好性更好,没有额外的 Node 对象开销。而且 ThreadLocal 的 hash 是 magicNumber * 0x61c88647(黄金分割数),分布均匀,冲突概率低。

Q6: ThreadLocalMap 的扩容阈值是 2/3?

是的,ThreadLocalMap 负载因子是 2/3。threshold = len * 2 / 3。超过 threshold 且没有清理到 stale entry 时触发 rehash,先清理所有 key=null 的 Entry,如果清理后仍超过 threshold 的 3/4,则扩容(2 倍)。

十、总结

csharp 复制代码
ThreadLocal 核心知识:
  结构: Thread.threadLocals → ThreadLocalMap → Entry(WeakRef<ThreadLocal>, value)
  泄漏根因: key 弱引用 → 被GC → key=null; value 强引用 → 无法回收; 线程池场景 → 线程不死亡 → 永久泄漏
  解决方案: 用完必须 remove() (finally块)
  InheritableThreadLocal: 线程创建时复制 → 线程池场景失效
  TransmittableThreadLocal: 任务提交时快照 → 执行前回放 → 执行后恢复
  使用原则: 1. static final 2. 用完一定 remove 3. 避免存大对象 4. 线程池场景用 TTL

ThreadLocal 是一把双刃剑------简单优雅的线程隔离方案,但使用不当就是内存泄漏的温床。牢记 finally { threadLocal.remove(); } 是面试加分项,更是生产环境的保命符。

相关推荐
珍珠先生1 小时前
P11 · MySQL 驱动版本过旧:Client does not support authentication protocol
java
~木雨1 小时前
Java 并发编程架构全景:并发层的五域设计 —— 线程模型、线程池隔离、并发安全到问题治理(全体系汇总)
java·安全·架构·线程池·并发编程·高并发架构
极小狐1 小时前
从复制粘贴到可复用组件:极狐GitLab CI/CD 组件目录实战
java·ci/cd·gitlab·devops·组件化
麻瓜code1 小时前
【Agent】Spring AI RAG 实战:本地向量库 + 云端知识库
java·人工智能·spring
小刘在重生~2 小时前
Java 集合|Collection、List、ArrayList、LinkedList、泛型、Collections 工具类
java·数据结构·list
我不会起名字3222 小时前
一天一道算法题(29):单调栈
java·数据结构·python·算法·leetcode·golang·单调栈
XR1234567882 小时前
医院移动医护零漫游无线网络选型指南
java·后端·struts
干到60岁退休的码农2 小时前
13.构建登录接口响应数据
java·spring boot·mybatis
水巷石子2 小时前
学习langChain4j的第二天,体验springBoot中的starter
java·spring boot·学习·spring·langchain4j