JUC多线程:ThreadLocal 原理总结

Posted 张维鹏

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JUC多线程:ThreadLocal 原理总结相关的知识,希望对你有一定的参考价值。

1、什么是 ThreadLocal:

        ThreadLocal 提供了线程内部的局部变量,当在多线程环境中使用 ThreadLocal 维护变量时,会为每个线程生成该变量的副本,每个线程只操作自己线程中的变量副本,不同线程间的数据相互隔离、互不影响,从而保证了线程的安全。

        ThreadLocal 适用于无状态,副本变量独立后不影响业务逻辑的高并发场景,如果业务逻辑强依赖于变量副本,则不适合用 ThreadLocal 解决,需要另寻解决方案。

2、ThreadLocal 的数据结构:

        在 JDK8 中,每个线程 Thread 内部都维护了一个 ThreadLocalMap 的数据结构,ThreadLocalMap 中有一个由内部类 Entry 组成的 table 数组,Entry 的 key 就是线程的本地化对象 ThreadLocal,而 value 则存放了当前线程所操作的变量副本。每个 ThreadLocal 只能保存一个副本 value,并且各个线程的数据互不干扰,如果想要一个线程保存多个副本变量,就需要创建多个ThreadLocal。

        一个 ThreadLocal 的值,会根据线程的不同,分散在 N 个线程中,所以获取 ThreadLocal 的 value,有两个步骤:

  • 第一步,根据线程获取 ThreadLocalMap

  • 第二步,根据自身从 ThreadLocalMap 中获取值,所以它的 this 就是 Map 的 Key

当执行 set() 方法时,其值是保存在当前线程的 ThreadLocal 变量副本中,当执行get() 方法中,是从当前线程的 ThreadLocal 的变量副本获取。所以对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了线程的隔离,互不干扰。

3、ThreadLocal 的核心方法:

ThreadLocal 对外暴露的方法有4个:

  • initialValue()方法:返回为当前线程初始副本变量值。

  • get()方法:获取当前线程的副本变量值。

  • set()方法:保存当前线程的副本变量值。

  • remove()方法:移除当前前程的副本变量值

3.1、set()方法:

// 设置当前线程对应的ThreadLocal值
public void set(T value) {
    Thread t = Thread.currentThread(); // 获取当前线程对象
    ThreadLocalMap map = getMap(t);
    if (map != null) // 判断map是否存在
        map.set(this, value); 
        // 调用map.set 将当前value赋值给当前threadLocal。
    else
        createMap(t, value);
        // 如果当前对象没有ThreadLocalMap 对象。
        // 创建一个对象 赋值给当前线程
}

// 获取当前线程对象维护的ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
// 给传入的线程 配置一个threadlocals
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

执行流程:

  • 获得当前线程,根据当前线程获得 map。

  • 如果 map 不为空,则将参数设置到 map 中,当前的 Threadlocal 作为 key。

  • 如果 map 为空,则给该线程创建 map,设置初始值。

3.2、get()方法:

public T get() {
    Thread t = Thread.currentThread();//获得当前线程对象
    ThreadLocalMap map = getMap(t);//线程对象对应的map
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);// 以当前threadlocal为key,尝试获得实体
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 如果当前线程对应map不存在
    // 如果map存在但是当前threadlocal没有关连的entry。
    return setInitialValue();
}

// 初始化
private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

执行流程:

  • (1)先尝试获得当前线程,再根据当前线程获取对应的 map

  • (2)如果获得的 map 不为空,以当前 threadlocal 为 key 尝试获得 entry

  • (3)如果 entry 不为空,返回值。

  • (4)如果 2 跟 3 出现无法获得,则通过 initialValue 函数获得初始值,然后给当前线程创建新 map

3.3、remove()方法:

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

执行流程:

首先尝试获取当前线程,然后根据当前线程获得map,从map中尝试删除enrty。

3.4、initialValue() 方法:

protected T initialValue() {
    return null;
}

执行流程:

  • (1)如果没有调用 set() 直接 get(),则会调用此方法,该方法只会被调用一次,

  • (2)默认返回一个缺省值null,如果不想返回null,可以Override 进行覆盖。

4、ThreadLocal 的哈希冲突的解决方法:线性探测

        和 HashMap 不同,ThreadLocalMap 结构中没有 next 引用,也就是说 ThreadLocalMap 中解决哈希冲突的方式并非链表的方式,而是采用线性探测的方式,当发生哈希冲突时就将步长加1或减1,寻找下一个相邻的位置。如下图所示:

流程说明:

  • 根据 ThreadLocal 对象的 hash 值,定位到 table 中的位置 i;

  • 如果当前位置是 null,就初始化一个 Entry 对象放在位置 i 上;

  • 如果位置 i 已经有 Entry 对象了,如果这个 Entry 对象的 key 与即将设置的 key 相同,那么重新设置 Entry 的 value;

  • 如果位置 i 的 Entry 对象和 即将设置的 key 不同,那么寻找下一个空位置;

具体源码如下:

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)]) { // 开放定址法解决哈希冲突
        ThreadLocal<?> k = e.get();

        //直接覆盖
        if (k == key) {
            e.value = value;
            return;
        }

        if (k == null) {// 如果key不是空value是空,垃圾清除内存泄漏防止。
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 如果ThreadLocal对应的key不存在并且没找到旧元素,则在空元素位置创建个新Entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

// 环形数组 下一个索引
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

5、ThreadLocal 的内存泄露:

        在使用 ThreadLocal 时,当使用完变量后,必须手动调用 remove() 方法删除 entry 对象,否则会造成 value 的内存泄露,严格来说,ThreadLocal 是没有内存泄漏问题,有的话,那也是忘记执行 remove() 引起的,这是使用不规范导致的。

        不过有些人认为 ThreadLocal 的内存泄漏是跟 Entry 中使用弱引用 key 有关,这个结论是不对的。ThreadLocal 造成内存泄露的根本原因并不是 key 使用弱引用,因为即使 key 使用强引用,也会造成 Entry 对象的内存泄露,内存泄露的根本原因在于 ThreadLocalMap 的生命周期与当前线程 CurrentThread 的生命周期相同,且 ThreadLocal 使用完没有进行手动删除导致的。下面我们就针对两种情况进行分析:

5.1、如果 key 使用强引用:

如果在业务代码中使用完 ThreadLocal,则此时 Stack 中的 ThreadLocalRef 就会被回收了。

但是此时 ThreadLocalMap 中的 Entry 中的 Key 是强引用 ThreadLocal 的,会造成 ThreadLocal 实例无法回收。

如果我们没有删除 Entry 并且 CurrentThread 依然运行的情况下,强引用链如下图红色,会导致Entry内存泄漏。

 所以结论就是:强引用无法避免内存泄漏。

5.2、如果 key 使用弱引用

如果在业务代码中使用完 ThreadLocal,则此时 Stack 中的 ThreadLocalRef 就会被回收了。

但是此时 ThreadLocalMap 中的 Entry 中的 Key 是弱引用 ThreadLocal 的,会造成 ThreadLocal 实例被回收,此时 Entry 中的 key = null。

但是当我们没有手动删除 Entry 以及 CurrentThread 依然运行的时候,还是存在强引用链,但因为 ThreadLocalRef 已经被回收了,那么此时的 value 就无法访问到了,导致value内存泄漏

 所以结论就是:弱引用也无法避免内存泄漏

5.3、内存泄露的原因:

从上面的分析知道内存泄漏跟强弱引用无关,内存泄漏的前提有两个:

ThreadLocalRef 用完后 Entry 没有手动删除。

ThreadLocalRef 用完后 CurrentThread 依然在运行。

  • 第一点表明当我们在使用完 ThreadLocal 后,调用其对应的 remove() 方法删除对应的 Entry 就可以避免内存泄漏

  • 第二点是由于 ThreadLocalMap 是 CurrentThread 的一个属性,被当前线程引用,生命周期跟 CurrentThread 一样,如果当前线程结束 ThreadLocalMap 被回收,自然里面的 Entry 也被回收了,但问题是此时的线程不一定会被回收,比如线程是从线程池中获取的,用完后就放回池子里了

所以,我们可以得出在这小节开头的结论:ThreadLocal 内存泄漏根源是 ThreadLocalMap 的生命周期跟 Thread 一样,如果用完 ThreadLocal 没有手动删除就会内存泄漏。

5.4、为什么使用弱引用:

        前面讲到 ThreadLocal 的内存泄露与强弱引用无关,那么为什么还要用弱引用呢?

(1)Entry 中的 key(Threadlocal)是弱引用,目的是将 ThreadLocal 对象的生命周期跟线程周期解绑,用 WeakReference 弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

(2)当我们使用完 ThreadLocal,而 Thread 仍然运行时,即使忘记调用 remove() 方法, 弱引用也会比强引用多一层保障:当 GC 发生时,弱引用的 ThreadLocal 被收回,那么 key 就为 null 了。而 ThreadLocalMap 中的 set()、get() 方法,会针对 key == null (也就是 ThreadLocal 为 null) 的情况进行处理,如果 key == null,则系统认为 value 也应该是无效了应该设置为 null,也就是说对应的 value 会在下次调用 ThreadLocal 的 set()、get() 方法时,执行底层 ThreadLocalMap 中的 expungeStaleEntry() 方法进行清除无用的 value,从而避免内存泄露。

6、ThreadLocal 的应用场景:

(1)Hibernate 的 session 获取:每个线程访问数据库都应当是一个独立的 session 会话,如果多个线程共享同一个 session 会话,有可能其他线程关闭连接了,当前线程再执行提交时就会出现会话已关闭的异常,导致系统异常。使用 ThreadLocal 的方式能避免线程争抢session,提高并发安全性。

(2)Spring 的事务管理:事务需要保证一组操作同时成功或失败,意味着一个事务的所有操作需要在同一个数据库连接上,Spring 采用 Threadlocal 的方式,来保证单个线程中的数据库操作使用的是同一个数据库连接,同时采用这种方式可以使业务层使用事务时不需要感知并管理 connection 对象,通过传播级别,巧妙地管理多个事务配置之间的切换,挂起和恢复。

7、如果想共享线程的 ThreadLocal 数据怎么办

        使用 InheritableThreadLocal 可以实现多个线程访问 ThreadLocal 的值,我们在主线程中创建一个 InheritableThreadLocal 的实例,然后在子线程中得到这个InheritableThreadLocal实例设置的值。

private void test() {    
final ThreadLocal threadLocal = new InheritableThreadLocal();       
threadLocal.set("主线程的ThreadLocal的值");    
Thread t = new Thread() {        
    @Override        
    public void run() {            
      super.run();            
      Log.i( "我是子线程,我要获取其他线程的ThreadLocal的值 ==> " + threadLocal.get());        
    }    
  };          
  t.start(); 
} 

8、为什么一般用 ThreadLocal 都要用 static?

        ThreadLocal 能实现线程的数据隔离,不在于它自己本身,而在于 Thread 的 ThreadLocalMap,所以,ThreadLocal 可以只实例化一次,只分配一块存储空间就可以了,没有必要作为成员变量多次被初始化。

参考文章:https://juejin.cn/post/6890446289411145741

以上是关于JUC多线程:ThreadLocal 原理总结的主要内容,如果未能解决你的问题,请参考以下文章

[Java复习] 多线程 并发 JUC 补充

多线程总结-JUC中常用的工具类

Juc17_ThreadLocal概述解决SimpleDateFormat出现的异常内存泄漏弱引用remove方法

Java多线程:JUC(上)

JUC包实现的同步机制,原理以及简单用法总结

Java多线程系列--“JUC线程池”03之 线程池原理