当前位置: 首页 > 科技观察

ThreadLocal能问的问题我都写了,

时间:2023-03-14 12:45:08 科技观察

你好,我是。今天我们再来看看ThreadLocal。本文力求对ThreadLocal一网打尽,全面了解ThreadLocal的机制。有了这个基础之后,下一篇就是ThreadLocal的进阶版,等着我吧。话不多说,本文要解决的问题如下:为什么需要ThreadLocal如何设计ThreadLocal从源码看ThreadLocal的原理ThreadLocal内存泄漏为什么要对ThreadLocal使用弱引用最佳实践InheritableThreadLocal好了,驱动!为什么需要ThreadLocal?开启三孩政策,假设你有三个孩子。现在你带着三个孩子出去逛街,路过一家玩具店,三个孩子都看上了变形金刚。所以你买了一个变形金刚,打算让三个孩子轮流玩。回到家,你发现孩子们已经因为这个玩具吵架了,三个人都争先恐后地要玩,一个也不让一个。这个时候怎么办?可以打架、讲道理、劝孩子轮流玩,但是这样很累。所以一个简单的办法就是出去再买两台变形金刚,这样三个孩子就可以拥有自己的变形金刚了,天下就可以暂时太平了。映射到我们今天的话题,Transformer是共享变量,children是程序运行的线程。如果有多个线程(孩子),竞争同一个共享变量(玩具),就会产生冲突,解决方案就是加锁(父母劝说,讲道理,轮流玩),但是加锁意味着性能损失消耗(父母比较累)。所以一种解决方案是避免共享(让每个孩子都有自己的Transformer),这样线程就不需要竞争共享变量(孩子之间没有争用)。那么为什么需要ThreadLocal呢?就是通过资源本地化来避免共享,避免多线程竞争导致的锁的消耗。这里需要强调的是,并不是所有的事情都可以通过避免共享直接解决,因为有时候必须要共享。例如:使用多个线程同时累加一个变量时,此时必须共享,因为一个线程对变量的修改需要影响另一个线程,否则累加的结果是错误的。另一个不需要共享的例子:比如现在每个线程都需要判断当前请求的用户来判断权限,那么用户信息不需要共享,因为每个线程只需要管理用户当前操作的信息。不需要与其他用户交互。嗯,原因很简单,现在你一定明白ThreadLocal出现的原因了。下面来看一下ThreadLocal使用的小demo。publicclassYesThreadLocal{privatestaticfinalThreadLocalthreadLocalName=ThreadLocal.withInitial(()->Thread.currentThread().getName());publicstaticvoidmain(String[]args){for(inti=0;i<5;i++){newThread(()->{System.out.println("threadName:"+threadLocalName.get());},"yes-thread-"+i).start();}}}输出结果如下:可以看到,我在新建线程的时候,设置了每个线程的名字,每个线程都操作了相同的ThreadLocal对象,但返回了它自己的线程名称。是不是很神奇?ThreadLocal应该如何设计?那么ThreadLocal应该怎么设计来实现上面的操作呢,也就是本地化资源呢?我们的目标已经明确了,就是利用ThreadLocal变量来实现线程隔离。从代码上看,最直接的实现方式就是把ThreadLocal看成一个map,然后每个线程就是一个key,这样每个线程在调用ThreadLocal.get的时候,都是以自己为key去寻找map,所以它可以得到它们各自的值。听起来很完美?错了!这样ThreadLocal就变成了一个共享变量。如果是多个线程竞争ThreadLocal,那么ThreadLocal的并发安全就必须要保证,然后就必须加锁,这样循环一圈就回去了。那么这个解决方案是行不通的,那怎么办呢?上面已经提到了答案。有必要在每个线程中本地存储一个值。说白了就是每个线程都需要一个变量来存放这些需要本地化的资源。值,而且可能有多个值,那怎么办呢?在thread对象内部做一个map,把ThreadLocal对象本身作为key,用它的value作为map的value。这样每个线程都可以使用同一个对象作为key,在自己的map中找到对应的value。这不是很完美吗!例如,我现在有3个ThreadLocal对象和2个线程。ThreadLocalthreadLocal1=newThreadLocal<>();ThreadLocalthreadLocal2=newThreadLocal<>();ThreadLocalthreadLocal3=newThreadLocal<>();那么ThreadLocal对象与线程的关系如下图所示:这样就满足了本地化资源的需求。每个线程维护自己的变量,互不干扰。实现了变量的线程隔离,也满足了存储多个局部变量的需要。完美的!JDK是这样实现的!让我们看一下源代码。从源码看ThreadLocal的原理我们前面提到Thread对象中会有一个map来保存局部变量。下面看一下jdk的Thread实现ThreadLocalthreadLocal1=newThreadLocal<>();ThreadLocalthreadLocal2=newThreadLocal<>();ThreadLocalthreadLocal3=newThreadLocal<>();可以看到,确实有一个map,但是这个map是ThreadLocal的一个静态内部类,记住这个变量threadLocals的名字,下面会有用的。看到这里,想必很多朋友都有一个疑问。这个map虽然在Thread中使用,为什么要定义为ThreadLocal的静态内部类呢?首先,内部类是一个编译层面的概念,就像语法糖一样。经过编译器,内部类实际上会被提升为外部的顶级类,和平时定义在外部的类没有区别,也就是说JVM中没有内部类的概念。一般情况下,非静态内部类是在内部类中使用的,与其他类无关。它们是本外部类专用的,调用外部类的成员变量和方法也很方便,比较方便。静态外部类其实就相当于一个顶层类,可以独立于外部类使用,所以只展示了类结构和命名空间。所以这个定义的目的是说明ThreadLocalMap与ThreadLocal强相关,专门用来保存线程局部变量。下面我们看一下ThreadLocalMap的定义:重点我都标出来了。首先可以看到在这个ThreadLocalMap里面有一个Entry数组。熟悉HashMap的朋友可能有点感觉。ThisEntry继承了WeakReference,也就是弱引用。这里要注意,并不是说Entry本身就是一个弱引用。看到我标记的Entry构造函数的super(k)不是,这个key是弱引用。所以ThreadLocalMap中有一个Entry数组,这个Entry的key就是ThreadLocal对象,value就是我们需要保存的值。那么如何通过key在数组中找到Entry,然后获取value呢?这从上面的threadLocalName.get()开始。我不记得这段代码了。向上轻扫以查看示例。其实就是调用ThreadLocal的get方法。至此,我们进入了ThreadLocal#get方法,这里就可以知道为什么不同的线程在同一个ThreadLocal对象上调用get方法可以得到不同的值了。这个中文评论一定很清楚!ThreadLocal#get方法首先获取当前线程,然后获取当前线程的ThreadLocalMap变量,即threadLocals,然后以自身为key从ThreadLocalMap中找到Entry,最后返回Entry中的值。这里我们看key是如何从ThreadLocalMap中找到Entry的,也就是map.getEntry(this)是如何实现的,其实很简单。可以看出,虽然ThreadLocalMap和HashMap一样,都是基于数组实现的,但是它们对于Hash冲突的解决方案是不同的。HashMap通过链表(红黑树)方式解决冲突,而ThreadLocalMap通过开放寻址方式解决冲突。听起来很高级,其实道理很简单。我们看一张图就很清楚了。因此,如果通过key的hash值得到的下标不能直接命中,则下标+1,即继续遍历数组寻找Entry,直到找到或返回null。可以看出这种hash冲突的解决效率其实并不高,但是一般ThreadLocal不会太多,所以可以用这个简单的方法来解决。至于代码中的expungeStaleEntry,后面再分析。我们先看ThreadLocalMap#set方法,看看写法是如何实现的,看看hash冲突的解决方案和上面的是否一致。可见set的逻辑也很清晰。先通过key的hash值计算出一个数组下标,然后检查下标是否被占用。如果被占用,检查是否是你要找的Entry。如果是,则更新,如果不是,则下标++,即向后遍历数组,找到下一个位置,找一个空位,新建一个Entry,占坑。当然,这种数组操作一般都免不了要判断阈值,超过阈值就需要扩容。上面的清理操作和key为空的情况接下来会分析,这里略过。至此,我们分析完了ThreadLocalMap的核心操作get和set。想必大家已经从源码层面了解了ThreadLocalMap的原理吧!可能有小伙伴对key的hash值的来源有点疑惑,所以我加key.threadLocalHashCode分析。可以看到key.threadLocalHashCode实际上是在调用nextHashCode来积累一个原子类。注意上面都是静态变量和静态方法,所以在ThreadLocal对象之间是共享的,然后通过固定累加一个奇怪的数字0x61c88647来分配hash值。当然这个数不是乱写的,是经过实验证明的一个值,即0x61c88647累加产生的值和取2的幂的结果可以更均匀的分布在长度为2的幂的数组中,可以减少哈希冲突。有兴趣的朋友可以深入研究一下,反正我没兴趣。ThreadLocal内存泄露为什么要用弱引用接下来就是解决上面挖的坑,也就是key的弱引用,为什么Entry的key可能为null,以及清理Entry的操作。前面说过,Entry是对key的弱引用,那么为什么是弱引用呢?我们知道,如果一个对象没有强引用而只有弱引用,那么这个对象将无法存活一次GC,所以这个设计就是为了让ThreadLocal对象在没有外部强引用的情况下,可以清理ThreadLocal对象。那为什么要这样设计呢?假设Entry对key的引用是强引用,我们看一下这个引用链:从这个引用链可以知道,如果线程一直在,那么相关的ThreadLocal对象也会一直在,因为它已被强烈引用。看到这里,可能有人会说线程回收了就好了。重点来了!线程在我们的应用中经常以线程池的形式使用。比如Tomcat的线程池处理一堆请求,线程池中的线程一般不会被清理,所以这个引用链会一直存在,所以即使ThreadLocal对象没用,它也会一直存在着存在的线程!所以这个引用链需要弱化,只能操作Entry和key之间的引用,所以都使用弱引用来实现。与之对应的是一个引用链,我结合上面的线程引用链画了一个:另一个引用链是栈上的ThreadLocal引用指向堆中的ThreadLocal对象,这个引用就是强引用。如果有这个强引用,说明此时ThreadLocal有用。如果此时发生GC,ThreadLocal对象不会被清除,因为存在强引用。当方法执行完毕,对应的栈帧也被弹出栈。这个时候这条强引用链就没有了。如果没有其他栈有对ThreadLocal对象的引用,就意味着不能再访问ThreadLocal对象(另一种定义为静态变量的方式)。这时候ThreadLocal只和Entry有一个弱引用,此时发生GC的时候可以清除掉,因为不能对外使用,没有用,是垃圾,应该处理节省空间。到这里,你肯定明白为什么entry和key要设计成弱引用了。是因为平日使用线程基本都是线程池,所以线程的生命周期很长,可能你部署上线后就存在了。ThreadLocal对象的生命周期可能不会那么长。因此,为了回收不用的ThreadLocal对象,Entry和Key应该设计成弱引用。否则,如果Entry和key是强引用,ThreadLocal对象将一直存在于内存中。但是这种设计可能会造成内存泄漏。那么什么是内存泄漏呢?意味着程序中无用的内存得不到释放,造成系统内存的浪费。当Entry中的key,即ThreadLocal对象被回收时,Entry中的key就会为null。其实这个Entry是没有用的,但是不能回收,因为有一个很强的Thread->ThreadLocalMap->Entry的引用,这种无用的不能回收的内存就是内存泄漏。那么既然会内存泄露,还这么实现?这里需要补一下上面的坑,就是expungeStaleEntry相关的操作,也就是清理过期的Entry。当然,设计者知道会出现这种情况,所以在很多地方都清理掉了无用的Entry,也就是key被回收的Entry。比如key查找一个Entry,如果不能直接命中下标,那么就会向后遍历数组。这个时候如果遇到key为null的Entry,就会清理掉,然后贴出这个方法:这个方法也很简单,我们来看看它的实现:所以在找Entry的时候,没用Entry会一路清理,防止一些内存泄露!还有,在扩容的时候,无用的Entry也会被清理掉:其他的,我就不贴了,反正就是知道设计者做了一些操作,回收无用的Entry。ThreadLocal的最佳实践当然,等待这些操作被被动回收并不是最好的方法。假设以后没人调用或者调用直接命中或者没有展开,那无用的Entry不就一直存在吗?所以上面说的只能防止部分内存泄漏。所以,最好的做法是用完再调用remove方法,手动清理Entry,这样才不会出现内存泄露!voidyesDosth{threadlocal.set(xxx);try{//dosth}finally{threadlocal.remove();}}这是使用Threadlocal的正确姿势,不需要的时候去掉。当然,如果不是线程池的使用方式,也不用担心内存泄露。反正线程执行完会被回收,但是一般我们用的都是线程池,可能你只是感觉不到。比如你使用tomcat,请求的执行实际上使用的是tomcat的线程池,这就是隐式使用。还有一个关于withInitial的问题,就是初始化值的方法。由于像tomcat一样存在隐式线程池,即线程第一次调用并执行Threadlocal后,如果没有显示remove方法,则Entry仍然存在,所以下次线程执行任务时,它不会再次执行。调用withInitial方法,也就是说你会得到上次执行的值。但是你认为执行任务的新线程会初始化这个值,但是它是线程池中的老线程,这与预期不符,所以这里需要注意。InheritableThreadLocal其实在之前的文章里都有写过,但是这次真的写了threadlocal然后拿出来了。这个东西可以理解为可以把父线程的threadlocal传给子线程,所以如果要这样传,就用InheritableThreadLocal代替threadlocal。原理其实很简单。Thread中已经包含了该成员:当父线程创建子线程时,子线程的构造函数可以拿到父线程,然后判断父线程的InheritableThreadLocal是否有值,如果有就复制过来超过。这里需要注意的是InheritableThreadLocal的值只会在线程创建的时候被复制,子线程不会因为之后父线程如何变化而受到影响。最后,关于ThreadLocal的知识点就差不多了。想必你已经知道ThreadLocal的原理,包括如何实现,为什么要将key设计成弱引用,以及在线程池中使用它的注意点等等。其实我也没打算写ThreadLocal,因为最近在看Netty,所以想写FastThreadLocal,但是前置知识点是ThreadLocal,所以做了这篇文章。消化完这篇文章,出去面试ThreadLocal是没问题的,最后留个思考小题。那为什么Entry中的值不是弱引用呢?这个话题来自于一群朋友的面试题。看完这篇文章,这个话题就不麻烦你了。欢迎在留言区写下答案!等我下一个ThreadLocal进入步进版吧!