多线程基础

多线程基础 ThreadLocal

多线程基础 ThreadLocal:线程隔离、线程级复用、上下文传递,避免加锁和层层传参,提升性能和可维护性。

#多线程基础#并发#ThreadLocal

多线程基础-ThreadLocal

一、ThreadLocal 是什么?

1.1 简述

ThreadLocal 是一种线程隔离机制,用来为每个线程保存一份独立的变量副本。

  • 同一个 ThreadLocal 对象
  • 在不同线程中
  • 取到的是 不同的值

1.2 ThreadLocal 解决了什么问题?

在多线程环境中,我们经常遇到:

问题代价
共享变量需要加锁,性能下降
每次 new 对象安全但 GC 压力大
参数层层传递侵入性强,可维护性差

ThreadLocal 的目标:

用「线程隔离」代替「加锁」, 用「线程级复用」代替「方法级创建」。


二、ThreadLocal 的核心好处

好处说明
线程安全每个线程一份数据
无锁不需要 synchronized
对业务无侵入不用层层传参
对象可复用生命周期提升到线程级
非常适合上下文数据用户、TraceId、事务

三、ThreadLocal 的底层原理(简述)

  • 每个 Thread 内部维护一个 ThreadLocalMap
  • ThreadLocal 作为 key
  • 真正的 value 存在 线程内部
Thread
 └── ThreadLocalMap
      ├── ThreadLocalA -> valueA
      └── ThreadLocalB -> valueB

⚠️ ThreadLocal 本身不存值,值是线程持有的


四、用法一:SimpleDateFormat(线程级复用,工具类)

4.1 问题背景(不用 ThreadLocal 的坏处)

private static final SimpleDateFormat SDF =
        new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

❌ 问题:

  • SimpleDateFormat 非线程安全

  • 并发下可能:

  • 格式错乱

  • 时间错误

  • 偶发异常(最难排查)


4.2 每次 new 的写法(安全但低效)

public static String format(Date date) {
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date);
}

缺点:

  • 每次调用都创建对象
  • 高并发下 GC 压力大

4.3 推荐方案:ThreadLocal + SimpleDateFormat

Demo 说明

  • 使用线程池模拟 Tomcat
  • 每个线程只创建一次 SimpleDateFormat
  • 后续请求直接复用

完整 Demo

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * ThreadLocalDateDemo
 *
 * @author suremotoo
 * @date 2025/12/28 23:18
 */
public class ThreadLocalDateDemo {

  private static final ThreadLocal<SimpleDateFormat> SDF =
      ThreadLocal.withInitial(() ->
          new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")
      );


  public static void main(String[] args) {
    ExecutorService pool = Executors.newFixedThreadPool(2);

    for (int i = 0; i < 4; i++) {
      pool.execute(() -> {
        SimpleDateFormat sdf = SDF.get();
        String time = sdf.format(new Date());

        System.out.println(
            Thread.currentThread().getName()
                + " -> " + time
                + " | identityHashCode="
                + System.identityHashCode(sdf)
        );
      });
    }

    pool.shutdown();
  }

}

运行结果特征

  • 同一个线程:SimpleDateFormatidentityHashCode 相同
  • 不同线程: identityHashCode 不同

4.4 是否需要 remove?

场景是否 remove
工具类 / 线程级缓存❌ 可不 remove
请求级 / 用户级数据✅ 必须 remove

五、用法二:用户登录上下文(请求级 ThreadLocal)

场景: 用户登录后,希望在 多个 Service / 方法中直接获取用户信息, 而不是 controller → service → service 层层传参。


5.1 设计目标

  • 用户信息只在 当前请求线程 中可见
  • 请求结束后自动清理
  • 不污染方法签名

5.2 “伪 Web”完整 Demo

说明:

  • 用线程池模拟 Tomcat
  • 每个任务模拟一次 HTTP 请求
  • 模拟登录 → Service 调用 → 清理上下文
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * ThreadLocalUserDemo
 *
 * @author suremotoo
 * @date 2025/12/28 23:32
 */
public class ThreadLocalUserDemo {

  // ==== = 模拟登录用户 ==== =
  static class LoginUser {

    private final Long userId;
    private final String username;

    public LoginUser(Long userId, String username) {
      this.userId = userId;
      this.username = username;
    }

    public Long getUserId() {
      return userId;
    }

    public String getUsername() {
      return username;
    }
  }

  // ==== = 用户上下文 ==== =
  static class UserContext {

    private static final ThreadLocal<LoginUser> USER_HOLDER = new ThreadLocal<>();

    public static void set(LoginUser user) {
      USER_HOLDER.set(user);
    }

    public static LoginUser get() {
      return USER_HOLDER.get();
    }

    public static void clear() {
      USER_HOLDER.remove();
    }
  }

  // ==== = 模拟 Service ==== =
  static class OrderService {

    public void createOrder() {
      LoginUser user = UserContext.get();
      System.out.println(Thread.currentThread().getName() + " 创建订单,用户=" + user.getUsername());
      // 调用查询历史服务, 实际不用每次都 new 一个, 这里只是为了演示
      new HistoryService().queryHistory();
    }
  }

  static class HistoryService {

    public void queryHistory() {
      LoginUser user = UserContext.get();
      System.out.println(Thread.currentThread().getName() + " 查询历史,用户=" + user.getUsername());
    }
  }

  public static void main(String[] args) {
    ExecutorService pool = Executors.newFixedThreadPool(2);
    OrderService orderService = new OrderService();

    for (int i = 0; i < 4; i++) {
      final int userId = i;
      pool.execute(() -> {
        try {
          // ==== = 模拟登录拦截器 ==== =
          LoginUser user = new LoginUser((long) userId, "user-" + userId);
          UserContext.set(user);

          // ==== = 业务调用 ==== =
          orderService.createOrder();

        } finally {
          // ==== = 请求结束必须清理 ==== =
          UserContext.clear();
        }
      });
    }

    pool.shutdown();
  }
}

5.3 不使用 ThreadLocal 的坏处

❌ 方法侵入:

createOrder(userId);

❌ 层层传参:

Controller → Service → Service → Dao

❌ 后期维护成本极高


六、ThreadLocal 使用注意事项(非常重要)

6.1 线程池 + ThreadLocal = 必须清理

场景后果
不 remove用户数据串线程
不 remove内存泄漏
不 remove安全事故

比如如果仅仅是泄露几个简单的 User 对象或 DateFormat 对象,确实很难直接把现在的服务器内存撑爆(OOM)。

但是,实际生产环境中 ThreadLocal 导致 OOM 的根本原因,往往不是因为泄露的对象本身太大,而是因为 它拖住了一连串“庞然大物”无法回收

我们需要分两个层面来解释:一个是 基础的内存泄露机制,一个是 导致 OOM 的另一个原因(ClassLoader 泄露)


6.2 基础机制:为什么会泄漏?(弱引用 Key vs 强引用 Value)

首先,回顾一下 ThreadLocalMap 的内部结构。它的 Entry 是继承自 WeakReference 的。

  • Key (ThreadLocal 对象):是 弱引用
  • Value (你的业务对象):是 强引用

泄露过程:

  1. 当你的业务代码跑完,ThreadLocal 的引用(Key)在外部没有被使用了,GC 会把这个 Key 回收掉(因为它是弱引用)。
  2. 此时,ThreadLocalMap 里就出现了一行 Key = null 的记录:[null, UserObject]
  3. 问题来了:虽然 Key 没了,但是 Value (UserObject) 还是通过 Thread -> ThreadLocalMap -> Entry -> Value 这条 强引用链 关联在当前线程上的。
  4. 只要这个线程不结束(Tomcat 线程池的线程通常是常驻的),这个 Value 就永远无法被 GC。

“就算泄露,也就 200 个线程泄露 200 个 User 对象,也就是几 MB 的事,怎么会 OOM?”

如果在同一个 ClassLoader 生命周期内,且对象很小,确实不容易 OOM。但是,Web 应用有一个致命的操作叫做:热部署(Redeploy/Reload)。


6.3 OOM :Classloader 泄露 (The ClassLoader Leak)

这是最常见、最严重的场景。

背景知识:

  • Tomcat 的线程池属于 容器(Server),它的生命周期很长。
  • 你的 Web 应用(War 包)属于 应用,它由 WebAppClassLoader 加载。
  • Java 中,对象持有类的引用,类持有加载它的 ClassLoader 的引用

泄漏推演:

  1. 应用启动WebAppClassLoader A 加载了你的应用。
  2. 请求处理:Tomcat 线程(比如 http-nio-1)处理请求,你在 ThreadLocal 里 set 了一个对象 MyContext(这个类是由 WebAppClassLoader A 加载的)。
  3. 忘记清理:请求结束,你忘了调用 remove()。此时,Tomcat 线程(http-nio-1)持有 MyContext 的强引用。
  4. 应用重启/热部署
  • 你修改了代码,Tomcat 重新加载应用。
  • Tomcat 会创建一个新的 WebAppClassLoader B 来加载新代码。
  • 旧的 WebAppClassLoader A 应该被 GC 回收,连同它加载的所有类(Class)和静态变量(Static)。
  1. GC 失败(泄露发生)
  • GC 想要回收 WebAppClassLoader A
  • 检查引用链
  • Tomcat 线程(活得好好的) -> ThreadLocalMap -> Entry -> Value (MyContext 对象)
  • MyContext 对象 -> MyContext.class
  • MyContext.class -> WebAppClassLoader A
  • 结果:因为 Tomcat 线程还抓着旧应用的一个小对象,导致 整个旧应用的 ClassLoader 无法被回收
  1. 滚雪球效应
  • ClassLoader 无法回收,意味着它加载的 成千上万个 Class、所有的 Static 变量、元数据(Metaspace) 都无法释放。
  • 如果你热部署了 5 次,内存里就存着 5 份完整的旧应用代码和静态数据。
  • OOM 爆发:通常是 java.lang.OutOfMemoryError: Metaspace

结论:

你以为只是泄露了一个 User 对象,实际上你通过这个对象,“绑架”了整个 Web 应用的历史版本。


6.4 另外一种情况:积少成多(Heap OOM)

如果不涉及热部署,仅仅是长时间运行,也可能 OOM,但这通常是因为 value 本身比较大。

场景:

假设你在 ThreadLocal 里放了一个 List<Data> 做缓存,每次请求往里加一点数据,但是逻辑有 Bug,只加不删,也不 remove。

  • Tomcat 线程是复用的。
  • 第 1 次请求:List size = 100
  • 第 2 次请求(复用同一个线程):List size = 200
  • ...
  • 第 10000 次请求:List size = 1,000,000

虽然线程数有限(比如 200 个),但每个线程挂着的数据越来越大,最终占满堆内存(Heap),导致 java.lang.OutOfMemoryError: Java heap space


6.5 总结

可能的大头原因:是 ThreadLocal 导致 ClassLoader 无法卸载

  • 引用链Thread -> ThreadLocalMap -> Value -> Class -> ClassLoader -> 整个应用的所有类和静态资源
  • 这会导致 Metaspace/PermGen 内存泄漏,这才是 Web 容器中 ThreadLocal 导致 OOM 的最主要原因。

所以,无论对象多小,try-finally { xxx.remove(); } 是铁律。

ThreadLocal 不适合的场景

  • 线程间通信
  • 生命周期不清晰的数据
  • 大对象、不可控对象
  • static 变量共享对象(可直接访问)

七、什么时候值得用 ThreadLocal?

条件是否满足
线程会被复用
对象会被多次使用
非线程安全
上下文型数据

框架中的应用

Spring 框架是 ThreadLocal 的大户。它利用 ThreadLocal 实现了组件之间的解耦(不用层层传参),同时通过框架底层的切面(AOP)或 拦截器 严格管理了 set 和 remove 的生命周期,从而避免了内存泄漏。

比如: 时间工具 DateTimeContextHolder请求上下文 RequestContextHolder国际化 (LocaleContextHolder) 等等。


八、时间格式化建议

Java 8+ 时间 API 优先

DateTimeFormatter formatter =
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
  • 线程安全
  • 不可变
  • 不需要 ThreadLocal

九、总结

ThreadLocal 的本质是:

用线程隔离保证线程安全, 用线程生命周期复用对象, 在不侵入业务代码的前提下传递上下文信息。