3

Java基础(1)——ThreadLocal - 迈吉

 2 years ago
source link: https://www.cnblogs.com/stepfortune/p/16311274.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

1. Java基础(1)——ThreadLocal

1.1. ThreadLocal

ThreadLocal是一个泛型类,当我们在一个类中声明一个字段:private ThreadLocal<Foo> threadLocalFoo = new ThreadLocal<>();时,这时候,即使不同的线程持有了该类的同一个实例,那么它们在访问该实例的threadLocalFoo的时候访问的是不同的Foo对象,这些Foo对象和这些线程是一一对应的关系,并被这些线程所私有,因此每个线程不需要对自己从threadLocalFoo获得的Foo实例进行加锁(加锁也没用啊),这种无锁化的设计提高了并行能力,但注意ThreadLocal并不是万能的,有些场景可以使用ThreadLocal(比如Spring中的事务),但有些场景它的语义就是必须对同一个对象实例进行加锁后独占地访问,比如单例模式,这种ThreadLocal就起不了作用了。

当然ThreadLocal还提供了initialValue这个protected方法,用来创建声明的泛型类型对象,因此我们还可以以下面这种方式来声明一个thread local:

ThreadLocal<Foo> threadLocal = new ThreadLocal<Foo>(){ @Override protected Foo initialValue() { return new Foo(); } };

同时ThreadLocal还提供了一个withInitial静态方法,该方法接收一个相同泛型类型的Supplier,返回ThreadLocal。

Java的每个Thread实例中,都有一个ThreadLocalMap类型的实例字段,它存放了该线程所用到过的所有ThreadLocal式样的实例对象,比如,有个类中声明了这个字段private ThreadLocal<Foo> threadLocalFoo = new ThreadLocal<>();,虽然它的一个实例被多个线程持有,但这些线程不一定都访问过这个实例的threadLocalFoo字段,只有访问过这个字段的Thread,它的thread local map中才会存Foo对象(以Entry的方式存,key为该ThreadLocal实例(共享),value为每个线程自己持有的Foo对象(私有))。

注意,我们使用ThreadLocal的是因为有些对象每个线程都可以持有一份,然后我们才使用ThreadLocal来避免同一个对象的实例方法的并发操作,但这样的话我们要谨防ThreadLocal的退化:如果使用它的时候,用之前都是set,之后就remove,那么相当于每访问一次ThreadLocal都要创建出一个新的对象出来,这样发挥不出ThreadLocal节省对象数量的作用。ThreadLocal一般被声明为static字段。

1.1.1. get方法

如果当前的Thread中的thread local map字段不空,并且其中存的有对应的对象,那么返回。

如果thread local map字段不空,但是没有存对应的对象,那么使用initialValue创建对象,然后将它和该ThreadLocal实例,打包成Entry放入当前的thread local map中,返回创建的对象。

如果thread local map字段为空,那么首先创建对象,然后创建该线程的thread local map,然后再存Entry,再返回创建的对象。

总而言之呢,get方法就是说返回的对象都必须从当前线程的thread local map中取,thread local map没创建,就创建thread local map,创建了但里面没有需要的对象,那么就创建对象并将其塞进去,反正必须从thread local map中拿就对了。

494551561242896.png

setInitialValue方法:

494693988097152.png

createMap方法:

39352476938854.png

1.1.2. set方法

Set方法,将传入的对象设置到当前的线程的thread local map中,注意,Entry的Key为set方法所在的ThreadLocal实例。

还是一样,没有thread local map就创建thread local map,反正必须塞入当前的thread local map中。

268421938157709.png

1.1.3. remove方法

remove方法,就是获取当前线程的thread local map,如果它不空的话,就移除key为remove方法所在的ThreadLocal的Entry(不同的ThreadLocal实例对应着不同的Entry,而同一个ThreadLocal实例在一个thread local map中最多存一个,但是可以存在多个thread local map中)。

43363054877104.png

1.2. ThreadLocalMap(ThreadLocal内部类)

ThreadLocalMap是ThreadLocal机制的关键,它不被使用ThreadLocal的用户所感知,它是ThreadLocal的静态内部类,它的所有方法都是private方法,并且该类的可见性是包可见的,因此ThreadLocalMap类中的所有方法都只能被ThreadLocal的方法调用。

ThreadLocalMap的底层存储是ThreadLocalMap.Entry类型的数组,它的碰撞处理策略不是HashMap的开链法(开散列方法),而是线性探测法(linear probing,属于闭散列方法,常见的其他闭散列方法还有:平方探测法、双散列法)。这个线性探测法就是说:

  • 在put的时候,先根据key的hash值定位到在数组中的槽位,如果对应的位置没有Entry,那么就可以把当前的键值对放入这里,反之,如果该位置已经被占用的话,那么需要获取该位置的下一个位置(如果当前位置为数组最后一个位置,那么下一个位置为0),直到找到空位为止

  • 在get的时候,根据key找Entry,也是首先先根据key的hash值定位到在数组中的槽位,如果这个槽位空着,那么说明当前map没有存这个key,如果这个槽位不空,那么还要检查Entry中的key是否就是当前的key,如果不是的话还要继续向后探测,直到遇到了空位或者遇到了key为当前key的Entry。

  • 在remove的时候,首先跟get一样,找到key对应的Entry,然后将其移除,但是移除完之后,如果该槽位后面连续的槽位也都被占用了,那么还要对这些槽位中的Entry再进行位置修正。

和Map接口中的Entry不一样,ThreadLocalMap.Entry声明为:

557805716576208.png

ThreadLocalMap.Entry是一个对ThreadLocal对象的弱引用,也就是说,虽然该Entry会持有ThreadLocal对象,但是并不会影响该ThreadLocal对象的GC,而这个弱引用对象Entry本身是个寻常的Java对象,它还持有了ThreadLocal的泛型类型的对象(比如上面例子中的Foo),这个持有关系是强引用,只有当ThreadLocalMap的底层数组不再持有这个Entry时,该Entry才会被GC。因此,也就是说,如果ThreadLocalMap如果不做特殊处理的话,那么即使是ThreadLocal实例都被GC了,但是它们对应的Entry依旧无法被GC,导致实际使用的泛型类型对象也无法被GC,只是这些Entry引用的ThreadLocal变成null了,这个问题其实就是内存泄露

为了解决这个内存泄露问题,ThreadLocalMap在线性探测操作中,如果发现了持有的thread local已经被GC的Entry(Stale Entry),那么就不再持有这个Entry,使得这个Entry可以被GC,但是即使这样依然无法完全保证stale entry都能及时的被清理,这个残留的问题就是伪内存泄露问题

这个伪内存泄露问题一般存在于线程池的场景下,因为如果线程本身被销毁,那么thread local map也会销毁,也不存在什么泄露问题。

为了解决这个伪内存泄露问题,我们作用应用程序的开发者,在使用到threadlocal时,如果我们不再需要它时,那么就要手动进行remove操作,使得对应的Entry可以被GC。

这个Entry数组初始容量为16,threshold为当前数组长度的三分之二(hard code),每次向Thread local map放入entry之后,会检查更新后的size(数组中的Entry数量)是否达到了threshold,如果达到了,那么就需要进行扩容,扩容的逻辑是,先把所有stale entry清理后,判断清理的数量是否达到了四分之一threshold,如果是,那么说明当前thread local map只是因为stale entry太多的缘故导致的容量紧张,就只需执行清理动作,而不用将底层数组容量翻倍并进行entry的迁移,这个策略的目的:

  1. 数组容量翻倍本身占用空间,并且扩容时搬运entry的操作相对相不扩容清理stale entry的操作来说开销更大。

  2. 更好的去抑制上面讲的伪内存泄露问题。

注意,thread local map底层的Entry数组只会扩容,不会缩容。

1.2.1. 构造函数

387414265734304.png

1.2.2. getEntry方法

372263759250661.png

getEntryAfterMiss

getEntryAfterMiss就是get操作的线性探测步骤。

578293125524611.png

expungeStaleEntry:

这个expungeStaleEntry就是说呢,需要删除那些Stale的Entry(已经被GC后的ThreadLocal实例对应的Entry)。

它不止删除给定stale位置的entry,它还有线性探测该位置之后被连续占用的位置的entry。在这些entry中,对于不是stale的,我们需要把它们挪到更正后的位置上,对于是stale的,将其删除。

52654122941162.png

expungeStaleEntries:

expungeStaleEntries方法就是遍历数组中的所有Entry,检查是否stale,如果stale,那么调用expungeStaleEntry来删除并调整。

120013299615411.png

1.2.3. set方法

Set方法往thread local map中添加一个Entry。

如果该Entry未经线性探测时的位置未被占用,那么直接占用,更新size计数,并且从该位置尝试清理一些stale entry(见cleanSomeSlots方法)。如果清理成功,那么此时size铁定没有超出threshold(因为此时至少清理了一个Entry,而set方法一次只set一个,并且初始情况下size小于threshold)。如果没有清理到到,那么就判断更新后的size是否超过了threshold,如果超过了,那么要扩容。

如果原始位置被占用了,那么就需要通过线性探测,探测之后的位置,在探测过程中:

  • 如果发现已经有给定的Key的Entry了,那么直接替换value就完事了。

  • 如果没有发现stale entry,那么就将遇到的第一个空位用来放置该Entry,然后完事,此时同样需要像上面一样尝试清理stale entry,如果清理失败看需不需要扩容等。

  • 如果在探测中发现了stale entry,那么就进行替换操作,注意这个替换操作很复杂,见replaceStaleEntry方法。

570551890826319.png

replaceStaleEntry:

前两个参数是需要放置的Entry的信息,最后一个参数是stale entry的位置。

首先是向前探测,因为给的stale entry的位置可能是处于一个连续被占用段的中间,因此来向前探测,来找到该连续占用段的第一个stale位置。

然后再从给定的stale位置向后探测,在这个向后探测的过程中:

  • 如果遇到了跟传入key对应的Entry,那么就将该Entry给挪到传入的stale位置。如果上一步向前探测时没有找到stale entry,那么就从当前的位置向后回收连续占用段的stale entry;如果向前探测时找到了的话,就从这个找到的位置向后回收本连续占用段的stale entry。

  • 如果没有遇到该key对应的Entry,并且之前向前探测的时候也没有找到当前连续占用段的第一个stale位置,那么就需要在这个向后探测从保存第一个stale entry的位置,探测结束后将传入的stale位置放入entry,然后从这个向后探测过程中保存的stale位置开始向后回收所在连续占用段的stale entry。

上面两种情况结束后,如果它们expungeStaleEntry的开始位置不是传入的stale位置,那么在这个expungeStaleEntry操作的结束位置(这个结束位置是一个空位)的下一位置开始向后尝试回收一些stale entry,见cleanSomeSlots方法。

23032317122686.png
324072065937030.png

cleanSomeSlots:

这个方法的作用是说从给定position(不包含该position)开始向后找stale entry,如果连续找了 log(n) 个位置都不是stale entry,那么就结束,反之如果找到一个stale entry的话,那么需要再重新向后看 log(len) 个位置。

注意,这个方法在set方法、replaceStaleEntry方法中的末尾都有调用,区别在于,set方法中调用cleanSomeSlots时设置初始初始向后看的位置数目为log(size),而replaceStaleEntry设置的是log(len)

163052075807216.png

rehash:

先把所有stale entry清理后,判断清理的数量是否达到了四分之一threshold,如果是,那么说明当前thread local map只是因为stale entry太多的缘故导致的容量紧张,就只需执行清理动作,而不用将底层数组容量翻倍并进行entry的迁移。

389873207901356.png
591596514648732.png

1.2.4. remove方法

562861308268796.png

1.3. ThreadLocal内存泄露

内存泄露(Memory Leak)指由于对象永远无法被垃圾回收导致其占用的Java虚拟机内存无法被释放。持续的内存泄露会导致Java虚拟机可用内存主键减少,并最终可能导致Java内存溢出(OOM),直到Java虚拟机宕机。

伪内存泄露(Memory Psedo-leak)类似于内存泄露,伪内存泄露中对象占用的内存在其不再被使用的相当长时间内仍然无法回收,甚至永远无法回收。就是说,伪内存泄露的对象,理论上将是可以被回收的,但是这个等待回收的时间太长了。

谈及ThreadLocal map的时候,我们谈到了,当使用threadlocal任务不进行remove操作,并且任务又在线程池中运行时,有伪内存泄露的风险,这个风险被thread local map本身的实现抑制了,但是仍然存在,解决的办法就是即使使用remove操作。

此外还有一种更加严重的内存泄露:每个线程实例持有thread local map,然后间接持有了线程特有对象(thread local的泛型类型),在Tomcat环境下,Web应用(打包成WAR)自身定义的类由类加载器WebAppClassLoader负责加载, JDK的标准类由类加载器StandardClassLoader负责加载。不管类每个类被哪个加载器加载,它都持有了加载它的加载器的引用,除了最特殊的那个。对于WebAppClassLoader来说,它还会持有它加载过的所有class的引用,这样就导致,如果如果某个由WebAppClassLoader加载的类型(假设为ThreadLocalMemoryLeak)有个静态的ThreadLocal字段(threadLocalFoo),那么该线程特有对象(foo对象)会持有该对象的Class对象(Foo.class),Foo类型会持有WebAppClassLoader,WebAppClassLoader又会持有ThreadLocalMemoryLeak的Class对象,这个Class对象又持有了threadLocalFoo这个静态字段,也就是说,foo对象这个线程特有对象,最终又反过来持有ThreadLocal实例了,这就导致,如果不及时remove的话,那么thread local map中的Entry永远不会stale,即使这个Web app不运行了,但是Tomcat容器还在运行的话,由于底层的这些线程不会被销毁,因此thread local就产生了内存泄露,更进一步讲Foo类的Class对象、ThreadLocalMemoryLeak的Class对象,以及它们的静态变量所引用的所有对象,都无法被回收。当然Tomcat提供了一套内存泄露的检查机制以及一定程度的自动规避,但我们不要依赖这个机制。为了解决这个问题,我们要及时remove。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK