深入理解ThreadLocal,线程:别塞了别塞了...

272 阅读12分钟

我正在参加「掘金·启航计划」

1、概述

ThreadLocal叫做“线程变量”,其中填充的变量属于当前线程,对其他线程是隔离的,不会被别的线程读取或修改。这种思路叫线程封闭。

原理是,每个线程内部都有一个ThreadLocalMap,它用ThreadLocal作为键,能够存储值。

ThreadLocal的适用场景:

  • 每个线程需要有自己的实例,且该实例需要在多个方法间传递,但不希望线程间共享。
  • 实例需要在多个方法间传递,就可以保存在当前线程的ThreadLocal中,就不需要通过参数传递了。需要时直接get()取出。

2、ThreadLocal与sync的区别

它们都用于解决多线程的并发访问,区别是:

  • synchronized用于线程间的数据共享,而ThreadLocal用于线程内的数据共享,线程间的数据隔离

  • synchronized利用锁机制,让变量或代码块在同一时刻只能被一个线程访问。

    而ThreadLocal不涉及到锁,它在每个线程内都提供了变量的副本,每个线程只能操作自己内部的副本,避免了共享问题。

3、使用方法

ThreadLocal的变量通常用private static修饰

public class ThreadLocalDemo {
    private static ThreadLocal<String> localVar = new ThreadLocal<>();
​
    //打印出本线程内的localVar值
    public static void printLocalVar(String str){
        System.out.println(str + " " + localVar.get());
        //清除本地内存中的本地变量
        localVar.remove();
    }
​
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            ThreadLocalDemo.localVar.set("t1的localVar");
            printLocalVar("t1");
        }, "t1");
        t1.start();
​
        Thread.sleep(1000);
​
        Thread t2 = new Thread(() -> {
            ThreadLocalDemo.localVar.set("t2的localVar");
            printLocalVar("t2");
        }, "t2");
        t2.start();
    }
}

image-20220315083206920

注意:

  • ThreadLocal的泛型是Object的,可以定义成map、set、list等等
  • 一个类中可以定义多个ThreadLocal属性

4、原理分析

1、set方法

image-20220315083357613

赋值过程:

  • 首先获取当前线程对象,并获取当前线程的ThreadLocalMap
  • 如果这个map为null,就调用createMap()方法初始化map,初始化需要传入当前线程对象和要存储的值
  • 如果map不为null,就调用ThreadLocalMap的set方法,它会将当前线程对象作为键,存储值。

createMap()

image-20220817201457308

这里可以看出,线程对象Thread对ThreadLocalMap是强引用。

2、ThreadLocalMap的set()

1、概述

这个方法本身非常简单,但包含很多优化,导致最终的方法逻辑其实非常复杂。

image-20220817202403956

这个方法要做的事情是,往ThreadLocalMap自身中插入一个键值对,Key为ThreadLocal类型,Value为指定泛型。

  • 首先,获取当前ThreadLocalMap的Entry数组,然后得到数组长度

  • 获取当前Key的threadLocalHashCode属性,然后和 当前数组长度-1 做与运算,得到当前Key对应的数组下标。

    从这里可以看出,ThreadLocalMap的数组长度应该也是和HashMap一样,固定为2的幂次。

  • 之后,获取该下标对应的数组元素

    • 如果不为null就进行开放定址,做法是:如果下标没有超过数组长度,就+1;如果超过了就置为0

    • 对于不为null的数组元素,也就是Entry,会进行两个判断:

      • 查它的Key是否等于当前Key,等于就说明是替换操作,将Entry的Value置为当前的Value,插入成功

      • 检查它的Key是否为null,如果为null,说明Key已经被GC,调用replaceStaleEntry()方法把Key和Value存放进去,插入成功

        所以,在刚遇到被淘汰的Key时就会把新的键值对给替换进去,而不是仅仅把过期Entry占用的空间释放掉。

  • 如果两个条件一直都不符合,就一直往后查找,找到目标数组元素后,把Key和Value存入

  • 理论上当前的数组容量为之前的size+1,可以直接检查是否大于扩容阈值。

    但是由于可能存在被GC掉的Key,所以先调用cleanSomeSlots()来判断是否存在过期的Entry,如果没有再去判断是否大于扩容阈值

    如果需要扩容,就调用rehash()进行扩容操作

2、什么是threadLocalHashCode

这是ThreadLocal类的成员属性,用于充当Key的Hash值,仅用于ThreadLocalMap。

image-20220817203316789

可见,在ThreadLocal每次实例化时,会调用nextHashCode()方法

image-20220817203445702

这里的nextHashCode是ThreadLocal类的静态属性,它是一个原子整数

image-20220817203531157

所以:

  • threadLocalHashCode其实就是一个原子整数,它是ThreadLocal的静态属性,不同对象间是共享的。
  • 每次new一个ThreadLocal,会在原先nextHashCode的基础上,增加HASH_INCREMENT,作为当前ThreadLocal的threadLocalHashCode

为什么设计threadLocalHashCode,为啥不用Object的hashcode()方法

由于threadLocalHashCode是个静态的原子整数,所以在同一个线程内创建多个ThreadLocal,它们的threadLocalHashCode肯定不会重复,而且差值就是固定好的HASH_INCREMENT。而在不同的线程之间,显然不会影响。

官方的说法是,这样做是为了消除同一个线程下连续创建多个ThreadLocal对象可能存在的哈希冲突问题。

可以这么理解:

  • 连续创建多个ThreadLocal对象,它们的内存地址就很可能是连续的,导致算出的hashcode也是连续的

    因为ThreadLocal没有重写hashcode()方法,Object的hashcode()方法是,把对象的内存地址转换成整数来作为hashcode。

  • 重点来了,由于ThreadLocalMap想要进行防止内存泄露的优化,所以使用开放定址法来解决哈希冲突,这就要求Key最好不要连续存放,以免连续访问好几个Entry都有数据,降低插入和查找的效率。

  • 因此,ThreadLocal使用了自定义的threadLocalHashCode作为hashcode,每个值的间隔是HASH_INCREMENT。据官方说,在长度为2的幂次的表上,这是最适合开放定址法效率的间隔大小。

3、replaceStaleEntry()

这个方法的作用是,用指定的键值对来替换目标索引下的Entry内容。

image-20220817211725535

它被调用时,是出现了哈希冲突,然后往后查找,遇到的第一个过期Entry,将它的下标传入了方法。

但是这个方法的设计思想并不是对号入座,而是一次性检查所有失效的Entry,然后顺带把新的键值对放到第一个过期的Entry中。

所以方法内部的逻辑是:

  • 当前找到的过期Entry的下标是staleSlot,它作为参数传入,含义是“过期的槽”。

  • 首先要找到slotToExpunge,含义是“准备删除的槽”。

    • 它最开始就是staleSlot
    • 先从staleSlot开始,往左遍历数组,找到数组从左往右的第一个过期Entry,记录它的下标,作为slotToExpunge。
  • 之后,从staleSlot的下一个下标开始,往右遍历数组,检查每个Entry的Key。

    • 如果当前Entry的Key等于当前的Key,说明是一次更新操作,把当前找到的过期Entry的Value置为新的Value,然后把该过期Entry和当前Entry的存储位置调换一下。

      并且,如果slotToExpunge等于staleSlot,就把slotToExpunge置为当前下标,因为发生了交换,此时当前下标对应的是原先staleSlot位置的过期Entry。

4、cleanSomeSlots()

2、ThreadLocalMap

createMap()

image-20220315083922560

其实线程对象的threadLocals属性,就是用来存储ThreadLocalMap的。

查看ThreadLocalMap源码,很长,截取一部分

image-20220315084313150

ThreadLocalMap是ThreadLocal的静态内部类。它用Entry保存数据,而且继承了弱引用。

在构造器中,调用父类构造,来弱引用连接Key

Entry内部使用ThreadLocal类型的变量作为键,保存传入的值。

ThreadLocalMap如何工作

image-20220315091630617

这个Map使用哈希确定下标,将值保存在数组中,类似于HashMap。但没有实现Map接口,也没有链表结构。

一个线程只有一个ThreadLocalMap,但是可以创建多个ThreadLocal字段,所以需要使用数组存储每个Entry。

不使用链表,它解决哈希冲突的方式是,找空隙:

  • 要插入一组数据,根据ThreadLocal对象的哈希值,计算出一个下标
  • 如果该下标对应的位置是空的,就初始化一个Entry,存入数据
  • 如果不为空,就检查它的key,如果正好和要存入的key一样,此次是覆盖操作,直接替换Value
  • 如果不为空且key不符,说明出现了哈希冲突,就找下一个空的位置,继续判断,直到成功插入
  • 在get的时候也是,如果下标中的key不符,说明插入时有哈希冲突,就找下一个位置,直到找到key

3、get方法

image-20220315084655443

获取流程:

  • 获取当前线程对象
  • 如果map不为null,就通过ThreadLocal对象,取出对应的Entry
  • 如果entry不为空,就获取Entry中的Value,返回。
  • 如果前一步中map为空,就调用setInitialValue()方法

setInitialValue()

这个方法是给ThreadLocal设置初始值

image-20220315084929567

4、remove方法

image-20220315085046767

将ThreadLocal的值,从当前线程的ThreadLocalMap中删除。

5、总结

ThreadLocal的值,存储在当前线程对象的threadLocals属性中,这个属性对应一个ThreadLocalMap对象,在第一次调用ThreadLocal的set方法时被初始化。

ThreadLocalMap保存对象的策略是,以ThreadLocal为键,映射存储值。

这个ThreadLocal是多线程共享的,而ThreadLocalMap是线程私有的,所以每个线程都可以根据ThreadLocal存储不同的值,别的线程也无法获取到。

5、使用场景

1、Spring实现事务隔离级别

Spring使用ThreadLocal的方式,保证一个线程中的数据库操作都是使用的同一个连接对象

private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);
​
private static final ThreadLocal<Map<Object, Object>> resources =
    new NamedThreadLocal<>("Transactional resources");
​
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
    new NamedThreadLocal<>("Transaction synchronizations");
​
private static final ThreadLocal<String> currentTransactionName =
    new NamedThreadLocal<>("Current transaction name");
​
……

2、解决日期的线程安全

项目中有部分用户的时间出错,发现是多个线程共享一个SimpleDataFormat的问题。

使用SimpleDataFormat的parse()方法,内部有一个Calendar对象,调用SimpleDataFormat的parse()方法会先调用Calendar.clear(),然后调用Calendar.add()。

如果一个线程先调用了add()然后另一个线程又调用了clear(),这时候parse()方法解析的时间就不对了。

但是每个线程内部都new一个SimpleDataFormat对象也不太好,所以使用ThreadLocal包装SimpleDataFormat,解决了线程安全的问题。

3、多个方法调用

一个线程经常需要横跨多个方法调用,那么它的参数就必须层层传递,给每个方法都加上相同的参数不太优雅。

而且,如果中间遇到第三方类库,参数就无法传递了。可以使用ThreadLocal,开始时把参数存进去,需要时直接get取出即可。

4、JDBC的数据库连接

从数据库连接池里获取的连接 Connection,在 JDBC 规范里并没有要求这个 Connection 必须是线程安全的。

数据库连接池通过线程封闭技术,保证一个 Connection 一旦被一个线程获取之后,在这个线程关闭 Connection 之前的这段时间里,不会再分配给其他线程,从而保证了 Connection 不会有并发问题

6、ThreadLocal 的内存泄漏问题

1、为什么存在内存泄漏问题

在“阅后即焚”的线程中,在Thread中使用了ThreadLocal不会有内存泄漏的问题,因为线程执行完就会被销毁,释放它占用的所有资源

但是在线程池中,线程会重用,线程对象持有ThreadLocalMap,这是强引用的,所以每个线程的ThreadLocalMap都会存活到线程销毁为止。

由于无法确认线程池的具体用途,比如可能有的业务需要使用ThreadLocal,有的不需要,而且线程使用哪个ThreadLocal作为Key也是未知。所以,如果一直不remove,持续添加ThreadLocal,就会引发OOM。

ThreadLocal内存泄漏隐患的本质

ThreadLocalMap 的生命周期和 线程对象Thread 一样长,如果没有手动删除,就会导致每个Value占用的value一直存在。

2、Key和Value的释放时机

Key会被GC掉

ThreadLocalMap中,Entry的Key是ThreadLocal对象的弱引用。如果一个对象只存在弱引用,那它在下一次GC中一定会被清理。

所以,如果ThreadLocal对象没有外部的强引用,在垃圾回收时,Entry的key会被清理掉,所以key变为null。

但是Entry的Value是强引用,不会被GC回收掉。

为什么ThreadLocal对象可能没有外部的强引用?因为如果线程被重用,之前的代码会给Thread类的ThreadLocalMap里面存放数据,当前线程不一定需要使用对应的ThreadLocal,所以可能不存在强引用。

Value不会GC掉

在currentThread 存在,并且没有手动remove掉这个entry时,存在一条强引用链:currentThread -> ThreadLocalMap -> entry -> value

因为线程对象通过强引用指向ThreadLocalMap,而ThreadLocalMap也是通过强引用指向Entry,所以Entry的value也是强引用。

key不再使用,被清理掉了,就没有任何途径能访问到这个value,所以value属于垃圾。

所以,只是将Key弄成弱引用,并不能保证ThreadLocal肯定不会发生内存泄漏,因为Value是强引用的,不会被自动GC。

Value什么时候被释放

官方建议,使用完ThreadLocal的值后,就手动调用remove()方法,把对应的值清理掉,这样value就置为null,能被垃圾回收了。

但是,如果不去remove,久而久之就肯定会内存泄漏,ThreadLocalMap在实现时考虑了这种情况。因此调用set()、get()时,会清理掉Key为null的Entry对象

能这么做的原因就是Key是弱引用,所以它被GC掉之后就会变为null,所以可以根据key是否为null来判断Value是否需要移除。

这属于多了一层保障,不手动remove,Value占用的空间也有机会被释放

调用set、get清理对象具体的流程是:

  • 调用ThreadLocal的get(),它会先获取当前线程对象的ThreadLocalMap
  • 调用ThreadLocalMap的getEntry(),它会调用哈希函数,计算出一个数组下标。
  • 如果发生了哈希冲突(下标的key不等于所需的key),就调用getEntryAfterMiss()
  • ThreadLocalMap使用开放定址法解决哈希冲突,即向后寻找所需的key值。
  • 期间如果遇到key为null的Entry,就会调用expungeStaleEntry(),将key为null的Entry的value也设置为null

3、Key和Value引用的设计思想

为什么Key弄成弱引用

因为ThreadLocal的设计思想其实有些暴力,直接往线程对象中塞数据,如果考虑不周到就很容易OOM。

这属于官方的巧妙优化手段,目的是尽量减少内存泄漏。

这个优化思路做了两件事情:

  • 把Key定义成弱引用,当ThreadLocal没有外部强引用时,说明当前线程的代码不需要线程中的这个数据,下次GC它就会被垃圾回收,对应的Entry的Key将置为null。
  • 每次get()或set(),如果发生哈希冲突就会进行开放定址,期间会把遇到的Key为null的Entry的Value置为null,这样这个Entry就能被回收了。

这样,即使不去调用remove()方法,也有机会从线程对象中清理掉无用数据。

但是,把Key做成弱引用可无法完全避免内存泄漏,最靠谱的当然还是手动remove()

为什么Value不弄成弱引用

如果把Value设置成弱引用,在不存在其他引用的时候,Value就会被GC掉,变成null。

但是假如Key所引用的 ThreadLocal 对象还被其他的引用对象强引用着,说明当前线程需要使用到这个ThreadLocal ,那么这个 ThreadLocal 对象就不会被 GC 回收。但是此时如果再去获取Value,就会获取到null,产生空指针问题。

显然这个逻辑是有问题的。因为我们引用了Key,肯定是希望通过Key来获取到Value,所以Value不能被无缘无故GC掉。所以Value弄成了强引用。

7、常见问题

1、ThreadLocal对象存放在哪里

Java中,栈内存是线程私有的,堆内存是线程共享的。

ThreadLocal对象存放在堆上。

2、如何共享ThreadLocal数据

使用InheritableThreadLocal,可以实现主线程和子线程共享ThreadLocal数据。

在主线程new一个InheritableThreadLocal的实例,子线程就可以获取到它的值。它也是ThreadLocal类型。

final ThreadLocal threadLocal = new InheritableThreadLocal(); 

如何传递的?

在线程类中,有这样一条属性:

image-20220315092750013

线程初始化时,有这样一个逻辑:

image-20220315092905251

只要它自己的inheritThreadLocals和父线程的inheritThreadLocals都不为null,就把父线程的inheritThreadLocals给到子线程。

这里的parent,是获取的当前线程。因为这个子线程还没有创建,那么调用它初始化方法的线程就是它的父线程。

3、ThreadLocal与Thread、ThreadLocalMap

Thread有一个threadLocals属性,存放一个线程私有的ThreadLocalMap类型变量。

ThreadLocalMap是ThreadLocal的静态内部类。它类似Map,使用Entry存放数据,key为ThreadLocal对象。

ThreadLocal相当于ThreadLocalMap的工具类。调用ThreadLocal对象的get、set方法,底层是在调用ThreadLocalMap的get、set方法

ThreadLocal帮助ThreadLocalMap初始化。

4、请求线程中清理ThreadLocal

请求线程是跑在线程池中的,所以有些线程会被复用。

所以请求的末尾去清理ThreadLocal,一方面是避免内存泄漏,一方面也是为了避免由于线程复用导致信息错乱。