【源码】【Java并发】【ThreadLocal】适合中学者体质的ThreadLocal源码阅读

👋hi,我不是一名外包公司的员工,也不会偷吃茶水间的零食,我的梦想是能写高端CRUD

🔥 2025本人正在沉淀中... 博客更新速度++

👍 欢迎点赞、收藏、关注,跟上我的更新节奏

📚欢迎订阅专栏,专栏名《在2B工作中寻求并发是否搞错了什么》

前言

经过了上一篇的学习,聪明的你一定知道了ThradLocal的怎么样使用的。

【Java并发】【ThreadLocal】适合初学体质的ThreadLocal

下面,跟上主播的节奏,马上开始ThreadLocal源码的阅读( ̄▽ ̄)"

内部结构

如下图所示,我们可以知道,每个线程,都有自己的threadLocals字段,指向ThreadLocalMap

ThreadLocalMap中有一个Entry数组(table),用来存储我们set进ThreadLocal的值。

Entry的key指向ThreadLocal(弱引用),value就是我们set的值(强引用)。

Set流程

java 复制代码
// set方法入口
public void set(T value) {
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 设置值
        map.set(this, value);
    } else {
        // 为当前线程创建ThradLocalMap
        createMap(t, value);
    }
}


// getMap方法。获取当前线程,ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

map为null的情况,开始初始化

初始化

java 复制代码
void createMap(Thread t, T firstValue) {
    // 为当前线程设置ThreadLocalMap
    // key是TheadLocal,value是我们要塞入ThreadLocal线程的值
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

具体new的逻辑

java 复制代码
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 设置table大小,初始容量为16。ThreadLocalMap的table就是用来存Entry的。
    table = new Entry[INITIAL_CAPACITY];	
    // 哈希算法算法,决定新的Entry插入到哪个槽里,后面会具体说这个。
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    // 当前在ThreadLocalMap中的Entry数量
    size = 1;
    // 设置扩容的阈值,2/3的时候扩容
    setThreshold(INITIAL_CAPACITY);
}

初始容量,16个,强制要求为2的幂次,用于优化位运算性能。通过静态final修饰确保全局唯一且不可修改。

java 复制代码
/**
 * The initial capacity -- MUST be a power of two.
 */
private static final int INITIAL_CAPACITY = 16;

扩容阈值,2/3 的时候扩容

java 复制代码
/**
 * Set the resize threshold to maintain at worst a 2/3 load factor.
 */
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

好了,让我们具体来说说说是怎么哈希的

java 复制代码
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);

为什么是 &? firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)?

当容量 INITIAL_CAPACITY 是 2 的幂(如 16)时,INITIAL_CAPACITY - 1 的二进制形式为全 1(例如 15 -> 1111)。此时,hash & (INITIAL_CAPACITY - 1) 等效于 hash % INITIAL_CAPACITY,但位运算的效率远高于取模运算。

firstKey.threadLocalHashCode,这个咋来的?

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

// nextHashCode方法
private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

// nextHashCode,从0开始的hashCode
private static AtomicInteger nextHashCode = new AtomicInteger();

// HASH_INCREMENT
private static final int HASH_INCREMENT = 0x61c88647;

为什么这个魔数是0x61c88647?

0x61c88647 近似于 (√5 - 1)/2 * 2^32(黄金分割比例的 32 位扩展)。这种设计确保哈希码在 2 的幂容量下均匀分布,减少冲突概率。

map不为null的情况,直接set

java 复制代码
private void set(ThreadLocal<?> key, Object value) {
    // ThreadLocalMap的table,table是个数组里面是放Entry的
    Entry[] tab = table;
    int len = tab.length
    // hash到哪个槽位(上面初始化的时候,具体有说)
    int i = key.threadLocalHashCode & (len-1);

    // 判断当前的槽是否已经有Entry了
    // - 如果有:就for循环table,看是不是这次要更新的槽。
    //      - 是的话更新。
    // 		- 不是的话,说明存在哈希冲突,需要通过开放地址法,找到为空的槽插入。
    // - 如果没有:就不执行下面的for循环,直接插入即可。
    // ===== ThreadLocal哈希冲突解决 =====
    // 我们都知道,这里的哈希冲突解决方案是 ---》 开放地址法(线性探测法)
    // 这里的处理:如果enty不为null的话,就一直i+1往下找下一个,直到table[i] == null。
    //(下面有nextIndex的代码,就是i+1。)
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        // 如果当前Entry的key,就是这次set需要的Entry的key,这里就更新
        if (k == key) {
            e.value = value;
            return;
        }

        // 如果这个Entry的key为null(可能是线程执行完被GC了)
        if (k == null) {
            // 清除这个Entry(后面会具体说怎么清除的)
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    // 对该槽赋值
    tab[i] = new Entry(key, value);
    // table中Entry数量+1
    int sz = ++size;
    // 1、cleanSomeSlots清理没用的槽
    // 2、如果清空失败 并且 table中的Entry数量大于等于扩容阈值
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();	// 重新hash
}

寻找下一个地址的方法nextIndex。

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

rehash做了什么?

java 复制代码
private void rehash() {
    // 清除过期的Entry(Entry的key为null就是过期的意思,后面会说具体说怎么清理的)
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    // 通过主动提前扩容,防止哈希表在临界负载时因性能骤降影响用户体验
    // 当前Entry的数量 大于等于 扩容阈值的1/2
    if (size >= threshold - threshold / 4)
        resize();
}

为什么是threshold - threshold / 4

因为要保持低附载。所以我们也可以说这是1 / 2扩容。

在开放寻址法中,通过主动降低扩容阈值(从 2/3 容量降至 3/4 * 2/3 = 1/2 容量),确保哈希表始终处于低负载状态,从而:减少线性探测的冲突概率,平滑性能波动,避免滞后现象,以可控的内存增长换取稳定的高性能。

那resize方法做了什么捏?省流版:扩容2倍扩容,旧的table里的Entry,重新哈希放入新的table里。

java 复制代码
private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    // 2倍当前长度大小
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;

    // 遍历旧table里的Entry,将Entry重新哈希,放入到新的table里
    for (int j = 0; j < oldLen; ++j) {
        Entry e = oldTab[j];
        if (e != null) {
            ThreadLocal<?> k = e.get();
            // 清理失效的Entry
            if (k == null) {
                e.value = null; 
            } else {
                // 哈希到新的槽位
                int h = k.threadLocalHashCode & (newLen - 1);
                // 开放地址法解决哈希冲突,如果这个节点有值了,就i+1找下一个槽位
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);	// h+1,寻找下一个槽位
                // 新table里,节点的赋值
                newTab[h] = e;
                // 旧table的有效Entry数量 + 1
                count++;
            }
        }
    }

    // 设置新的扩容阈值(数组长度的 2/3)
    setThreshold(newLen);
    // 修改为新的table的size
    size = count;
    // 指向新的table
    table = newTab;
}

// 设置新的扩容阈值
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

Get流程

get方法入口

java 复制代码
public T get() {
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 根据当前线程的ThreadLocal获取Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 当前线程没有ThreadLocalMap
    return setInitialValue();
}

获取Entry,getEntry方法。

java 复制代码
private Entry getEntry(ThreadLocal<?> key) {
    // 获取槽位
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    // 如果这个槽不为null 且 这个槽位Entry的key是当前的ThreadLocal
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);	// 当前槽找不到这次要获取的key
}

如果找不到,执行getEntryAfterMiss方法。

为什么会找不到?

可能是真没有,也有可能是哈希冲突导致的槽位一直++,所以Entry被放入到了后面。

java 复制代码
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;

    while (e != null) {
        ThreadLocal<?> k = e.get();
        // 找到了返回
        if (k == key)
            return e;
        // 清除过期的Entry
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);	// 获取下一个槽位
        // e变为下一个Entry
        e = tab[i];
    }
    // 实在找不到
    return null;
}

我们来看看,如果当前线程没有ThreadLocalMap,会怎么处理setInitialValue方法

java 复制代码
private T setInitialValue() {
    // 初始value值,默认为null。子类可以重写,返回不同的初始值。
    T value = initialValue();
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocal。(上面有说过这个方法,就是t.threadLocals。)
    ThreadLocalMap map = getMap(t);
    // 这个和的set流程一样了。有就set初始值进去,没有就创建map。
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }

    // 返回初始值
    return value;
}

initialValue,默认返回null,子类可以重写。

java 复制代码
protected T initialValue() {
    return null;
}

下面,主播为大家表演一手,子类重写。

java 复制代码
// 子类重写
public class MyThreadLocal extends ThreadLocal {
    @Override
    protected Object initialValue() {
        return "aaaa";
    }
}

// 测试类 
public class MyThreadLocalTest {
    public static void main(String[] args) {
        MyThreadLocal myThreadLocalTest = new MyThreadLocal();
        String o = (String) myThreadLocalTest.get();
        System.out.println(o);	// 输出aaaa
    }
}

删除Entry

在ThreadLocal中存在2种删除方法。

探测删除(线性探测清理)

  • 对应方法expungeStaleEntry(int staleSlot)

  • 核心目标:清理当前过期的 Entry,并重新整理哈希表,解决因哈希冲突导致的 Entry 位置偏移问题。

  • 省流版流程:做了2件事情。

    1. 清空当前Entry 。
    2. 判断后续Entry是否需要清除,需要就清除,不需要的话,就重新哈希放到table里。
java 复制代码
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // 清理当前槽点的数据
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    Entry e;
    int i;
    // 从当前槽点开始向后遍历,判断每个Entry是否需要清空
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // 失效的Entry,就给它清除
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // ====没有失效的Entry====
            // 重新哈希槽位,Entry重新放入table中
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                // 哈希冲突,开放址法
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    // 返回停止的槽位
    return i;
}

⚠️ 探测性修复是有局限性的,并不会从当前要删除的槽点一直往后遍历到table的所有槽点,它也会在某个为null的槽点中停下来。(🤔不过话又说回来了,真扫了整个table,性能包炸的)

java 复制代码
for (i = nextIndex(staleSlot, len);
     (e = tab[i]) != null;		// 当tab[i]为null会停止清除,即使tab[i+1]是过期的Entry
     i = nextIndex(i, len))

启发式清除(概率性扫描)

对应方法cleanSomeSlots(int i, int n)
核心目标:以较低的成本扫描部分槽位,清理可能的过期 Entry,避免全表扫描的性能开销。

java 复制代码
// 入参解释:
// - i,一个为null的槽位。
// - n,一般传的是table的长度。
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    // 一直修改len的值,如果len的默认值为16,16 -> 8 -> 4 -> 2 -> 0
    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;
}

while ((n >>>= 1) != 0) 是一种高效且巧妙的设计:

  1. 位运算控制循环次数 :以 O(log n) 时间实现启发式扫描。
  2. 平衡性能与清理效果:避免全表扫描,但覆盖足够多的槽位。
  3. 自适应哈希表容量:天然适配 2 的幂次容量的哈希表。

Remove流程

上面介绍了Entry是怎么删除的,这里来看看,remove到底干了什么勒?😄

java 复制代码
 public void remove() {
     ThreadLocalMap m = getMap(Thread.currentThread());	// 获取当前线程的ThreadLocalMap
     if (m != null)
         m.remove(this);
 }

具体的remove方法,我们总结下做了啥?

  1. 哈希开始的槽位,找到要删除的Entry
  2. 断开Entry对ThreadLocal key的弱引用(置为null)
  3. 探测式删除Entry。
java 复制代码
private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    // 获取开始的槽位
    int i = key.threadLocalHashCode & (len-1);
    // 可能出现哈希冲突,所以要一直向后找Entry。
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();	// 删除key的弱引用
            expungeStaleEntry(i);	// 上面介绍过勒,这个是探测式删除。
            return;
        }
    }
}

为什么会内存泄漏?

这就不得不提到我们的Entry了,我们可以看到,我们的Key是一个弱引用。

java 复制代码
static class Entry extends WeakReference<ThreadLocal<?>> {
    
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);  // 调用父类 WeakReference 的构造函数,将 k 作为弱引用存储
        value = v;
    }
}

那么,弱引用会怎么样勒?来主播带大家回忆下,Java的四种引用:

强引用 (Strong Reference)

  • 清除时机:当对象没有任何强引用指向时,会在下一次GC时被回收。即使内存不足,也不会被回收(可能导致OOM)。
java 复制代码
 Object obj = new Object();  // 强引用

软引用 (SoftReference)

  • 清除时机:当内存不足时(即将抛出OutOfMemoryError前),JVM会尝试回收。
java 复制代码
SoftReference<Object> softRef = new SoftReference<>(new Object());

弱引用 (WeakReference)

  • 清除时机:只要发生垃圾回收,无论内存是否充足,对象都会被回收。
java 复制代码
WeakReference<Object> weakRef = new WeakReference<>(new Object());

虚引用 (PhantomReference)

  • 清除时机: 对象被回收后,虚引用会被放入关联的ReferenceQueue,需手动处理。无法通过虚引用获取对象实例,仅用于追踪对象回收状态。
java 复制代码
// 1. 必须绑定引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();
Object heavyObject = new Object(); // 假设这是一个占用大量资源的对象

// 2. 创建虚引用(必须关联队列)
PhantomReference<Object> phantomRef = 
    new PhantomReference<>(heavyObject, queue);

// 3. 关键特性:无法通过虚引用获取对象
System.out.println(phantomRef.get()); // 输出 null ❌

// 4. 手动解除强引用(触发回收条件)
heavyObject = null;

// 5. 强制触发垃圾回收
System.gc();

// 6. 检查队列:当对象被回收后,虚引用会被加入队列
Reference<?> ref = queue.remove(500); // 阻塞500ms等待队列
if (ref == phantomRef) {
    System.out.println("对象已被回收,可执行后续清理操作 ✅");
    // 例如:若对象管理了堆外内存,可在此释放
}

ok,前面铺垫了这么久,让我们分析下,内存泄漏的原因:

Key 的弱引用特性

  • ThreadLocal 实例失去强引用时 (例如 threadLocal = null),由于 Entry 的 key 是弱引用,GC 会回收该 ThreadLocal 实例,此时 Entry 的 key 变为 null
  • 问题Entryvalue 仍是强引用,无法被自动回收。

线程长期存活

如果线程是线程池的核心线程(长期存活),它的 ThreadLocalMap 会一直存在。即使 key 被回收,value 仍然通过 Entry 的强引用保留在内存中。

代码模拟,没有及时remove,导致OOM:

java 复制代码
public class ThreadLocalOOMExample {
    
    // 设置 JVM 参数:-Xmx64m -Xms64m (限制堆内存为 64MB)
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executor = Executors.newFixedThreadPool(5); // 线程池复用线程
        for (int i = 0; i < 1000; i++) { // 提交 1000 个任务
            executor.submit(() -> {
                ThreadLocal<BigObject> threadLocal = new ThreadLocal<>();
                try {
                    // 每个任务创建一个 1MB 的对象,存到 ThreadLocal 中
                    threadLocal.set(new BigObject());
                    // 模拟业务逻辑(不调用 remove())
                } finally {
                    // 此处故意不调用 threadLocal.remove()
                }
            });
            Thread.sleep(10); // 控制任务提交速度
        }
        executor.shutdown();
    }

    static class BigObject {
        // 每个对象占用 1MB 内存
        private final byte[] data = new byte[1024 * 1024];
    }
}

运行结果

shell 复制代码
Exception in thread "pool-1-thread-2" java.lang.OutOfMemoryError: Java heap space
    at ThreadLocalOOMExample$BigObject.<init>(ThreadLocalOOMExample.java:23)
    at ThreadLocalOOMExample.lambda$main$0(ThreadLocalOOMExample.java:15)
    ...

后话

聪明的你,是不是对ThreadLocal有更多的了解呢?

  • ThreadLocalMap的数据结构是怎么样
  • value是怎么set进去
  • 怎么get到我们set的value
  • 如何删除过期的Entry
  • 内存泄漏的可能原因

相信聪明的你一定有了答案(‾◡◝)

相关推荐
喵爸的小作坊2 分钟前
StreamPanel:一个让 SSE 调试不再痛苦的 Chrome 插件
前端·后端·http
神奇小汤圆2 分钟前
字符串匹配算法
后端
无限大69 分钟前
为什么网站需要"域名"?——从 IP 地址到网址的演进
后端
树獭叔叔14 分钟前
LangGraph Memory 机制
后端·langchain·aigc
BullSmall14 分钟前
Tomcat11证书配置全指南
java·运维·tomcat
永不停歇的蜗牛17 分钟前
K8S之创建cm指令create和 apply的区别
java·容器·kubernetes
Java编程爱好者17 分钟前
OpenCVSharp:了解几种特征检测
后端
爱学习的小可爱卢22 分钟前
JavaEE进阶——SpringBoot统一功能处理全解析
java·spring boot·后端·java-ee
汤姆yu24 分钟前
基于springboot的二手物品交易系统的设计与实现
java·spring boot·后端