java 为什么使用ThreadLocal?什么情况下适用?

我以前写过相关的用法,但是从来没有在现实开发中使用过。最近又看到了这个关键字,趁机研究一下。

 

一、ThreadLocal的用法及作用

在并发编程中时常有这样一种需求:每条线程都需要存取一个同名变量,但每条线程中该变量的值均不相同。

如果是你,该如何实现上述功能?常规的思路如下:

使用一个线程共享的Map<Thread,Object>,Map中的key为线程对象,value即为需要存储的值。那么,我们只需要通过map.get(Thread.currentThread())即可获取本线程中该变量的值。

这种方式确实可以实现我们的需求,但它有何缺点呢?——答案就是:需要同步,效率低!

由于这个map对象需要被所有线程共享,因此需要加锁来保证线程安全性。当然我们可以使用java.util.concurrent包下的ConcurrentHashMap提高并发效率,但这种方法只能降低锁的粒度,不能从根本上避免同步锁。而JDK提供的ThreadLocal就能很好地解决这一问题。

(1)JDK中的定义

首先看看JDK中关于ThreadLocal的定义:

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).

翻译过来大概是:

ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get或set方法访问)时能保证各个线程里的变量相对独立于其他线程内的变量。ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程的上下文。

综上,可以总结为一句话:

ThreadLocal的作用是提供线程内的局部变量,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量的传递的复杂度。

(2)继承Thread类创建线程

根据这篇文章:

java 创建线程的两种不同方式

可以得知,java中有两种创建线程的方式:继承Thread类和实现Runnable接口。两者最大的区别是:“继承Thread类”是让多条线程分别完成自己的任务,“实现Runnable接口”是使用多条线程共同完成一个任务。

再根据这篇文章:

java ThreadLocal保护线程的局部变量

可以得知:ThreadLocal的作用是“保护线程的局部变量”,即“线程中的局部变量归当前线程所属,不同线程之间无法相互干涉”。

事实上,如果我使用“继承Thread类”的方式创建一条线程,那么这条线程中的局部变量已经是“别的线程无法干涉的”,也就是说,在这种情况下,无需特地使用ThreadLocal。

(3)用法

请参考:

java ThreadLocal保护线程的局部变量

二、ThreadLocal的原理

(1)简单理解

可以简单理解为:

每个Thread对象的内部都有一个ThreadLocalMap(注意是Thread自己维护的一个ThreadLocalMap),当线程访问ThreadLocal对象时,会在线程内部的ThreadLocalMap新建一个Entry。这样,每个线程内都会有一个ThreadLocal对象的副本,(因为线程无法干涉其他线程私有的ThreadLocal对象)就保证了并发场景下的线程安全。(说到底,最终目的还是为了线程安全)

那么问题来了:ThreadLocal和同步都能实现线程安全,两者之间有什么区别呢?

个人认为:解决问题的思想不一样。对于多线程资源共享的问题,同步机制采用了“以时间换空间”的方式,ThreadLocal采用了“以空间换时间”的方式。前者仅提供一份变量,让不同的线程排队访问,后者为每一个线程都提供了一份变量副本,因此可以同时访问而互不影响。

(2)具体的实现

请参考:https://zhuanlan.zhihu.com/p/20213204中的图片:

9671b789e1da4f760483456c03e4f4b6_hd

之所以只参考其图片,是因为文章内容似乎有点问题,和我的个人理解不一样(图片倒是一点问题都没有)。

对照着示意图,设计原理为:

每个Thread维护一个ThreadLocalMap映射表,这个映射表的key是ThreadLocal实例本身,value是真正需要存储的Object。

先看看set方法,有助于我们理解key和value是什么:

看到map.set(this, value),我们就知道key是ThreadLocal对象本身,value是我们要维护的对象。

再解析一下ThreadLocal提供的get方法,实质上是对ThreadLocalMap的操作(和HashMap差不多):

  1. 首先获取当前线程对象。
  2. 获取当前线程的ThreadLocalMap对象。
  3. 如果获取的ThreadLocalMap对象不为空,则在ThreadLocalMap中以ThreadLocal实例作为key来在Map中获取对应的value e,否则转到5。
  4. 如果e不为null,则返回e.value,否则转到5。
  5. 如果ThreadLocalMap为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用(即ThreadLocal对象)和value作为firstKey和firstValue创建一个新的ThreadLocalMap。

(3)写得更好的文章

写完这篇文章的第二天,我在知乎关注的一个前辈也发了一篇解析ThreadLocal的文章…看完之后更加深了我的理解。

参考:https://zhuanlan.zhihu.com/p/34494674

文中提到了ThreadLocal的内存泄漏问题:

在ThreadLocalMap中,只有key是弱引用,value仍然是一个强引用。当某一条线程中的ThreadLocal使用完毕,没有强引用指向它的时候,这个key指向的对象就会被垃圾收集器回收,从而这个key就变成了null;然而,此时value和value指向的对象之间仍然是强引用关系,只要这种关系不解除,value指向的对象永远不会被垃圾收集器回收,从而导致内存泄漏!

java中的ThreadLocal往往是这样使用的:

这里的ThreadLocalMap对象本身就是ThreadLocalMap中的key,是一个弱引用。i是ThreadLocalMap中对应的value,是一个强引用。

那么问题来了,为什么说key是一个弱引用?

我们看看ThreadLocalMap中Entry的源码(ThreadLocalMap就是由Entry组成的):

Entry继承自WeakReference<ThreadLocal<?>>,一个EntryThreadLocal对象和Object构成。由此可见,Entry的key是一个ThreadLocal对象,并且是弱引用。如果没有指向这个key的强引用,该key就会被垃圾收集器回收。与此同时,value还存在着强引用(value = v),只有Thread退出以后,value的强引用链条才会断掉,value对象才会被回收。

也就是说,一旦某条线程中的ThreadLocal使用完毕(主动把ThreadLocal的引用置为null),如果ThreadLocal对象被GC回收掉了,那么key将变成null。value就会留在内存中(因为线程还没结束(在线程池中甚至永远无法结束),还有强引用关联着,不会被GC),容易造成内存泄漏。

为了解决这个问题,java做的处理是:

每次操作set、get、remove操作时,ThreadLocal都会将key为null的Entry删除,从而避免内存泄漏。

意思是:key是个弱引用,而且被GC掉了,我认为key不再被程序需要,那么value我也帮你清理掉好了!

这样就没问题了吗?如果一个线程运行周期较长,而且将一个大对象放入LocalThreadMap后便不再调用set、get、remove方法,此时该仍然可能会导致内存泄漏。

这个问题确实存在,而且没办法通过ThreadLocal解决…所以,程序员在完成ThreadLocal的使用后,要养成手动调用remove的习惯,从而避免内存泄漏。

三、适用场景

(1)保护某些对象

某些对象在并发访问时可能出现问题,这时就适合使用ThreadLocal。

1.SimpleDataFormat

比如使用SimpleDataFormat的parse()方法,内部有一个Calendar对象。在调用SimpleDataFormat的parse()方法时,会先调用Calendar.clear(),然后调用Calendar.add()。

那么问题来了:在并发条件下,如果一个线程先调用了add(),另一个线程又调用了clear(),那么,parse()方法解析出来的时间几乎百分之百会有问题。

解决方法也比较简单,SimpleDataFormat对象设置为线程私有即可。对此,我们可以定义一个ThreadLocal<SimpleDataFormat>,直接搞定。

2.Random

Random实例包括java.util.Random的实例,Math.random()的实例。

虽然多线程共享同一Random实例是线程安全的,但是,生成随机数时需要用到一个seed,这个seed会被众多线程竞争。如果竞争激烈,将出现性能问题。

因此,有两种解决方案:

  • 在JDK7之后,可以直接使用API ThreadLocalRandom。
  • 在JDK7之前,可以把Random放在ThreadLocal里,只在本线程中使用。

(2)体现“绑定”的效果

在Spring事务中,事务是和线程绑定起来的。Spring框架在事务开始时,会给当前线程绑定一个Jdbc Connection,在整个事务过程中,都用该线程绑定的connection执行数据库操作,从而实现事务的隔离性。

(隔离性(Isolation):一个事务的执行过程中不能影响到其他事务的执行,即一个事务内部的操作及使用的数据对其他事务是隔离的,并发执行各个事务之间无不干扰。)

Spring是如何“绑定线程和唯一Jdbc Connection”的呢?答案就是使用ThreadLocal。

四、总结

还是要找机会多用,才能有更深的理解。

发表评论

电子邮件地址不会被公开。 必填项已用*标注