全面理解ThreadLocal(详细简单)

一、ThreadLocal简介

从Java官方文档中的描述:ThreadLocal类用来提供线程内部的局部变量。 这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程的变量。ThreadLocal的实例通常来说都是private static 类型的,用于关联线程和线程上下文。

我们可以得知ThreadLocal的作用是:提供线程内部的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量传递的复杂度。

ThreadLocal功能总结:

  • 线程并发:在多线程并发的场景下;
  • 传递数据:可以通过ThreadLocal在同一个线程,不同组件中传递公共变量;
  • 线程隔离:每个线程的变量都是独立的,不会互相影响;

ThreadLocal的应用场景:

  • 前面执行的方法保存了信息后,后续方法可以通过ThreadLocal 直接获取到,避免了传参,类似于全局变量的概念,例如当前登录用户放入ThreadLocal 中,避免每个方法一直传递过去,直接从ThreadLocal获取即可;
  • 将数据库Connection连接对象存入ThreadLocal中,保证了不同的线程使用线程相关的Connection,而不会使用其他线程的Connection;

二、ThreadLocal的基本使用

在使用之前,我们先来看看ThreadLocal的一些常用方法:

方法声明 作用
ThreadLocal() 创建ThreadLocal对象
public void set(T value) 设置当前线程绑定的局部变量
public T get() 获取当前线程绑定的局部变量
public void remove() 移除当前线程绑定的局部变量

我们来看下面这个示例,感受一下ThreadLocal线程隔离的特点:

java 复制代码
package com.wsh.mybatis.mybatisdemo.entity;
 
public class ThreadLocalDemo {
 
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
 
    public static void main(String[] args) {
        new Thread(() -> {
            //设置t1线程中本地变量的值
            threadLocal.set("t1线程");
            //获取t1线程中本地变量的值
            System.out.println("t1线程局部变量的value : " + threadLocal.get());
        }, "t1").start();
 
        new Thread(() -> {
            //设置t2线程中本地变量的值
            threadLocal.set("t2线程");
            //获取t1线程中本地变量的值
            System.out.println("t2线程局部变量的value : " + threadLocal.get());
        }, "t2").start();
    }
}

下面是运行后的结果:

java 复制代码
t1线程局部变量的value : t1线程
t2线程局部变量的value : t2线程

可以看到,每个线程只会拿到属于它们的线程局部变量副本信息,不会出现t1线程拿到t2线程的变量value,这就是ThreadLocal的线程隔离性。

三、ThreadLocal与Synchronized的区别

在前面的案例中,其实我们也可以使用synchronized关键字加锁来达到同样的线程隔离的效果,虽然ThreadLocal与synchronized关键字都用于处理多线程并发访问变量的问题,不过两者处理问题的角度和思路不同。

区别 Synchronized ThreadLocal
原理 同步机制采用"以时间换空间"的方式,只提供了一份变量,让不同的线程排队访问 ThreadLocal采用"以空间换时间"的方式,为每一个线程都提供了一份变量的副本,从而实现同时访问而相不干扰
侧重点 多个线程之间访问资源的同步 多线程中让每个线程之间的数据相互隔离

四、ThreadLocal的内部结构

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

JDK最早期的ThreadLocal的设计:每个ThreadLocal都创建一个ThreadLocalMap,然后用线程thread对象作为Map的key,要存储的局部变量作为Map的value,这样就能达到各个线程的局部变量隔离的效果。大概如下图所示:

在JDK1.8中,JDK后面优化了设计方案:每个线程维护一个ThreadLocalMap,这个Map的key是ThreadLocal实例本身,value才是真正要存储的值Object。

具体的过程如下:

  • 每个线程Thread内部都有一个ThreadLocalMap对象;
  • ThreadLocalMap里面存储ThreadLocal对象(key)和线程的变量副本(value);
  • 线程内部的ThreadLocalMap是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值;
  • 对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰;

大体结构图如下所示:

比较上面两种方案,JDK1.8优化后的好处主要有下面两点:

(1)、每个ThreadLocalMap中存储的Entry数量变少;(因为1.8之前的设计,thread线程越多,ThreadLocalMap中的Entry数量自然就越多;而1.8之后,Entry的数量是由ThreadLocal的数量来决定的);

(2)、当Thread线程销毁的时候,ThreadLocalMap也会随之销毁,减少内存的使用;

五、ThreadLocal的核心方法源码

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

方法声明 描述
protected T initialValue() 返回当前线程局部变量的初始值
public void set(T value) 设置当前线程绑定的局部变量
public T get() 获取当前线程绑定的局部变量
public void remove() 移除当前线程绑定的局部变量

set方法:设置当前线程对应的ThreadLocal的值

java 复制代码
public void set(T value) {
    //获取当前线程对象
    Thread t = Thread.currentThread();
    //获取当前线程中维护的ThreadLocalMap对象
    //getMap(t)实际上就是获取当前线程对象自己的成员变量threadLocals
    ThreadLocalMap map = getMap(t);
    //判断ThreadLocalMap是否存在
    if (map != null)
         //如果ThreadLocalMap存在的话,调用map.set设置实体entry
        //key为当前定义的ThreadLocal变量的this引用,值为添加的本地变量值
        map.set(this, value);
    else
        //当前线程thread不存在ThreadLocalMap对象,那么调用createMap方法创建一个ThreadLocalMap对象
        //t.threadLocals:将当前ThreadLocal对象和value(线程变量的副本)作为第一个entry, 存放至ThreadLocalMap对象中
        createMap(t, value);
}
 
//获取当前线程thread对应维护中的ThreadLocalMap对象
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
 
//初始化一个ThreadLocalMap对象
void createMap(Thread t, T firstValue) {
    //java.lang.Thread#threadLocals
    //ThreadLocal.ThreadLocalMap threadLocals = null;
    //并设置到当前线程thread的属性threadLocals中
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

get()方法:获取当前线程的局部变量的副本

java 复制代码
public T get() {
    //获取当前线程对象
    Thread t = Thread.currentThread();
    //获取当前线程维护的ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    //如果ThreadLocalMap对象存在
    if (map != null) {
        //以当前threadLocal为key,调用getEntry方法获取对应的存储实体entry
        ThreadLocalMap.Entry e = map.getEntry(this);
         //判断entry对象是否为空
         if (e != null) {
            @SuppressWarnings("unchecked")
            //获取存储实体entry对应的value值
            //即为我们想要的当前线程对应此ThreadLocal的值
            T result = (T)e.value;
            return result;
        }
    }
    //初始化:有两种情况会执行下面代码:
    //1. map不存在,表示当前线程没有维护的ThreadLocalMap对象;
    //2. map存在,但是没有与当前ThreadLocal管理的Entry对象;
    return setInitialValue();
}
 
//初始化
private T setInitialValue() {
    //调用initialValue()获取初始化值
    //可以被子类重写,如果没有重写默认返回null
    T value = initialValue();
    //获取当前线程
    Thread t = Thread.currentThread();
    //获取此线程对象中维护的ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    //如果ThreadLocalMap不为空
    if (map != null)
        //设置value初始值为initialValue()方法返回的值
        map.set(this, value);
    else
        //如果ThreadLocalMap为空,创建一个ThreadLocalMap对象
        //t.threadLocals:将当前ThreadLocal对象和value(线程变量的副本)作为第一个entry存放至ThreadLocalMap对象中
        createMap(t, value);
    return value;
}

remove():移除当前线程局部变量的副本

java 复制代码
public void remove() {
    //获取到当前线程的ThreadLocalMap对象
    ThreadLocalMap m = getMap(Thread.currentThread());
    //如果ThreadLocalMap对象不为空
    if (m != null)
        //以当前ThreadLocal对象作为key从ThreadLocalMap对象中移除对应的实体Entry对象
        m.remove(this);
}

initialValue():设置当前线程局部变量的初始值

java 复制代码
//调用initialValue()获取初始化值
//可以被子类重写,如果没有重写默认返回null
protected T initialValue() {
    return null;
}

六、ThreadLocalMap的基本结构

前面已经分析了ThreadLocal的几个核心方法实现,我们了解到ThreadLocal实际上都是围绕ThreadLocalMap来展开的,ThreadLocalMap的源码相对比较复杂,我们从下面几个方面进行讨论。

基本结构:

ThreadLocalMap是ThreadLocal的一个静态内部类,没有实现Map接口,用独立的方式实现了Map的功能,其内部的Entry也是独立实现,跟Map中的不是同一个。

java 复制代码
static class ThreadLocalMap {
 
    /**
     * Entry继承弱引用对象WeakReference
     */
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;
 
        //key其实就是ThreadLocal对象
        //v其实就是线程局部变量的副本
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
 
    /**
     * 初始容量大小,必须是2的n次幂
     */
    private static final int INITIAL_CAPACITY = 16;
 
    /**
     * 存放数据的entry数组对象
     */
    private Entry[] table;
 
    /**
     * 数组里面entry的数量
     */
    private int size = 0;
 
    /**
     * 扩容阈值,容量大于它时会扩容
     */
    private int threshold; // Default to 0
    
    //省略........
    
}    

跟HashMap类似,INITIAL_CAPACITY就是初始化容量,必须是2的n次幂,table是一个Entry类型的数组,用于存储数据;size代表表中的存储数目;threshold代表需要扩容时对应的size的阈值;

存储结构 - Entry:

java 复制代码
//Entry继承自WeakReference,并且用ThreadLocal作为key
//如果key为null,意味着key不再被引用,因此这时候entry也是可以从table中清除的
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
 
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。不过Entry中的key只能是ThreadLocal对象。另外,Entry继承自WeakReference,也就是key(ThreadLocal)是弱引用,其目的是将ThreadLocal对象的生命周期和线程Thread生命周期解绑。

七、ThreadLocal的内存泄露问题

  • 弱引用和内存泄露

有些时候,我们在使用ThreadLocal的时候可能会发现有内存泄露的情况发生,就猜测这个内存泄露跟Entry中使用了弱引用的key有关系,其实这个理解是不对的。

先来看下内存泄露的概念:

  • 内存溢出:没有足够的内存提供申请者使用;
  • 内存泄露:指的是程序中已经动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。内存泄露的堆积终将导致内存溢出,也就是我们常说的OOM。

Java中的引用主要有四种类型:强引用、弱引用、软引用、虚引用。当前这个问题主要涉及到强引用和弱引用:

  • 强引用:就是我们最常见的普通对象引用,只要还有强引用指向一个对象,就能表明对象还活着,垃圾回收器就不会回收这种对象;
  • 弱引用:垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存;

如果key使用强引用:

分析:

  1. 假设在业务代码中使用完ThreadLocal,ThreadLocal Ref也就被回收了;
  2. 但是因为ThreadLocalMap中的Entry中的key强引用了threadLocal,造成threadLocal无法被回收;
  3. 在没有手动删除这个Entry对象以及当前线程CurrentThread依然还在运行的前提下,始终有强引用链threadRef -> currentThread -> threadLocalMap -> Entry,这样entry就不会被回收(entry中包含了threadLocal实例和value),导致entry内存泄露;

总结:ThreadLocalMap中的key使用了强引用,是无法完全避免内存泄露问题的。

如果key使用弱引用:

分析:

  1. 假设在业务代码中使用完ThreadLocal,ThreadLocal Ref也就被回收了;
  2. 由于ThreadLocalMap的key只持有了ThreadLocal的弱引用,没有任何强引用链指向ThreadLocal对象,此时垃圾回收器就能顺利回收ThreadLocal对象,回收完后,此时entry中的key就是null了。
  3. 但是在没有手动删除这个Entry对象以及当前线程CurrentThread依然还在运行的前提下,始终有强引用链threadRef -> currentThread -> threadLocalMap -> Entry -> value,value不会被回收,而这块value永远不会被访问到了,导致value的内存泄露;

总结:ThreadLocalMap中的key使用了弱引用,也是无法完全避免内存泄露问题的。

比较上面两种情况,可以发现,内存泄露的发生跟ThreadLocalMap中的key是否使用弱引用是没有关系的。那么内存泄露的真正原因是什么呢?

我们发现,以上两种内存泄露的情况中,都有两个前提:

  • 没有手动删除这个entry对象,只要使用完ThreadLocal对象,调用其remove方法删除对应的entry,就会避免内存泄露;
  • currentThread当前线程依然在运行;

由于ThreadLocalMap是Thread的一个成员属性,被当前线程所引用,所以它的生命周期跟线程Thread一样长。那么在使用完ThreadLocal时,如果当前Thread也随之执行结束,ThreadLocalMap自然也会被垃圾回收器回收,从根源上避免了内存泄露。

综上,ThreadLocal内存泄露的根源是:由于ThreadLocalMap的生命周期跟Thread当前线程一样长,如果没有手动删除对应可以就会导致内存泄露。

既然强引用和弱引用都无法避免内存泄露问题,那么ThreadLocalMap的key要使用弱引用呢?

事实上,在ThreadLocalMap中的set、getEntry方法中,会对key为null的ThreadLocal进行判断,如果为null的话,它是会对value置空的。这就意味着使用完ThreadLocal,当前线程依然运行的前提下,就算忘记调用remove方法,弱引用比强引用可以多一层保障。弱引用的ThreadLocal会被回收,对应的value在下一次ThreadLocalMap调用set、get、remove中的任意一个方法的时候会被清除,从而避免内存泄露。

八、ThreadLocalMap的hash冲突问题

hash冲突的解决是Map中的一个重要内容,我们以hash冲突的解决为线索,来研究一下ThreadLocalMap的核心源码。

(1)、首先从ThreadLocalMap的构造方法入手

java 复制代码
//firstKey: 当前ThreadLocal实例
//firstValue: 要保存的线程本地变量
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //初始化entry数组    
    table = new Entry[INITIAL_CAPACITY];
    //计算索引
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    //设置值
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    //设置扩容阈值
    setThreshold(INITIAL_CAPACITY);
}

构造方法首先创建一个长度为16的entry数组,然后计算出firstKey对应的索引,然后存储到table中,并设置size和threshold。

重点分析一下跟hash冲突有关的代码,也就是计算索引的代码:

java 复制代码
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  • 关于firstKey.threadLocalHashCode:
java 复制代码
//threadLocal的hash值
private final int threadLocalHashCode = nextHashCode();
 
//返回下一个hash值
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}
 
//使用AtomicInteger通过原子操作类线程安全的方式操作加减,适合高并发情况下的使用
private static AtomicInteger nextHashCode = new AtomicInteger();
 
//特殊的一个hash值
private static final int HASH_INCREMENT = 0x61c88647;    

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

  • 关于& (INITIAL_CAPACITY - 1)

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

ThreadLocalMap的set方法:

java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    //获取到entry[]数组
    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)]) {  //nextIndex()方法: 获取环形数组的下一个索引
        ThreadLocal<?> k = e.get();
 
        //ThreadLocal对应的key存在,直接覆盖之前的值
        if (k == key) {
            e.value = value;
            return;
        }
        
        //key为null,但是值不为null,说明之前的ThreadLocal对象已经被回收了
        //当前数组中的Entry是一个陈旧的元素
        if (k == null) {
            //用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄露
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
    //ThreadLocal对应的key不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    //cleanSomeSlots用于清理那些e.get() == null的元素
    //这种数据key关联的对象已经被回收,所以这个entry可以被置空
    //如果没有清除任何entry,并且当前使用量达到了负载因子所定义的阈值,那么进行
    //rehash,执行一次全表的扫描清理工作。
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}
 
//获取环形数组的下一个索引
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

set()方法代码执行流程:

  1. 首先还是根据key计算出索引,然后查找对应索引位置上的entry;
  2. 如果entry已经存在并且key等于传入的key,那么这时候直接给这个entry赋予新的value值;
  3. 如果entry存在,但是key是null,则调用replaceStaleEntry来更换这个key为null的entry;
  4. 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return结束掉,那么就在这个null的位置新建一个entry,并且插入,同时size增加1;
  5. 最后调用cleanSomeSlots,清理key为null的entry,最后返回是否清理了entry,接下来再判断sz是否大于threshold达到了rehash的条件,达到的话,就会调用rehash函数执行一次全表的扫描清理。

重点分析:ThreadLocalMap使用线性探测法来解决哈希冲突的,依次探测下一个地址,一直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。

举个例子,假设当前table的长度是16,也就是说如果计算出来的key的hash值是14,如果table14上已经有值,并且其key与当前key不一致,那么就发生了hash冲突。这个时候将14加1得到15,取出table15进行判断,这个时候如果还是冲突,会回到0,取出table0,以此类推,直到可以插入。

按照上面的描述,可以把Entry\[\] table看成一个环形数组。

以上就是关于ThreadLocal的一些分析和总结,希望对大家有所帮助。

相关推荐
驭渊的小故事1 小时前
Spring AI Alibaba 实战:从maven依赖治理到声明式工具调用
java·人工智能·spring
Elastic 中国社区官方博客1 小时前
我们如何通过 70 多个拉取请求,将 OpenTelemetry Java agent 迁移到 invokedynamic
java·大数据·开发语言·elasticsearch·搜索引擎·全文检索·es
tyn1881 小时前
记录本地安装nacos服务的方法及异常
java·开发语言
步行cgn2 小时前
Spring MVC 视图机制的核心接口与类
java·开发语言·后端·spring·mvc
y1su2 小时前
Leetcode 字符串模板
java·数据结构·算法·leetcode·职场和发展·动态规划
码事漫谈3 小时前
AI 读你的提示词,其实很没耐心
后端
用户8356290780513 小时前
如何使用 Python 格式化 Excel 单元格中的长文本
后端·python
我命由我123453 小时前
Python 开发 - Python 的 pathlib
java·开发语言·后端·python·学习·java-ee·学习方法
IT_陈寒3 小时前
Java性能优化完全指南
前端·人工智能·后端
电子IT笔记4 小时前
对接群晖NAS,上传和删除踩坑记录
前端·后端