首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

ThreadLocal 线程安全机制与小地雷

提示:本文源码分析较多,点击文章末尾的阅读全文,在 PC 浏览器中打开可以获得更好的阅读体验~

Java 多线程类库之于共享数据的读写控制主要采用锁机制来保证线程安全,本文所要探究的 ThreadLocal 则采用了一种完全不同的策略。ThreadLocal 不是用来解决共享数据的并发访问问题的,它让每个线程都将目标数据复制一份作为线程私有,后续对于该数据的操作都是在各自私有的副本上进行,线程之间彼此相互隔离,也就不存在竞争问题。

下面的例子演示了 ThreadLocal 的典型应用场景,在 jdk 1.8 之前,如果我们希望对日期进行格式化操作,需要使用 SimpleDateFormat 类,我们都知道它是是线程不安全的,在多线程并发执行时会出现一些奇怪的问题,而对于该类使用的最佳实践则是采用 ThreadLocal 进行包装,以保证每个线程都有一份属于自己的 SimpleDateFormat 对象,如下所示:

线 程 安 全 机 制

那么 ThreadLocal 是怎么做到让修饰的对象能够在每个线程中各自持有一份呢?我们先来简单的概括一下:在 ThreadLocal 类中定义了一个静态内部类ThreadLocalMap,可以将其理解为一个特有的 Map 类型,而在 Thread 类中声明了一个 ThreadLocalMap 类型的属性 threadLocals,所以针对每个 Thread 对象,也就是每个线程来说都包含了一个 ThreadLocalMap 对象,即每个线程都有一个属于自己的内存数据库,而数据库中存储的就是我们用 ThreadLocal 修饰的对象,这里的 key 就是对应的 ThreadLocal 对象,而 value 就是我们记录在 ThreadLocal 中的值。当希望获取该对象时,我们首先需要拿到当前线程对应的 Thread 对象,然后获取到该对象对应的 threadLocals 属性,也就拿到了线程私有的内存数据库,最后以 ThreadLocal 对象为 key 获取到其修饰的目标值。整个过程还是有点绕的,可以借助下面这幅图进行理解:

1.1 内存数据库 ThreadLocalMap

接下来看一下相应的源码实现,首先来看一下内部定义的 ThreadLocalMap 静态内部类:

ThreadLocalMap 是一个定制的 Map 实现,这里可以简单将其理解为一般的 Map,用作键值存储的内存数据库,至于为什么要专门实现而不是复用已有的 HashMap,我们在后面进行说明。

1.2 ThreadLocal 方法实现

了解了 ThreadLocalMap 的定义,我们再来看一下 ThreadLocal 的实现。对于 ThreadLocal 来说,对外暴露的方法主要有 get、set,以及 remove 三个,下面逐一探究:

获取线程私有值:get()

与一般的 Map 取值操作不同,这里的 get() 并没有要求提供查询的 key,也正如前面所说的,这里的 key 就是调用 get() 方法的对象自身:

如果当前线程对应的内存数据库 map 对象还未创建,则会调用 setInitialValue() 方法执行创建,如果在构造 ThreadLocal 对象时覆盖实现了 initialValue() 方法,则会调用该方法获取构造的初始化值并记录到创建的 map 对象中:

添加线程私有值:set(T value)

再来看一下 set 方法,因为 key 就是当前 ThreadLocal 对象,所以 set 方法也不需要指定 key:

和 get 方法的流程大致一样,都是操作当前线程私有的内存数据库 ThreadLocalMap,并记录目标值。

删除线程私有值:remove()

remove 方法以当前 ThreadLocal 为 key,从当前线程内存数据库 ThreadLocalMap 中删除目标值,具体逻辑比较简单:

ThreadLocal 对外暴露的功能虽然有点小神奇,但是具体对应到内部实现并没有什么复杂的逻辑,如果我们把每个线程持有的专属 ThreadLocalMap 对象理解为当前线程的私有数据库,那么也就不难理解 ThreadLocal 的运行机制,每个线程自己维护自己的数据,彼此相互隔离,不存在竞争,也就没有线程安全问题可言。

真的就高枕无忧了吗?

虽然对于每个线程来说数据是隔离的,但这也不表示任何对象丢到 ThreadLocal 中就万事大吉了,思考一下下面几种情况:

如果记录在 ThreadLocal 中的是一个线程共享的外部对象呢?

引入线程池,情况又会有什么变化?

如果 ThreadLocal 被 static 关键字修饰呢?

先来看第一个问题,如果我们记录的是一个外部线程共享的对象,虽然我们以当前线程私有的 ThreadLocal 对象作为 key 对其进行了存储,但是恶魔终究是恶魔,共享的本质并不会因此而改变,这种情况下的访问还是需要进行同步控制,最好的方法就是从源头屏蔽掉这类问题。我们来举个例子:

以上程序最终的输出如下:

可以看到虽然使用了 ThreadLocal 修饰,但是 list 还是以共享的方式在多个线程之间被访问,如果不加同步控制,则会存在线程安全问题。

再来看第二个问题,相对问题一来说引入线程池就更加可怕,因为大部分时候我们都不会意识到问题的存在,直到代码暴露出奇怪的现象,这个时候并没有违背线程私有的本质,只是一个线程被复用来处理多个业务,而这个被线程私有的对象也会在多个业务之间被 “共享”。例如:

以上程序的最终输出如下:

在我的 8 核处理器上,我用一个大小为 2 的线程池进行了模拟,可以看到初始化方法被调用了两次,所有线程的操作都是复用这两个线程。回忆一下前文所说的,ThreadLocal 的本质就是每个线程维护一个线程私有的内存数据库来记录线程私有的对象,但是在线程池情况下线程是会被复用的,也就是说线程私有的内存数据库也会被复用,如果在一个线程被使用完准备回放到线程池中之前,我们没有对记录在数据库中的数据执行清理,那么这部分数据就会被下一个复用该线程的业务看到,从而间接的共享了该部分数据(哈哈,你的笔记本电脑在送人之前一定要对硬盘执行多次格式化,不然冠希哥会对你微笑哦)。

最后我们再来看一下第三个问题,我们尝试将 ThreadLocal 对象用 static 关键字进行修饰:

以上程序的最终输出如下:

由程序运行结果可以看到 static 修饰并没有引出什么问题,实际上这也是很容易理解的,ThreadLocal 采用 static 修饰仅仅是让数据库中记录的 key 是一样的,但是每个线程的内存数据库还是私有的,并没有被共享,就像不同的公司都有自己的用户信息表,即使一些公司之间的用户 ID 是一样的,但是对应的用户数据却是完全隔离的。

以上例子演示了一开始抛出的三个问题,其中问题一和问题二都是 ThreadLocal 使用过程中的小地雷。例子举的不一定恰当,实际中可能也不一定会如示例中这样去使用 ThreadLocal,主要还是为了传达一些意识。如果明白了 ThreadLocal 的内部实现细节,就能够很自然的绕过这些小地雷。

真的会内存泄露吗?

关于 ThreadLocal 导致内存泄露的问题,曾经有一段时间在网上争得沸沸扬扬,那么到底会不会导致内存泄露呢?这里先给出答案:

如果使用不恰当,存在内存泄露的可能性。

我们来分析一下内存泄露的条件和原因,在最开始看 ThreadLocal 源码的时候,我就有一个疑问,ThreadLocal 为什么要专门实现 ThreadLocalMap,而不是采用已有的 HashMap 代替?后来分析具体实现时看到执行存储时的 key 为当前 ThreadLocal 对象,不需要专门指定 key 能够在一定程度上简化使用,但这并不足以为此专门去实现 ThreadLocalMap。继续阅读我发现 ThreadLocalMap 在实现 Entry 的时候有些奇怪,居然继承了 WeakReference:

从而让 key 成为一个弱引用,我们知道弱引用对象拥有非常短暂的生命周期,在垃圾收集器线程扫描其所管辖的内存区域过程中,一旦发现了弱引用对象,不管当前内存空间是否足够都会回收它的内存。也就是说这样的设计会很容易导致 ThreadLocal 对象被回收,线程所执行任务的时间长度是不固定的,这样的设计能够方便垃圾收集器回收线程私有的变量。

所以作者这样设计的目的是为了防止内存泄露,那怎么就变成了被很多文章所分析的是内存泄漏的导火索呢?这些文章的共同观点就是 key 被回收了,但是 value 是一个强引用没有被回收,这些 value 就变成了一个个的僵尸。这样的分析没有错,value 确实存在,且和线程是同生命周期的,但是如下策略可以保证尽量避免内存泄露:

ThreadLocal 在每次执行 get 和 set 操作的时候都会去清理 key 为 null 的 value 值

value 与线程同生命周期,线程死亡之时,也是 value 被 GC 之日

策略一没啥好说的,看看源码就知道,我们来举例验证一下策略二:

以上程序的最终输出如下:

可以看到 value 最终还是被 GC 了,虽然第一次 GC 的时候没有被回收,这也验证 value 和线程是同生命周期的,之所以示例中等待 60 秒是因为线程默认生命周期是 60 秒,如果生命周期内该线程没有被再次复用则会死亡,我们这里就是要等待线程死亡,一但线程死亡,value 也就被 GC 了。所以 出现内存泄露的前提必须是持有 value 的线程一直存活,这在使用线程池时是很正常的,在这种情况下 value 一直不会被 GC,因为线程对象与 value 之间维护的是强引用。此外就是 后续线程执行的业务一直没有调用 ThreadLocal 的 get 或 set 方法,导致不会主动去删除 key 为 null 的 value 对象,在满足这两个条件下 value 对象一直常驻内存,所以存在内存泄露的可能性。

那么我们应该怎么避免呢?前面我们分析过线程池情况下使用 ThreadLocal 存在小地雷,这里的内存泄露一般也都是发生在线程池的情况下,所以在使用 ThreadLocal 时,对于不再有效的 value 主动调用一下 remove 方法来进行清除,从而消除隐患,这也算是最佳实践吧。

  • 发表于:
  • 原文链接http://kuaibao.qq.com/s/20180408G0BG4200?refer=cp_1026
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券
http://www.vxiaotou.com