来挖一挖ThreadLocal的神秘之处

1. 什么是ThreadLocal

如果我们有两位黄金矿工,而他们只有一个矿袋子时,则两个矿工同时捞上来一个矿石时需要去竞争袋子这个互斥资源,没拿到袋子的只能先等着。为了避免这种情况发生,我们可以给他们一人准备一个矿袋子——废话

在Java并发编程中也是同样的道理,多个线程需要对一个共享变量进行写入时,需要进行同步。那么我们也可以给每个线程准备这个变量的副本,这个副本是线程独享的,这样就可以避免线程安全问题。

ThreadLocal是JDK包提供的用于支持线程本地变量的一种方式。如果创建了一个ThreadLocal变量时,那么每个访问这个变量的线程都会有这个变量的一个本地副本。

ThreadLocal介绍

ThreadLocal使用示例

public class ThreadLocalTest {

    static void print(String str) {
        System.out.println(str + ":" + threadLocal.get());
        threadLocal.remove();
    }
    
    //定义一个ThreadLocal变量
    static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        Thread threadOne = new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocal.set("线程1的本地副本");
                print("线程1");
                System.out.println("线程1 remove了自己的副本后,获取变量:" + threadLocal.get());
            }
        });

        Thread threadTwo = new Thread(new Runnable() {
            @Override
            public void run() {
                threadLocal.set("线程2的本地副本");
                print("线程2");
                System.out.println("线程2 remove了自己的副本后,获取变量:" + threadLocal.get());
            }
        });

        threadOne.start();
        threadTwo.start();
    }
}
线程1:线程1的本地副本
线程2:线程2的本地副本
线程1 remove了自己的副本后,获取变量:null
线程2 remove了自己的副本后,获取变量:null

可以看到我们只定义了一个ThreadLocal变量,并且两个线程都对这个变量进行了set值,但是从之后调用的get方法可以看出,它们都是互不影响的。


2. ThreadLocal的实现原理

2.1 和Thread之间的关系

先看ThreadLocal的类图

ThreadLocal的类图

再看到在Thread类中,定义了这两个成员变量:

class Thread implements Runnable {
    //...
    ThreadLocal.ThreadLocalMap threadLocals = null;
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
    //...
}

也就是说,每个Thread对象中都包含着属于自己的ThreadLocal,并且ThreadLocal是通过ThreadLocalMap存储的,而ThreadLocalMap是由一个一个Entry构成的。

这样的话,就可以知道每个线程的副本都来自于自己的threadLocals变量中了

不过这只是初步的分析和猜想,接下来就前往源码看一下是否真的是这样吧~

2.2 ThreadLocal的方法

首先先看比较重要的几个方法和他的构造方法

public class ThreadLocal<T> {
    
    //只有一个无参构造方法
    public ThreadLocal() {
        }
    
    public T get() {
        Thread t = Thread.currentThread(); //拿到当前线程
        ThreadLocalMap map = getMap(t);    //以当前线程为参数得到一个ThreadLocalMap对象
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this); //调用ThreadLocalMap的getEntry方法
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    public void set(T value) {
        Thread t = Thread.currentThread(); //和上面差不多的逻辑
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value); //调用的是ThreadLocalMap的set方法
        else
            createMap(t, value); // 如果拿到的map为空,则执行createMap方法
    }
    
    public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread()); //同样也是对map进行操作
         if (m != null)
             m.remove(this);
     }
    
    //..省略其他方法
}

可以看到ThreadLocal的操作都是基于ThreadLocalMap来进行的,接下来看看这个ThreadLocalMap是啥

2.3 ThreadLocalMap(核心)

它虽然是定义在ThreadLocal中的一个内部类,但是却占据了ThreadLocal类2/3的内容

成员变量

先看一下它的成员变量:

static class ThreadLocalMap {
    
    //Entry内部类
    static class Entry extends WeakReference<ThreadLocal<?>> {
        //成员变量 value
        Object value;
        //构造方法
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    private Entry[] table;
    private static final int INITIAL_CAPACITY = 16;
    
}

其中还有个内部类Entry,它是由一个弱引用的ThreadLocal对象作为k(这里埋个内存泄漏的伏笔,后面会具体说明),Object作为v的key-value类型

看上去是不是相当眼熟,16的初始容量,Entry数组,这不就是和HashMap差不多嘛。但是每个Entry并没有next指针,也就是并不是和HashMap一样的数组+链表的结构,具体结构我们继续往下看

getMap方法

从2.2部分可知,我们调用ThreadLocal的set方法时,首先调用的是ThreadLocalMapgetMap方法

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
    //也就是直接将线程的threadLocals这个成员变量直接返回(类型为ThreadLocalMap)
}

在2.2中如果getMap拿到的ThreadLocalMap对象为空,则执行createMap方法,不为空则执行set方法,我们先看createMap方法

createMap方法与构造方法

void createMap(Thread t, T firstValue) {
    //调用的构造方法new了一个ThreadLocalMap对象
    t.threadLocals = new ThreadLocalMap(this, firstValue);    
}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    //按照初始容量16 new了一个Entry数组
    table = new Entry[INITIAL_CAPACITY];
    //将当前线程的ThreadLocal作为key,得到hashcode,并且与数组长度相与得到索引下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    //数组对应索引下标插入新元素
    table[i] = new Entry(firstKey, firstValue);
    //设置元素个数和扩容门限信息
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

也就是,当线程第一次对ThreadLocal类型的公共变量执行set方法的时候,会new一个ThreadLocalMap对象作为自己的成员变量,并对这个Map插入一个键值对,其中key是公共变量ThreadLocal,value由set方法传入

Set方法

private void set(ThreadLocal<?> key, Object value) {         
    
    //拿到之前创建的table,并获取长度
    Entry[] tab = table;
    int len = tab.length;
    //计算对应ThreadLocal的hashcode,并与数组长度-1相与得到索引下表
    int i = key.threadLocalHashCode & (len-1);

    //开放定址法——线性探测法处理冲突
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        
        //数组对应位置k不为空,并且和传入的key相同,则直接更新value
        if (k == key) {
            e.value = value;
            return;
        }
        //将key与value都为null的Entry设置为null
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    //如果不冲突则直接插入
    tab[i] = new Entry(key, value);
    //判断是否要扩容
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

//很简单的代码,也就是如果对于索引i,如果i+1超过数组长度了则返回0,否则就返回i+1
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

不同于HashMap的链地址法处理冲突,ThreadLocalMap采用的是线性探测法来处理冲突。也就是当计算hash得到地址之后,如果当前地址已经存在元素且key不冲突,则探测下一个地址存不存在元素,不存在则插入,存在则判断是否冲突,若冲突则继续向下一个地址探测……这种方法称为线性探测法

ThreadLocal往往存放的数据量不会特别大(而且key是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间

getEntry以及相关方法

由2.2部分可知当我们对ThreadLocal调用get方法时,会调用到这个方法

实际上也是同样的,先按照索引寻址,再根据线性探测法直到找到目标元素为止

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); //没找到则进行下面的方法
}

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];
    }
    //找不到则返回null
    return null;
}

remove方法

首先看ThreadLocal的remove方法

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

先拿到ThreadLocalMap对象,如果对象存在则执行ThreadLocalMap对象的remove方法

接下来看ThreadLocalMap的remove方法

private void remove(ThreadLocal<?> key) {
    //拿到Entry数组和长度
    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) {
            //找到了对应的ThreadLocal对象则调用clear方法将对应Entry设置为null
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

3. 总结

如下图所示,在每个线程内部都有一个名为threadLocals的成员变量,该变量的类型为ThreadLocalMapThreadLocalMapEntry数组组成,其中每个Entry的key为我们定义的ThreadLocal的变量的this引用,value则为我们使用set方法设置的值。

其中ThreadLocalMap同样采取的是hash索引的方式存储,并且使用的是开放定址法中的线性探测法来处理hash冲突问题。

每当我们使用某个线程第一次对ThreadLocal变量使用get或者set方法时,会在当前线程内创建一个新的ThreadLocalMap对象,并且将key作为ThreadLocal,value使用get方法时会设置为null(这部分源码细节没放出来),使用set方法时会设置为我们指定的值。并将这个key-value对象作为Entry存入数组中。


4. 补充——关于内存泄漏与内存溢出

防止内存泄漏

之前提到过,在Entry中对于key是弱引用的,而对于value是强引用的

static class Entry extends WeakReference<ThreadLocal<?>> {
    //...
}

其中关于弱引用,《深入理解JVM虚拟机》中是这样描述的:

弱引用是用来描述那些非必须对象,但是它的强度比软引用更弱一些,
被弱引用关联的对象只能够生存到下一次垃圾回收发生为止。
当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

至于为什么会设置成弱引用呢?假设我们使用的是强引用,那么由于每个线程访问某 ThreadLocal 变量后,都会在自己的 map 内维护该 ThreadLocal 变量与具体实例的映射,如果我们不删除这个引用,则这些ThreadLocal不能被回收,这样就会导致内存泄漏问题。

而我们使用弱引用的话,如果没有强引用来指向ThreadLocal变量时,它就可被回收,从而避免上面所说的问题。

但这样又会出现另外一个问题,当ThreadLocal变量被回收之后,Entry的键就会变为null了,并且这个Entry无法被移除,从而导致另一种内存泄漏。针对这个问题,ThreadLocal采用下面两个方法来解决:

  • replaceStaleEntry:将所有key为null的Entry的value设置为null,使得该value可以被回收
  • expungeStaleEntry:将key和value为null的Entry设置为null,使得该Entry可以被回收

其中set方法中在for循环遍历中使用到了上面的方法,而removegetMap使用到了下面的方法

内存溢出

通过上面的分析,我们知道expungeStaleEntry 方法是帮助垃圾回收的,根据源码,我们可以发现getset方法都可能触发清理方法expungeStaleEntry,所以正常情况下是不会有内存溢出的。

但是如果我们没有调用getset的时候就会可能面临着内存溢出,特别是线程池的情况下,当我们使用线程池来存储对象时,因为线程池有很长的生命周期,所以线程池会一直持有value值,那么垃圾回收器就无法回收value,所以就会导致内存一直被占用,从而导致内存溢出问题的发生。

ThreadLocal内存溢出的解决方案很简单,我们只需要在使用完ThreadLocal之后,执行remove方法就可以避免内存溢出问题的发生了。经过2.3部分的分析我们可知,调用remove方法会移除整个Entry,所以即使线程一直存活着也不会因为ThreadLocalMap的内存占用导致内存溢出了

Last modification:June 10th, 2021 at 02:05 pm