栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

java并发类(Java高并发实战)

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

java并发类(Java高并发实战)

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

本篇将讲述Java中的ThreadLocal类。

文章目录

ThreadLocal

ThreadLocal应用场景ThreadLocal实现原理

ThreadLocalMapEntry中的弱引用 Entry的内存泄露问题ThreadLocal对过期key的清理

探测式清理启发式清理探测式清理和启发式清理分别是什么时候会发生 ThreadLocalMap扩容机制线程池配合ThreadLocal使用时可能存在的问题

ThreadLocal

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

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

这里引用JavaGuide中举的例子:

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

ThreadLocal示例代码:

public class Sample {
    static ThreadLocal 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值默认为

捋一下关系:
也就是说,每一个线程对象中都会拥有一个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>

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值并放到新的散列表中,处理哈希冲突的方式与之前一样是开放地址法,之后再重新计算新的扩容阈值。

 

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

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

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


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

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


参考:

    JavaGuide中的一篇文章JavaGuide中的另一篇文章B站up主 free-coder 的一个视频参考文章(写的真的很好,有空可以多看看)
转载请注明:文章转载自 www.mshxw.com
本文地址:https://www.mshxw.com/it/773542.html
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号