Java 中的 ThreadLocal
Contents
使用 Java 中的 ThreadLocal 类可以创建只能由同一个线程读写的变量。也就是说,即使两个线程在执行相同的代码,并且代码中都有对同一个 ThreadLocal 变量的引用,那么这两个线程也不会看到对方的 ThreadLocal 变量。因此,Java 中的 ThreadLocal 类提供了一种简单的方式来使代码中的线程变得更加安全。
概述
在多线程并发的情况下,需要考虑到线程安全的问题,例如同步、互斥等。一般采用以下三种方式来实现线程安全:
- 互斥同步:通过加锁来实现对临界资源的访问,如 synchronized 和 Lock;
- 非阻塞同步:上面的互斥同步属于悲观锁机制,而非阻塞同步属于乐观锁机制,典型的实现方式是 CAS;
- 无同步:要保证线程安全,并不一定就需要同步。同步只是保证共享数据争用时正确的手段。如果一个方法本来就不涉及共享数据,那它就不需要任何同步措施去保证正确性。因此就引出了 ThreadLocal 的概念。
从字面上看,ThreadLocal 就是线程本地
或线程局部
的意思,但该类提供了线程本地变量(thread-local variables),这种变量与普通变量的不同之处在于:每个访问 ThreadLocal 变量的线程都有自己、独立的变量副本,并且每个线程都可以独立的改变自己的副本,不会与其它线程中的副本发生冲突。
也就是说,如果想要将某些共享数据的可见范围限制在同一个线程之内,并且想要达到无需同步也能保证线程之间不会出现数据争用的问题,此时就可以使用 ThreadLocal。即如果某个变量想要被某个线程独享,则可以使用 ThreadLocal 来实现线程本地的功能。
从另一方面想,只要线程还存活,并且被 ThreadLocal 变量可以访问,则每个线程都会有一个引用到该线程本地变量的副本。当线程结束执行后,它的所有的线程本地实例的副本就会被垃圾回收。
JDK1.8 中的 ThreadLocal 定义如下所示:
|
|
从定义来看,ThreadLocal 在声明变量时可以自定义变量所属的类型,在其附带的说明文档中,需要注意以下几点:
- 每个线程都有关于该 ThreadLocal 变量的私有值,即每个线程都有一个独立于其它线程的变量值,并且对其它线程是不可见的;
- 该 ThreadLocal 变量的
初始值
也是相互独立的,即 ThreadLocal 可以给定一个初始值,这样每个线程就会获得这个初始值的一个拷贝,并且就每个线程对这个值的改变这一动作来说,对其它线程也是不可见的; - ThreadLocal 的作用就是将类的状态和线程关联起来,此时可以通过声明一个 private static ThreadLocal 实例进行实现。
综上所述,这里大体概括一下 ThreadLocal 的实现思路:
- 在 Thrad 类中有一个类型为 ThreadLocal.ThreadLocalMap 的实例变量 threadLocals,即每个线程都拥有一个自己的 ThreadLocalMap;
- ThreadLocalMap 位于 ThreadLocal 类中,并且有自己具体的实现方式,可以将它的 key 视为 ThreadLocal,value 视为代码中放入的值,但实际上 key 并不是 ThreadLocal 本身,而是它的一个弱引用;
- 每个线程在往某个 ThreadLocal 里放入值的时候,都会往自己的 ThreadLocalMap 里存,读的时候也是以某个 ThreadLocal 作为引用,在自己的 map 里找对应的 key,从而实现线程隔离。
基本使用
下面通过一个具体的实例,来说明 ThreadLocal 的基本还是用。
|
|
输出结果(顺序不唯一)如下所示:
|
|
实例中,ThreadA 类的 run 方法中初始化了 ThreadLocal 变量,调用 set(a + 10) 方法将其设置成了 20,随后执行了 remove 方法,此时该变量的值就为 null 了。然而,当 ThreadB 线程想要获得该值时,由于每个线程都有自己的一份变量,因此 ThreadB 线程获得的值也是 null,这个 null 值与 ThreadA 中的 remove 方法是没有关系的,也就是说,ThreadA 的 remove 只是删除了它自己的值,而不能删除其它线程中的值。
我们先从总体
再到局部
,通过这种方式来分析 ThreadLocal 类。从总体上来说,ThreadLocal 类中有一个静态内部类 ThreadLocalMap,这个 ThreadLocalMap 是自定义的哈希表,用于维护线程本地值。哈希表里面的每个位置就对应一个 Entry 对象,而每个 Entry 对象中存放了键值对,其中 key 表示的是 ThreadLocal 实例,而 value 表示的是该 ThreadLocal 所对应的值。
下面先看 内部类 ThreadLocalMap。
ThreadLocalMap 类
ThreadLocalMap 是 ThreadLocal 的重点,它专门为 ThreadLocal 定制了一种高效的实现,并且自带一种基于弱引用的垃圾回收机制。
Entry
ThreadLocalMap 中的 map 是概念上的 map,因此它也会有 key 和value。但需要注意的是:为了便于理解,我们可以将 key 视为 ThreadLocal,将 value 视为 实际放入的值。但实际上,ThreadLocal 中存放的是 ThreadLocal 的弱引用。
|
|
Entry 是 ThreadLocalMap 中定义的节点,它继承了 WeakReference 类,然后定义了一个 Object 类型的 value,用于存放塞到 ThreadLocal 里的值。
需要注意的是,为什么 Entry 要继承弱引用?
在垃圾回收机制中,JVM 针对不同对象存在着不同的引用形式,具体在 《JVM 对象存活和垃圾收集算法》中提到过四种引用方式。如果这里使用普通的 key-value 形式来定义存储结构,实际上就会造成节点的生命周期与线程之间的强绑定。只要线程没有被销毁,那么节点在 GC 分析的时候就一直处于引用状态,没办法被回收,同时程序本身也无法判断是否可以清理这个节点。
在四种引用类型(强引用、软引用、弱引用、虚引用)中,弱引用比软引用还要弱一些,如果一个对象没有被强引用,那么一般活不过下次 GC。也就是说,只要一个对象被强引用绑定,那么垃圾收集器就不会回收掉该对象。而一个对象被软引用、弱引用、虚引用绑定后,会被下次垃圾收集器进行回收。当某个 ThreadLocal 已经没有绑定强引用的时候,随着它被垃圾收集器回收,在 ThreadLocalMap 里对应的 Entry 的键值就会失效,这位 ThreadLocalMap 本身的垃圾清理提供了便利。
成员变量
静态内部类 ThreadLocalMap 中定义了用于存储键值对的 Entry 数组,即 table,它的长度必须是 2 的次幂。既然是哈希表,肯定会涉及到初始化容量以及扩容的概念,如下所示:
|
|
- INITIAL_CAPACITY:表示哈希表的初始化容量为 16,必须保证是 2 的次幂;
- table:表示用于存储键值对的 Entry 数组,它的长度也必须是 2 的次幂;
- size:表示当前数组中 entry 的个数;
- threshold:表示当数组中的元素超过了该值时,则进行扩容,再进行扩容时,重新分配 Entry 数组大小的阈值,默认为 0;
成员方法
可以通过 setThreshold() 方法来设置负载因子的大小,负载因子的概念在 HashMap 中已经很熟悉了。然后 ThreadLocal 通过两个方法用来获得上一个或下一个索引。需要注意的是,ThreadLocalMap 使用线性探测
法来解决散列的冲突,所以实际上 Entry 数组在程序逻辑上是以环状数组存在的。数组中元素 Entry 的 key 存储的是某个 ThreadLocal 对象,即指向该 ThreadLocal 对象的弱引用,value 表示往 ThreadLocal 变量实际塞入的值。
|
|
构造函数
该构造函数在初始化的时候,会构造一个包含 firstKey 和 firstValue 的 map。ThreadLocalMap 是通过惰性的方式构造的,所以只有当至少要往里面放一个元素的时候才会构造它。
|
|
在计算 firstKey 的哈希值的时候,使用了hashCode & (size - 1)
的方法,这相当于取模运算hashCode % size
的一个更高效的实现,HashMap 中也采用了类似的方法。正是因为这种方法,所以我们要求 size 必须是 2 的次幂,因为这样可以使得 hash 发生冲突的次数减小。
getEntry()
该方法的作用是返回与 key 关联的 entry 对象,也就是获取 map 中某个 ThreadLocal 存放的值,如下所示:
|
|
因此,ThreadLocal 在读一个值的时候,首先需要根据 key 的 threadLocalHashCode 对数组容量取模得到 index,如果 index 对应的位置就是要读的 ThreadLocal,,则直接返回结果。否则调用 getEntryAfterMiss() 方法进行线性探测,在探测的过程中每次碰到无效的位置,则调用 expungeStaleEntry() 进行清理。如果找到了 key,则返回对应的 entry,没有找到 key,则返回 null。
set()
set() 的作用是设置与 key 对应的 value,这里的 key 就是线程本地对象(thread local object),value 就是准备要设置的值,如下所示:
|
|
整体流程为:
- 在探测过程中,solt(即数组中的每个位置)都不是无效的,并且能够顺利的找到 key 所在的 solt,则直接通过 set() 替换即可。
- 如果在探测过程中,发现 solt 存在无效的情况,则调用 replaceStaleEntry() 把 key 和 value 放在这个 solt 上;
- 如果在探测的过程中没有发现 key,则在连续末尾的后一个空位置上放置一个 entry,然后做一次启发式的清理,即 cleanSomeSlots()。如果没有将 key 清理掉,并且当前 table 的大小已经超过了阈值,则需要进行 rehash() 操作。
需要注意的是,由于需要保证 table 的容量为 2 的次幂,因此 rehash() 里面的 resize() 方法在扩容的时候将会扩大 2 倍,即newLen = oldLen * 2
。
remove()
删除 key 所对应的 entry,也就是从 map 中删除 ThreadLocal。即直接在 table 数组中找 key,如果找到了,那就把弱引用断开,然后做一次段清理。如下所示:
|
|
ThreadLocal 类
get()
get 方法将会返回当前线程的线程本地变量值
,如果对于当前线程来说没有对应的值,则会调用 setInitialValue() 方法并返回此方法的值。
|
|
从上面可以看到,再进行 get 时,如果从当前线程获取到的成员变量不为空的话,则会调用 map.getEntry(this) 去找到对应的值并进行返回。而如果 map 为空,则会调用 setInitialValue() 方法来设置一个初始化值,下面看一下这个方法:
|
|
可以看出,如果 map 为空,则会对 threadLocals 进行初始化,以当前 ThreadLocal 变量为 key,以 ThreadLocal 要保存的值为 value,存储到 threadLocals。get 方法会获得当前线程的 threadLocals,然后以该 ThreadLocal 对象作为键从而取得其对应的值,也就是 ThreadLocal 对象中所存储的值。
然后是 ThreadLocalMap 中的 getEntry 方法,以及 getEntryAfterMiss 方法:
|
|
get 方法的总体流程:
-
- 首先获取当前线程;
-
- 尝试去当前线程中获得它的 ThreadLocal.ThreadLocalMap;
-
- 判断当前线程中是否有 ThreadLocal.ThreadLocalMap:
- 3.1 如果有,则根据当前 ThreadLocal 的 threadLocalHashCode 取模去 table 中取值。有值的话就返回,没有的话就给模加 1 继续找;
- 3.2 如果没有,则调用 setInitialValue 方法给当前线程 ThreadLocal.ThreadLocalMap 设置一个初始值。
set()
set() 方法的大体流程是:
- 首先获得当前的线程;
- 然后获得当前线程中的 ThreadLocal.ThreadLocalMap;
- 判断该 ThreadLocal.ThreadLocalMap 是否存在,如果存在则设置一个值,如果不存在就给线程创建一个 ThreadLocal.ThreadLocalMap。
源码如下:
|
|
再看一下这里的 createMap 方法:
|
|
这里需要注意的是,虽然 Entry 中存储的是键值对,但当 table 中已经有了数据的时候,则采用的是开放地址法,也就是会采用加 1 的方式来到下一个位置继续进行判断。
再来看一下静态内部类 ThreadLocalMap 中的 set 方法:
|
|
set 方法小结:
- 首先对 ThreadLocal 里面的 threadLocalHashCode 取模得到一个 table 中的位置;
- 如果这个位置上有数据,则获取该位置上的 ThreadLocal:
- 判断该位置上的 ThreadLocal 和当前的 ThreadLocal 是不是同一个,如果是的话就覆盖,然后返回;
- 如果不是同一个的话,再判断一下该位置上的 ThreadLocal 是不是为空。因为 Entry 是 ThreadLocal 的弱引用,即 static class Entry extends WeakReference<ThreadLocal> 有可能这个 ThreadLocal 被垃圾回收了,这个时候把新设置的 value 替换到当前位置上,然后返回。
- 如果上面都没有返回,则使用开放地址法进行加 1 操作,即来到下一个 table 的位置。在加 1 操作之后需要判断新的 table 位置是不是为空,如果为空则再次进行加 1 操作,一直找到一个 table 上不是空的为止,然后放入一个 value。
remove()
|
|
在 remove 方法中,同样也是获取到当前线程的 ThreadLocal.ThreadLocalMap,如果有的话则找到对应的 Entry 然后删除即可。
ThreadLocal 小结
-
- 线程里面当前的 ThreadLocal.ThreadLocalMap 是通过开放地址法实现的;
-
- 每次 set 的时候往线程里面的 ThreadLocal.ThreadLocalMap 中的 table 数组中某一个位置上放一个值,该位置由 ThreadLocal 中的 threadLocaltHashCode 取模得到。如果该位置上已经有数据了,则往后一直找,从而找到没有数据的一个位置;
-
- 每次 get 的时候也是根据 ThreadLocal 中的 threadLocaltHashCode 取模得到 ThreadLocal.ThreadLocalMap 中的 table 数组中对应的位置,如果有数据则返回该数据,没有的话就来到下一个位置找;
-
- 如果想要往 ThreadLocal 中存放不同类型的数据,则需要使用泛型,如 ThreadLocal、ThreadLocal 等。
内存泄漏问题
ThreadLocal 类解决的是变量在不同线程间的通信问题。常见的使用场景,如多线程场景下每个线程需要单独的变量实例、数据库连接问题、存储用户的 Session 等。
需要注意的是,如果使用 ThreadLocal 不当的话,则有可能出现内存泄漏
的问题。其中内存泄漏
指的是由于疏忽或者错误造成程序未能释放已经不再使用的内存,这些不再使用的内存会占用着内存空间,从而导致内存泄漏。也就是说,内存泄漏并非指的是内存在物理上的消失,而是应用程序分配某段内存后,由于错误设计,导致在释放该内存之前就失去了对该段内存的控制,从而造成了内存的浪费。
ThreadLocal 造成内存泄漏的原因分为两种场景:主线程仍然对 ThreadLocal 具有引用和主线程不存在对 ThreadLocal 的引用。第一种场景因为主线程仍然在运行,所以还是具有对 ThreadLocal 的引用,那么 ThreadLocal 变量的引用和对应的 value 是不会被回收的。第二种场景是虽然主线程不存在对 ThreadLocal 的引用,并且该引用属于弱引用,所以在 GC 的时候会被回收,但是对应的 value 不是弱引用,不会被回收,因此会造成内存泄漏的问题。
如何解决内存泄漏问题?
既然 ThreadLocalMap 中存在 key 为 null 的 Entry 对象,从而导致内存泄漏,那么只要把这些 Entry 都给删除掉就可以了。因此,可以调用 remove 方法,根据 key 删除掉对应的 Entry 即可。上面提到的 remove 方法里面调用了 ThreadLocalMap 中的 remove 方法,如下所示:
|
|
参考
- http://tutorials.jenkov.com/java-concurrency/threadlocal.html
- https://blog.csdn.net/justloveyou_/article/details/54613085
- https://blog.csdn.net/xlgen157387/article/details/78114278
- https://www.cnblogs.com/xrq730/p/4854820.html
- https://allenwu.itscoder.com/threadlocal-source
- https://www.cnblogs.com/xrq730/p/4854813.html
- https://www.cnblogs.com/micrari/p/6790229.html