文章目录
一、ThreadLocal作用与数据结构
ThreadLocal简称人手一支笔,可以在自己的线程保存一份变量,每个线程Thread都拥有一份副本变量,多个线程之间互不干扰。
作用
用线程隔离的思想做到线程安全
以往多个线程对共享变量进行操作的时候,一般都是加锁。
比如用synchronized
或者ReentranLock
这种锁,但是除了加锁,还有一种思想,让每一个线程都拥有自己一份专属变量副本,人手一支笔,用线程隔离的思想就做到了线程安全。
线程内需要保持全局变量
比如在web场景下,在某个service中的方法获取到了用户的信息,但是这个方法里面还需要用到用户的信息,一般的做法都是通过参数的形式传递下去:
public void service(){
User user = new User();
service2(user);
service3(user);
}
public void service2(User user){}
public void service3(User user){}
这样不好维护,因此把变量保存在ThreadLocal中,做到线程内的全局变量。
数据结构
路线:Thread
-> ThreadLocalMap
-> Entry
-> key | value
Thread
类当中有一个threadLocals
的变量,这个变量的类型是TheadLocalMap
,ThreadLocalMap
来自ThreadLocal
的静态内部类,也就是说每个Thread
类都有一份map
,真正做到线程隔离。
ThreadLocalMap
的底层跟HashMap
有点类似,map
的底层是Entry
数组,跟HashMap
一样会发生Hash
冲突,但是跟HashMap
的哈希冲突解决方法不一样,在set
方法中都有体现,而且还涉及到探测性清除跟启发式清除。
底层的Entry里面是没有大家常说的key的,只有value,那么大家常说的key是来自于Entry继承的弱引用weakReference,里面有一个Refenct字段指向ThreadLocal,可以近似看做成key是ThreadLocal,但是实际上是ThreadLocal的弱引用。
二、内存泄露问题吗?
正常情况下,如果线程终止,value也会被回收,不会存在内存泄露问题。但是如果Thread是类似像线程池这种的线程,线程一直存在的,具体流程可以按照这么分析:
Thread
-> ThreadLocalMap
-> entry
-> key|value
下次GC时,key被回收了,但是value还存在着强引用,没办法被回收,就存在内存泄露了。
为什么要设计成弱引用?
主要是ThreadLocalMap
的生命周期跟Thread
有关,如果一个线程长期不关闭,像线程池这种,那么就会一直存在像上面那样的强引用关系链,无法回收,假如使用弱引用机制,那么下次GC时就可以回收key。虽然弱引用会发生泄漏的问题,但是里面提供了探测性清理、启发式清理以及remove方法都能清除value。
如何避免内存泄露(阿里手册)
调用remove方法,删除对应的Entry对象。
三、set源码讲解
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
进入到set方法里,获取当前的线程,获取当前线程的ThreadLocalMap
,如果ThreadLocalMap
为空则会创建。
Hash计算
可以看到,hash值的计算是通过threadLocalHashCode
& 长度-1来实现的,那么threadLocalHashCode
是跟一个黄金分隔数(HASH_INCREMENT
)有关,每次通过整形原子类来获取该黄金分隔数的getAndAdd,使用黄金分隔数目的是为了让整个hash更加分布均匀。
回到set源码上面来,这个时候计算好index后,开始put数据,会发生下面四种情况:
- 当前槽位为空,那么直接将数据放到槽位即可。
- 当前槽位不为空,但是key相等,更新value即可。
- 当前槽位不为空,key也不等,那么会往后遍历,找到一个Entry为null的槽,将数据放到该槽上即可。
- 当前槽位不为空,key也不等,那么会往后遍历,但是找到Entry为null的槽之前遇到了key过期的,也就是key为null的结点,因此进行下一个阶段
replaceStaleEntry
。
replaceStaleEntry
这个阶段分为两步,首先设置两个变量:
- slotToExpunge:记录当前槽位最前的一个key为null的index下标,目的是看看当前槽位之前有没有过期的key,方便后面做探测性检测。
- staleSlot:当前槽位。
向前迭代:从当前槽位向前迭代,如果keynull,则设置slotToExpunge为当前下标,直到entrynull为止。
向后迭代
从当前槽位向后迭代,分为两种情况:
1.往后迭代的过程中遇到key相等的情况,交换槽的内容,更新value值就OK了,如上图所示。
2.往后迭代的过程中没有遇到key相等情况,则新建Entry,并取代之前staleSlot槽位的内容,如下图所示。
可以发现无论是哪种情况,最后面都会执行cleanSomeSlots(expungeStaleEntry(slotToExpunge), len)
,也就是第四种情况添加完成后,会利用之前的slotToExpunge
变量来进行探测式清理。
探测式清理–expungeStaleEntry
探测式清理的方法名叫expungeStaleEntry
,大概逻辑如下:
从之前的slotToExpunge出发。
如果遇到key==null,则将当前Entry设置为null,并且size–。
如果遇到key!=null,此时会对当前的key重新hash计算一下index。
- 如果index!=当前下标,表明之前没有发生哈希冲突,便会重新将该Entry放回应该属于他的位置,
如果之前的位置有值,那么就按照开放地址法解决,在后面找一个Entry为null的槽位存储起来。
这样操作之后,key不等于null的结点也就会离原来的位置更近了,查找更加方便。
- 如果index==当前下标,表明之前没有发生哈希冲突,那么没事发生。
探测式清理一边清理过期的entry,一边对不为null的key进行hash定位,放在离原本位置更近的地方。
探测式清理遇到Entry为null的结点就会停下来,并返回当前的下标index。
之后从当前下标开始进行启发式清理。
启发式清理–cleanSomeSlots
从名字就可以看出cleanSomeSlots,清理一些肮脏的槽位,刚才说到,探测式清理遇到Entry为null的结点会停止清理,并返回当前index,但是不能保证index后面还有没有过期的结点,所以开启了启发式清理。
启发式清理利用当前下标的一个位预算,每次清理一个,会将当前下标通过位运算右移两位,直到右移结果为0。
自此,刚才说的set是第四种情况下,会开启向前迭代和向后迭代,并且完成后会开启探测性清理和启发式清理,并回到set函数中。调用逻辑如下:
扩容前的准备
如果当前的size >= thresShlod(size的3分之2),那么会进行一次探测式清理。
清理完成之后如果size >= threShold的4分之3,那么就会开启resize。
resize挺简单的,扩容无非就是newTable,旧table rehash就可以了。
四、get()源码详解
key通过原子类累加黄金分隔数得到threadLocalHashCode得到Hash值定位到槽位,如果当前槽位有值并且key相等则直接return。
否则会往后遍历,如果遇到key相等的情况就直接返回。
如果遇到了过期key的情况,那么开启一次探测性清理,清理完成之后再判断key相不相等。
五、ThreadLocal的实际使用场景
除了开头提到的,用线程隔离的思想做到线程安全,以及线程内需要线程的全局变量。
在Spring中的应用
在实际应用中也有存在,比如我们知道Spring中bean基本上都是单例的,而我们把web服务部署在Tomcat的时候,实际上Tomcat是通过多线程处理请求的方式来访问我们的bean,也就是说是多线程处理单例对象,如果涉及到多线程对单例对象中的成员变量进行写操作时,肯定会发生线程安全,因此可以用ThreadLocal这种方式。
在Spring中就有DateTimeContextHolder
或者requestContextHolder
等等都是对ThreadLocal的封装。
参考
https://github.com/Snailclimb/JavaGuide/blob/master/docs/java/Multithread/ThreadLocal.md#threadlocal%E4%BB%A3%E7%A0%81%E6%BC%94%E7%A4%BA