Java并发编程:ThreadLocal

一、ThreadLocal基础全解

1.1 ThreadLocal定义

ThreadLocal是java.lang包下线程本地存储工具类 ,核心作用:实现线程数据隔离,为每一个线程创建专属独立变量副本,线程之间变量互不共享、互不干扰,从底层规避多线程并发竞争问题。还可以通过ThreadLocal在同一线程,不同组件中传递公共变量。

核心底层定论:ThreadLocal本身不存储数据,仅作为存取入口,数据真正存储在绑定当前线程的ThreadLocalMap中。

核心注解:解决多线程共享变量并发修改、线程安全问题,属于空间换时间并发方案。

1.2 ThreadLocal标准使用规范

1.2.1 标准使用步骤

  1. 定义static final修饰ThreadLocal常量(生产强制规范)

  2. set():绑定当前线程专属变量值

  3. get():获取当前线程专属变量副本

  4. remove():线程业务结束手动清除数据,规避内存泄漏

1.2.2 基础使用语法

复制代码
// 生产写法:static final 全局唯一,避免重复创建
private static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

// 设置当前线程专属值
THREAD_LOCAL.set("用户登录信息");
// 获取当前线程专属值
String value = THREAD_LOCAL.get();
// 移除当前线程数据,必写收尾代码
THREAD_LOCAL.remove();

1.3 线程隔离实战案例

案例效果:多线程共用同一个ThreadLocal对象,各自存取数据互不干扰,读取不到其他线程数据。

先写一个普通的线程存取内容的代码:

复制代码
# 代码说明:未使用ThreadLocal,成员变量content属于共享对象,多线程并发读写会出现数据错乱、线程数据互相覆盖
public class ThreadLocalDemo {
    private String content;

    private String getContent() {
       return content;
    }

    private void setContent(String content) {
       this.content = content;
    }

    public static void main(String[] args) {
       ThreadLocalDemo demo = new ThreadLocalDemo();
       for (int i = 0; i < 5; i++) {
                    Thread thread = new Thread(new Runnable() {
                           @Override
                           public void run() {
                                 demo.setContent(Thread.currentThread().getName() + "的数据");
                                 System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
                           }
                     });
                    thread.setName("线程" + i);
                    thread.start();
       }
    }
}

输出结果:

复制代码
线程0--->线程1的数据
线程2--->线程2的数据
线程1--->线程1的数据
线程4--->线程4的数据
线程3--->线程3的数据

从结果可以看出多个线程在访问同一个变量的时候出现的异常,线程间的数据没有隔离。下面我们来看下采用ThreadLocal 的方式来解决这个问题的例子。

复制代码
# ThreadLocal:为每个线程单独存储一份私有变量,线程间数据隔离,无并发冲突
public class ThreadLocalDemo {
    # 创建ThreadLocal,每个线程单独持有一份String副本
    private static ThreadLocal<String> contentLocal = new ThreadLocal<>();

    private String getContent() {
       return contentLocal.get();
    }

    private void setContent(String content) {
       contentLocal.set(content);
    }

    public static void main(String[] args) {
       ThreadLocalDemo demo = new ThreadLocalDemo();
       for (int i = 0; i < 5; i++) {
                    Thread thread = new Thread(new Runnable() {
                           @Override
                           public void run() {
                                 demo.setContent(Thread.currentThread().getName() + "的数据");
                                 System.out.println(Thread.currentThread().getName() + "--->" + demo.getContent());
                                 # 使用完移除,避免内存泄漏
                                 contentLocal.remove();
                           }
                     });
                    thread.setName("线程" + i);
                    thread.start();
       }
    }
}

输出:

复制代码
线程0--->线程0的数据
线程4--->线程4的数据
线程1--->线程1的数据
线程3--->线程3的数据
线程2--->线程2的数据

案例结论:多线程读写互不影响,无并发竞争,无需加锁即可保证线程安全。

1.4 ThreadLocal与synchronized全方位对比

|------|---------------------|----------------------|
| 对比维度 | synchronized | ThreadLocal |
| 并发原理 | 时间换空间,同一资源排队访问,串行执行 | 空间换时间,线程独有副本,并行无竞争执行 |
| 作用目的 | 多线程共享同一变量,保证修改安全 | 线程变量隔离,线程不共享变量 |
| 锁机制 | 内置排他锁,存在线程阻塞、上下文切换 | 无锁设计,零阻塞、无排队开销 |
| 资源开销 | 内存开销低,线程共用一份变量 | 内存开销高,每个线程独立存储副本 |
| 适用场景 | 多线程共享修改同一变量 | 变量线程独享,无需跨线程共享 |

1.5 ThreadLocal核心优势

  • 无锁并发:彻底规避synchronized锁竞争、线程阻塞、上下文切换开销,并发性能极高

  • 线程数据强隔离:线程变量完全独立,天然线程安全,无需手动管控并发

  • 上下文透传:一站式实现线程内全局参数传递,省去方法多层传参冗余代码

  • 使用极简:API轻量化,set/get/remove即可完成数据管控,上手成本低

  • 降低代码耦合:统一封装线程专属上下文,解耦业务参数传递逻辑

1.6 ThreadLocal原生缺点

  • 内存开销大:每条线程独立存储变量副本,线程量大时占用堆内存陡增

  • 无法跨线程共享:数据仅限当前线程使用,线程间无法通信取值

  • 存在内存泄漏风险:配合线程池复用线程时,不手动remove极易内存泄漏

  • 强依赖线程生命周期:线程销毁前,绑定数据会常驻内存

  • 类隔离限制:父子线程无法默认继承数据,需要InheritableThreadLocal拓展

1.7 线上生产高频使用场景

  1. 登录用户上下文透传:拦截器存入当前登录用户信息,全局业务任意位置获取,无需接口传参

  2. 数据库连接隔离:单线程绑定独立数据库Connection,保证事务同线程复用连接

  3. MDC日志链路追踪:存储traceId,同一线程全链路日志绑定同一个追踪ID,排查线上问题

  4. 时间格式化工具隔离:SimpleDateFormat非线程安全,ThreadLocal绑定线程独享工具实例

  5. 脱敏、国际化线程缓存:缓存当前线程用户语种、脱敏规则,全局复用


二、ThreadLocal内部结构、设计原理

通过以上的学习,我们对ThreadLocal的作用有了一定的认识。现在我们一起来看一下ThreadLocal的内部结构,探究它能够实现线程数据隔离的原理。

2.1 JDK8 ThreadLocal全新设计原理

2.1.1 整体层级结构

Thread ----持有--> ThreadLocalMap ----存储--> Entry(key,value)

结构详解:

  1. 每个Thread线程内部,独有一个ThreadLocalMap成员变量

  2. ThreadLocalMap内部存储Entry数组,Entry键值对存储数据

  3. Entry.key = ThreadLocal对象(弱引用),Entry.value = 线程绑定业务数据

  4. ThreadLocal仅为工具操作入口,不存储任何业务数据

2.1.2 JDK7与JDK8结构区别

JDK7:所有ThreadLocal共用一个全局Map,存储多线程数据,极易内存溢出

JDK8优化:线程绑定专属Map,数据分散至各自线程,降低内存耦合

2.2 JDK8结构设计优点

  • 数据分散存储:Map归属线程自身,线程销毁直接回收Map,回收效率更高

  • 弱引用优化key:ThreadLocal对象使用弱引用,方便GC自动回收无用ThreadLocal

  • 哈希冲突概率降低:单线程Entry数量少,数组寻址更快,读写效率提升

  • 适配线程池:线程复用场景下,仅需清理当前线程Entry,管控粒度更细

2.3 JDK8结构设计缺点

  • key弱引用、value强引用不对称,产生key回收、value滞留的内存泄漏漏洞

  • ThreadLocalMap自定义哈希算法,不使用HashMap链地址法,冲突处理逻辑复杂

  • Entry过期清理为被动触发,不主动读写则无法清理脏数据

  • 线程池核心线程常驻不销毁,常驻线程Entry脏数据永久滞留堆内存


三、ThreadLocal核心方法、源码+执行流程全解析

基于ThreadLocal的内部结构,我们继续分析它的核心方法源码,更深入的了解其操作原理。除了构造方法之外,ThreadLocal对外暴露的方法有以下4个:

核心四大方法:get()、set()、remove()、initialValue(),附JDK8完整源码+链路流程

3.1 initialValue() 初始化方法

3.1.1 核心源码

复制代码
/**
 * 返回当前线程对应的ThreadLocal的初始值
 *
 * 此方法的第一次调用发生在,当线程通过get方法访问此线程的ThreadLocal值时
 * 除非线程先调用了set方法,在这种情况下,initialValue 才不会被这个线程调用。
 * 通常情况下,每个线程最多调用一次这个方法。
 *
 * <p>这个方法仅仅简单的返回null {@code null};
 * 如果程序员想ThreadLocal线程局部变量有一个除null以外的初始值,
 * 必须通过子类继承{@code ThreadLocal} 的方式去重写此方法
 * 通常, 可以通过匿名内部类的方式实现
 *
 * @return 当前ThreadLocal的初始值
 */
# initialValue() 源码方法说明:
# 1. 保护方法,默认返回null,线程第一次get()且未执行set()时自动触发执行;
# 2. 若线程提前执行set()存入数据,后续get不会调用该初始化方法;
# 3. 单一线程生命周期内,该方法最多只会执行1次;
# 4. 想要自定义初始值,需要重写该方法,常用匿名内部类/lambda withInitial实现;
# 5. 作用:避免get()直接返回null,给每个线程提供默认初始数据
protected T initialValue() {
   return null;
}

3.1.2 执行流程

  1. 线程首次调用get(),无绑定Entry数据时自动触发

  2. 返回默认初始值,存入当前线程ThreadLocalMap

3.1.3 使用方式

复制代码
// lambda重写初始化方法,创建即赋值
private static final ThreadLocal<Integer> LOCAL = ThreadLocal.withInitial(() -> 0);

此方法的作用是返回该线程局部变量的初始值。

  1. 这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。
  2. 这个方法缺省实现直接返回一个null。
  3. 如果想要一个除null之外的初始值,可以重写此方法。(备注:该方法是一个protected的方法,显然是为了让子类覆盖而设计的)

3.2 set() 设置线程数据方法

3.2.1 核心源码精简版

复制代码
public void set(T value) {
    // 1.获取当前执行线程
    Thread t = Thread.currentThread();
    // 2.获取线程专属ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 3.Map存在:新增/覆盖Entry键值对
        map.set(this, value);
    } else {
        // 4.Map不存在:创建ThreadLocalMap,存入首个Entry
        createMap(t, value);
    }
}

3.2.2 完整执行流程

  1. 获取当前运行线程Thread

  2. 获取线程内部绑定的ThreadLocalMap

  3. Map不为空:以当前ThreadLocal的引用为key,覆盖value值

  4. Map为空:新建ThreadLocalMap,初始化存入Entry

3.3 get() 获取线程数据方法

3.3.1 核心源码精简版

复制代码
public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 1.通过当前ThreadLocal获取Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            // 2.Entry存在,直接返回value
            return (T)e.value;
        }
    }
    // 3.Map为空/Entry为空:执行初始化方法
    return setInitialValue();
}

3.3.2 执行流程

  1. 获取当前线程、线程绑定Map

  2. 匹配当前ThreadLocal对应的Entry

  3. 匹配成功:返回value

  4. 匹配失败:则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的Map

总结: 先获取当前线程的ThreadLocalMap 变量,如果存在则返回值,不存在则创建并返回初始值。

3.4 remove() 移除线程数据方法【生产必调用】

3.4.1 核心源码精简版

复制代码
public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null) {
        // 删除当前ThreadLocal对应的Entry键值对
        m.remove(this);
    }
}

3.4.2 执行流程&核心作用

获取当前线程Map,精准删除当前ThreadLocal关联Entry,断开key、value引用,帮助GC回收,唯一主动规避内存泄漏的API


四、ThreadLocalMap底层源码深度分析

在分析ThreadLocal方法的时候,我们了解到ThreadLocal的操作实际上是围绕ThreadLocalMap展开的。ThreadLocalMap的源码相对比较复杂, 我们从以下几三个方面进行讨论。

4.1 ThreadLocalMap基本结构

ThreadLocalMap是ThreadLocal的内部类,没有实现Map接口,用独立的方式实现了Map的功能,其内部的Entry也是独立实现。

4.1.1 核心成员变量

复制代码
// 底层存储Entry数组,和HashMap数组结构一致
private Entry[] table;
// 数组已存储元素个数
private int size = 0;
// 扩容阈值,默认容量16,负载因子2/3,阈值=10
private int threshold;
// 初始容量,必须为2的幂次方,方便哈希取模寻址
private static final int INITIAL_CAPACITY = 16;

跟HashMap类似,INITIAL_CAPACITY代表这个Map的初始容量;table 是一个Entry 类型的数组,用于存储数据;size 代表表中的存储数目;threshold 代表需要扩容时对应size 的阈值。

4.1.2 存储结构Entry

复制代码
/*
 * Entry继承WeakReference,并且用ThreadLocal作为key.
 * 如果key为null(entry.get() == null),意味着key不再被引用,
 * 因此这时候entry也可以从table中清除。
 */
# ThreadLocalMap内部静态Entry源码注释解读:
# 1. Entry 继承 WeakReference<ThreadLocal<?>>,说明key(ThreadLocal实例)是弱引用;
# 2. 构造方法把ThreadLocal传给父类WeakReference,value为业务存储的数据(强引用);
# 3. 当外部不存在ThreadLocal强引用时,GC会回收ThreadLocal对象,entry.get()返回null;
# 4. ThreadLocalMap扩容/清理时会遍历table,删除key为null的Entry,防止内存泄漏;
# 5. 隐患:value是强引用,若线程长期存活(线程池),不手动remove会导致value无法回收,造成内存泄漏
static class Entry extends WeakReference<ThreadLocal<?>> {
   /** The value associated with this ThreadLocal . */
   Object value;

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

核心结论:Entry = 弱引用key(ThreadLocal) + 强引用value(业务数据)

4.2 Java四大引用类型完整拓展

|---------------------|----------------------|---------------|-------------|------------------------|
| 引用类型 | 回收规则定义 | 优点 | 缺点 | 适用场景 |
| 强引用 | 默认引用,GC永不回收,直至引用断开 | 对象常驻、访问速度快 | 极易引发内存泄漏 | 常规业务对象、全局常量 |
| 软引用SoftReference | 内存充足不回收,内存OOM前强制回收 | 缓存可控,兼顾性能与内存 | 回收时机不可控 | 图片缓存、大对象缓存 |
| 弱引用WeakReference | 下次GC到来,无论内存是否充足,直接回收 | 自动释放内存,防泄漏能力强 | 生命周期短,不可常驻 | ThreadLocal key、临时关联对象 |
| 虚引用PhantomReference | 无法获取对象,仅做GC回收通知 | 监控对象回收状态 | 无法使用取值,功能单一 | 堆外内存回收监控 |

4.3 ThreadLocal内存泄漏闭环解析

4.3.1 内存泄漏概念

内存泄漏:程序已不再使用某对象,但是GC无法回收该对象占用堆内存,内存持续堆积,最终导致服务OOM宕机。

4.3.2 内存泄漏与四大引用关系

内存泄漏本质:强引用滞留无法断开 ;ThreadLocal设计不对称:key弱引用可自动GC,value强引用永远不会自动断开引用。

4.3.3 ThreadLocal内存泄漏真实原因(标准答案)

  1. ThreadLocal对象无外部强引用,触发GC,Entry.key弱引用被回收置为null

  2. 当前线程(线程池核心线程)长期存活,线程持有ThreadLocalMap强引用

  3. key为null,但value依旧被Entry强引用绑定,无法被GC回收

  4. 脏Entry堆积,业务不再使用value,内存永久占用,形成内存泄漏

误区纠正:不是弱引用导致泄漏,是key弱引用、value强引用不对称+线程常驻导致泄漏

4.3.4 四种解决内存泄漏方案(优先级排序)

  1. 业务finally强制remove()(生产最优):业务执行完毕手动删除Entry,断开value引用

  2. 定义static final ThreadLocal:全局强引用ThreadLocal,避免key被GC误回收

  3. 线程池自定义包装线程:线程归还池内前,清空当前线程所有ThreadLocal数据

  4. 依托get/set被动清理:读写Map时,底层自动清理key为null的脏Entry


五、ThreadLocalMap Hash冲突全解

5.1 Hash冲突产生原理

5.1.1 寻址哈希算法

复制代码
// 哈希寻址公式:天然取模2的幂数组下标
int i = key.threadLocalHashCode & (table.length - 1);

a. 关于firstKey.threadLocalHashCode:

复制代码
# ThreadLocal 哈希相关源码注释解析
# 1. threadLocalHashCode:每个ThreadLocal实例唯一哈希值,实例创建时一次性初始化,final不可修改
# 2. nextHashCode():静态原子自增方法,每次生成下一个哈希偏移量
# 3. nextHashCode:AtomicInteger原子整数,多线程并发创建ThreadLocal也能保证自增安全
# 4. HASH_INCREMENT = 0x61c88647 黄金分割数,魔数,用于降低哈希冲突,均匀散列到ThreadLocalMap数组
private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
   return nextHashCode.getAndAdd(HASH_INCREMENT);
}
//AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减,适合高并发情况下的使用
private static AtomicInteger nextHashCode =  new AtomicInteger();
//特殊的hash值
private static final int HASH_INCREMENT = 0x61c88647;

这里定义了一个AtomicInteger类型,每次获取当前值并加上HASH_INCREMENT,HASH_INCREMENT= 0x61c88647 ,这个值跟斐波那契数列(黄金分割数)有关,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry\[\] table中,这样做可以尽量避免hash冲突。

b. 关于& (INITIAL_CAPACITY - 1)

计算hash的时候里面采用了hashCode & (size - 1)的算法,这相当于取模运算hashCode % size的一个更高效的实现。正是因为这种算法,我们要求size必须是2的整次幂,这也能保证在索引不越界的前提下,使得hash发生冲突的次数减小。

5.1.3 和HashMap冲突处理区别

HashMap:链地址法,数组+链表+红黑树;ThreadLocalMap:线性探测法,向后遍历空位存放,无链表结构。

5.2 Hash冲突源码执行流程

  1. 根据threadLocalHashCode计算下标i,命中数组位置

  2. 下标位置Entry.key == 当前ThreadLocal:直接覆盖value,流程结束

  3. 下标位置key不为null、且不匹配:判定Hash冲突,线性向后i++寻址

  4. 寻址遇到key==null脏Entry:复用当前位置,存入新Entry,顺带清理脏数据

  5. 寻址遇到空位:新建Entry存入,size++,判断是否触发扩容

5.3 ThreadLocalMap解决Hash冲突方案

5.3.1 核心方案:线性探测法

发生冲突后,按照数组下标依次向后遍历,寻找空闲槽位存储数据,同时顺路清理key为null的过期脏Entry,一举两得。

5.3.2 前置优化:自定义哈希值增量

ThreadLocal自定义哈希魔数HASH_INCREMENT = 0x61c88647,黄金分割哈希增量,最大限度打散哈希值,从源头降低Hash冲突概率。

5.3.3 后置兜底:扩容机制

数组元素size达到阈值2/3容量时,触发数组二倍扩容,重新rehash迁移所有Entry,减少数组拥挤,降低后续冲突概率。

5.3.4 冲突优缺点总结

  • 优点:结构简单、无链表开销、冲突时自动清理脏Entry,优化内存

  • 缺点:高并发大量写入时,线性寻址变长,读写性能下降明显