线程本地存储 ThreadLocal

前面讲了 读写锁 因为 AQS 只能记录写线程,无奈之下,只好把读线程的信息记录到各自线程的 ThreadLocal 中,那 ThreadLocal 中,那 ThreadLocal 里的数据就没有并发问题吗?

并发问题是多线程对共享变量同时进行修改,如果每个线程都有一个"独占的储物柜",即拥有自己的独立变量,那么就不存在多个线程竞争修改同一个变量的问题。本篇介绍的 ThreadLocal 就是这个"独占的储物柜"。

下面看一下 ThreadLocal 基本使用,再分析一下实现原理。

基本使用

ThreadLocal 的核心方法主要用于为每个线程提供独立的变量副本,并控制这些副本的创建、访问和删除。

方法 作用 说明
set(T value) 将值存入当前线程的私有副本 这个值对于当前线程来说是独立的,其他线程无法访问或修改。
get() 获取当前线程之前存入的值 如果当前线程是第一次调用 get(),并且没有通过 set() 设置过值,则会通过调用 initialValue() 返回默认值
remove() 移除当前线程的值 这个方法在多线程环境中可以防止内存泄漏,特别是在线程池中使用时尤为重要
initialValue() / withInitial() 设置默认值 通常在第一次调用 get() 且没有 set() 时会调用该方法。默认实现返回 null,但是可以通过子类覆盖此方法或者通过 ThreadLocal.withInitial() 提供一个初始化函数

ThreadLocal 用法还是挺简单的,无非增(set)删(remove)改(set)查(get)而已,比如每个线程都有自己的计数器:

java 复制代码
package com.cango.thread.threadlocal;

public class ThreadLocalExample {
    // 创建一个 ThreadLocal 变量,每个线程都有自己的初始值 0
    private static ThreadLocal<Integer> threadLocalCounter = ThreadLocal.withInitial(() -> 0);

    public static void main(String[] args) {
        // 定义一个任务,增加计数器并打印当前线程的计数值
        Runnable task = () -> {
            for (int i = 0; i < 5; i++) {
                // 获取当前线程的计数器值
                Integer counter = threadLocalCounter.get();
                // 增加计数器
                threadLocalCounter.set(counter + 1);
                // 打印当前线程的计数值
                System.out.println(Thread.currentThread().getName() + " 的计数器值: " + threadLocalCounter.get());
            }
        };

        // 启动两个线程,执行相同的任务
        Thread thread1 = new Thread(task, "线程1");
        Thread thread2 = new Thread(task, "线程2");

        thread1.start();
        thread2.start();
    }
}

输出结果如下:

js 复制代码
线程1 的计数器值: 1
线程2 的计数器值: 1
线程2 的计数器值: 2
线程1 的计数器值: 2
线程2 的计数器值: 3
线程1 的计数器值: 3
线程2 的计数器值: 4
线程1 的计数器值: 4
线程2 的计数器值: 5
线程1 的计数器值: 5

可以看到,两个线程操作同一个 ThreadLocal 对象,但数据互不干扰,各自独立。

如果将 threadLocalCounter 换成普通的 int 变量,每个线程对 threadLocalCounter 进行自增,肯定会有并发问题。

那为啥每个线程从相同的 ThreadLocal 对象中获取的变量都是线程独立的呢?

如何做到在多个线程对同一个共享变量进行 set 操作时,实现线程的完全隔离呢?

自定义 ThreadLocal

如果让我实现 ThreadLocal 的功能,我肯定会想到使用 Map,将线程作为 key,那每个线程都可以对应不同的 value,互不打扰,美滋滋。

java 复制代码
public class MyThreadLocal<T> {
    // 使用静态的 ConcurrentHashMap 来存储每个线程的变量值
    private static ConcurrentHashMap<Thread, Object> threadLocalMap = new ConcurrentHashMap<>();
    

    // 获取当前线程的变量值,如果没有则返回初始值
     public static <T> T get() {
        Thread currentThread = Thread.currentThread();
        return (T) threadLocalMap.computeIfAbsent(currentThread,null);
    }

    // 为当前线程设置变量值
    public static <T> void set(T value) {
        Thread currentThread = Thread.currentThread();
        threadLocalMap.put(currentThread, value);
    }

    // 移除当前线程的变量值
    public static void remove() {
        Thread currentThread = Thread.currentThread();
        threadLocalMap.remove(currentThread);
    }
}    

如上,threadLocalMap 使用 Thread 作为 key,存储每个线程的独立变量值。

ConcurrentHashMap是专门为多线程高并发场景而设计的Map。

这样做简单是简单,但有个问题就是这个 threadLocalMap 是静态的,生命周期与 JVM 一致。

当线程销毁后,Thread 对象作为 Key 虽然不再被使用,但仍然被 Map 强引用着,导致已销毁的 Thread 对象无法被 GC 回收,Thread 对象内部持有的所有资源(包括它的栈、本地变量等)都无法释放,线程创建得越多,泄漏越严重,这不完犊子了。

下面就看看 Josh Bloch 和 Doug Lea 这两个大神是怎么利用弱引用实现的这个线程唯一的存储柜的。

Josh Bloch 曾担任首席 Java 架构师,主导了许多 Java 标准库的设计和实现,著有《Effective Java》一书,被视为学习 Java 语言的经典入门书籍。

Doug Lea 是并发编程领域的专家,最大贡献之一就是开发了 java.util.concurrent 包,他是《Concurrent Programming in Java》一书的作者,这本书也是并发编程的经典著作之一。

源码分析

先看一下ThreadLocal 类结构上的嵌套关系:

ThreadLocalMap 是 ThreadLocal 的静态内部类 ,Entry 又是 ThreadLocalMap 的静态内部类。

再来一张 UML 类图。

⨳ Thread :作为数据的主人,内部有 threadLocals 字段,指向该线程专属的 ThreadLocalMap。

java 复制代码
public class Thread implements Runnable {

    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
	

因为是线程私有,所以不同线程操作同一个 ThreadLocal 时,实际读写的是各自不同的 Map,天然隔离,不需要加锁。

⨳ ThreadLocalMap :存储容器,底层是一个 Entry[] 数组。

js 复制代码
static class ThreadLocalMap {
    private static final int INITIAL_CAPACITY = 16;  // 初始容量为16
    private Entry[] table;  // 底层数组结构
    private int size = 0;   // 当前存储的元素数量

数组的初始容量为 16,当元素太多时,会扩展数组。

⨳ Entry:实际数据单元。

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;  // 存储当前 ThreadLocal 的值

    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // 将 ThreadLocal 的弱引用作为 key
        value = v;  // 将线程的局部变量作为 value
    }
}

Entry 的 key 是 ThreadLocal 实例的 弱引用 ,value 是 ThreadLocal 对应的值。

⨳ ThreadLocal:访问入口,提供 set/get/remove,本身不存数据。

感觉是不是有点复杂了,下面看一下 set 方法,把这几个类串起来。

java 复制代码
public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();

    // 获取当前线程的 ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    
    // 如果当前线程的 ThreadLocalMap 不为 null,则在 map 中存储值
    if (map != null) {
        map.set(this, value);
    } else {
        // 如果当前线程还没有 ThreadLocalMap,创建一个新的 map
        createMap(t, value);
    }
}

ThreadLocal 实例本身是全局共享的,通常声明为 static final,那就是说调用 ThreadLocal 的 set 方法时,是多个线程调用同一个对象的 set 方法。

所以 set 方法第一步,就是获取调用该方法该线程的专属 Map,然后把自己(this)作为 key 存入该线程的专属 Map。

最终的结果就是,同一个 ThreadLocal 对象,在不同线程的专属 Map 中,是同一个 key,但对应不同的 value 。

一个Thread 多个 ThreadLocal

既然是 Map,那就意味着 同一个线程 可以用多个 ThreadLocal 作为自己的存储柜。

总得来说,Thread 是储物柜的主人,ThreadLocalMap 是它专属的柜子,Entry 是柜子里的格子,而 ThreadLocal 只是开格子用的钥匙。

java 复制代码
public class MultiThreadLocalExample {
    private static final ThreadLocal<String> userSession = ThreadLocal.withInitial(() -> null);
    private static final ThreadLocal<Integer> userId = ThreadLocal.withInitial(() -> 0);

    public static void setUserSession(String session) {
        userSession.set(session);
    }

    public static String getUserSession() {
        return userSession.get();
    }

    public static void setUserId(Integer id) {
        userId.set(id);
    }

    public static Integer getUserId() {
        return userId.get();
    }

    public static void main(String[] args) {
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            setUserSession(threadName + "'s Session");
            setUserId(threadName.length());

            System.out.println(threadName + " - Session: " + getUserSession());
            System.out.println(threadName + " - User ID: " + getUserId());
        };

        new Thread(task).start();
        new Thread(task).start();
    }
}

在这个例子中,每个线程都有自己的 userSession 和 userId。输出结果如下:

js 复制代码
Thread-1 - Session: Thread-1's Session
Thread-1 - User ID: 8
Thread-0 - Session: Thread-0's Session
Thread-0 - User ID: 8

也可以将 ThreadLocal 实例当做储物柜的票据,根据这个小票就可以获得对应的 value,如果小票不同,取不到值不能怪储物柜不好使。所以一般会将 ThreadLocal 实例声明成静态常量。

弱引用与内存泄漏

那为啥搞这么复杂,既然对于多线程来说,Thread 是多个,Thread 持有的 ThreadLocalMap 也是相互不同的实例,ThreadLocalMap 本就相互隔离,还把 ThreadLocalMap 设计这么复杂干啥?

直接让 Thread 持有 一个普通的 HashMap ,省去 ThreadLocal 这个操作Map 的类,用户直接操作 Thread 对象持有的 HashMap 可以吗?

可以是可以,但不安全,也没啥规范。

⨳ 规范问题

如果用字符串 或者 普通对象做 key:

js 复制代码
// 模块A
Thread.currentThread().threadLocalMap.put("user", userDto);
// 模块B,第三方包,也写了
Thread.currentThread().threadLocalMap.put("user", traceId);

两个完全无关的模块,不小心用了同一个字符串 key,互相覆盖数据 ,bug 极其隐蔽,多线程环境很难复现;而且多人协作、多模块、多框架混在一起的时候,字符串 key 就是定时炸弹,哪怕大家命名很规范也防不住冲突,你用 "user" 存用户信息,我还用 "USER" 存呢,他还用 "usr" 存呢?

而 ThreadLocal 实例本身就是唯一 key , 每个业务定义自己的 static final ThreadLocal<User> USER_TL,每个 ThreadLocal 对象是独一无二的。 别的模块就算变量名也叫 USER_TL,也是不同对象,不会发生 key 碰撞。

ThreadLocalMap 不实现 Map 接口,对外完全不可见,只能通过 ThreadLocal 的 get/set 间接操作,这就大大避免了大家对 key 的约定有冲突的问题。

⨳ 内存泄漏

而且 HashMap 全部是强引用,在线程池场景下,线程不会销毁,业务代码临时创建一个 Object 作为 key 存入 map,业务结束后,没有任何地方引用这个 key 对象了。 但是 HashMap 强引用保存 key,key 对象永远无法 GC,value 也无法 GC,这就导致了内存泄漏。

ThreadLocal 怎么解决这个问题的呢?

ThreadLocalMap.Entry 的 Key 是用弱引用包装过的:

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

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

前文讲过JVM的四种引用,强引用 只有对象存活(GC Roots 可达),GC 永远不会回收 ,软引用 当 GC 后内存仍然不足时回收;弱引用 ,是只要对象只剩弱引用可达,GC 发生时就会被回收。

当外部没有强引用持有 ThreadLocal 实例时,key 会被 GC ,钥匙可以被回收,但格子里的东西不会自动清空,所以当后续调用 get()/set()/remove() 及扩容时会触发启发式清理,尝试清除 key 为 null 的过期 Entry。

当然,弱引用仅仅是兜底方案,针对ThreadLocal 实例本身生命周期很短 的场景,我们业务里绝大多数都是 static final ThreadLocal,所以还得养成用完 remove() 的习惯,可以将其看作是一种资源,放到 finally 里面释放。

应用场景

ThreadLocal 用于保存线程私有变量,多线程之间互相隔离,变量绑定线程,不是绑定任务,如果数据属于"当前线程/当前请求"的,才适合放 ThreadLocal;需要跨线程共享的,就不适合了。

⨳ 链路透传上下文

Web / RPC 服务,一次请求贯穿很多层:Controller → Service → Mapper。 需要传递:userId、traceId、请求 IP。如果所有方法签名都要加 traceId 参数,代码污染严重。

那就可以在拦截器 / 过滤器入口 set,业务代码任意地方 get,方法不用改参数;请求结束 finally remove。

js 复制代码
// 全局静态TL
private static final ThreadLocal<String> TRACE_ID = new ThreadLocal<>();

// 过滤器
try {
    TRACE_ID.set(generateTraceId());
    doFilter();
} finally {
    TRACE_ID.remove();
}

比如还可以在一次请求内埋点收集临时指标,贯穿整个调用链路,请求结束丢弃。

⨳ 线程内复用资源

同一个线程多次调用方法,可以复用同一个资源实例。

比如旧版 Spring 事务,把数据库连接放到 ThreadLocal,保证同一个线程内所有数据库操作拿到同一个 Connection,从而实现事务统一提交 / 回滚。

比如 SimpleDateFormat 不是线程安全的,不能用 static 修饰全局共享,这种就可以每个线程放一份这个资源。

js 复制代码
private static final ThreadLocal<SimpleDateFormat> SDF_TL 
                = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

当然现在推荐线程安全的 DateTimeFormatter ,不再推荐使用 SimpleDateFormat 了。

多个线程同时对同一个对象的同一个属性进行并发修改时,才可能会产生并发问题。DateTimeFormatter 是个不可变对象 Immutable Object ,实例一旦创建成功,内部所有状态永远不会被修改,成员变量都用 final 修饰,不会被修改那肯定就没有并发问题喽。

java 复制代码
public final class DateTimeFormatter {
   private final CompositePrinterParser printerParser;
   private final Locale locale;
   private final DecimalStyle decimalStyle;
   private final ResolverStyle resolverStyle;
   private final Set\<TemporalField\> resolverFields;
   private final Chronology chrono;
   private final ZoneId zone;

总结

ThreadLocal 其实是线程特有存储模式 (Thread Specific Storage,TSS)在 Java 里的经典实现。在 G 语言中,这种实现叫 pthread_key_t,在 Python 中叫 threading.local()...

线程特有存储模式:在多线程应用程序中,不同线程可能需要独立地访问和维护各自的数据,TSS 模式允许每个线程拥有自己的数据副本,而不会相互干扰。

Specific 是 "特有的" 的意思,Storage 是 "存储" 的意思,每个线程特有的存储,意味着存在里面的数据对于每个线程都是独立的,不会引起并发问题。

附:UML 类图

java 复制代码
@startuml
class Thread  {
  + ThreadLocal.ThreadLocalMap threadLocals
}

class "ThreadLocal.ThreadLocalMap" as ThreadLocalMap {
  - Entry[] table
}

class "ThreadLocalMap.Entry" as Entry {
  + ThreadLocal<?> key  // 弱引用
  + Object value        // 强引用
}

class ThreadLocal {
  + set(T value)
  + get()
  + remove()
}

Thread --> ThreadLocalMap : has >
ThreadLocalMap o-- Entry : contains >
ThreadLocal ..> ThreadLocalMap : uses as Key >
@enduml
相关推荐
ALONE阿龙太原微码1 小时前
RBAC 以及主流权限模型
后端
你顶住我先撤1 小时前
RocketMQ 消息类型
后端
Wang's Blog2 小时前
Java框架 SpringCloud 快速入门: 服务拆分案例 Demo
java·开发语言·spring cloud
DongQiShanRen2 小时前
裁决台账双向互校(上):名册与实物的第一道对账
java·linux·运维·数据库·人工智能·自然语言处理·数据挖掘
cpolar技术支持3 小时前
Spring Boot 接口本地正常,异地前端却报跨域?用 cpolar 跑通 CORS 预检与白名单
java·springboot·cpolar·前后端分离·cors
高频因子挖掘机3 小时前
批量行情返回后,怎样把请求失败的股票单独挑出来?
后端·github·api
wuminyu3 小时前
Virtual Thread重投递至ForkJoinPool任务队列过程解析
java·linux·c语言·jvm·c++
小蒜学长3 小时前
在线保险服务与管理平台的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端·服务平台·在线保险
掉进电商坑三年没爬出来的东叔3 小时前
青龙面板进阶:一个面板聚合多平台签到,所有积分自动领
java·开发语言