注册

聊一聊ThreadLocal,终于搞明白了

ThreadLocal是什么?


试想以下情况:


在多线程的情况下,对一同一个变量操作可能会出现冲突,解决的办法就是对这个变量加锁。但是我们有时候其实就是想要一个全局变量,不想让多个线程去干扰。那么能不能有一个变量,名字相同,但是多个线程操作的时候又不会相互影响呢?


从另外一个角度来说,对于一个变量,在一个线程的任何一个地方都可能需要用到,但是通过传参的方式又比较麻烦,有没有一个变量是贯穿整个线程,我们想取就能取到的呢。


ThreadLocal就是这么一个变量,那么这个变量是怎么实现的呢?


ThreadLocal源码分析


ThreadLocal github地址


首先看用法


public class Client {
private static final ThreadLocal<String> myThreadLocal = ThreadLocal.withInitial(() -> "This is the initial value");

public static void main(String[] args) {

for (int i = 0; i < 6; i++){
new Thread(new MyRunnable(), "线程"+i).start();
}

}

public static class MyRunnable implements Runnable {

@Override
public void run() {
String name = Thread.currentThread().getName();
System.out.println(name + "的threadLocal"+ ",设置为" + name);
myThreadLocal.set(name);
try {
Thread.sleep(1000);
} catch (InterruptedException ignored) {}
System.out.println(name + ":" + myThreadLocal.get());
}

}
}

------

线程0的threadLocal,设置为线程0
线程3的threadLocal,设置为线程3
线程4的threadLocal,设置为线程4
线程2的threadLocal,设置为线程2
线程1的threadLocal,设置为线程1
线程5的threadLocal,设置为线程5
线程0:线程0
线程4:线程4
线程5:线程5
线程2:线程2
线程1:线程1
线程3:线程3

例子中有六个线程,myThreadLocal里存的都是自己线程独有的变量。这样就实现了变量的线程隔离,而且如果不向传参数,在另外一个函数里直接就能get到这个变量,这对于很多场景下都非常有用。


我们下面来分析一下源码:



  1. 首先每一个Thread,都有一个ThreadLocalMap,变量名字叫做threadLocas,里面保存的是多个ThreadLocal,所以每一个线程才能保存属于自己线程的值。
  2. ThreadLocal封装了getMap()、Set()、Get()、Remove()4个核心方法。主要是对ThreadLocalMap来进行操作。
  3. ThreadLocalMap是一个ThreadLocal的内部类,它实现了一个自定义的Map,ThreadLocalMap中的Entry[]数组存储数据。
  4. Entry的键是threadLocal变量本身,值就是设置的变量的值。Entry的key是对ThreadLocal的弱引用,当ThreadLocal不再有强引用的时候,就会清理掉这个key,防止内存泄漏(然而并不能,后面会说)

5abe86d1459c394b7552c1ef9d7e370c.png


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();
}

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



  1. 获取当前的Thread对象,通过getMap获取Thread内的ThreadLocalMap,ThreadLocalMap的定义如下:ThreadLocal.ThreadLocalMap threadLocals = null;
  2. 如果map已经存在,以当前的ThreadLocal为键,获取Entry对象,并从从Entry中取出值
  3. 否则,调用setInitialValue进行初始化。

setInitialValue


private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}



  1. 首先是调用initialValue生成一个初始的value值,深入initialValue函数,我们可知它就是返回一个null;
  2. 然后还是在get以下Map,如果map存在,则直接map.set,这个函数会放在后文说;
  3. 如果不存在则会调用createMap创建ThreadLocalMap,这里正好可以先说明下ThreadLocalMap了。

ThreadLocalMap


createMap


void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}

static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private static final int INITIAL_CAPACITY = 16;

private Entry[] table;

private int size = 0;

private int threshold; // Default to 0

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
...
}


  1. 首先是Entry的定义,前面已经说过;
  2. 初始的容量为INITIAL_CAPACITY = 16
  3. 主要数据结构就是一个Entry的数组table;
  4. size用于记录Map中实际存在的entry个数;
  5. threshold是扩容上限,当size到达threashold时,需要resize整个Map,threshold的初始值为len * 2 / 3
  6. nextIndex和prevIndex则是为了安全的移动索引,后面的函数里经常用到。

map.getEntry


private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}



  1. 计算索引位置
  2. 获取Entry,如果Entry存在,且key和threadLocal相等,那么返回
  3. 否则,调用getEntryAfterMiss。

getEntryAfterMiss


private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}


  1. 如果k==key,那么代表找到了这个所需要的Entry,直接返回;
  2. 如果k==null,那么证明这个Entry中key已经为null,那么这个Entry就是一个过期对象,这里调用expungeStaleEntry清理该Entry。


为什么会需要清理呢?


如果说ThreadLocal变量被人为的置为null了,ThreadLocal对象只有一个弱引用指着,就会被GC,Entry的key没有了,value可能会内存泄漏。ThreadLocal在每一个get,set的时候都会清理这种过期的key。


为什么需要循环查找key?


这是一种解决hash冲突的手段,这里用的是开放地址法,既有冲突之后,把要插入的元素放在要插入的位置后面为null的地方。HashMap则采用的是链地址法。



expungeStaleEntry


private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

// expunge entry at staleSlot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;

// Rehash until we encounter null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;

// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}


  1. expunge entry at staleSlot:这段主要是将i位置上的Entry的value设为null,Entry的引用也设为null,那么系统GC的时候自然会清理掉这块内存;
  2. Rehash until we encounter null: 这段就是扫描位置staleSlot之后,null之前的Entry数组,清除每一个key为null的Entry,同时若是key不为空,做rehash,调整其位置。


这里rehash的作用是什么?


我们清理的过程中会把某个值设置为null,如果之前这个值后面的区域是和前两连起来的,那么下次循环查找的时候,就会只查到null为止。比如三个hash值碰撞的key,中间的那个被删除了,那么第三个key在查找的时候会从第一个开始查找,查找到第二个就停止了,第三个就查不到了。



set


public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}

map.set


private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);

for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();

if (k == key) {
e.value = value;
return;
}

if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}


  1. 首先还是根据key计算出位置i,然后查找i位置上的Entry,
  2. 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值。
  3. 若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry
  4. 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。
  5. 最后调用cleanSomeSlots,这个函数就不细说了,你只要知道内部还是调用了上面提到的expungeStaleEntry函数清理key为null的Entry就行了,最后返回是否清理了Entry,接下来再判断sz>thresgold,这里就是判断是否达到了rehash的条件,达到的话就会调用rehash函数。

rehash


private void rehash() {
expungeStaleEntries();

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


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}


  1. 首先,size大于threshold的时候才会rehash。
  2. 清理空key,如果size大于3/4的threshold,调用resize()函数。
  3. 每次扩容大小扩展为原来的2倍,然后再一个for循环里,清除空key的Entry,同时重新计算key不为空的Entry的hash值,把它们放到正确的位置上,再更新ThreadLocalMap的所有属性。

remove


private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

先计算出hash值,若是第一次没有命中,就循环直到null,在此过程中也会调用expungeStaleEntry清除空key节点。


什么是内存泄漏?


当程序分配了空间但是却忘了回收导致以后的程序都无法或暂时无法使用这块空间,就发生了内存泄漏。和内存溢出不一样,内存溢出是内存不足的时候出现的。这块要理解清楚,才能明白ThreadLocal为什么会导致内存泄漏。


Java 引用类型


要说到ThreadLocal引起内存泄漏,还得从java的四种引用类型说起。


java中有四种引用类型,分别是强软弱虚。


强引用


一个对象被强引用,那么他就不会被回收。


软引用


如果一个对象只具有软引用,那么它的性质属于可有可无的那种。如果此时内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。


软引用可以和一个引用队列联合使用,如果软件用所引用的对象被垃圾回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。


    Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
SoftReference reference = new SoftReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;
当内存不足时,软引用对象被回收时,reference.get()为null,此时软引用对象的作用已经发挥完毕,这时将其添加进ReferenceQueue 队列中

如果要判断哪些软引用对象已经被清理:


    SoftReference ref = null;
while ((ref = (SoftReference) queue.poll()) != null) {
//清除软引用对象
}

弱引用


弱引用和软引用的区别就是,如果一个对象只有弱引用,那么只要GC,不管内存够不够,都会回收他的内存。注意这里的”只有弱引用“。如果这个对象还被其他变量强引用,那么他是不会被回收的。


虚引用


如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收的活动。





































引用类型被垃圾回收时间用途生存时间
强引用从来不会对象的一般状态JVM停止运行时终止
软引用在内存不足时对象缓存内存不足时终止
弱引用在垃圾回收时对象缓存垃圾回收时终止
虚引用UnkonwnUnkonwnUnkonwn


为什么要有四种引用类型?



  1. 可以让程序员通过代码的方式来决定某个对象的生命周期。
  2. 有利于垃圾回收
  3. 能够实现一些复杂的数据结构。


ThreadLocal什么情况下会出现内存泄漏?


threadlocal里面使用了一个存在弱引用的map,当释放掉threadlocal的强引用以后,map里面的value却没有被回收.而这块value永远不会被访问到了. 所以存在着内存泄露。


如果这个时候还会去调用get set方法,那么这块内存可能会被清理掉。


如果没有去调用get set方法,如果这个线程很快销毁了,那么也不会内存泄漏。


最坏的情况就是,threadLocal对象设置成null了,然后使用线程池,这个线程被重复使用了,但是有一直没有调用get set方法,这个期间就会发生真正的内存泄漏。


其实ThreadLocal发生内存泄露的条件还是比较苛刻的,只要是使用规范,那么就没有什么问题。


ThreadLocal最佳实践



  1. 每次使用完手动调用remove函数,删除不再使用的ThreadLocal.
  2. 可以将ThreadLocal设置成private static的,这样ThreadLocal会尽量和线程本身一起消亡。

ThreadLocal应用案例



管理数据库连接。


  假如A类的方法a中,会调用B类的方法b和C类的方法c,a方法开启了事务,b方法和c方法会去操作数据库。我们知道,要想实现事务,那么b方法和c方法中所使用的的数据库连接一定是同一个连接,那怎么才能实现所用的是同一个数据库连接呢?答案就是通过ThreadLocal来管理。


MDC日志链路追踪。


MDC(Mapped Diagnostic Contexts)主要用于保存每次请求的上下文参数,同时可以在日志输出的格式中直接使用 %X{key} 的方式,将上下文中的参数输出至每一行日志中。而保存上下文信息主要就是通过ThreadLocal来实现的。
假如在交易流程每个环节的日志中,你都想打印全局流水号transId,流程可能涉及多个系统、多个线程、多个方法。有一些环节中,全局流水号并不能当做参数传递,那你怎么才能获取这个transId参数呢,这里就是利用了Threadlocal特性。每个系统或者线程在接收到请求时,都会将transId存放到ThreadLocal中,在输出日志时,将transId获取出来,进行打印。这样,我们就可以通过transId,在日志文件中查询全链路的日志信息了。



InheritableThreadLocal


使用ThreadLocal时,子线程获取不到父线程通过set方法保存的数据,要想使子线程也可以获取到,可以使用InheritableThreadLocal类。


作者:dsvshx
链接:https://juejin.cn/post/7041215155408994317
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

0 个评论

要回复文章请先登录注册