Java并发(四)ThreadLocal类

在我的Java并发学习笔记专栏的前三篇文章中,讲述了关于Java锁机制、乐观锁和悲观锁以及AQS、Reentrantlock、volatile关键字等关于Java并发的内容。

本篇将讲述Java中的ThreadLocal类。

ThreadLocal

JDK提供的ThreadLocal类用于实现每一个线程拥有自己的专属本地变量

当创建一个ThreadLocal类型的变量时,访问这个变量的每个线程都会有这个变量的本地副本,存储着每个线程的私有数据,每个线程可以通过get方法和set方法来获取和更改当前线所存的副本的值,进而避免了线程安全问题。

这里引用JavaGuide中举的例子:

比如有两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的。

ThreadLocal示例代码:

public class Sample {
    static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            System.out.println(threadLocal.get());
            threadLocal.set(0);
            System.out.println(threadLocal.get());
        });
        Thread t2 = new Thread(() -> {
            System.out.println(threadLocal.get());
            threadLocal.set(0);
            System.out.println(threadLocal.get());
        });
        t1.start();
        t1.join();
        t2.start();
    }
}

上述程序的输出:

null
0
null
0

ThreadLocal变量的默认值是null,在线程 t1 和 t2 对该变量副本赋值之前,变量副本的值都是null。可见线程 t2 无法读取到线程 t1 设置的变量副本的值。

 

ThreadLocal应用场景

假设有一个处理流程由一批操作组成,每一个操作都需要读取到一个全局变量。对于全局变量的声明我们经常使用的是static关键字来修饰。

但是这个方法在多线程场景下是不适用的,因为不同线程可能同时正在进行这个处理流程,就会造成这个static全局变量的数据混乱,例如会产生static变量中一会儿是线程A中的数据,一会儿是线程B中的数据的情况。

ThreadLocal正是应用于上述的场景中,它相当于每个线程都会有一个自己的全局变量,解决了全局变量被多个线程使用的问题。

 

ThreadLocal实现原理

ThreadLocal其实不存储任何值。

我们不妨看看ThreadLocal中的get方法:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

可以发现,get方法先得到当前运行的线程,接着得到当前线程的ThreadLocalMap类型成员属性threadlocals,我们看看getMap方法:

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

每一个线程有一个ThreadLocalMap类型的成员属性threadLocals,在Thread类中有以下语句:

ThreadLocal.ThreadLocalMap threadLocals = null;

 

ThreadLocalMap

ThreadLocalMap是ThreadLocal的内部类,用于存储Key-Value键值对Entry。在ThreadLocalMap的键值对中,使用ThreadLocal的对象作为key。我们说ThreadLocal变量的默认值是null,其实是因为ThreadLocalMap中Entry的value值默认为null

捋一下关系:
也就是说,每一个线程对象中都会拥有一个ThreadLocalMap类型的成员属性threadlocals,该成员属性用于存储每个线程自己的私有本地属性。
threadLocals是ThreadLocalMap类型,意味着它是一个Map,用于存储多个Entry,每个Entry是一个键值对,具有key和value。而这个key就是ThreadLocal类型,value是Object类型。
在这里插入图片描述
在上图中,我们有一个ThreadLocal类型的变量localString,当我们在线程A中调用localString.set("IamBoger")时,会将当前ThreadLocal变量localString作为key,到线程A的ThreadLocalMap中找到key是localString的Entry,然后将该Entry的value值设为"IamBoger"。调用localString.get()同理,只不过是将设置Entry中的value值换为获取Entry中的value值。

注意,不同于HashMap中使用链表法解决哈希冲突,ThreadLocalMap使用的是线性探测法解决哈希冲突

这里顺便提一下解决冲突方法的名称对应关系:
开散列法 - 链表法
闭散列法 - 开放地址法 - 线性探测法

那为什么ThreadLocalMap要用开放地址法解决哈希冲突?我觉得是因为以下原因:

  • 开放地址法的缺点是处理哈希冲突效率较低。ThreadLocal变量不多的话,ThreadLocalMap中的Entry数也相对不多,哈希冲突概率较低,所以开放地址法的寻址效率也不会太低。
  • 开放地址法的优点是节省空间且易于实现,链表法需要额外的空间存储节点指针等,而使用开放地址法比较节省空间。

 

Entry中的弱引用

ThreadLocalMap类的定义中:

static class Entry extends WeakReference<ThreadLocal<?>>

ThreadLocalMap中Entry使用的key是对ThreadLocal实例的弱引用。

什么是弱引用? 这里引用JavaGuide中对弱引用的介绍:

如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。

在这里插入图片描述
为什么要这么设计?我们这里先假设这个引用是强引用的话,如下图:
在这里插入图片描述
如果此时,ThreadLocal类型的ref出栈或者将ref置为null,即将图中的1号线断开。
在这里插入图片描述
这时候,Entry中的key对堆中的ThreadLocal实例是强引用,则导致该ThreadLocal实例不会被回收。

因此,我们需要将Entry中的key对堆中的ThreadLocal实例的引用设为弱引用,该弱引用在遇到GC时会断开。

在这里插入图片描述
这时候,如果ThreadLocal类型的变量ref出栈或者置为null,则堆中实例就不会有对其的强引用,就会被回收。

因此,需要将Entry中的key对ThreadLocal的引用设置为弱引用。
在这里插入图片描述
但是其实这里还有一个问题值得讨论,在发生一次GC时,key与堆中的ThreadLocal实例的弱引用断开了,也即是下图这种情况下:

在这里插入图片描述
key与堆中的ThreadLocal实例的弱引用断开了,那么key不就是null了吗?如果这时我们调用ref.get(),还能获取到对应的value的值吗?其实是可以的!

要注意这时候,key的值并不是null!这里涉及到弱引用相关的知识:当弱引用所指向的对象与弱引用断开引用链时,如果此时该对象因为有其它引用而不被回收,那么此时该弱引用依然可以通过断开前的连接地址去获取值。

也就是说引用的断开不会影响我们引用的寻址功能。引用的断开只会导致引用链断开进而导致对象被GC回收。但是!此时若有一个强引用引用着该对象,那么弱引用就可以在无引用链的情况下继续访问该对象。(这里扩展一下。若对象的地址强制改变,弱引用将无法继续跟踪)

举一个简单的案例:假设你买票上火车,找到了座位坐了进去。但是记性很差的你,上了个厕所回来找不到自己的座位了。此时,列车员始终可以根据你的购票档案查到你的座位号。

因此,这就是key与ThreadLocal实例间的弱引用断开而仍然可以通过ref.get()访问到value的值的原因。只有当ref与堆中ThreadLocal实例间的强引用断开时,key才会被真正置为null。

 

Entry的内存泄露问题

上述我们讲到了Entry中的key是对ThreadLocal实例的弱引用,以此解决了ThreadLocal实例的内存泄露问题。

但这样又存在一个问题,那就是Entry对象的内存泄露问题

当堆中的ThreadLocal实例被回收时,Entry中的key被设为了null,但是这时候value是还存在的,但是已经没法通过key来访问到它了,这就造成了新的内存泄漏问题。
在这里插入图片描述
ThreadLocalMap的实现其实已经考虑了这种情况。在调用ThreadLocal中的 set()、get()、remove() 方法时,会清理掉 key 为 null 的记录。 因此,在我们使用完 ThreadLocal 方法后,最好手动调用remove()方法。

使用完ThreadLocal方法后手动调用remove方法的好处还包括可以解决“前世记忆”的问题,在下文会提到这个问题。

那么ThreadLocal是如何清理key为null的Entry的呢?下面我们来看看。

 

ThreadLocal对过期key的清理

ThreadLocal对过期key的清理方式分为两种清理方式,分别是探测式清理启发式清理

探测式清理

探测式清理会进行遍历ThreadLocalMap中的散列数组,从开始位置向后探测清理过期数据,将过期的Entry设置为null,沿途中碰到未过期的Entry则将此Entry重新计算哈希值后重新在table散列数组中定位。

为什么碰到未过期的数据要进行重新哈希和定位?
因为当前Entry在之前有可能是因为遇到了哈希冲突才被安排在这里,而此时原本与它发生哈希冲突的Entry可能已经被清理掉了,所以当前Entry需要进行重新哈希和定位判断是否需要放回到它原本该在的地方。
如果重新哈希和定位后再次发生冲突,处理同理是用线性探测找坑位。

启发式清理

启发式清理会调用cleanSomeSlots方法,这个方法有两个参数,分别是 i 和 n,i 是开始清理的地方。在 i 处往前每扫描一个Entry,如果该Entry不需要被清理,那么 n 会往右移动一位(即除以2),直到 n 等于0,此时结束扫描。如果在这个过程中扫描到了需要清理的Entry,那么 n 会被设置为table散列数组(即Entry数组)的大小,然后在该处往后进行一段连续段的探测式清理,接着继续回来进行启发式清理。

如果想深入了解这两种清理方式,可以看JavaGuide中对ThreadLocal的源码分析的文章。

探测式清理和启发式清理分别是什么时候会发生

  • 探测式清理:
    ①在启发式清理、get操作、remove操作中一旦发现需要清理的Entry时就会发生
    ②rehash方法中会先进行一轮探测式清理
  • 启发式清理:
    在set操作后会发生

 

ThreadLocalMap扩容机制

在ThreadLocalMap进行set之后会进行一次启发式清理,清理之后会判断当前散列数组中的Entry数量是否已经达到了扩容阈值,这里的扩容阈值是散列数组大小的2/3。如果达到了,下一步就调用rehash方法。

set方法最后的部分:

if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();

其中cleanSomeSlots是进行启发式清理,threshold是扩容阈值,值为散列数组大小的2/3。

在rehash方法中会先进行探测性清理,之后再判断Entry数量是否达到了扩容阈值的3/4,如果是,就进行扩容。

reahsh方法:

private void rehash() {
    expungeStaleEntries();
    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
         resize();
}

注意两个地方判断的数值是不一样的!

  • 扩容阈值是散列数组大小的2/3,在set之后进行启发式清理,如果之后Entry个数达到扩容阈值会进行rehash
  • 在rehash中先进行探测式清理,如果之后Entry个数达到扩容阈值的3/4就进行扩容

扩容后的散列表的大小为原本的2倍,然后遍历原本的散列表,将其中的每一个Entry重新计算hash值并放到新的散列表中,处理哈希冲突的方式与之前一样是开放地址法,之后再重新计算新的扩容阈值。

总结一下ThreadLocalMap的扩容机制:
①在set操作之后,会先进行一次启发式清理
②判断启发式清理后Entry数量是否大于等于扩容阈值(数组大小的 2 / 3),如果达到了,再进行一次探测式清理
③判断进行探测式清理之后Entry数量是否大于等于扩容阈值的 3 / 4,如果达到了,则将数组大小扩容为原本的两倍,并将元素重新进行哈希。

 

线程池配合ThreadLocal使用时可能存在的问题

线程池中的线程有可能被重复利用,而被重复利用的线程如果在被重新使用前没有清理掉ThreadLocal变量的数据,那么在重新使用时可以读取到这个线程在之前使用时ThreadLocal变量中的数据,这个被形象地称为 “前世记忆”

所以为了避免被前世的记忆干扰今生的行为,最好在使用完ThreadLocal变量后就进行一次remove操作,将ThreadLocal变量清除掉。


关于Java并发中的ThreadLocal就介绍到这里,之后我也会接着更新关于线程池等与Java并发有关的知识。

如果你喜欢这篇文章的话,不妨给我个赞吧!


参考:

  1. JavaGuide中的一篇文章
  2. JavaGuide中的另一篇文章
  3. B站up主 free-coder 的一个视频
  4. 参考文章(写的真的很好,有空可以多看看)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值