threadlocal个人理解
0.threadlocal的应用场景?
1.存放数据的容器底层的数据结构是什么样的?
2.为什么会内存泄露?
3.为什么会设计成两个引用指向同一个threadlocal对象?
4.软引用 优化了什么问题?
5.创建一个threadlocal对象为何使用static修饰,有哪些本质原因?
6.为什么必须通过finally块去调用remove方法清理对象?
7.扩容机制带来的内存泄露?
0A:A方法调用B方法,B方法调用C方法,如果想要传值,可以通过方法传参或者包装的对象属性进行传参,那如果这个调用链路很长呢?A->B->C->....->Z方法,但是我们发现程序的执行流顺序是自上而下的,那如果我在A点把值存到当前线程上,再在Z方法把值从当前线程上取出来用,那就不需要通过传参的方式去传,因此在 一些框架上通常可以看到XXXContext,其实就是应用了threadlocal。
1A:问题0上说明了我们要把值存在线程类里,那在线程类里就会有一个容器,因为线程里面有可能会放很多值嘛,这个容器就是一个ThreadLocalMap,它的底层是一个Entry[],Entry里有两个属性,Key和Value,Value是存的值,Key是当前的threadlocal对象且弱引用指向,key这么去设计问题3会具体说明,虽然ThreadLocalMap也叫map,但是和HashMap在数据结构是有本质区别,ThreadLocalMap仅仅只有一个数组,出现hash冲突不会像HashMap还能添加链表节点或者树节点,ThreadLocalMap没有这些概念,ThreadLocal是使用了线性探测法去找自己的位置,寻址算法是,先去通过key的hash值去计算出数组中的一个位置,如果这个位置没有Entry节点,自己占了,如果有Entry节点,从当前位置往后探测,探测到没有Entry节点占用位置为止,那如果探测到数组的最大下标位置时怎么办,会重新回到下标0位置继续探测,所以 Entry数组,可以理解是一个“数组环”,当然了,在探测过程中有可能遇到一些Entry节点的key部分为null的节点,遇到这种情况呢,会进入这个下标,往前探到null,往后探到null,中间的区域会将key部分为null的节点清理掉,并会将这个区域里的正常节点rehash,那至于为什么会出现key部分为null的节点,其实是和问题4,5有关的。因此,ThreadLocal的哈希冲突有可能确实是计算的位置都一样,也有可能别的Entry节点的位置也被占了,线性探测到当前这个Entry节点上的。
2A:由于java线程的特殊性,java线程就是CPU线程的映射,创建线程,销毁线程都存在内核用户态的切换,而且开启一个线程就需要分配栈内存,大量创建有可能造成内存的溢出,因此都是通过线程池去缓存线程,那可以认为这些线程都属于gc root下的对象,难以被回收,因此ThreadLocalMap里的对象都是难以被回收,gc root根可达性算法这个不需要多说吧,因此造成内存泄露。
3A:首先上面知道为什么要把值存到线程里了,并且知道存在的容器其实就是一个数组,那么问题来了,Thread类属于一个很重要的类,会给你直接开放取值存值的方法吗,如果这样设计,你还需要不停的去调用Thread.currentThread(),在去通过这个线程去调用取值存值的方法,还需要去在Thread类应对数组的扩容问题,那这个从解耦的角度上讲,是不是就不行,Thread类里面只给你提供一个容器,那至于容器里面存取值或者扩容,我们写一个类给你,并且优雅的给你提供一些解决内存泄露的机制和方法。因此ThreadLocal其实就是一个操作当前线程ThreadLocalMap的门户或者是API。那现在门户有了,该来聊聊存取值的规则了,一个线程容器里面可以存多个值,那该如何区分呢,之前说过嘛,就是Entry节点的key,使用的就是当前ThreadLocal对象,那为什么会采用这样的设计,而不是采用类似于HashMap的设计,那如果采用HashMap的设计,我估计是这样的写法:threadlocal.set(Key, "value"),Key是个Object类型,这个设计思路要求Key这个值在A->B->C->....->Z的调用链路上要做到可见性,最好做成static对象并且还要维护起来,如果真这么玩的话,Threadlocal就肯定做成单例的了。但是你发现本身ThreadLocal这个API入口的对象在调用链路上本身就具有可见性,那干脆直接以自己为key,那就是现在的写法threadlocal.set("value"),因此要想多存几个值,你就需要多个ThreadLocal对象,因此ThreadLocal对象是多例的。当然了,很多人就得反驳了,就单单是这一点好处显然站不住脚,这只是原因1,原因2,得从问题4上去寻找答案。
4A:从问题3可以得出,有两个引用指向了同一个ThreadLcoal对象,外面是个强引用,里面key部分是个弱引用。那为什么设计成弱引用,假设现在有个场景,我通过ThreadLocal将值放入线程容器里面,在还没有清理值之前,突然访问线程容器的门户消失了,因为你不可能null.remove()吧,也有人说为什么会出现这种情况,比方说外面强引用挂载的那个对象被gc回收了,是不是就会出现外面的强引用消失,那现在有且仅有一个弱引用指向这个ThreadLocal对象,这种情况下在下一次gc收集时就会把这个ThreadLocal对象清掉,这个时候Entry节点的key部分会“快速”反应为null,这样会有一个好处,等这个线程重新被其他业务请求拿到,框架层面或者业务本身层面使用到ThreadLocal时,做线性探测找自身Entry节点位置时,探测过程中key==null为条件,发现这类Entry节点时,会帮你清理,但是不要过于依赖这套优化机制,比如说人家一下子就找到自己的位置,没有进行探测,也没触发扩容(扩容会做一次全局清理),因此软引用只是针对外面强引用在手动清理值之前突然消失处理的一套优化机制。由此可以得出Entry数组里面的节点key部分为null的就是有问题的,那key部分不为null就没有问题吗,这个问题要带入到 问题6里面去回答。
5A:其实这个问题隐隐约约在问题3里面已经回答了,因为本身ThreadLocal在调用链路上是具有可见性的,所以做成静态变量是非常方便调用链路上的方法引入使用的,当然这只是原因其中之一,还有一个原因就是static变量是gc root下的一个分支,这就保证了ThreadLocal对象难以被回收,那问题来了,这种情况下问题4中key为null还会出现吗,答案是不会,ThreadLocal对象一直在堆内存中,所以当你使用了static去做修饰,清理的代码永远不会走到,软引用的优化仅仅是针对外面强引用在手动清理值之前突然消失。
6A:问题5说过当我们static修饰后,保证了ThreadLocal不会被回收,还记得问题4最后说的问题吗,key部分不为null就保证一定不是泄露对象吗?很显然不一定,如果我们没有手动清除,更为准确的说是不在finally块里面调用remove方法清理对象,都有可能产生内存泄露问题,你只set值,不remove或者不在finally块调用remove,之前抛出异常了。这两种情况都会产生内存泄露而且没有办法解决,因为ThreadLocal不会被回收,不能依靠软引用机制去清理了,此时内存泄露对象和非内存泄露对象key部分都是非null,泄露对象和正常的对象“如出一辙”,因此在内部机制是没有办法解决的,这也是为什么官方必须提供一个remove方法让大家强制调用,所以在使用ThreadLocal过程中,在一次请求的生命周期内,必须要做到用完就清理的好习惯!
7A:从问题1可以了解Entry容器是一个数组,但是为什么设计成数组而不是一个链表,众所周知,数组的唯一优势就是可以从下标索引直接访问,劣势就是会占用连续的内存,如果是设计成链表,定位位置会随着链表的长度导致迭代的复杂度增加,好处就是不需要连续的内存,可以利用链表的特性形成真正的“数组环”。很显然,官方选择了数组,虽然定位很快(存在探测有可能效率降低),但是内存问题也会随之而来,令人忽视。假设现在有n个ThreadLocal,分别set一次和remove一次,操作次数总数就是2n,情景1:前n次都是set,后n次都是remove;情景2:n次set和n次remove交叉进行。请问对于我们使用ThreadLocal更希望趋向哪一种情景,很显然是情景2,因为情景1前面都是在set,有可能会将Entry数组不停的去扩容,导致分配了很多内存,且后面全部都remove了,然后一直占用这块内存,因为Entry数组是不存在缩容的。因此,得出两个结论,结论1:前n次,set次数越多,remove次数越少,就说明扩容次数越多,有可能会导致数组内存的浪费。结论2:采用二分法观察,每次分出来的次数块中的set次数和remove次数持平,可以继续做二分,因此二分的次数越多,说明扩容次数越少,内存越节省。其实可以通过生活中的例子对这类现象做类比,例如高铁,高铁的每一个站都有人上有人下,所以它的容量可以保持一个恒态,容量不会太高,如果像情景1一样,在第1站就有大批人上,请问是否需要加车厢去扩容,扩容之后等到了第2站,所以人全部下车,假设中途没人上车了,是不是就造成了资源浪费了。因此对于这个问题,官方其实也没有做出任何的解决方案,只能让我们使用大量ThreadLocal的去向场景2的趋势接近。