线程的私有领地 ThreadLocal

362 阅读9分钟

从名字上看,『ThreadLocal』可能会给你一种本地线程的概念印象,可能会让你联想到它是一个特殊的线程。

但实际上,『ThreadLocal』却营造了一种「线程本地变量」的概念,也就是说,同一个变量在每个线程的内部,都有一份副本,且相互之间具有不同的取值。

这样的设计具有怎样的应用场景呢?是怎么样的一种设计原理呢?

别急,本篇就来详细的探讨探讨它。

基本介绍

上面我们粗略的介绍了「什么是 ThreadLocal ?」的这个问题,下面我们来看看它的一个基本使用是什么样的,以及设计出来旨在解决什么问题等相关内容。

我们先看这么一段程序:

image

函数 A 调用了函数 B,接着调用了函数 C、D,这么深层次的调用体系在真实的业务场景下是很常见的。

但是假如我现在要对函数 D 中要打印的字符串进行动态的传入,那你是不是得修改每一个方法的形参列表,增加一个形参位,接着在函数 A 中的调用上传入一个参数过来?

这太繁琐了,我们使用 ThreadLocal 就可以简单解决这种「需求变更」的问题:

image

这一连串函数的调用必然是同一个线程调用的,那么我们只要在最开头存储下一个变量,无论当前线程调用了多少层函数,这个局部变量一直都存在。

这是 ThreadLocal 的一种使用场景,但有点低估它的价值了,ThreadLocal 最常用的使用场景是,在多线程并发情境下避免一些由于共享变量竞争访问导致的并发问题。

我们来看看广为大家诟病的 SimpleDateFormat,周所周知,这是个多线程不安全的类,我们再次回顾下以前的内容:

SimpleDateFormat 是一个用于格式化日期和字符串的工具类,主要有两个核心方法,format 和 parse,前者用于将一个日期转换成指定格式的字符串,后者用于将一个指定格式的字符串转换成一个日期对象。

但是,这两个方法都不是线程安全的,format 方法倒还好,最多导致传入的 Date 格式化成错误的值,而 parse 将直接导致多种异常。原因很简单,他们公用了同一个局部变量。

image

format 方法的第一个行就是将传入的 Date 对象保存到父类 DateFormat 的字段 calendar 上,然后会在后面逻辑中读取这个 Date 实例并完成转换字符串的逻辑。

但是完全有可能在你设置完日期时间后,其他线程也执行 format 方法并覆盖了你的日期时间 calendar 中的值,这样你后续的转换字符串的动作基于的日期已经不再是传入的日期对象了,导致的最终结果就是错误将别人的日期 Date 转换成字符串并返回了。

不信,你看这么一段代码:

image

执行后,我给你找一个错误的数据打印日志:

image

明显的是构造的上一个线程传入的 Date 参数,也就是在格式化的过程中被别的线程覆盖了自己传入的 Date 导致的错误的格式化数据。

parse 方法的线程不安全就不带大家重现了,它更严重,因为方法内部会执行一个 clear 操作清空 calendar 字段保存的值,并且还是非线程安全式的清空,会导致某些其他线程发生转换异常的,具体的大家可以自己去看。

而我们简单的使用 ThreadLocal 就可以解决上述 format 的线程不安全问题:

image

ThreadLocal 的 set 方法将导致每个线程的内部都持有一个 SimpleDateFormat 的实例,自己用自己的,也就不存在因为共享变量而导致的数据一致性问题了。

以上,我们介绍了 ThreadLocal 的两种不同的使用场景,其中第二种更加的常见一点,下面我们来看原理。

基本原理

ThreadLocal 在使用上还是很简单的,但是其内部实现以及与各个线程的关联还是有些绕的,接下来我们深入去看看。

基本字段属性

image

除了 threadLocalHashCode 是一个常量,每当创建一个新的 ThreadLocal 实例的时候就会根据 nextHashCode 和 HASH_INCREMENT 去计算初始的赋值。

因为 nextHashCode 是静态的,是类共享的,所以,每创建一个 ThreadLocal 实例,它的 threadLocalHashCode 是前一个实例的基础上加固定常量 0x61c88647

这个值经换算是一个斐波那契数,每次增量该常量可以分散 hash 值的分布,减少后续在 map 中定位保存数据时产生冲突。

内部类 ThreadLocalMap

ThreadLocalMap 的内部实现是很类似 HashMap 的内部实现的,如果你分析过 HashMap,这一块会容易理解很多,下面我们看其中重要的几个字段:

image

首先,Entry 这个类是 ThreadLocalMap 中定义的内部类,很简单,保存了两个主要内容,一个是 ThreadLocal 的局部变量,一个是 Object 类型的 value 值。

INITIAL_CAPACITY 指定了 table 的初始化容量,或者说是默认的数组初始化长度。

size 指定了 table 中实际有效的 Entry 数量。

threshold 是一个阈值的概念抽象,当 table 的 size 达到了这个阈值,就会触发一个动态扩容动作,扩容 table。

所以,对于 ThreadLocal 的一个不太恰当的理解是,它只是一个封装了 hashCode 的 key,这个 key 决定了我们的 value 该保存在 ThreadLocalMap 内部 table 的哪个位置。

这一点也在它的构造函数中也可见一斑:

image

这个 i 就是当前 Entry 要保存在 table 上的具体索引,它是如何计算的?

就是用我们的 key(ThreadLocal 实例)内部保存的 hashcode 取余 table 容量计算而来。

threshold 会被设置为 table 容量的三分之二。

至于其中的 set、get 方法我们待会分析,至此 ThreadLocal 中已经不剩下什么重要的东西了,虽然 ThreadLocalMap 是 ThreadLocal 的内部类,但是与 ThreadLocal 所表现出来的语义并没有很密切的关系,可能为了某些安全性吧,将 ThreadLocalMap 定义为了 ThreadLocal 的静态内部类。

set、get方法原理

介绍之前,我们先看 Thread 类中的一个字段:

image

Thread 类中持有了两个 ThreadLocalMap 实例,两个实例稍有区别,inheritableThreadLocals 相比于 threadLocals 来说具有更大的特殊性。

区别在于,如果父线程(即创建自己的那个线程)使用了 inheritableThreadLocals 存储线程本地变量,那么本线程的创建过程中也会使用 inheritableThreadLocals 进行本地变量的存储并且将父线程中所有的本地变量进行一份拷贝,填充到自己的 inheritableThreadLocals 中。

具体怎么实现的大家可以自行去查看,jdk 中重新定义了一个 InheritableThreadLocal 类,继承的 ThreadLocal 并重写了其中的 getMap 方法,导致你外部的 get 操作会转而返回 inheritableThreadLocals 而不再是 threadLocals。

现在我们来看 ThreadLocal 的 set 方法:

image

set 方法还是很简单的,获取当前线程内部的 ThreadLocalMap 实例,如果不是空的就往里面增加一条记录,反之先初始化一个 map 再增加一条记录进去。

核心还是在 ThreadLocalMap 的 set 方法:

image

这个方法的大体逻辑如下:

  1. 根据 ThreadLocal 这个 key 计算出当前节点应该保存在 table 的哪个索引位置
  2. 如果该位置上不是空,产生了 hash 冲突,被别的节点提前占有了。那么会将该节点保存在 i+1 的索引位置上
  3. 如果该位置是空,那么将自己挂在这个位置上
  4. 最后,如果添加结束后,发现 table 中有效节点数达到了阈值 threshold,那么将调用 rehash 方法进行一次扩容并转移数据的过程。

可能有些细心的人会疑问,为什么整个方法内没看到一行处理并发的同步语句?

有这样的疑问,你可能还没有完全理解 ThreadLocal 的设计思路,ThreadLocalMap 已经是线程的私有领地了,别的线程是不可能访问的到的,又何来同步问题?

get 方法:

image

既然存是用的 ThreadLocal 实例作为 key,取自然也是根据该实例进行 get 了,并不难理解。

到这里,关于 ThreadLocal 基本的类结构体系、与 Thread 的关联关系,以及核心的 set、get 方法逻辑实现我们都予以了分析,不知道你理解的怎样了呢?欢迎你和我交流!

内存泄露

在这之前,我们关注一个问题,很多人对 ThreadLocal 的一个误解,觉得他是不安全的,会产生『内存泄漏』的问题,我们一起来看看是不是这样。

首先,ThreadLocal 确实是存在『内存泄漏』这个内存隐患的,但是一大堆人把源头指向 Entry 这个节点类。

image

很明显,我们 Entry 将 key 存储为『弱引用』,什么是弱引用这里不再赘述了,而将 value 存储为『强引用』,于是他们的内存结构就是这样的(盗了张图):

image

我们的 ThreadLocal 实例被创建在堆中,方法栈中存在一个对它的强引用,我们的 Entry 实例中存在一个对他的弱引用。

重点来了,有人就认为,一旦我在主程序中丢失了对该实例的强引用,或是赋空了该实例,那么 GC 会无视该实例存在着一个弱引用,而直接回收了该资源,以至于你永远无法访问到该 Entry 实例的 value 属性且无法回收它,所以导致的内存泄漏。

看起来是有道理,但是不使用弱引用就没有内存泄漏了吗?

你换成强引用,会导致整个 Entry 实例都是无用数据,更大的内存泄漏。反而使用弱引用后,当你调用 get 方法的时候,会由于 key 为 null,执行清除逻辑,将 Entry 实例赋 null,最后由 GC 回收该内存资源。

但这始终不能解决 ThreadLocal 的内存泄漏问题,建议的做法是,当某个本地变量不用的时候,手动的调用 remove 方法进行移除。期待 jdk 能更新 ThreadLocal 的实现,代码层解决这个问题。

关注公众不迷路,一个爱分享的程序员。 公众号回复「1024」加作者微信一起探讨学习! 每篇文章用到的所有案例代码素材都会上传我个人 github github.com/SingleYam/o… 欢迎来踩!

YangAM 公众号