深入详解ThreadLocal

本文已收录至GitHub,推荐阅读 👉 Java随想录

微信公众号:Java随想录

原创不易,注重版权。转载请注明原作者和原文链接

在我们日常的并发编程中,有一种神奇的机制在静悄悄地为我们解决着各种看似棘手的问题,它就是「ThreadLocal」。

这个朴素却强大的工具,许多Java开发者可能并没有真正了解过其内部运作原理和应用场景。

本篇文章,我将和大家一起探索 JDK 中这个独特而又强大的类------ThreadLocal。

透过本文,我们将揭开它神秘的面纱,并深入理解它是如何优雅处理线程级别的数据隔离,以及在实际开发中如何有效地利用它。

话不多说,我们进入正题。

什么是ThreadLocal

ThreadLocal是Java中的一个类,它提供了一种线程绑定机制,可以将状态与线程(Thread)关联起来。每个线程都会有自己独立的一个ThreadLocal变量,因此对该变量的读写操作只会影响到当前执行线程的这个变量,而不会影响到其他线程的同名变量

我们先来看一个简单的ThreadLocal使用示例:

java 复制代码
public class ThreadLocalTest {
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
 
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            threadLocal.set("本地变量1");
            print("thread1");
            System.out.println("线程1的本地变量的值为:"+threadLocal.get());
        });
 
        Thread thread2 = new Thread(() -> {
            threadLocal.set("本地变量2");
            print("thread2");
            System.out.println("线程2的本地变量的值为:"+threadLocal.get());
        });
 
        thread1.start();
        thread2.start();
    }
 
    public static void print(String s){
        System.out.println(s+":"+threadLocal.get());
     
    }

执行结果如下:

makefile 复制代码
thread2:本地变量2
thread1:本地变量1
线程2的本地变量的值为:本地变量2
线程1的本地变量的值为:本地变量1

通过上面的例子,我们可以很轻易的看出,ThreadLocal消除了不同线程间共享变量的需求,可以用来实现「线程局部变量」,从而避免了多线程同步(synchronization)的问题。

OK,下面开始讲解ThreadLocal,讲ThreadLocal之前,我们得先从 Thread 类讲起。

Thread 类中有维护两个 ThreadLocal.ThreadLocalMap 对象,分别是:threadLocalsinheritableThreadLocals

源码如下:

java 复制代码
/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
 * InheritableThreadLocal values pertaining to this thread. This map is
 * maintained by the InheritableThreadLocal class.
 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

初始它们都为 null,只有在调用 ThreadLocal 类的 set 或 get 时才创建它们。ThreadLocalMap可以理解为线程私有的HashMap。

ThreadLoalMap是ThreadLocal中的一个静态内部类,是一个类似HashMap的数据结构,但并没有实现Map接口。

ThreadLoalMap中初始化了一个「大小16的Entry数组」,Entry对象用来保存每一个key-value键值对。key是ThreadLocal对象。

有图有真相,源码中的定义如下:

细心的你肯定发现了,Entry继承了「弱引用(WeakReference)」。在Entry内部使用ThreadLocal作为key,使用我们设置的value作为value。

ThreadLocal 原理

ThreadLocal中我们最常用的肯定是set()get()方法了,所以先从这两个方法入手。

set方法

当我们调用 ThreadLocal 的 set() 方法时实际是调用了当前线程的 ThreadLocalMap 的 set() 方法。

ThreadLocal 的 set() 方法中,会进一步调用Thread.currentThread() 获得当前线程对象 ,然后获取到当前线程对象的ThreadLocalMap,判断是不是为空。

为空就先调用creadMap()创建 ThreadLocalMap 对象,在构造参数里set进变量。

不为空就直接set(value)

这种保证线程安全的方式有个专业术语,称为「线程封闭」,线程只能看到自己的ThreadLocal变量。线程之间是互相隔离的。

get方法

get()方法用来获取与当前线程关联的ThreadLocal的值。

如果当前线程没有该ThreadLocal的值,则调用「initialValue函数」获取初始值返回,所以一般我们使用时需要继承该函数,给出初始值(不重写的话默认返回Null)。

java 复制代码
/**
 * Returns the value in the current thread's copy of this
 * thread-local variable.  If the variable has no value for the
 * current thread, it is first initialized to the value returned
 * by an invocation of the {@link #initialValue} method.
 *
 * @return the current thread's value of this thread-local
 */
public T get() {
    Thread t = Thread.currentThread();
    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();
}

get方法的流程主要是以下几步:

  1. 获取当前的Thread对象,通过getMap获取Thread内的ThreadLocalMap。
  2. 如果map已经存在,以当前的ThreadLocal为键,获取Entry对象,并从从Entry中取出值。
  3. 否则,调用setInitialValue进行初始化。

我们可以重写initialValue(),设置初始值,具体写法如下:

java 复制代码
private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
     @Override
     protected Integer initialValue() {
     return Integer.valueOf(0);
     }
}

推荐设置初始值,如果不设置为null,在某些情况下会引发空指针的问题。

remove方法

最后一个需要探究的就是remove()方法,它用于在map中移除一个不用的Entry。

先计算出hash值,若是第一次没有命中,就循环直到null,在此过程中也会调用「expungeStaleEntry」清除空key节点。代码如下:

java 复制代码
public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
}


/**
 * Remove the entry for key.
 */
private void remove(ThreadLocal<?> key) {
            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)]) {
                if (e.get() == key) {
                    e.clear();
                    expungeStaleEntry(i);
                    return;
                }
            }
}

上面我们看了ThreadLocal的源码,我们知道 ThreadLocalMap 中使用的 key 为 ThreadLocal 的弱引用,弱引用的特点是,如果这个对象只存在弱引用,那么在下一次垃圾回收的时候必然会被清理掉

所以如果 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap中使用这个 ThreadLocal 的 key 也会被清理掉。但是,value 是强引用,不会被清理,这样一来就会出现 key 为 null 的 value。

出现「内存泄漏」的问题。

其实在执行 ThreadLocal 的 set、remove、rehash 等方法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就可以被正常回收了。

但是假设 ThreadLocal 已经不被使用了,那么实际上 set、remove、rehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么刚才的那个调用链就一直存在,也就导致了内存泄漏。

ThreadLocal 的Hash算法

ThreadLocalMap类似HashMap,它有自己的Hash算法。

java 复制代码
private final int threadLocalHashCode = nextHashCode();

private static final int HASH_INCREMENT = 0x61c88647;
  
private static int nextHashCode() {
  return nextHashCode.getAndAdd(HASH_INCREMENT);
}
    
public final int getAndAdd(int delta) {
  return unsafe.getAndAddInt(this, valueOffset, delta);
}

HASH_INCREMENT这个数字被称为「斐波那契数 」 也叫 「黄金分割数」,其中的数学原理我们不去纠结,

我们只需知道用斐波那契数去散列,带来的好处就是 hash分布非常均匀。

每当创建一个ThreadLocal对象,这个ThreadLocal.nextHashCode 这个值就会增长 0x61c88647

讲到Hash就会涉及到Hash冲突,跟HashMap通过「链地址法 」不同的是,ThreadLocal是通过「线性探测法/开放地址法」来解决hash冲突。

ThreadLocal 1.7和1.8的区别

ThreadLocal 1.7版本的时候,entry对象的key是Thread。到了1.8版本entry的key是ThreadLocal。

1.8版本的好处是当Thread销毁的时候,ThreadLocalMap也会随之销毁,减少内存的使用。因为ThreadLocalMap是Thread的内部类,所以只要Thread消失了,那ThreadLocalMap就不复存在了。

ThreadLocal 的问题

ThreadLocal 内存泄露问题

在 ThreadLocalMap 中的 Entry 的 key 是对 ThreadLocal 的 WeakReference 弱引用,而 value 是强引用。

注意构造函数里的第一行代码super(k),这意味着ThreadLocal对象是一个弱引用

java 复制代码
/**
 * The entries in this hash map extend WeakReference, using
 * its main ref field as the key (which is always a
 * ThreadLocal object).  Note that null keys (i.e. entry.get()
 * == null) mean that the key is no longer referenced, so the
 * entry can be expunged from table.  Such entries are referred to
 * as "stale entries" in the code that follows.
 */
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

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

其实要解决也简单,只需要使用完 ThreadLocal 后手动调用 remove() 方法。

但其实在 ThreadLocalMap 的实现中以及考虑到这种情况,因此在调用 set()get()remove() 方法时,也会清理 key 为 null 的记录。

为什么使用弱引用而不是强引用?

为什么采用了弱引用的实现而不是强引用呢?

这个问题在源码的注释上有说明,我们来瞅瞅。

原谅我英语不好,这段话使用的某道翻译,翻译如下:

为了协助处理数据比较大并且生命周期比较长的场景,hash table的entry使用了WeakReference作为key。

所以,貌似看起来弱引用反而是为了解决内存存储问题而专门使用的?

仔细思考一下,实际上,采用弱引用反而多了一层保障。

如果ThreadLocal作为key使用强引用,那么只要ThreadLocal实例本身在内存中,它的entry(包括value)就会始终存在于ThreadLocalMap中,即使线程已经不再需要它的ThreadLocal变量。它们也不会被回收,这导致了一种形式的内存泄漏。

而如果我们使用WeakReference作为key。这意味着当对ThreadLocal实例的所有强引用都被垃圾收集器清除后,它的entry(包括value)也可以从ThreadLocalMap中清除,防止了潜在的内存泄漏。

这样设计让ThreadLocal生命周期的控制权交给了用户,用户可以选择什么时候结束ThreadLocal实例的生命周期。

ThreadLocal被清理后key为null,对应的value在下一次ThreadLocalMap调用set、get时清除,这样就算忘记调用 remove 方法,弱引用比强引用可以多一层保障。

所以,内存泄露的根本原因在于是否手动清除操作,而不是弱引用。

ThreadLocal 父子线程继承

java 复制代码
/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
 * InheritableThreadLocal values pertaining to this thread. This map is
 * maintained by the InheritableThreadLocal class.
 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

Thread类里一共有两个ThreadLocal变量,一个是threadLocals,也就是我们常说的threadlocal,还有一个是inheritableThreadLocals,这个是干啥用的呢?

inheritableThreadLocals是用于父子线程之间继承的,这个变量用来存储那些需要被子线程继承的变量。

并且子线程可以访问和修改这个变量的值,而不会影响到父线程对应变量的值。

异步场景下无法给子线程共享父线程的线程副本数据,可以通过 「InheritableThreadLocal」类解决这个问题。

它的原理就是当新建一个线程对象时,子线程是通过在父线程中调用 new Thread() 创建的,在 Thread 的构造方法中调用了 Thread的init() 方法。

init()方法中父线程的「inheritableThreadLocals」数据会复制到子线程:

java 复制代码
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals) 

下面是一个简单的示例代码:

csharp 复制代码
public class Main {
    public static void main(String[] args) {
        // 在主线程中设置 InheritableThreadLocal
        InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
        inheritableThreadLocal.set("Hello from the main thread");

        // 创建新的线程
        Thread thread = new Thread(() -> {
            // 子线程尝试获取 InheritableThreadLocal 的值
            String value = inheritableThreadLocal.get();
            System.out.println("In child thread, value from InheritableThreadLocal: " + value);
        });

        thread.start();  // 启动子线程
        
        try {
            thread.join();  // 等待子线程结束
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("In main thread, value from InheritableThreadLocal: " + inheritableThreadLocal.get());
    }
}

以上代码首先在主线程中创建了一个 InheritableThreadLocal 对象,并设置了其值。然后,该代码创建并启动了一个新的线程,在新线程中尝试读取 InheritableThreadLocal 的值。因为 InheritableThreadLocal 的值是从父线程继承的,所以新线程能够读取到在主线程中设置的值。

输出如下:

arduino 复制代码
In child thread, value from InheritableThreadLocal: Hello from the main thread
In main thread, value from InheritableThreadLocal: Hello from the main thread

请注意,这种继承是一次性的,只在创建新线程的那一刻发生,之后父子线程对 InheritableThreadLocal 的修改就互不影响了。

同时,由于使用的是浅拷贝,所以如果 InheritableThreadLocal 的值是可变对象,那么依然可能存在多个线程共享数据的情况。

但是我们做异步处理一般使用线程池,线程池会复用线程,所以InheritableThreadLocal 在线程池场景中会失效。

不过网上有开源框架,我们可以使用阿里巴巴的TTL解决这个问题:github.com/alibaba/tra...

探测式清理 & 启发式清理

能把上面那些知识消化完,足够应付90%的面试和工作场景了。

但是也只能让面试官觉得你有点东西,但不是很多。如果想让面试官直呼牛B,那咱就得来聊聊「探测式清理 」和「 启发式清理」了。

ThreadLocal 使用了两种清理无效条目(即键为 null 的条目)的方式:探测式清理和启发式清理。

  • 探测式清理(源码中:expungeStaleEntry() 方法 )
  • 启发式清理(源码中:cleanSomeSlots() 方法 )

探测式清理

这种清理方法基于一个事实:在查找特定键时,如果遇到无效条目(即键为null的条目),可以安全地删除它,因为它肯定不是正在寻找的键。

以下是expungeStaleEntry() 方法的源码:

java 复制代码
private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // expunge entry at staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

简单叙述下源码说了什么:

遍历散列数组,从开始位置(hash得到的位置)向后探测清理过期数据,如果遇到过期数据,则置为null。

如果碰到的是未过期的数据,则将此数据rehash,然后重新在 table 数组中定位。

如果定位的位置已经存在数据,则往后顺延,直到遇到没有数据的位置。

说白了就是:从当前节点开始遍历数组,将key等于null的entry置为null,key不等于null则rehash重新分配位置,若重新分配上的位置有元素则往后顺延。

启发式清理

启发式清理需要接收两个参数:

  1. 探测式清理后返回的数字下标。
  2. 数组总长度。

cleanSomeSlots()源码:

java 复制代码
private boolean cleanSomeSlots(int i, int n) {
            boolean removed = false;
            Entry[] tab = table;
            int len = tab.length;
            do {
                i = nextIndex(i, len);
                Entry e = tab[i];
                if (e != null && e.get() == null) {
                    n = len;
                    removed = true;
                    i = expungeStaleEntry(i);
                }
            } while ( (n >>>= 1) != 0);
            return removed;
        }

根据源码可以看出,启动式清理会从传入的下标 i 处,向后遍历。如果发现过期的Entry则再次触发探测式清理,并重置 n

这个n是用来控制 do while 循环的跳出条件。如果遍历过程中,连续 m 次没有发现过期的Entry,就可以认为数组中已经没有过期Entry了。

这个 m 的计算是 n >>>= 1 ,可以理解为是数组长度的2的几次幂。

例如:数组长度是16,那么2^4=16,也就是连续4次没有过期Entry。

说白了就是: 从当前节点开始,进行do-while循环检查清理过期key,结束条件是连续n次未发现过期key就跳出循环,n是经过位运算计算得出的,可以简单理解为数组长度的2的多少次幂次。

触发时机

这两种清理方式会在源码中多个位置被触发。

下面的触发场景中,我都从源码中找到了对应的位置,直接对号入座即可,有兴趣的可以去深入阅读这部分的源码。

  • set() 方法中,遇到key=null的情况会触发一轮探测式清理流程。
  • set() 方法最后会执行一次启发式清理流程。
  • rehash() 方法中会调用一次探测式清理流程。
  • get() 方法中遇到key过期的时候会触发一次探测式清理流程。
  • 启发式清理流程中遇到key=null的情况也会触发一次探测式清理流程。

最后,给本篇文章做个总结。

总结

ThreadLocal是Java提供的一种非常有用的工具,它可以帮助我们在每个线程中存储并管理各自独立的数据副本。这种特性使得ThreadLocal在处理多线程编程中的某些问题时极为高效且易于使用,例如实现线程安全、维护线程间的数据隔离等。

然而,ThreadLocal也要谨慎使用,因为不正确的使用可能会导致内存泄漏。特别是在使用完ThreadLocal后,我们需要记住及时调用其remove()方法清理掉线程局部变量,防止对已经不存在的对象的长时间引用,引发内存泄漏。

总的来说,ThreadLocal具有强大的功能,但必须了解其工作原理和可能的风险,才能充分利用它而不会产生意料之外的问题。因此, 深入理解并合理使用ThreadLocal是每个Java开发者的必备技能。

本篇文章到这结束了~,那就下次再见吧👋🏻,觉得有收获点个赞哦。


感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。

老铁们,关注我的微信公众号「Java 随想录」,专注分享Java技术干货,文章持续更新,可以关注公众号第一时间阅读。

一起交流学习,期待与你共同进步!

相关推荐
摘星编程6 分钟前
JVM深入原理(七)(一):运行时数据区
jvm
桑榆080628 分钟前
Scala数组
开发语言·后端·scala
咖啡里的茶i35 分钟前
随机产生4位随机码(java)
java
bobz96536 分钟前
GUI 开发工具 QT VS .NET
后端
小小鸭程序员37 分钟前
在Spring Boot中实现图片上传和修改
java·数据库·python·mysql·spring
东方窅瞳1 小时前
MDX语言的云计算
开发语言·后端·golang
凸头1 小时前
juc并发包的常用类、线程安全实现方式、锁机制及 JVM 优化策略
java
橘子青衫2 小时前
掌握HttpClient技术:从基础到实战(Apache)
java·后端·架构