透彻理解ThreadLocal

作用和使用场景

  • threadLocal是线程本地储存,在一个线程中通过threadLocal.set()方法赋值一个对象T(假设是T@48533e64),在赋值后当前线程内通过threadLocal.get()方法得到的值都是T@48533e64对象,以便后续处理时用到。使用场景:跨类跨方法传递数据。假如你再A类计算得到一个值,而在C类的某个方法中要用到该值,那么需要在C类的该方法上新增一个参数用于传递该值,但是使用threadLocal后,就可以省略这个参数,直接通过threadLocal.get()获取得到。

  • 每个线程设定的对象与其他线程是隔离的,线程安全控制使用场景:典型的如多个线程与数据库交互时,每个线程都拥有各自的Connections对象;再比如在对日期进行格式化的工具类中,如果用静态的SimpleDateFormat成员变量去做格式化,那么可能会发生错误,可以通过静态的ThreadLocal<SimpleDateFormat>处理解决。

内存模型图

看如下代码示例及其对应的内存模型图

public class ThreadLocalTest {
    @Test
    public void test() {
        ThreadLocal<String> threadLocal = new ThreadLocal<>();
        Thread thread = new Thread(() -> threadLocal.set("value"));
        thread.start();
    }    
}

透彻理解ThreadLocal

ThreadLocalMap

从内存图知,ThreadLocalMap是一个容器类,key是threadLocal对象,value是我们要保存的对象,threadLocal赋值的key和value都是保存在各自线程的threadLocalMap对象中的。threadLocalMap对象底层也是entry数组(注意这个entry和hashMap中的entry不是同一个类),而与HashMap不同的是,他们解决hash冲突的方式不一样,threadLocalMap使用开放寻址法(线性探测),而hashMap使用的是链地址法

  • 开放寻址法(线性探测)

    当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为。

    缺点:当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n),因此该方法适用于存储少量元素的hash表

    为什么threadLocalMap使用线性探测方法?

    答:我们在编码时,大多数情况下只设置了一个或几个threadLocal对象,不会有太多threadLocal对象,因此由用户编码而存入到threadLocalMap中的元素大多也就一个或几个而已,因此threadLocalMap存放的元素数量很少,适合使用线程探测法来解决hash冲突

  • 链地址法

    每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向  链表连接起来

ThreadLocal源码详解

get方法

/**
  * Returns the value in the current thread's copy of this
  * thread-local variable.  If the variable has no value for the
  * current thread, it is first initialized to the value returned
  * by an invocation of the {@link #initialValue} method.
  *
  * @return the current thread's value of this thread-local
  */
public T get() {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 得到当前线程的threadLocals属性对象(即Entry[]对象)
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 以threadLocal对象做为key,通过“开放定址法”去寻找对应位置的entry
        // 特别注意:在寻找过程中,会对遇到的无效entry进行清除以避免内存泄漏
        // 解释:"清除无效entry"即打断如内存图所示的”引用7“和”引用5“的引用
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 若该entry对象不为null则返回该entry.value
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 若当前线程的threadLocals属性对象为null,或者未找对应的entry,返回初始化的值
    return setInitialValue();
}

set方法

/**
 * Sets the current thread's copy of this thread-local variable
 * to the specified value.  Most subclasses will have no need to
 * override this method, relying solely on the {@link #initialValue}
 * method to set the values of thread-locals.
 *
 * @param value the value to be stored in the current thread's copy of
 *        this thread-local.
 */
public void set(T value) {
    // 获取当前线程
    Thread t = Thread.currentThread();
    // 得到当前线程的threadLocals属性对象(即Entry[]对象)
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 以threadLocal对象做为key,通过“开放定址法”去寻找对应位置的entry,若找到正确的entry,则直接对该entry.value赋值,若未找到则new一个新的entry放在正确的位置。
        // 特别注意:在寻找entry的过程中,会对遇到的无效entry进行清除避免内存泄漏。必要时会进行扩容(扩容前会清除所有的无效entry再判断是否需要扩容)
        // 解释:"清除无效entry"即打断如内存图所示的”引用7“和”引用5“的引用
        map.set(this, value);
    else
        // 新建一个ThreadLocalMap对象,赋值到该线程t的threadLocals属性上,然后将key(threadLocal对象)和value对象放到map中正确的位置
        createMap(t, value);
}

remove方法

public void remove() {
    // 得到当前线程的threadLocals属性对象(即Entry[]对象)
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        // 相当于将内存图中的”引用5、引用6、引用7“全部指向null
        m.remove(this);
}

内存泄漏问题

内存泄漏现象是存在无用的且一直无法被垃圾回收器GC掉的对象。而使用threadLocal涉及到的内存泄漏指的是各个线程中threadLcoalMap属性对象的entry数组引用到的一些无用的对象(即无效的entry)。这里分如下2个方面来讲

  • 一、threadLocal生命周期比线程的生命周期短(同时也解释了Entry为什么要用弱引用) 根据内存图,在threadLocal生命周期结束后,"引用1"断开,threadLocal对象只会被“弱引用6”引用到,由于是弱引用,GC时,threadLocal对象将会被回收,此时该entry对应的key就是null了,对于这种entry我称呼其为无效entry。这时该无效entry以及它引用的value对象都是无用的对象,但此时还占用的内存。不过在该线程中,一旦有其他的threadLocal对象在进行get或set等操作时,根据之前【ThreadLocal源码详解】可知,该线程的threadLocalMap对象中的无效entry就有可能会被清除掉,尤其在其扩容时,无效entry一定会被清除掉。故这种情况下是不会产生内存泄漏的。

    • 思考:如果"引用6"不是弱引用而是强引用,那么就无法判断threadLocalMap中哪些是无效entry对象,就不能有针对性的进行清除操作,从而导致内存泄漏了。这也是java.lang.ThreadLocal.ThreadLocalMap.Entry类在设计时继承WeakReference类使用弱引用的原因。

  • 二、threadLocal生命周期比线程的生命周期长

    该情况也是我们编码时的常用情况,一般将threadLocal申明为静态的常量的成员变量,供各个线程去保存自己的数据。 当线程的生命周期结束后,内存图中的”引用2“断开,此时thread对象及其直接或间接引用的对象都变成了GC Root不可达对象(在内存图中指的是“thread对象、threadLcoalMap对象、entry[]对象、entry对象、value对象),即变成了垃圾,在GC后这些垃圾对象都会被回收掉,因此这种情况也不会产生内存泄漏。

    特别注意:由于我们在实际开发过程中,我们一般是在这些线程中使用threadLocal。1)处理API接口的主线程;2)线程池中获取的线程。以上2种线程都有一个特点,那就是在处理完一个请求之后线程不会立即消亡,会被放到线程池中以便处理后续的请求(即线程的复用,当然可以配置超过指定的空闲时间线程消亡)。既然不会立即消亡,那么该线程所引用到所有entry都没办法被垃圾回收器回收,此时就产生了内存泄漏;更糟糕的是,该线程在处理上一次请求时赋值到threadLocal中的数据,在下一请求处理时依然会被获取到,而这个值对于这次的请求来说是一个脏值,即读取了脏数据。因此要解决中这种情况下导致的内存泄漏和 脏读的问题,那么在线程每一次处理完请求之后都需要调用threadLocal.remove()方法。

  • 内存泄漏问题总结

    通过上面两种情况的分析知道,只要对应的线程消亡或者threadLocal对象消亡,不管有没有调用remvoe方法都不会发生内存泄漏问题。只有当线程和threadLocal都不消亡时才会存在内存泄漏问题,而这种情况一般都是将threadLocal对象设置为静态常量类型,在线程池中使用。而线程池,我们一般都会配置最大活动线程数(即线程池中最多活动的线程)和空闲超时时间(即空闲线程超过这个时间会消亡),随着线程池中活动线程的数量上升直到达到最大线程活动数量时,有关threadLocal所产生的内存占用也会随之上升并达到一个顶值,即使没有调用remove方法,那么产生的内存泄漏也不会无上限的增加,危害不算特别大,只是这种情况下的脏读问题是一个严重的问题,因此这种情况一定要调用remove方法避免产生脏读(同时也避免了内存泄露问题),即对于静态常量的threadLoca在线程池(不管是tomcat线程池还是自己构造的线程池)中进行操作完成后一定要调用remove方法以避免产生脏读和内存泄露

上一篇:ThreadLocal 源码分析


下一篇:ThreadLocal 源码阅读