深入理解 ThreadLocal:拨开迷雾,探究本质
ThreadLocal 是 JDK1.2 提供的一个工具,它为解决多线程程序的并发问题提供了一种新的思路。使用这个工具类可以很简洁地编写出优美的多线程程序,解决共享参数的频繁传递与线程安全等问题[^1]。如果开发者掌握了 ThreadLocal 用法与原理,那么使用起来将得心应手,那么请跟随本文的节奏,拨开迷雾,探究本质吧!
本文将带领读者深入理解 ThreadLocal,为了保证阅读质量,我们可以先一起来简单理解一下什么是 ThreadLocal?如果你从字面上来理解,很容易将 ThreadLocal 理解为『本地线程
』,那么你就大错特错了。首先,ThreadLocal 不是线程,更不是本地线程,而是 Thread 的局部变量,也许把它命名为ThreadLocalVariable
更容易让人理解一些。它是每个线程独享的本地变量,每个线程都有自己的 ThreadLocal,它们是线程隔离的。接下来,我们通过一个生活案例来开始理解 ThreadLocal。
一、问题场景引入
假如语文老师有一本书,但是班上有 30 名学生,老师将这本书送给学生们去阅读,30 名学生都想阅读这本书,为保证每个学生都能阅读到书籍,那么基本可以有两种方案,一是按照某种排序(例如姓名首字母排序),让每个学生依次阅读。二是让 30 名学生同时争抢,谁抢到谁就去阅读,读完放回原处,剩下的 29 名学生再次争抢。显然第一种方案,基本表现为串行阅读,时间成本较大,第二种方案为多个学生争抢,容易发生安全问题(学生发生冲突或者书籍在争抢过程中被毁坏)。为了解决这两个问题,那么有没有更加好的方案呢?当然有,老师可以将书籍复印 30 本,每个学生都发一本,这样既大大提高了阅读效率,节约了阅读时间,还能保证每个学生都能有自己的书籍,这样就不会发生争抢,避免了安全问题。
其实阅读到这里,读者应该有点感觉了,因为生动的例子能帮助读者迅速理解关键点,在本例中,书籍作为共享变量,那么很多学生去争抢,学生可以理解为线程,同时去争抢(并发执行)有很大可能会引起安全问题(线程安全问题),这往往是老师不愿意看到的后果。
我们在结合 Java Demo 来演示类似的案例。假如我们有一个需求,那就是在多线程环境下,去格式化时间为指定格式yyyy-MM-dd HH:mm:ss
,假设一开始只有两个线程需要这么做,代码如下:
在线程少的情况下是没有问题的,我们在每个线程里调用 date 方法,也就是在每个线程里都执行了创建 SimpleDateFormat 对象,每个对象在各自的线程里面执行格式化时间,但是我们是否会思考到,假如有 1000 个线程需要格式化时间,那么需要调用 1000 次 date 方法,也就是需要创建 1000 个作用一样的 SimpleDateFormat 对象,这样是不是太浪费内存了?也给 GC 带来压力?于是我们联想到,1000 个线程来共享一个 SimpleDateFormat 对象,这样 SimpleDateFormat 对象只需要创建一次即可,代码如下:
上述代码我们使用到了固定线程数的线程池来执行时间格式化任务,我们来执行一下,看看结果:
截取了部分执行结果,发现执行结果中有很多重复的时间格式化内容,这是为什么呢?这是因为 SimpleDateFormat 是一个线程不安全的类,其实例对象在多线程环境下作为共享数据,会发生线程不安全问题。说到这里,很多读者肯定会说,我们可以尝试一下使用锁机制,我们将 date 方法内的格式化代码使用synchronized
关键字概括起来,保证同一时刻只能有一个线程来访问 SimpleDateFormat 的 format 方法,代码如下所示:
有了锁的保证,那么这次执行后就不会再出现重复的时间格式化结果了,这也就保证了线程安全。使用锁机制确实可以解决问题,但是多数情况下,我们不大愿意使用锁,因为锁的使用会带来性能的下降(比如 10 个线程重复排队执行DATE_FORMAT.format(date)
代码),那么有没有其他方法来解决这个问题呢?答案当然是有,那就是本文的主角——ThreadLocal。
二、理解 ThreadLocal 的用法
这里还是使用固定线程数的线程池来执行格式化时间的任务,我们的基本思想是,使用 ThreadLocal 来给线程池中每个线程赋予一个 SimpleDateFormat 对象副本,该副本只能被当前线程使用,是当前线程独享的成员变量,当 SimpleDateFormat 对象不存在多线程共同访问的时候,也就不会产生线程安全问题了,基本原理图如下所示:
我们使用 ThreadLocal 的目的是为了避免创建 1000 个 SimpleDateFormat 对象,且在不使用锁的情况下保证线程安全,那么如何实现只创建一个 SimpleDateFormat 对象且能被多个线程同时使用呢?改造后的案例代码如下所示:
上面的代码使用到了 ThreadLocal,将 SimpleDateFormat 对象用 ThreadLocal 包装了一层,使得多个线程内部都有一个 SimpleDateFormat 对象副本,每个线程使用自己的 SimpleDateFormat,这样就不会产生线程安全问题了。那么以上介绍的是 ThreadLocal 的第一大场景的使用,也就是利用到了 ThreadLocal 的initialValue()
方法,使得每个线程内都具备了一个 SimpleDateFormat 副本。接下来我们一起来看看 ThreadLocal 的第二大使用场景,在使用之前,我们先把两个场景总结如下:
场景 1:每个线程需要一个独享的对象,通常是工具类,比如典型的 SimpleDateFormat 和 Random 等。
场景 2:每个线程内需要保存线程内的全局变量,这样线程在执行多个方法的时候,可以在多个方法中获取这个线程内的全局变量,避免了过度参数传递的问题。
那么如何理解第二个问题呢?我们还是使用一个 Demo 来理解:假设有一个学生类,类成员变量包括姓名,性别,成绩,我们需要定义三个方法来分别获取学生的姓名、性别和成绩,那么我们传统的做法是:
从上面的代码中可以看出,每个类的方法都需要传递学生的信息才可以获取到正确的信息,这样做能达到目的,但是每个方法都需要学生信息作为入参,这样未免有点繁琐,且在实际使用中通常在每个方法里面还需要对每个学生信息进行判空,这样的代码显得十分冗余,不利于维护。也许有人会说,我们可以将学生信息存入到一个共享的 Map 中,需要学生信息的时候直接去 Map 中取,如下图所示:
其实这也是一种思路,但是在并发环境下,如果要使用 Map,那么就需要使用同步的 Map,比如 ConcurrentHashMap 或者 Collections.SynchronizedMap(),前者底层用的是 CAS 和锁机制,后者直接使用的是 synchronized,性能也不尽人意。
其实,我们可以将学生信息存入到 ThreadLocal 中,在同一个线程中,那么直接从 ThreadLocal 中获取需要的信息即可!案例代码如下所示:
上面的代码就省去了频繁的传递参数,也没有使用到锁机制,同样满足了需求,思想其实和上面将学生信息存储到 Map 中的思想差不多,只不过这里不是将学生信息存储到 Map 中,而是存储到了 ThreadLocal 中,原理图如下所示:
那么总结这两种用法,通常分别用在不同的场景里:
场景一:通常多线程之间需要拥有同一个对象的副本,那么通常就采用 initialValue()方法进行初始化,直接将需要拥有的对象存储到 ThreadLocal 中。
场景二:如果多个线程中存储不同的信息,为了方便在其他方法里面获取到信息,那么这种场景适合使用 set()方法。例如,在拦截器生成的用户信息,用 ThreadLocal.set 直接放入到 ThreadLocal 中去,以便在后续的方法中取出来使用。
三、理解 ThreadLocal 原理
3.1 理解 ThreadLocalMap 数据结构
通过本文的第二小节的介绍,相信大家基本上可以掌握 ThreadLocal 的基本使用方法,接下来,我们来一起阅读 ThreadLocal 源码,从源码角度来真正理解 ThreadLocal。在阅读源码之前,我们一起来看看一张图片:
上图中基本描述出了 Thread、ThreadLocalMap 以及 ThreadLocal 三者之间的包含关系。Thread 类对象中维护了 ThreadLocalMap 成员变量,而 ThreadLocalMap 维护了以 ThreadLocal 为 key,需要存储的数据为 value 的 Entry 数组。这是它们三者之间的基本包含关系,我们需要进一步到源码中寻找踪迹。
查看 Thread 类,内部维护了两个变量,threadLocals 和 inheritableThreadLocals,它们的默认值是 null,它们的类型是ThreadLocal.ThreadLocalMap
,也就是 ThreadLocal 类的一个静态内部类 ThreadLocalMap。在静态内部类 ThreadLocalMap 维护一个数据结构类型为 Entry 的数组,节点类型如下代码所示:
从源码中我们可以看到,Entry 结构实际上是继承了一个 ThreadLocal 类型的弱引用并将其作为 key,value 为 Object 类型。这里使用弱引用是否会产生问题,我们这里暂时不讨论,在文章结束的时候一起讨论一下,暂且可以理解 key 就是 ThreadLocal 对象。对于 ThreadLocalMap,我们一起来了解一下其内部的变量:
这几个变量和 HashMap 中的变量十分类似,功能也类似,有兴趣的读者可以阅读另外一篇文章《深入理解JDK7 HashMap》。
ThreadLocalMap 的构造方法如下所示:
从构造方法的注释中可以了解到,该构造方法是懒加载的,只有当我们创建一个 Entry 对象并需要放入到 Entry 数组的时候才会去初始化 Entry 数组。分析到这里,也许我们都有一个疑问,平常使用 ThreadLocal 功能都是借助 ThreadLocal 对象来操作的,比如 set、get、remove 等,使用上都屏蔽了 ThreadLocalMap 的 API,那么到底是如何做到的呢?我们一起继续看下面的代码。
3.2 理解 ThreadLocal 类 set 方法
试想我们一个请求对应一个线程,我们可能需要在请求到达拦截器之后,可能需要校验当前请求的用户信息,那么校验通过的用户信息通常都放入到 ThreadLocalMap 中,以方便在后续的方法中直接从 ThreadLocalMap 中获取,但是我们并没有直接操作 ThreadLocalMap 来存取数据,而是通过一个静态的 ThreadLocal 变量来操作,我们从上面的图可以看出,ThreadLocalMap 中存储的键其实就是 ThreadLocal 的弱引用所关联的对象,那么键是如何操作类似 HashMap 的值的呢?我们一起来分析一下 set 方法:
上面的 set 方法是 ThreadLocal 的 set 方法,就是为了将指定的值存入到指定线程的 threadLocals 成员变量所指向的 ThreadLocalMap 对象中,那么具体是如何存取的,其实调用的还是 ThreadLocalMap 的 set 方法,源码分析如下所示:
这里的代码核心的地方就是 for 循环这一块,代码上面加了详细的注释,这里在复述一遍:
线性遍历,首先获取到指定下标的 Entry 对象,如果不为空,则进入到 for 循环体内,判断当前的 ThreadLocal 对象是否是同一个对象,如果是,那么直接进行值替换,并结束方法,如果不是,再判断当前 Entry 的 key 是否失效,如果失效,则直接将失效的 key 和值进行替换。这两点都不满足的话,那么就调用 nextIndex 方法进行搜寻下一个合适的位置,进行同样的操作,直到找到某个位置,内部数据为空,也就是 Entry 为 null,那么就直接将键值对设置到这个位置上。最后判断是否达到了扩容的条件,如果达到了,那么就进行扩容。
这里有两点需要注意:一是 nextIndex 方法,二是 key 失效,这里先解释第一个注意点,第二个注意点涉及到弱引用 JVM GC 问题,文章最后做出解释。
nextIndex 方法的具体代码如下所示:
其实就是寻找下一个合适位置,找到最后一个后还不合适的话,那么从数组头部重新开始找,且一定可以找到,因为存在扩容阈值,数组必定有冗余的位置存放当前键值对所对应的 Entry 对象。其实 nextIndex 方法就是大名鼎鼎的『开放寻址法
』的应用。这一点和 HashMap 不一样,HashMap 存储 HashEntry 对象发生哈希冲突的时候采用的是链表方式进行存储,而这里是去寻找下一个合适的位置,思想就是『开放寻址法
』。
3.3 理解 ThreadLocal 类 get 方法
在实际的开发中,我们往往需要在代码中调用 ThreadLocal 对象的 get 方法来获取存储在 ThreadLocalMap 中的数据,具体的源码如下所示:
值初始化过程是这样的一个过程,如果调用新的 ThreadLocal 对象的 get 方法,那么在当前线程的成员变量 threadLocals 中必定不存在 key 为当前 ThreadLocal 对象的 Entry 对象,那么这里值初始话就将此 ThreadLocal 对象作为 key,null 作为值存储到 ThreadLocalMap 的 Entry 数组中。
3.4 理解 ThreadLocal 的 remove 方法
使用 ThreadLocal 这个工具的时候,一般提倡使用完后及时清理存储在 ThreadLocalMap 中的值,防止内存泄露。这里一起来看下 ThreadLocal 的 remove 方法。
看了这么多 ThreadLocal 的源码实现,其实原理还是很简单的,基本上可以说是一看就懂,理解 ThreadLocal 原理,其实就是需要理清 Thread、ThreadLocal、ThreadLocalMap 三者之间的关系,这里加以总结:
线程类 Thread 内部持有 ThreadLocalMap 的成员变量,而 ThreadLocalMap 是 ThreadLocal 的内部类,ThreadLocal 操作了 ThreadLocalMap 对象内部的数据,对外暴露的都是 ThreadLocal 的方法 API,隐藏了 ThreadLocalMap 的具体实现,理清了这一点,ThreadLocal 就很容易理解了。
四、理解 ThreadLocalMap 内存泄露问题
这里所说的 ThreadLocal 的内存泄露问题,其实都是从 ThreadLocalMap 中的一段代码说起的,这段代码就是 Entry 的构造方法:
这里简单介绍一下 Java 内的四大引用:
强引用:Java 中默认的引用类型,一个对象如果具有强引用那么只要这种引用还存在就不会被回收。比如
String str = new String("Hello ThreadLocal");
,其中 str 就是一个强引用,当然,一旦强引用出了其作用域,那么强引用随着方法弹出线程栈,那么它所指向的对象将在合适的时机被 JVM 垃圾收集器回收。软引用:如果一个对象具有软引用,在 JVM 发生内存溢出之前(即内存充足够使用),是不会 GC 这个对象的;只有到 JVM 内存不足的时候才会调用垃圾回收期回收掉这个对象。软引用和一个引用队列联合使用,如果软引用所引用的对象被回收之后,该引用就会加入到与之关联的引用队列中。
弱引用:这里讨论 ThreadLocalMap 中的 Entry 类的重点,如果一个对象只具有弱引用,那么这个对象就会被垃圾回收器回收掉(被弱引用所引用的对象只能生存到下一次 GC 之前,当发生 GC 时候,无论当前内存是否足够,弱引用所引用的对象都会被回收掉)。弱引用也是和一个引用队列联合使用,如果弱引用的对象被垃圾回收期回收掉,JVM 会将这个引用加入到与之关联的引用队列中。若引用的对象可以通过弱引用的 get 方法得到,当引用的对象被回收掉之后,再调用 get 方法就会返回 null。
虚引用:虚引用是所有引用中最弱的一种引用,其存在就是为了将关联虚引用的对象在被 GC 掉之后收到一个通知。
我们从 ThreadLocal 的内部静态类 Entry 的代码设计可知,ThreadLocal 的引用 k 通过构造方法传递给了 Entry 类的父类 WeakReference 的构造方法,从这个层面来说,可以理解 ThreadLocalMap 中的键是 ThreadLocal 的所引用。
当一个线程调用 ThreadLocal 的 set 方法设置变量的时候,当前线程的 ThreadLocalMap 就会存放一个记录,这个记录的键为 ThreadLocal 的弱引用,value 就是通过 set 设置的值,这个 value 值被强引用。如果当前线程一直存在且没有调用该 ThreadLocal 的 remove 方法,如果这个时候别的地方还有对 ThreadLocal 的引用,那么当前线程中的 ThreadLocalMap 中会存在对 ThreadLocal 变量的引用和 value 对象的引用,是不会释放的,就会造成内存泄漏。考虑这个 ThreadLocal 变量没有其他强依赖,如果当前线程还存在,由于线程的 ThreadLocalMap 里面的 key 是弱引用,所以当前线程的 ThreadLocalMap 里面的 ThreadLocal 变量的弱引用在垃圾回收的时候就被回收,但是对应的 value 还是存在的这就可能造成内存泄漏(因为这个时候 ThreadLocalMap 会存在 key 为 null 但是 value 不为 null 的 entry 项)。
总结:ThreadLocalMap 中的 Entry 的 key 使用的是 ThreadLocal 对象的弱引用,在没有其他地方对 ThreadLocal 依赖,ThreadLocalMap 中的 ThreadLocal 对象就会被回收掉,但是对应的值不会被回收,这个时候 Map 中就可能存在 key 为 null 但是值不为 null 的项,所以在使用 ThreadLocal 的时候要养成及时 remove 的习惯。
[^1]: 引用自百度百科:ThreadLocal
了解更多干货,欢迎关注我的微信公众号:爪哇论剑(微信号:itlemon)
版权声明: 本文为 InfoQ 作者【itlemon】的原创文章。
原文链接:【http://xie.infoq.cn/article/0ea12b328c08f049a4b72248a】。文章转载请联系作者。
评论