threadlocal原理_扒一下 ThreadLocal

本文解析了ThreadLocal的工作原理,包括其内部实现机制、内存泄漏风险及如何利用特定的哈希算法确保线程间数据隔离。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

首先是安利。我在学习 ThreadLocal 时,看到两个很好视频,安利一下。一个是《【java】什么是ThreadLocal?》(短小紧凑,科普向,但是结尾有关弱引用的结论是错的,瑕不掩瑜,记得看评论区),另一个是《只有马士兵老师能把ThreadLocal底层原理、内存泄漏分析的这么测透》(马士兵老师讲的,比较长,但是非常出色,看得我很 high)。


ThreadLocal 类与线程局部存储(Thread Local Storage, TLS)的概念有关,意思是说对象是线程独有的:

维基:对象的存储是在线程开始时分配,线程结束时回收,每个线程有该对象自己的实例。

可以把 ThreadLocal 视为一个普通变量,他与普通的变量之间的区别在于,ThreadLocal 变量只属于某个线程。

String word1;
ThreadLocal<String> word2 = new ThreadLocal<>();
​
word1 = "pz";
word2.set("pz");

例如上面的代码,声明了两个变量 word1 和 word2。这两个变量在使用时都是字符串,也都声明成是"pz",但是前者 word1 在任何情况下都能获得,后者只有为它赋值的线程能获得,其他的线程都不能获得:

// 对于刚才赋值的线程而言,s是"pz",对于其他线程,s是null
String s = word2.get();

线程局部存储,可以让某些信息在同一个线程中,实现上下文数据共享,比如一个线程先后执行多个方法,每个方法都需要 User 信息,那么可以定义一个 ThreadLocal<User>,每个线程都能获取到各自的 User。

再举一个应对面试的 Spring 应用实例,在事务管理时(@Transactional),Spring 会把数据库连接或者 hibernate session 存储在 ThreadLocal 中,这样就可以在各个方法查询各张表时实现事务控制。

基本原理

ThreadLocal 的实现原理,是让每个线程自己维护一张 map,key 是 threadLocal 对象,value 是实际想存储的内容。

每个线程拿着 ThreadLocal 对象,把它作为 key,去自己独有的 map 里找到 key 对应的 value,由此实现了线程局部存储。

66ba6ab80a2a2d476d5026ee257c9460.png
ThreadLocal 存储结构

直接来看 get() 方法:

public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 根据当前线程,获取map
    ThreadLocalMap map = getMap(t);
    // 如果map不为空,去map里面找
    if (map != null) {
        // 根据this(也就是本ThreadLocal对象),去map里面找到对应的entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 如果map为空,初始化map,并返回null
    return setInitialValue();
}

基本逻辑很简单,获取当前线程,根据当前线程获取 map,在 map 里根据 ThreadLocal 对象找 entry,返回 entry 的 value。

在这个主要流程里,我们还需要去扒:

  1. 怎么根据当前线程获取 map
  2. 怎么在 map 里根据 ThreadLocal 找到 entry

第一个问题特别简单,map 就是 Thread 的一个成员变量,直接返回:

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
// 再去看Thread类的源码,可以看到有一条成员变量:
ThreadLocal.ThreadLocalMap threadLocals = null;

第二个问题有点难,涉及到 map 的数据结构。

先提一下 map 具体是什么,这里的 map 实际上就是一个数组(没有链表),这个数组里的每一个元素都是一个 Entry:

特殊的 hash 算法

ThreadLocal 计算数组,是通过内部的一个数字计算的,这个数字很奇特,每个 ThreadLocal 的数字都不一样,但是它们都是一个公共数字 HASH_INCREMENT 的整数倍(会溢出,但是无所谓)。在程序启动之后,出现的第一个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的一倍,第二个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的两倍,以此类推,第 n 个 ThreadLocal 对象内部的数字是 HASH_INCREMENT 的 n 倍。

每个 ThreadLocal 对象内部的那个数字是 threadLocalHashCode(见名知意,就是 hash 值),而以 HASH_INCREMENT 为基的这个数字,大小初看很奇怪:

private static final int HASH_INCREMENT = 0x61c88647;

这个数字用十进制表示是 1640531527,它是

的黄金分割数,也就是
。使用这个数字的整数倍,能计算得出很均匀的 map 下标(前提 map 的长度是 2 的幂)。具体的计算步骤是,获取 ThreadLocal 内部的那个数字(
threadLocalHashCode),然后对 map 的长度 -1 取模,计算出一个不超过 map 长度的数字,这个数字就是数组的下标。

举个例子,0 - 31 这 32 个数字分别与 0x61c88647 相乘,然后再对 31 (0b11111)取模,依旧能获得 32 个不同的数字:

int HASH_INCREMENT = 0x61c88647;
for (int i = 0; i < 32; i++) {
    System.out.print((i * HASH_INCREMENT & 0b11111) + " ");
}
// 控制台打印结果:0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 

当数组长度是 2 的幂时,都可以做到如此均匀,虽然我不知道为什么(试着找了一些文章,但是看不懂hhh),但是觉得好牛x啊。

下图来源自文章《Fibonacci Hashing》,感受一下分布均匀度。

05a2ac5e627264235ace438f53599532.png
多么平均!

有关 ThreadLocal 的 hash 算法就写到这里,然后我们来看 map 中的每一个元素:Entry。

Entry 与弱引用

这是 Entry 的源码:

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
​
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

它实际上就是 key-value 键值对,跟其他任何 map 里的设计都是一样的,有 key,也有 value。

它的 key,是 ThreadLocal 对象(尽管看源码有点懵),它的 value,就是线程局部存储的值,例如下面这两行代码:

ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("pz");

执行 set 方法的那个线程,它内部的 map 里的某一个格子,就是一个 Entry,这个 Entry 的 key 就是 threadlocal 对象,它的 value 是 "pz"。

Entry 源码里面看到了 value,但是没有看到 key,key 是通过 WeakReference(弱引用)来实现的。

弱引用是 JDK 1.2 出现的,这个概念跟 Java 虚拟机有关。弱引用是引用的一种,引用总共有四种,,我简单列一下:

  • 强引用(Strong Reference):正常引用,根据垃圾回收算法,当这个引用存在时,就无法对引用对象进行 GC(如果根可达的话)
  • 软引用(Soft Reference):能够获取到引用对象,当发生 FGC 时,会回收引用对象,应用在缓存等。
  • 弱引用(Weak Reference):能够获取到引用对象,当发生 GC 时,会回收引用对象,应用在 ThreadLocal 等。
  • 虚引用(Phantom Reference):不能获取到引用对象,作用是当引用对象被 GC 时,虚引用会获得一个系统通知,应用场景跟一般的代码无关。

上面写的引用,我只是简单一写,想理解的话去看《深入理解 Java 虚拟机》讲垃圾回收的部分,或是最开始提到的马士兵老师的视频,都讲得很好。

话说回来,继续看弱引用,看下面这两行代码:

WeakReference<String> r = new WeakReference<>("pz");
r.get();

上面创建了一个弱引用 WeakReference 对象 r,它引用到了一个字符串 "pz",如果没有发生垃圾回收,那么可以通过 r.get() 方法获取到 "pz",但如果发生了垃圾回收,这个虚引用并不会影响到字符串 "pz" 的回收,如果它真被回收了,那么执行 r.get() 方法将会获得 null。

因此再回去看 Entry 源码,发现 Entry 继承自 WeakReference,它可以通过虚引用获取到 ThreadLocal 对象,比如创建一个 Entry 对象(当然由于 private,下面的代码是实现不了的hh):

Entry entry = new Entry(threadLocal, "pz");
entry.get(); // ---> 能拿到threadLocal,即key
entry.value; // ---> 能拿到"pz",即value

如此可以看出,Entry 实际上就是 key-value 键值对,从 hash 开放定址法上来说,应该是没问题了,逻辑闭环了,但是有另一个问题:为什么要使用弱引用?

这主要与垃圾回收有关:如果 Entry 通过正常的引用关联 ThreadLocal 对象,那么如果线程不死,map 不清,由于强引用关系,ThreadLocal 对象就永远也不能被垃圾回收,这样即使某些 ThreadLocal 用不上了,它仍然不会被清理掉,造成内存泄漏。

(这里我还是有点疑惑,真的有这种内存泄漏的场景吗)

本来还想学点数据库事务的 ThreadLocal 原理的,但是写了这么多字懒了,告辞。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值