12.ThreadLocal的那点小秘密

好久不见,不知道大家新年过得怎么样?有没有痛痛快快得放松?是不是还能收到很多压岁钱?好了,话不多说,我们开始今天的主题:ThreadLocal。
我收集了4个面试中出现频率较高的关于ThreadLocal的问题:

什么是ThreadLocal?什么场景下使用ThreadLocal?
ThreadLocal的底层是如何实现的?
ThreadLocal在什么情况下会出现内存泄漏?
使用ThreadLocal要注意哪些内容?

我们先从一个“谣言”开始,通过分析ThreadLocal的源码,尝试纠正“谣言”带来的误解,并解答上面的问题。
流传已久的“谣言”
很多文章都在说“ThreadLocal通过拷贝共享变量的方式解决并发安全问题”,例如:

图片

这种说法并不准确,很容易让人误解为ThreadLocal会拷贝共享变量。来看个例子:
private static final DateFormat DATE_FORMAT = new SimpleDateFormat("yyyy-MM-dd");

public static void main(String[] args) throws InterruptedException {

for (int i = 0; i < 1000; i++) {
    new Thread(() -> {
        try {
            System.out.println(DATE_FORMAT.parse("2023-01-29"));
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }).start();
}

}
复制代码
我们知道,多线程并发访问同一个DateFormat实例对象会产生严重的并发安全问题,那么加入ThreadLocal是不是能解决并发安全问题呢?修改下代码:
/**

  • 第一种写法
    */

private static final ThreadLocal<DateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<>() {

@Override
protected DateFormat initialValue() {
    return DATE_FORMAT;
}

};

public static void main(String[] args) throws InterruptedException {

for (int i = 0; i < 1000; i++) {
    new Thread(() -> {
        try {
            System.out.println(DATE_FORMAT_THREAD_LOCAL.get().parse("2023-01-29"));
        } catch (ParseException e) {
            e.printStackTrace();
        }
    }).start();
}

}
复制代码
估计会有很多小伙伴会说:“你这么写不对!《阿里巴巴Java开发手册》中不是这么用的!”。把书中的用法搬过来:
/**

  • 第二种写法
    */

private static final ThreadLocal<DateFormat> DATE_FORMAT_THREAD_LOCAL = new ThreadLocal<>() {

@Override
protected DateFormat initialValue() {
    return new SimpleDateFormat("yyyy-MM-dd");
}

};
复制代码
Tips:代码小改了一下~~
我们来看两种写法的差别:

第一种写法,ThreadLocal#initialValue时使用共享变量DATE_FORMAT;
第二种写法,ThreadLocal#initialValue时创建SimpleDateFormat对象。

按照“谣言”的描述,第一种写法会拷贝DATE_FORMAT的副本提供给不同的线程使用,但从结果上来看ThreadLocal并没有这么做。
有的小伙伴可能会怀疑是因为DATE_FORMAT_THREAD_LOCAL线程共享导致的,但别忘了第二种写法也是线程共享的。
到这里我们应该能够猜到,第二种写法中每个线程会访问不同的SimpleDateFormat实例对象,接下来我们通过源码一探究竟。
ThreadLocal的实现
除了使用ThreadLocal#initialValue外,还可以通过ThreadLocal#set添加变量后再使用:
ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<>();
threadLocal.set(new SimpleDateFormat("yyyy-MM-dd"));
System.out.println(threadLocal.get().parse("2023-01-29"));
复制代码
Tips:这么写仅仅是为了展示用法~~
使用ThreadLocal非常简单,3步就可以完成:

创建对象
添加变量
取出变量

无参构造器没什么好说的(空实现),我们从ThreadLocal#set开始。
ThreadLocal#set的实现
ThreadLocal#set的源码:
public void set(T value) {,

Thread t = Thread.currentThread();

// 获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);

if (map != null) {
    // 添加变量
    map.set(this, value);
} else {
    // 初始化ThreadLocalMap
    createMap(t, value);
}

}
复制代码
ThreadLocal#set的源码非常简单,但却透露出了不少重要的信息:

变量存储在ThreadLocalMap中,且与当前线程有关;
ThreadLocalMap应该类似于Map的实现。

接着来看源码:
public class ThreadLocal<T> {

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

}

public class Thread implements Runnable {

ThreadLocal.ThreadLocalMap threadLocals = null;

}
复制代码
很清晰的展示出ThreadLocalMap与Thread的关系:ThreadLocalMap是Thread的成员变量,每个Thread实例对象都拥有自己的ThreadLocalMap。
另外,还记得在关于线程你必须知道的8个问题(上)提到Thread实例对象与执行线程的关系吗?

如果从Java的层面来看,可以认为创建Thread类的实例对象就完成了线程的创建,而调用Thread.start0可以认为是操作系统层面的线程创建和启动。

可以近似的看作是:Thread实例对象≈执行线程Thread实例对象\approx执行线程Thread实例对象≈执行线程。也就是说,属于Thread实例对象的ThreadLocalMap也属于每个执行线程。
基于以上内容,我们好像得到了一个特殊的变量作用域:属于线程。
Tips:

实际上属于线程也即是属于Thread实例对象,因为Thread是线程在Java中的抽象;
ThreadLocalMap属于线程,但不代表存储到ThreadLocalMap的变量属于线程。

ThreadLocalMap的实现
ThreadLocalMap是ThreadLocal的内部类,代码也不复杂:
public class ThreadLocal<T> {

private final int threadLocalHashCode = nextHashCode();

static class ThreadLocalMap {

    static class Entry extends WeakReference<ThreadLocal<?>> {
    
        Object value;
        
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    private Entry[] table;
    
    private int size = 0;
    
    private int threshold;
    
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }
    
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        table = new Entry[INITIAL_CAPACITY];
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }
}

}
复制代码
仅从结构和构造方法中已经能够窥探到ThreadLocalMap的特点:

ThreadLocalMap底层存储结构是Entry数组;
通过ThreadLocal的哈希值取模定位数组下标;
构造方法添加变量时,存储的是原始变量。

很明显,ThreadLocalMap是哈希表的一种实现,ThreadLocal作为Key,我们可以将ThreadLocalMap看做是“简版”的HashMap。
Tips:

本文不讨论哈希表实现中处理哈希冲突,数组扩容等问题的方式;
也不需要关注ThreadLocalMap#set和ThreadLocalMap#getgetEntry的实现;
与构造方法一样,ThreadLocalMap#set中存储的是原始变量。

到目前为止,无论是ThreadLocalMap#set还是ThreadLocalMap的构造方法,都是存储原始变量,没有任何拷贝副本的操作。也就是说,想要通过ThreadLocal实现变量在线程间的隔离,就需要手动为每个线程创建自己的变量。
ThreadLocal#get的实现
ThreadLocal#get的源码也非常简单:
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();

}
复制代码
前面的部分很容易理解,我们看map == null时调用的ThreadLocal#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);
}

if (this instanceof TerminatingThreadLocal) {
    TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;

}
复制代码
ThreadLocal#setInitialValue方法几乎和ThreadLocal#set一样,但变量是通过ThreadLocal#initialValue获得的。如果是通过ThreadLocal#initialValue添加变量,在第一次调用ThreadLocal#get时将变量存储到ThreadLocalMap中。
ThreadLocal的原理
好了,到这里我们已经可以构建出对ThreadLocal比较完整的认知了。我们先来看ThreadLocal,ThreadLocalMap和Thread三者之间的关系:

图片

可以看到,ThreadLocal是作为ThreadLocalMap中的Key的,而ThreadLocalMap又是Thread中的成员变量,属于每一个Thread实例对象。忘记ThreadLocalMap是ThreadLocal的内部类这层关系,整体结构就会非常清晰。
创建ThreadLocal对象并存储数据时,会为每个Thread对象创建ThreadLocalMap对象并存储数据,ThreadLocal对象作为Key。在每个Thread对象的生命周期内,都可以通过ThreadLocal对象访问到存储的数据。

4 声望
1 粉丝
0 条评论
推荐阅读
自动写代码?
这几天,GitHub 上有个很火的插件在抖音刷屏了——Copilot。这个神器有啥用呢?简单来讲,它就是一款由人工智能打造的编程辅助工具。我们来看看它有啥用。首先就是代码补全功能,你只要给出函数名和参数,Copilot ...

数据先声阅读 78

从零搭建 Node.js 企业级 Web 服务器(十五):总结与展望
总结截止到本章 “从零搭建 Node.js 企业级 Web 服务器” 主题共计 16 章内容就更新完毕了,回顾第零章曾写道:搭建一个 Node.js 企业级 Web 服务器并非难事,只是必须做好几个关键事项这几件必须做好的关键事项就...

乌柏木75阅读 7k评论 16

从零搭建 Node.js 企业级 Web 服务器(一):接口与分层
分层规范从本章起,正式进入企业级 Web 服务器核心内容。通常,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,如下图:从上至下,抽象层次逐渐加深。从下至上,业务细节逐渐清晰。视图...

乌柏木45阅读 8.4k评论 6

从零搭建 Node.js 企业级 Web 服务器(二):校验
校验就是对输入条件的约束,避免无效的输入引起异常。Web 系统的用户输入主要为编辑与提交各类表单,一方面校验要做在编辑表单字段与提交的时候,另一方面接收表单的接口也要做足校验行为,通过前后端共同控制输...

乌柏木35阅读 6.6k评论 10

从零搭建 Node.js 企业级 Web 服务器(五):数据库访问
回顾 从零搭建 Node.js 企业级 Web 服务器(一):接口与分层,一块完整的业务逻辑是由视图层、控制层、服务层、模型层共同定义与实现的,控制层与服务层实现了业务处理过程,模型层定义了业务实体并以 对象-关系...

乌柏木34阅读 5k评论 9

2022大前端总结和2023就业分析
我在年前给掘金平台分享了《2022年热点技术盘点》的前端热点,算是系统性的梳理了一下我自己对前端一整年的总结。年后,在知乎上看到《前端的就业行情怎么样?》,下面都是各种唱衰前端的论调,什么裁员,外包化...

i5ting27阅读 2.2k评论 4

封面图
从零搭建 Node.js 企业级 Web 服务器(十三):断点调试与性能分析
Node.js 官方提供了断点调试机制,出于安全性考虑默认为关闭状态,可以通过 node 参数 --inspect 或 --inspect-brk 开启,配合 IDE 能够非常方便地调试代码,本章就上一章已完成的项目 licg9999/nodejs-server-ex...

乌柏木31阅读 4.1k评论 9

4 声望
1 粉丝
宣传栏