12.ThreadLocal的那点小秘密

简介: 通常线程池不会销毁线程,因此在线程池中使用ThreadLcoal,且没有正确执行ThreadLocal#remove的话,线程中会一直存在ThreadLocal关联的Value,那么就需要考虑清楚,这次的ThreadLocal对下一是否还适用?

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

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

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

这种说法并不准确,很容易让人误解为ThreadLocal会拷贝共享变量。来看个例子:
Java复制代码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是不是能解决并发安全问题呢?修改下代码:
Java复制代码/**

  • 第一种写法
    */
    private static final ThreadLocal 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开发手册》中不是这么用的!”。把书中的用法搬过来:
Java复制代码/**

  • 第二种写法
    */
    private static final ThreadLocal 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添加变量后再使用:
Java复制代码ThreadLocal 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的源码:
Java复制代码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的实现。

接着来看源码:
Java复制代码public class ThreadLocal {
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的内部类,代码也不复杂:
Java复制代码public class ThreadLocal {

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的源码也非常简单:
Java复制代码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方法:
Java复制代码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对象访问到存储的数据。
到底是“谣言”吗?
那么“ThreadLocal通过拷贝共享变量的方式解决并发安全问题”是“谣言”吗?
我认为是的。ThreadLoal不会拷贝共享变量,它能“解决”并发安全问题的原理很简单,要求开发者为每个线程“发”一个变量,即变量本身就是线程隔离的。接近于以下写法:
Java复制代码public static Date parseDate(String dateStr) throws ParseException {
return new SimpleDateFormat("yyyy-MM-dd").parse(dateStr);
}

那这还能算是ThreadLocal去解决并发安全问题吗?
Tips:Stack Overflow上也有关于“谣言”的讨论。
既然不是解决共享变量并发安全问题的,那么ThreadLocal有什么用?我认为最主要的功能就是跳过方法的参数列表在线程内传递参数。举个例子:Dubbo借鉴Netty的FastThreadLocal,搞了InternalThreadLocal,用来隐式传递参数。
ThreadLocal的内存泄漏
在ThreadLocalMap的源码中可以看到,Entry继承自WeakReference,并且会将ThreadLocal添加到弱引用队列中:
Java复制代码static class Entry extends WeakReference> {

Object value;

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

}

我们知道,弱引用关联的对象只能存活到下一次GC。如果ThreadLocal没有关联任何强引用,只有Entry上的弱引用的话,发生一次GC后ThreadLocal就会被回收,就会存在ThreadLocalMap上关联Entry,但Entry上没有Key的情况:

此时Value依旧关联在ThreadLocalMap上,但无法通过常规手段访问,造成内存泄漏。虽然线程销毁后会释放内存,但在线程执行期间,始终有一块无法访问的内存被占用。
避免内存泄漏
为了避免内存泄漏,Java建议设置静态ThreadLocal变量,保证一直存在与之关联的强引用:

ThreadLocal instances are typically private static fields in classes.

另外,ThreadLocal自身也做了一些努力去清除这些没有Key的Entry,如:

ThreadLocalMap#getEntry调用ThreadLocalMap#getEntryAfterMiss;
ThreadLocalMap#set调用ThreadLocalMap#replaceStaleEntry。

这些方法中都会尝试清除无用的Entry,只是触发条件较为苛刻,实际作用较小。
除此之外,开发者主动调用ThreadLocal#remove清除无用变量才是正确使用ThreadLocal的方式。
ThreadLocal的注意事项
除了需要关注ThreadLocal的内存泄漏外,我们需要关注另外一种场景:线程池中使用ThreadLocal。
通常线程池不会销毁线程,因此在线程池中使用ThreadLcoal,且没有正确执行ThreadLocal#remove的话,线程中会一直存在ThreadLocal关联的Value,那么就需要考虑清楚,这次的ThreadLocal对下一是否还适用?
结语
ThreadLocal的内容到这里就结束了,使用方法,实现原理,包括内存泄漏都还是比较简单的。不过有一点比较难搞,因为有太多人去写“ThreadLocal通过拷贝共享变量的方式解决并发安全问题”,导致很多人认为这是ThreadLocal的核心功能,所以无法确认坐在对面的面试官是如何理解ThreadLocal的。
我也思考了“谣言”是如何产生的,大概有两点:
第一,《阿里巴巴Java开发手册》中使用ThreadLocal解决了DateFormat的并发安全问题,表现上看是ThreadLocal的能力,实际上是开发者自身保证了每个线程使用不同的DateFormat实例对象。
第二,ThreadLocal的注释中,提到了一句“independently initialized copy of the variable.”,搞得大家以为ThreadLocal会拷贝共享变量给线程使用。
如果真的遇到了这样面试官,那只能”见人说人话“了。

相关文章
|
安全 Java 编译器
Java泛型是什么?
本文回顾了作者五年的工作经历,强调了自我学习的重要性,并介绍了Java泛型的基础知识,包括泛型的概念、泛型集合、泛型方法、泛型接口、泛型类及类型擦除等内容,旨在帮助读者理解泛型机制及其在编程中的应用。
144 2
Java泛型是什么?
|
Java Maven Windows
使用Java创建集成JACOB的HTTP服务
本文介绍了如何在Java中创建一个集成JACOB的HTTP服务,使Java应用能够调用Windows的COM组件。文章详细讲解了环境配置、动态加载JACOB DLL、创建HTTP服务器、实现IP白名单及处理HTTP请求的具体步骤,帮助读者实现Java应用与Windows系统的交互。作者拥有23年编程经验,文章来源于稀土掘金。著作权归作者所有,商业转载需授权。
293 2
使用Java创建集成JACOB的HTTP服务
|
SQL 监控 关系型数据库
MySQL 延迟从库介绍
本文介绍了MySQL中的延迟从库功能,详细解释了其工作原理及配置方法。延迟从库允许从库在主库执行完数据变更后延迟一段时间再同步,主要用于快速恢复误操作的数据。此外,它还可用于备份、离线查询及数据合规性需求。通过合理配置,可显著提升数据库系统的稳定性和可靠性。
404 4
|
JavaScript Java Spring
@Async异步失效的9种场景
在Spring中,启用@Async异步功能需要在启动类或配置类上使用`@EnableAsync`。若未使用此注解,@Async将无效。另外,内部方法调用(如在一个类的方法中调用另一个被@Async注解的方法)会导致异步功能失效,因为这不涉及Spring的AOP代理。此外,@Async方法必须是public,返回类型为void或Future,不能是static或final,且其所在的类需被@Service等注解以使Spring管理。如果使用@ComponentScan,确保正确扫描包含@Async类的包路径。
383 1
|
11月前
|
监控 Java 数据库连接
Java线程管理:守护线程与用户线程的区分与应用
在Java多线程编程中,线程可以分为守护线程(Daemon Thread)和用户线程(User Thread)。这两种线程在行为和用途上有着明显的区别,了解它们的差异对于编写高效、稳定的并发程序至关重要。
222 2
|
12月前
|
Linux Shell
Linux常用命令-1
本课程要求学生熟悉Linux系统终端窗口和命令基础,掌握文件目录类、系统信息类、进程管理类及其他常用命令,学时为3-6小时。课程内容涵盖Linux命令的特点、常见命令的使用方法及其应用场景,如文件浏览、目录切换、内容显示等。建议学生逐个操作命令并及时反馈问题。
146 6
|
算法 安全 测试技术
golang 栈数据结构的实现和应用
本文详细介绍了“栈”这一数据结构的特点,并用Golang实现栈。栈是一种FILO(First In Last Out,即先进后出或后进先出)的数据结构。文章展示了如何用slice和链表来实现栈,并通过golang benchmark测试了二者的性能差异。此外,还提供了几个使用栈结构解决的实际算法问题示例,如有效的括号匹配等。
261 1
golang 栈数据结构的实现和应用
|
12月前
|
算法 大数据 Go
Go文件操作:掌握Go的文件读写与操作技巧
本文介绍了Go语言的文件操作功能,包括文件的打开、读写和关闭。Go语言通过`os`和`io`包提供了丰富的文件操作接口,使开发者能够轻松实现文件的读写和管理。文章详细讲解了核心概念、具体操作步骤和代码示例,并探讨了实际应用场景和未来发展趋势。
181 4
|
存储 Linux 网络安全
让我们来尝试利用第三方软件远程连接服务器
即将进入Linux操作系统第二模块的学习,需先通过MobaXterm配置与虚拟操作系统连接。课程将基于MobaXterm讲解命令及知识。准备阶段包括:安装红帽7系统与MobaXterm远程SSH软件,检查网络连接,并按步骤完成MobaXterm的连接配置。
325 1
|
Linux 虚拟化
Vmware 傻瓜式安装(不可不知道的Linux基础知识和技术 01)
本文介绍了VMware虚拟机的下载与安装步骤。首先,通过提供的网盘链接下载VMware安装包。接着,详细描述了安装流程,包括接受协议、选择安装路径(建议避免系统C盘)、取消更新选项等。最后,输入许可证密钥完成安装,并展示了打开虚拟机后的主界面。整个过程简单易懂,适合新手操作。
373 1
下一篇
开通oss服务