ThreadLocal那些事

为什么要有ThreadLocal

在并发编程中,无外乎多进程和多线程方式,但不管是多进程还是多线程,都会有线程安全问题。所以为了并发安全,而并发不安全的本质原因就是多进程/多线程同时去修改同一个变量,可能导致不确定性结果。既然本质是多进程/线程去同时修改同一个变量(数据),那么应对线层不安全的措施也就应运而生了:
1. 不让多个进程/线程去同时修改统一变量,这个就是锁。用锁的方式来保证统一时间只会有一个进程一个线程去修改统一变量。
2. 不允许修改。因为只有多进程/多线程去修改数据才会导致线程不安全,那么不可变的常量是不会有线程安全问题。ps:是真正的不可修改,当心java中final修饰的引用类型。
3. 不共享。不让多个进程/多个线程去共享一个变量,每个线程有自己的副本,修改的是自己的副本,那也就不会有线程安全问题了。ps:进程是分配资源的单位,所以除了进程间通信的共享内存,一般常规的大部分编程场景不会有多进程共享变量的问题。所以对于多线程中不共享的工具就是ThreadLocal

jdk中的ThreadLocal

ThreadLocal及InheritableThreadLocal

在java中,封装了操作系统线程的唯一接口就是:Thread,所以要达到线程独享变量副本的方式可以有两种:

  1.  ThrealLocal中维护一个Map接口,key=Thread对象+变量名,value=对应线程本地变量的值。
  2. 在Thread中保存所有需要独立副本的变量,那每个线程都是封装在了new 的一个Thread中,自然就是线程独享的副本

知道ThreadLocal原理的,都知道,jdk的ThreadLocal实现是第二种,但是jdk的的实现扩展性就会差一些,用户能够自己扩展的部分几乎么有,所以想要自己做一些优化,就会比较麻烦。但是可以尝试下用第一种去实现,会发现问题更多。

所以ThreadLocal实现线程本地存储变量副本的基本接口如下:

  • ThreadLocal#ThreadLocalMap其实就是个key-value的map结构,用来存储当前线程有哪些线程独享的变量,所以key=ThreadLocal对象的hash值;value=变量的值。这个地方Map的实现和java.util下的HashMap实现上有个不一样的地方,就是hash冲突的处理方式,这个地方采用的是二次hash的方式,不是链表法。
  • ThreadLocal其实里面没有存储任务信息,它只是一个访问线程本地变量副本的一个接口,最终的数据是落在Thread中的,数据结构是ThreadLocal#ThreadLocalMap。而且ThreadLocal和Thread在同一个包中,Thread的threadLocals变量是个包可见的,所以ThreadLocal是直接访问了Thread#threadLocals变量的。所以说,通过ThreadLocal读写线程本地变量的时候,首先要计算ThreadLocal的hash值,然后访问Thread#threadLocals,从中获取对应的值。

ThreadLocal是线程本地存储的变量,所以当在使用多线程的时候,如果子线程要能访问到父线程的ThreadLocal变量,那么我们不得不自己做些事情,将父线程的ThreadLocal变量copy到子线程中,其实在实际生产环境中,多线程编程中,这是一个很常见的场景。

所以jdk也提供了一个可以跨线程访问的ThreadLocal的子类:InheritableTheadLocal,它是利用了Thread中维护类了inheritableThreadLocals变量来实现的,但是实现上就有点尴尬,是在Thread的构造函数中,将父线程的本地变量copy了一份到子线程的inheritableThreadLocals中,这就导致了它的功能及其有限,以至于生产环境中实际没啥人使用。

由于是在Thread构造函数中完成的copy,当Thread对象创建完成以后:

  1. 通过InhertableThreadLocal是访问不到父线程的本地变量的修改的
  2. 子线程对InhertableThreadLocal变量的修改,父线程也感知不到

基于这两点,使用线程池的时候是不能使用InhertableThreadLocal的,所以再生产环境中慎重使用InhertableThreadLocal。

netty中对ThreadLocal的优化-FastThreadLocal

上面说了,ThreadLocal的读写都要计算一次ThreadLocal的hash值,这样才能去Thread#threadLocals这个Map中找到当前ThreadLocal封装的线程本地变量的值。

在追求高性能的场景下,这次hash计算是有一定开销的,所以netty的FastThreadLocal的优化思路就是省下这次hash计算。

在计算机的世界里,对性能的追求上,常见的无外乎两种:空间换时间以及时间换空间。我们实际生产中空间换时间的例子导出都是,最典型的就是缓存。这里优化的思路也是一样的,每次set/get的时候都要计算hash,那我就把hash值给缓存下来。

上面说过jdk中的ThreadLocal只是访问Thread#threadLocals的接口,没有存储任何数据,那我完全可以把ThreadLocal对象的hash值在ThreadLocal中存起来,第一次访问的时候计算一次hash值就可以了,后续直接通过这个hash值去Thread#threadLocals中获取值就可以了。

只是说FastThreadLocal更进一步,第一次访问是计算hash也省了,因为它用数组来存储线程本地变量,在FastThreadLocal中使用index记录下当前变量副本在Thread维护的那个数组的下标,set/get方法直接通过这个下表去访问数据就好了,从而省去hash值的计算。


但是threadLocals变量在Thread中的可见性是包可见的,ThreadLocal和Thread在同一个包中,所以ThreadLocal是直接访问这个变量的。但是要换成数组,而Thread中又不能增加,那就继承一个,所以FastThreadLocal就是集成了Thread:FastThreadLocalThread。
在这个类中维护数组,即InternalThreadLocalMap threadLocalMap,虽然叫map,其实是个数组(数组也可以看做是个map接口,只是key是下标)。所以要使用FastThreadLocal不用计算hash的过程,在线程上就需要使用FastThreadLocalThread。

如果使用的是Thread,是不是就不能用FastThreadLocalThread呢?,因为FastThreadLcoal根本就访问不到Thread.threadLocals变量,根本就将本地变量设置不到线程本地去。
对于这种情况,FastThreadLocal正式利用了ThreadLocal本身,来实现的,它维护了一个ThreadLocal<InternalThreadLocalMap>,通过ThreadLocal将这个InternalThreadLocalMap设置到线程本地去,这样即使使用Thread也可以使用FastThreadLocal,只是说要获得
InternalThreadLocalMap需要计算一次hash,所以FastThreadLocal中管这种使用的不是FastThreadLocalThread的线程的时候,叫slowGet。

ps:如果在自己扩展出来的FastThreadLocal中真的只是缓存hash值,在创建FastThreadLocal的时候就可以把hash值计算出来,然后存下来,后面所有的访问都不需要计算了,那是不是就没有netty的FastThreadLocal中的使用限制呢,必须要使用FastThreadLocalThread,否则就只能回到slow。不过这种方式依然有hash冲突问题,会降低效率,使用数组的方式是没有hash冲突问题的,性能更加稳定,对于框架自用的话,那还是netty这种更好。

附录-ThreadLocal源码解析

ThreadLocal的含义就不用多少了,是用于多线程环境下,线程本地存储的一个变量。这里主要看下它的实现。

ThreadLocal这个类本身是不会存储任何数据的,线程本地真正存储数据的是在Thread类中。就如如下这个变量:

即Thread保存了一个ThreadLocalMap,每当通过ThreadLocal#set()的时候,就会在执行这个方法的当前的Thread的threadLocals中增加一项,每次ThreadLocal#get的时候,也是拿到了当前线程Thread的threadLocals变量,进一步拿到线程本地存储的变量的。

ThreadLocalMap的key=ThreadLocal对象,value就是本地存储的变量的值。这个结构定义在ThreadLocal中。

ThreadLocalMap结构中,它的key-value元素类Entry继承了WeakReference,用WeakReference来封装key(ThreadLocal对象),也就是说,只要一次gc,key就会被gc回收掉。但是value是一个Object类型的强引用,key被回收掉了,但是value不会被回收。

备注:对于WeakReference来说,

WeakReference<ThreadLocal> a = new WeakReference<>(new ThreadLocal()),表示我new出来的ThreadLocal对象是一个弱引用,当gc的时候,会直接回收掉ThreadLocal对象。但是WeakReference本身也是个对象,引用a指向了这个WeakReference对象,即其中的ThreadLocal对象被gc回收了(即a.get()返回null),但是a引用指向的WeakReference由于存在强引用关联,所以是不会被回收掉的。

这样就好理解ThreadLocalMap中的Entry了。这里换个方式表达,可能更好理解:Entry<WeakReference<ThreadLocal>,Object>。

加入ThreadA中使用了两个ThreadLocal变量,在没有gc之前是如下的情况:

threadLocals变量有两个元素,元素类型就是TheadLocal#ThreadLocalMap中定义的Entry,它的key是一个WeakReference指向的ThreadLocal对象;而value是一个强引用指向的Object对象。

当发生一次gc后:

WeakReference指向的对象就被垃圾回收了,也就是说Enty的key中WeakReference包装的ThreadLocal对象没了,被回收了。但是由于WeakReference对象本身是强引用指向的,以及value也是强引用指向的,他们不会被回收,因为:GCRoots的可达性分析:

这样,和当前线程Thead有关的两个本地存储的变量,就还有4个对象未被回收。只有Thread没有结束,这4个对象一直都不会被回收。Gc干掉的仅仅是非常轻量的两个ThreadLocal对象。

如果线程长期存在,且使用了很多ThreadLocal变量,而且TheadLocal变量的value还是比较占用内存的,那么就很容易出现内存溢出。

为了应对这种情况,jdk的ThreadLocal已经作了一些处理了,比如在ThreadLocal#get(),ThreadLocal#set(),ThreadLocal#remove()的时候,都会去清理掉那些key被回收掉了的value以及WeakReference对象。

源码分析

代码结构还是很简单的。Thread中保存了一个ThreadLocal#ThreadLocalMap,用来保存本地变量的副本。而ThreadLocalMap实现Map的功能是采用了内部类Entry数组,Entry是继承了WeakReference,所以它是一个弱引用类型,使用若引用类型来作为key,使用Object类型作为value。

而ThreadLocal类本身,可以任务是一个线程本地变量的代理,它提供访问前线程存储的本地变量副本值的代理接口。

ThreadLocal初始化

两种初始化方式:

  1. 直接new ThreadLocal,这样构造的ThreadLocal使用前必须先ThreadLocal#set,否则将ThreadLocal#get返回的就是null。或者初始化的时候重写initialValue方法。

  1. 静态方法:ThreadLocal#withInitial()

这种方式其实就是将initialValue()方法封装成funcational interface Supplier了。效果上跟new ThreadLocal的时候重写initialValue是一样的,其内部也是定义了以内部类SuppliedThreadLoca,继承ThreadLocal,然后重写initialValue()方法;

SuppliedThreadLocal是ThreadLocal的子类。这种写法倒是可以借鉴。在策略模式或者模板方法模式,可以借鉴这里的实现,利用函数式接口来传递需要子类实现的抽象方法。

get()

总体的功能就是:拿到当前的线程,进而拿到在当前线程Threal中的threadlocals,即Thread中维护的ThreadLocalMap引用,然后调用ThreadLocalMap的getEntry方法,获得当前ThreadLocal对象对应的Entry。而setInitialValue()方法是比较简单的,就是调用了ThreadLocal#initialValue()方法,然后返回的也是hreadLocal#initialValue()方法的返回值。重点都在getEntry()方法中:

这跟Map#get()方法是一样的,计算hash值,然后定位到hash桶位,然后看key(WeakReference)封装的是否是当前ThreadLocal对象,如果是,就直接返回。如果不是,就执行额getEntryAfterMiss(),这个方法也是ThreadLocalMap比较核心的方法,其中有对为了防止OOM的一些努力:

expungeStaleEntry()就是将那些WeakReference#get()==null的Entry从ThreadLocalMap的table数组中删除,这样WeakReference对象(Entry对象)和Value对象也就会被gc回收了

具体的逻辑如下:

这样,上述的例子就变成了:

threadLocals实际就是个空数组了。

GCROOTS也就成了:

这样,WeakReference对象和Oject对象,没有了引用,下次gc也就会被回收了。

Set()方法

无非就是Thread中的threadLocals变量可能为null,如果为null就初始化一个ThreadLocalMap,然后让Thread#threadLocals指向它,并且放一个ThreadLocal副本。重要的逻辑还是在ThreadLocalMap#set方法上。

之所以有个for,需要说明一下:在java.util中实现的HashMap,解决冲突的方式都是使用的链表法,即一个桶位实际是个链表。但是ThreadLocalMap解决hash冲突采用的是再次hash(开放寻址发解决hash冲突)

第一次hash定位到的位置,发现已经有元素被占用了,那么就只能重新hash,这里可以选择一个新的hash算法(双重寻址),也可以就直接往后找一个槽位(二次探测)。ThreadLocalMap采用的就是找后面一个

这种方式需要注意的地方就是删除的时候:不能直接将删除的元素置成null,否则就断开了

这样,命名是有元素的,但是由于链条中间断开了,就找不到了。所以说删除的时候就需要处理,要么搞个删除标志,要么移动后面的元素,将后面没有删除的元素往前移动,

这个方法就是在干这个事情,将删除(其实是gc回收)的往前移动,然后对于那些key被gc回收掉的清除。因为如果是ThreadLocal#remove(),在那就处理了。所以这里实际处理的只是gc回收导致”被删除”的。

总结一下:开放寻址法和链表发解决冲突:

  1. 数据量比较小、装载因子小的时候,适合采用开放寻址法。如这里的ThreadLocalMap
  2. 存储大对象、大数据量的散列表,如Java.util中的Map的实现

ps:ThreadLocal除了使用不共享解决线程安全问题,在实际生产中,经常会使用ThreadLocal做全链路传参,最典型的就是分布式监控中的traceId,都是放在ThreadLocal中的,所以在实际生产证,使用多线程的时候,一定要注意traceId跨线程不断裂,且多线程之间不串扰的问题。如果不这么做,那么所有的方法,都将带上一个参数traceId,这对业务浸入太大了。

那么问题来了,一个线程内要全链路传参可以使用ThreadLocal,那如果是进程内呢?在jdk中,也是提供了一个机制来存储进程数据的,那就是System.properties。防止到System.properties就是在进程的任何地方都可以读取的。很多java框架都会利用这一点,启动时将自己的系统配置从配置文件读取后,放到System.properties,使用的时候直接从System.properties去读取。

另外,spring 其实基本上已经成了一个事实上的j2ee标准了,大家的企业软件中,基本都是基于spring framework的,它的核心思想就是依赖翻转,将依赖都使用ioc容器管理起来。在Ioc容器中,提供了一个Environment,其中也提供了properties的存储。@value注解,实际上就是取的Environment.properties中的数据。所以在spirng项目中,我们是可以利用Spring的这个机制的。在对spring比较了解的项目组中,在使用分布式配置中心的时候,一般都会这么去封装,将从分布式配置中心拉取的配置,放到Environment.properties中,这样在实际业务开发中,就直接可以统一使用@vaule注解去获取配置了。只是在实现的时候,是需要注意的,一般来说,会将外部配置的优先级实现成高于进程内配置文件的配置,以便不发代码的情况可以修改配置。

一点疑惑

ThreadLocalMapEntry承了WeakReference,它的key是一个WeakReference类型的,也就是说在一次gc后,key会被回收掉。那么也就是

threadLocal.get()方法拿到的Entry对象的key.get()会返回null

如下是测试:

按照理解,最后a.get()应该是initialValue()反回的aaaaaa(如果初始化ThreadLocal候不指定initialValue方法,应该返回null)。但是如上代返回的bbbbbb(如果初始化重写initialValue方法,返回的也是bbbbb,不是null)

我的理解:

正常情况下:

gc后:

这个时候threadlocal.get()拿到对应WeakReference对象,但是Weakreferece.get()返回null,所以和当前的threadLocal是不等的,应该不能返回

而且ThreadLocalMap中的getEntry()逻辑,会将keygc回收后的Entry给清理调。所以试验代码中调用了两次get。但是是拿到了bbbb

认发gc后,也通debug发现WeakReference.get()返回的也不是initialValue()的返回,而是ThreadLocal.set()进去的bbbbb,感觉和理论分析的不一样,到底哪儿出问题了?

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值