剖析ThreadLocal使用场景、实现原理、设计思想

前言

ThreadLocal可以用来存储线程的本地数据,做到线程数据的隔离

ThreadLocal的使用不当可能会导致内存泄漏,排查内存泄漏的问题,不仅需要熟悉JVM、利用好各种分析工具还耗费人工

如果能明白其原理并正确使用,就不会导致各种意外发生

本文将从使用场景、实现原理、内存泄漏、设计思想等层面分析ThreadLocal,并顺带聊聊InheritableThreadLocal

ThreadLocal使用场景

什么是上下文?

比如线程处理一个请求,请求会经过MVC流程,由于流程很长,会经历很多方法,这些方法就可以叫上下文

ThreadLocal作用在上下文中存储常用的数据、存储会话信息、存储线程本地变量等

比如使用拦截器在请求处理前,通过请求中的token获取登录用户信息,将用户信息存储在ThreadLocal中,方便后续处理请求时从ThreadLocal中直接获取用户信息

如果线程会重复利用,为了避免数据错乱,使用完(拦截器处理后)应该删除该数据

ThreadLocal 常用的方法有:set()get()remove()分别对应存储、获取和删除

可以将ThreadLocal放在工具类中方便使用

java 复制代码
public class ContextUtils {
    public static final ThreadLocal<UserInfo> USER_INFO_THREAD_LOCAL = new ThreadLocal();
}

拦截器伪代码

java 复制代码
//执行前 存储
public boolean postHandle(HttpServletRequest request)  {
    //解析token获取用户信息
	String token = request.getHeader("token");
	UserInfo userInfo = parseToken(token);   
	//存入
	ContextUtils.USER_INFO_THREAD_LOCAL.set(userInfo);
    
    return true;
}


//执行后 删除
public void postHandle(HttpServletRequest request)  {
    ContextUtils.USER_INFO_THREAD_LOCAL.remove();
}

使用时

java 复制代码
//提交订单
public void orderSubmit(){
    //获取用户信息
    UserInfo userInfo = ContextUtils.USER_INFO_THREAD_LOCAL.get();
    //下单
    submit(userInfo);
    //删除购物车
    removeCard(userInfo);
}

为了更好的使用ThreadLocal,我们应该了解其实现原理,避免使用不当造成意外发生

ThreadLocalMap

Thread 线程中有两个字段存储ThreadLocal的内部类ThreadLocalMap

java 复制代码
public class Thread implements Runnable {    
    
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

threadLocals用于实现ThreadLocal

inheritableThreadLocals 用于实现InheritableThreadLocal (可继承的ThreadLocal 后文再聊)

ThreadLocalMap 的实现是哈希表,其内部类Entry是哈希表的节点,由Entry数组实现哈希表 ThreadLocalMap

java 复制代码
public class ThreadLocal<T> {
    //,,,
	static class ThreadLocalMap {
        //...
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;

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

节点构造中的Key是ThreadLocal,Value是需要存储的值

同时节点继承弱引用,通过泛型和构造可以知道它将ThreadLocal设置为弱引用

不理解弱引用的同学可以查看这篇文章:深入浅出JVM(十四)之内存溢出、泄漏与引用 )

set

在存储数据的方法中

获取ThreadLocalMap,如果没有则初始化ThreadLocalMap(懒加载)

java 复制代码
public void set(T value) {
    //获取当前线程
    Thread t = Thread.currentThread();
    //获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
   
    if (map != null) {
        //添加数据
        map.set(this, value);
    } else {
        //没有就初始化
        createMap(t, value);
    }
}

createMap

创建ThreadLocalMap赋值给当前线程的threadLocals

java 复制代码
	void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

创建ThreadLocalMap,初始化长度为16的数组

java 复制代码
	ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //初始化数组 16
        table = new Entry[INITIAL_CAPACITY];
        //获取下标
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        //构建节点
        table[i] = new Entry(firstKey, firstValue);
        //设置大小
        size = 1;
        //设置负载因子
        setThreshold(INITIAL_CAPACITY);
   }

ThreadLocalMap.set

通过哈希获取下标,当发生哈希冲突时,遍历哈希表(不再使用链地址法)直到位置上没有节点再进行构建

遍历期间如果有节点,则根据节点取出key进行比较,如果是则是覆盖;如果节点没有key说明该节点的ThreadLocal被回收(已过期),为了防止内存泄漏会清理节点

最后会检查其他位置有没有已过期的节点进行清理,并检查扩容

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)]) {
        //获取key
        ThreadLocal<?> k = e.get();
		//key如果存在则覆盖
        if (k == key) {
            e.value = value;
            return;
        }
		//如果key不存在 说明该ThreadLocal以及不再使用(GC回收),需要清理防止内存泄漏
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    //构建节点
    tab[i] = new Entry(key, value);
    //计数
    int sz = ++size;
    //清理其他过期的槽,如果满足条件进行扩容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

获取哈希值时,使用哈希值自增的原子类获取,步长则是每次自增的数量(也许是经过研究、测试的,尽量减少哈希冲突)

java 复制代码
	//获取哈希值
    private final int threadLocalHashCode = nextHashCode();
    //哈希值自增器
    private static AtomicInteger nextHashCode =
        new AtomicInteger();
    //增长步长
    private static final int HASH_INCREMENT = 0x61c88647;

    //获取哈希值
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

nextIndex是获取下一个下标,超出上限时回到0

java 复制代码
private static int nextIndex(int i, int len) {
      return ((i + 1 < len) ? i + 1 : 0);
}

get

在获取数据时

获取当前线程的ThreadLocalMap,如果为空则初始化,否则获取节点

java 复制代码
	public T get() {
        //获取当前线程
        Thread t = Thread.currentThread();
        //获取线程的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            //获取节点
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //初始化(懒加载)
        return setInitialValue();
    }

在获取节点时,先根据哈希值获取到下标,再查看节点,比较key;如果匹配不上则说明key过期可能发生内存泄漏要去清理哈希表

java 复制代码
	    private Entry getEntry(ThreadLocal<?> key) {
            //获取下标
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            //如果匹配 则返回
            if (e != null && e.get() == key)
                return e;
            else
                //匹配不到 去清理
                return getEntryAfterMiss(key, i, e);
        }

内存泄漏

在设置、获取数据的过程中,都会去判断key是否过期,如果过期就清理

实际上ThreadLocal使用不当是会造成内存泄漏的

设计者为了避免使用不当导致的内存泄漏,在常用方法中尽量清理这些过期的ThreadLocal

前文说过节点继承弱引用,在构造中设置key为弱引用(也就是ThreadLocal)

当ThreadLocal在任何地方都不被使用时,下次GC会回收,此后节点的key为空

如果value也不再使用,但是由于节点Entry(null,value)存在,就无法回收value,导致出现内存泄漏

因此使用完数据后,尽量使用remove进行删除

并且设计者在set、get、remove等常用方法中都会检查key为空的节点并删除,避免内存泄漏

设计思想

为什么要把entry中的key,也就是ThreadLocal设置成弱引用?

我们先想象一个场景:线程在我们的服务中经常重复利用,而在某些场景下ThreadLocal并不长期使用

如果节点entry 的key、value都是强引用,一但不再使用ThreadLocal,那么这个ThreadLocal还作为强引用存储在节点中,那么就无法回收,相当于发生内存泄漏

把ThreadLocal设置为弱引用后,这种场景下如果value也不再使用依旧会发生内存泄漏,因此在set、get、remove方法中都会区检查删除key为空的节点,避免内存泄漏

既然value可能无法回收,为什么不把value也设置成弱引用?

由于value存储的是线程隔离的数据,如果将value设置成弱引用,当外层也不使用value对应的对象时,它就没有强引用了,再下次gc被回收,导致数据丢失

InheritableThreadLocal

InheritableThreadLocal 继承 ThreadLocal 用于父子线程间的线程变量传递

java 复制代码
	public void testInheritableThreadLocal(){
        InheritableThreadLocal<String> itl = new InheritableThreadLocal<>();

        itl.set("main");

        new Thread(()->{
            //main
            System.out.println(itl.get());
        }).start();
    }

前文说过线程中另一个ThreadLocalMap就是用于InheritableThreadLocal 的

在创建线程时,如果父线程中inheritableThreadLocals 不为空 则传递

java 复制代码
    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        //....
    
        //如果父线程中inheritableThreadLocals 不为空 则传递
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        
    }

总结

ThreadLocal 用于隔离线程间的数据,可以存储数据作用在上下文中,由于线程可能重复利用,使用后需要删除,避免出现数据混乱

Thread线程中存储ThreadLocalMap,ThreadLocalMap是一个使用开放定址法解决哈希冲突的哈希表,其中节点存储Key是ThreadLocal,Value存储的是线程要存储数据

节点继承弱引用,并设置ThreadLocal为弱引用,这就导致当ThreadLocal不再使用时,下次GC会将其回收,此时Key为空,如果Value也不再使用,但是节点未删除就会导致value被使用,从而导致内存泄漏

在ThreadLocal的set、get、remove等常用方法中,遍历数组的同时还回去将过期的节点(key为空)进行删除,避免内存泄漏

如果将ThreadLocal设置成强引用,当ThreadLocal不再使用时会发生内存泄漏;将ThreadLocal设置成弱引用时,虽然也可能发生内存泄漏,但可以在常用方法中检查并清理这些数据;如果将value设置成弱引用,当外层不使用value时会发生数据丢失

InheritableThreadLocal继承ThreadLocal ,用于父子线程间的ThreadLocal数据传递

最后(不要白嫖,一键三连求求拉~)

本篇文章被收入专栏 由点到线,由线到面,深入浅出构建Java并发编程知识体系,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 gitee-StudyJavagithub-StudyJava 感兴趣的同学可以stat下持续关注喔~

案例地址:

Gitee-JavaConcurrentProgramming/src/main/java/G_ThreadLocal

Github-JavaConcurrentProgramming/src/main/java/G_ThreadLocal

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

关注菜菜,分享更多干货,公众号:菜菜的后端私房菜

相关推荐
CopyLower23 分钟前
在 Spring Boot 中实现 WebSockets
spring boot·后端·iphone
24k小善1 小时前
Flink TaskManager详解
java·大数据·flink·云计算
想不明白的过度思考者1 小时前
Java从入门到“放弃”(精通)之旅——JavaSE终篇(异常)
java·开发语言
天天扭码1 小时前
总所周知,JavaScript中有很多函数定义方式,如何“因地制宜”?(ˉ﹃ˉ)
前端·javascript·面试
.生产的驴1 小时前
SpringBoot 封装统一API返回格式对象 标准化开发 请求封装 统一格式处理
java·数据库·spring boot·后端·spring·eclipse·maven
猿周LV1 小时前
JMeter 安装及使用 [软件测试工具]
java·测试工具·jmeter·单元测试·压力测试
景天科技苑1 小时前
【Rust】Rust中的枚举与模式匹配,原理解析与应用实战
开发语言·后端·rust·match·enum·枚举与模式匹配·rust枚举与模式匹配
晨集1 小时前
Uni-App 多端电子合同开源项目介绍
java·spring boot·uni-app·电子合同
时间之城1 小时前
笔记:记一次使用EasyExcel重写convertToExcelData方法无法读取@ExcelDictFormat注解的问题(已解决)
java·spring boot·笔记·spring·excel
椰羊~王小美2 小时前
LeetCode -- Flora -- edit 2025-04-25
java·开发语言