ThreadLocal学习

1. 序言

  • 上一篇博客中,从SimpleDateFormat的线程安全问题,引出了Java中解决线程安全的常用方法:不可变、互斥同步(锁)、非阻塞同步(CAS和原子变量)、无同步方案(栈封闭、ThreadLocal、可重入代码)
  • 其中,ThreadLocal可以实现线程隔离,从而避免多线程访问时的资源竞争问题
  • ThreadLocal也是面试时的高频知识点:
    • 对ThreadLocal的理解、ThreadLocal的使用场景、内存泄漏及解决办法、ThreadLocalMap的实现等

1.1 ThreadLocal概述

JDK源码对ThreadLocal类的注释如下:

  1. ThreadLocal提供线程局部变量,使得每个线程都有自己的、独立初始化的变量副本
  2. ThreadLocal实例通常是类中的private static字段,用于将状态与线程相关联,如用户ID、事务ID
  3. 只要线程处于活动状态并且ThreadLocal实例是可访问的,每个线程都将持有对线程局部变量副本的隐式引用
  4. 当线程终止,线程所绑定的线程局部变量都将被垃圾回收

对注释要点3的理解:

  • 这与JDK如何实现线程局部变量与线程的绑定有关,线程局部变量就是ThreadLocalMap.Entry中的value

ThreadLocal的使用场景:

  • 保存线程上下文信息,在需要的地方进行获取

    • 实际开发中使用较少,框架中使用较多,如Spring的事务管理
    • 一般,使用ThreadLocal管理数据库连接、Session会话等,保证每一个线程中使用的连接是同一个
  • 保存上下文信息,优雅地进行参数传递

    • 例如,类似责任链模式的代码中,需要在很多方法中传递context参数,后续的维护十分麻烦

    • 若果ThreadLocal进行参数传递,整个代码将更加优雅

    • 改造前的代码

    • 改造后的代码

  • 保证线程安全,避免同步操作带来的性能损耗.例如,SimpleDateFormat线程不安全的解决方案

局限性:

  • ThreadLocal实现了线程隔离,自然也就无法解决多线程间共享对象的更新问题

参考链接


1.2 如何将线程局部变量与线程绑定?

  • 从上面的概述可知,ThreadLocal使得线程拥有自己的、独立的变量副本
  • 那么,ThreadLocal如何将线程局部变量与线程实现一对一绑定的呢?

错误的实现方案: ThreadLocal维护线程与线程局部变量的映射

  • 很多人的第一想法应该是:
    • ThreadLocal中维护一个Map,线程做key,线程局部变量做value,这就实现了二者之间的一一对应
    • 线程通过 ThreadLocal 的 get() 方法获取实例时,只需要以线程为key,从 Map 中找出对应的实例即可
  • 上面的设计,每个线程访问ThreadLocal前,需要向Map中添加一个映射;线程终止后,需要从Map中清除映射,否则容易造成内存泄漏
  • 这样将会带来两个问题
    (1)多线程写Map,要求ThreadLocal中的Map应该是线程安全的,例如使用ConcurrentHashMap。但也需要通过锁来保证线程安全,其性能将会受影响
    (2)线程终止前,需要从所有包含该线程的ThreadLocal Map中清除映射,否则容易造成整个entry的内存泄漏
  • 根据博客的描述,问题(1)是JDK未使用该方案的原因

正确的实现方案:线程维护ThreadLocal与线程局部变量的映射

  • 上述实现方案,需要使用锁来保证多线程访问同一个Map的线程安全
  • 如果由线程维护ThreadLocal与线程局部变量的映射:ThreadLocal为key,线程局部变量为value
  • 线程访问自己内部的Map就不存在多线程写的问题,也就不需要锁
  • 上述方案也存在内存泄漏的问题:如果线程长时间运行,Map中的映射将一直存在。若不主动删除无用映射,将存在内存泄漏的风险
  • 博客中说,ThreadLocal不能被回收,笔者更倾向于整个映射不能被回收
    • 后续JDK将映射(entry)中对key(ThreadLocal)的引用设计为弱引用,保证了ThreadLocal能被及时回收
    • 同时也在set、get、remove等方法中,主动清理key为null的过期entry,以保证value的回收
    • JDK源码对ThreadLocal的设计,其实是在尽量保证整个entry的回收,并非单独针对ThreadLocal的回收

参考文档

2. JDK如何实现映射的?

2.1 threadLocals in Thread

  • 阅读Thread类的源码,发现一个名为threadLocals的成员变量,它拥有包访问权限

    // 维护与该线程有关的线程局部变量
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
  • threadLocals 的类型为 ThreadLocal.ThreadLocalMap,ThreadLocalMap是ThreadLocal中的静态内部类,拥有包访问权限

    static class ThreadLocalMap { ... }
    
  • 由于Thread和ThreadLocal同属于java.lang包,所以在Thread类中能访问ThreadLocal的静态内部类ThreadLocalMap

2.2 ThreadLocalMap的数据结构

ThreadLocalMap的类注释如下:

  • ThreadLocalMap是一个自定义的哈希表,用于维护线程局部变量(ThreadLocal与线程局部变量的映射)
  • 在ThreadLocal之外,无法操作ThreadLocalMap: ThreadLocalMap是ThreadLocal的静态内部类,其所有方法都是private的
  • ThreadLocalMap的entry,key使用弱引用,但没有使用引用队列。因此,在桶空间开始耗尽时,会主动清理陈旧的entry(key为null的entry)
    • 如果弱引用关联了引用队列,则key被垃圾回收后,弱引用将进入引用队列
    • 如果能从引用队列获取到该弱引用,则可以及时清理对应的value

ThreadLocalMap的Entry

  • Entry的定义如下,key为ThreadLocal的弱引用,value为Object的强引用,存储线程局部变量
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;
    
        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    

ThreadLocalMap的成员变量

  • 从成员变量可以看出,ThreadLocalMap采用桶数组的形式存储Entry

    private static final int INITIAL_CAPACITY = 16;
    // resize后,table的长度必须为2^n
    private Entry[] table;
    // 桶数组中,entry的数目
    private int size = 0;
    // 扩容的阈值,默认为0
    private int threshold; // Default to 0
    // 阈值后续会调整为容量的2/3
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }
    
  • 与使用桶数组的HashMap不同,ThreadLocalMap的桶数组,一个位置只存放一个Entry

2.3 ThreadLocalMap如何解决哈希冲突的?

  • HashMap采用链地址法(桶 + 链表/红黑树)解决哈希冲突,而ThreadLocalMap采用开放定址法解决哈希冲突

  • 开放定址法: 如果当前slot已有元素,则依次往后查找,直到某个slot为空,则将新的entry插入该slot

  • 特殊情况: 如果slot不为空但entry过期,则直接替换,具体可以查看ThreadLocalMap的replaceStaleEntry() 方法

  • 上图中信息有误:

    • hashCode % 4,而非hashCode % 3:与HashMap一样,ThreadLocalMap使用hashCode & (n - 1) = hashCode & 3 快速定址,,其实就是hashCode % 4
    • 开放定址法,而非链地址法:链地址发是HashMap使用的桶 + 链表/红黑树的方法

2.4 Thread、ThreadLocal、ThreadLocalMap三者之间的关系

  • 从对Thread和ThreadLocalMap的分析可知,Thread维护一个key为ThreadLocal、value为线程局部变量的Map
  • 三者之间的关系如图所示,图示中使用了2个ThreadLocal,使得线程将包含2个线程局部变量

3. ThreadLocal

3.1 成员变量

  • 说了这么久,ThreadLocal究竟是怎样的,想必大家都很好奇
  • ThreadLcoal的成员变量十分简单,且都与计算ThreadLocal的哈希有关
    public class ThreadLocal<T> {
        // 每个线程都包含一个基于线性探测的哈希表,ThreadLocal依靠该哈希表绑定到对应线程
        // ThreadLocal对象是哈希表中的key,可以通过threadLocalHashCode进行检索
        // 这是一种自定义的hashCode,具有很好的散列效果,仅在ThreadLocalMap中有效
        private final int threadLocalHashCode = nextHashCode();
         // 计算下一个ThreadLocal对象的哈希,初始值为0
         // 通过加上哈希增量0x61c88647,可以计算得到一个新的hashCode
        private static AtomicInteger nextHashCode = new AtomicInteger();
        // 固定的哈希增量
        private static final int HASH_INCREMENT = 0x61c88647;
        // 使用原子类的加法,保证哈希计算的线程安全
        private static int nextHashCode() {
            return nextHashCode.getAndAdd(HASH_INCREMENT); // 加法计算,返回旧值
        }
    }
    

关于哈希增量0x61c88647

  • 有博客说,这是一个神奇的数字,可以让哈希值均匀地分布到长度为 2 N 2^N 2N的哈希表中
  • 参考链接:为什么ThreadLocalMap 采用开放地址法来解决哈希冲突?
  • 确实如此,ThreadLocalMap中的桶数组Entry[] table初始长度为16,后续扩容也是按照2倍扩容
  • 感兴趣的读者,可以上网查一下相关的内容

3.2 构造函数

  • ThreadLocal的构造函数只有一个,其实就是个默认构函数

    public ThreadLocal() {}
    
  • 除此之外,还有一个静态工具方法withInitial(),支持创建具有初始值的ThreadLocal

    public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
        return new SuppliedThreadLocal<>(supplier);
    }
    
  • SuppliedThreadLocal的定义如下,它继承了ThreadLocal,重写了ThreadLocal的initialValue()方法

    • initialValue() 方法返回函数式编程接口Supplier中产生的值
    • 这个值其实就是ThreadLocalMap中key(ThreadLocal)对应的初始value,也就是线程局部变量
    static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {
    
        private final Supplier<? extends T> supplier;
    
        SuppliedThreadLocal(Supplier<? extends T> supplier) {
            this.supplier = Objects.requireNonNull(supplier);
        }
    
        @Override
        protected T initialValue() {
            return supplier.get();
        }
    }
    

函数式接口Supplier

  • Supplier是一个函数式接口,其get() 方法没有入参,但会自动生产一个值并返回

  • 如何生产这个值,由实现Supplier接口时的代码决定

    public static void main(String[] args) {
        // 返回一个随机数值字符串
        String randomString = getRandomString(() -> {
            return RandomStringUtils.random(8, false, true);
        });
        System.out.println(randomString);
        // 利用lambda表达式简写为
        randomString = getRandomString(() -> RandomStringUtils.random(8, false, true));
        System.out.println(randomString);
    }
    
    public static String getRandomString(Supplier<String> supplier) {
        return supplier.get();
    }
    
  • 执行结果如下:

withInitial() 方法使用示例

  • 示例代码如下,通过withInitial() 方法创建ThreadLocal对象threadLocal

  • 通过threadLocal .get() 方法,获取threadLocal 为当前线程绑定的线程局部变量

    public static void main(String[] args) {
        ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> {
            String prefix = "k8s-presto-";
            return prefix + RandomStringUtils.random(2, false, true);
        });
        // 打印main线程中, threadLocal对应的线程局部变量
        System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get());
    
        // 打印子线程中, threadLocal对应的线程局部变量
        new Thread(() -> System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get())).start();
    }
    
  • 执行结果如下

3.3 get方法

3.3.1 ThreadLocal的get()方法

  • 关于如何实现get方法,直观想法非常简单、清晰
  • 注意: 从本小节开始,若无特殊说明,value就是线程局部变量

直观想法

  • 如果不关注权限修饰符,根据Thread、ThreadLocal、ThreadLocalMap之间的三者之间的关系,要想通过threadlocal这个key获取对应的value,最直观的想法是
    1. 以某种方式,获取Thread中的threadLocals,如threadLocals = thread.getThreadLocals()
    2. 通过 value = threadLocals.get(threadlocal)获取threadlocal对应的value
  • 包访问权限使得上述构想难以实现
    • 首先,Thread类中,成员变量threadLocals为包访问权限(default),且未提供public的getter方法。因此,在用户代码中,不能从Thread获取其threadLocals

    • 就算提供了public的getter方法,get到的ThreadLocal.ThreadLocalMap也是包访问权限,不允许在用户代码中访问

    • 其次,虽然ThreadLocal类提供了getMap(Thread t)方法用于获取线程的threadLocals,但该方法也是包访问权限

      ThreadLocalMap getMap(Thread t) {
          return t.threadLocals;
      }
      
  • 可以说,精妙的包访问权限设计,使得只有在ThreadLocal或Thread类中,才能访问ThreadLocalMap
  • JDK源码的实现更加精妙:在Thread类中定义threadLocals,在ThreadLocal中初始化并读写当前线程的threadLocals

JDK源码的实现

  • 通过threadlocal对象获取线程绑定的value,由ThreadLocal类中的get()方法提供支持
    • 获取当前线程的ThreadLocalMap
    • 然后,将当前的threadlocal对象作为key,从ThreadLocalMap 中获取对应的value
    • 若尚未绑定value,则将value初始化为initialValue()方法返回的
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t); // 获取当前线程的ThreadLocalMap
        if (map != null) {  // 尝试从map中查找entry,获取对应的value
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue(); // map为空或不存在对应的entry,则返回一个默认初始值
    }
    
    

关于初始value

  • setInitialValue() 方法如下,无论是否需要新建ThreadLocalMap,最终都是将initialValue() 方法的返回值作为初始value

    • JDK源码中,还对该方法有如下说明:它是set()方法的变种,用于设置初始value,避免由于用户重写set()方法
    • 自己的理解:用户重写的set()方法可能并未真正的创建entry或设置value
      private T setInitialValue() { // 将initialValue()的返回值作为初始value
         T value = initialValue();
         Thread t = Thread.currentThread();
         ThreadLocalMap map = getMap(t);
         if (map != null) // 将initialValue()返回的值作为value
             map.set(this, value);
         else  // map为null,构建map
             createMap(t, value);
         return value; 
      }
      // 创建指定entry的ThreadLocalMap
      void createMap(Thread t, T firstValue) {
          t.threadLocals = new ThreadLocalMap(this, firstValue);
      }
      
  • initialValue() 方法如下,默认返回null值。

    • 在尚未通过set() 方法为线程绑定value时,第一次通过get()方法获取value,将会调用initialValue() 方法,将其返回值作为value的初始值
    • 若已经通过remove() 方法删除线程绑定的value,则紧随其后的get() 方法,也将调用initialValue() 方法
    • initialValue() 方法默认返回null值,建议以匿名类的方式继承ThreadLocal、重写 initialValue() 方法,使得初始值非空
      protected T initialValue() {
         return null;
      }
      
  • 这下,终于知道为什么withInitial() 方法在初始化ThreadLocal时,只重写了initialValue()方法。

  • 因为,withInitial() 方法作用就是实现一个具有初始value的ThreadLocal对象

3.3.2 ThreadLocalMap的getEntry()方法

getEntry() 方法

  • 获取key对应的entry,采用一种fast path机制:若直接命中,则直接返回对应的entry;否则,需要通过getEntryAfterMiss() 方法进行中继

    private Entry getEntry(ThreadLocal<?> key) {
        int i = key.threadLocalHashCode & (table.length - 1); // 计算index,等同于hashCode % table.length
        Entry e = table[i];
        if (e != null && e.get() == key)  // 直接命中
            return e;
        else  // 否则,通过getEntryAfterMiss() 方法进行中继(继续查找)
            return getEntryAfterMiss(key, i, e);
    }
    

getEntryAfterMiss() 方法

  • 传入key、index和首次获取到的entry,只要entry不为null,则获取entry对应的key进行判断

  • key相等,说明找到对应的entry(开放定址法导致entry并非处于期望的slot中)

  • key为null,说明该entry已经过期,通过expungeStaleEntry()方法进行处理;

  • key不为null,说明位置已被其他entry占据,需要继续向后查找(这是因为ThreadLocalMap采用开放定址法)

    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;
         	// 清理过期的entry,方便及时回收value和entry
         	// 清理完成后,继续从i开始比较,避免有效entry rehash后位于i这个slot
            if (k == null) 
                expungeStaleEntry(i); 
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        return null;
    }
    
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }
    

expungeStaleEntry()方法

  1. 清理过期的entry和最近的空位置(null slot)之间的过期entry,有利于GC回收,避免内存泄漏(所谓过期entry,就是key为null的entry)

  2. 同时,将有效的entry放到新的、合适的位置

    private int expungeStaleEntry(int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
    
        // 清理过期的entry
        tab[staleSlot].value = null;
        tab[staleSlot] = null;
        size--;
    
        // 对后续entry进行rehash,直到遇到空slot
        Entry e;
        int i;
        for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            if (k == null) {  // 过期entry,继续清理
                e.value = null;
                tab[i] = null;
                size--;
            } else { // 有效entry,rehash到合适的位置(补齐空slot)
                int h = k.threadLocalHashCode & (len - 1);
                // 期望的index与当前index不相等,说明是开放地址法移位导致的,需要将其放到最近的有效entry之后
                if (h != i) { 
                    tab[i] = null;
                    while (tab[h] != null)
                        h = nextIndex(h, len);
                    tab[h] = e;
                }
            }
        }
        return i; // 返回空slot的index
    }
    
  • 也就是说,expungeStaleEntry() 方法的目标:清除当前过期entry到下一个空slot之间所有过期entry,并将有效entry进行rehash
  • 后续的学习中,我们将会发现:ThreadLocal的get()、set()、remove() 方法,遇到key为`null的情况,都会调用expungeStaleEntry() 方法清理过期entry

3.3.3 get方法总结

ThreadLocal的get() 方法

  • ThreadLocal的get() 方法,两个大的方向:
    • ThreadLocalMap不为null,则通过ThreadLocalMap.getEntry(key) 获取对应的entry
    • 如果map为空或未找到对应的entry,则添加新的entry。其中,value为initialValue() 方法返回的默认值
  • ThreadLocalMap.getEntry(key)方法,采用fast path模式:
    • 直接命中,则返回命中的entry;
    • 否则,通过ThreadLocalMap.getEntryAfterMiss() 继续查找

ThreadLocalMap.expungeStaleEntry()方法

  • ThreadLocalMap.expungeStaleEntry()方法,清理从index开始过期的entry,并对有效entry进行rehash

3.4 set方法

3.4.1 ThreadLocal的set方法

  • set()方法如下,可以为当前线程绑定一个线程局部变量

    public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) // 向已存在的map中添加值
            map.set(this, value);
        else // 否则,需要新建ThreadLocalMap
            createMap(t, value);
    }
    

3.4.2 ThreadLocalMap的set方法

  • ThreadLocal的set方法的关键还是在ThreadLocalMap的set()方法
    private void set(ThreadLocal<?> key, Object value) {
        Entry[] tab = table;
        int len = tab.length;
        int i = key.threadLocalHashCode & (len-1); // 计算index
        // 开放定址法:对应的位置存在entry,则尝试下一个位置
        for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
            ThreadLocal<?> k = e.get();
    
            if (k == key) { // key已经存在,则更新值
                e.value = value;
                return;
            }
    
            if (k == null) { // 使用当前key和value替代已经过期的entry
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        // 找到空slot,新建entry
        tab[i] = new Entry(key, value); 
        int sz = ++size;
        // 清理过期的entry,如果仍然超过阈值则需要扩容
        if (!cleanSomeSlots(i, sz) && sz >= threshold) 
            rehash();
    }
    

举一个例子

  • 假设ThreadLocalMap的长度为10,布局如下:

  • 新来的ThreadLocal的hashCode为15,应该放在index为5的slot。该slot中的entry已经过期,则执行如下代码:

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

replaceStaleEntry() 方法

  • replaceStaleEntry() 方法并非简单地使用新entry替换过期entry
  • 而是从过期entry所在的slot(staleSlot)向前、向后查找过期entry,并通过slotToExpunge 标记过期entry最早的index
  • 最后,使用cleanSomeSlots() 方法从slotToExpunge开始清理过期entry
    private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;
    	// slotToExpunge记录需要清理的index,始终指向最早的过期entry的索引
        int slotToExpunge = staleSlot;
        // 从staleSlot的前一个位置开始,向前查找过期entry并更新slotToExpunge,直到遇到空slot
        for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
            if (e.get() == null) 
                slotToExpunge = i;
    
        // 从staleSlot的后一个位置开始,向后查找
        for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();
            if (k == key) { 
            	// 由于开放定址法,可能相同的key存放于预期的位置(staleSlot)之后
           	    // 如果遇到相同的key,则更新value,并交换staleSlot与当前index的entry
           	    // 交换的原因:让有效的entry占据预期的位置(staleSlot),避免重复key的情况
                e.value = value;
    
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;
    			// 向前查找,未找到过期entry,更新slotToExpunge为当前index
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                // 从slotToExpunge开始,清理一些过期entry
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }
    
            // 向后查找,未找到过期entry,更新slotToExpunge为当前index
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }
        // 直到遇到空slot也未发现相同的key,则在staleSlot的位置新建一个entry
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);
    	// 存在过期entry,需要进行清理
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }
    
  • 总体来说,过期entry所在的staleSlot是key瞄准的位置:
    • 如果key已经存在,则需要将原entry更新、与staleSlot对应的过期entry交换,使其位于staleSlot这个位置
    • 如果遇到了空slot,都未发现key相等的entry,说明key不存在 ⇒ \Rightarrow 直接在在staleSlot这个位置新建entry
    • 不管是哪种情况,只要发现过期entry,都需要通过cleanSomeSlots() 进行清理
    • 而过期entry存在的判断条件为:slotToExpunge != staleSlot

  • 接着上面的示例:key为15,staleSlot为5,向前查找过期的entry,直到遇到空slot。

  • 由于index为3的entry已过期,将执行如下代码更新slotToExpunge的值为3

    if (e.get() == null) 
       slotToExpunge = i;
    
  • key为15,staleSlot为5,向后查找。发现index为6的entry,其key与当前key相等,于是执行如下代码:

    if (k == key) { 
        e.value = value;
    
        tab[i] = tab[staleSlot];
        tab[staleSlot] = e;
    
        if (slotToExpunge == staleSlot)
            slotToExpunge = i;
       
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        return;
    }
    
  • 首先,将index为6的entry的值更新为new,然后将index为6和staleSlot的entry进行交换

  • 然后执行cleanSomeSlots() 方法,清理过期entry


为何需要staleSlot和key相等时的slot交换?

  • 假设不进行交换,清理完过期entry后,ThreadLocalMap将如下图所示

  • 后续如果再set哈希值为15的threadlocal对应的value为new1),发现期望的index为5的slot就是空的,于是直接插入新的entry

  • 这时,ThreadLocalMap中,将存在两个entry的key为15,显然不符合map的定义

  • 其实,expungeStaleEntry() 方法对有效entry的rehash,也是出于对这样的情况的考虑

expungeStaleEntry() 方法的执行

  • 在调用cleanSomeSlots() 方法前,首先需要调用expungeStaleEntry() 方法清理过期的entry

  • 通过之前的学习,expungeStaleEntry() 方法将清理从指定slot开始到下一个空slot之间所有的过期entry,并对有效的entry进行rehash

  • 根据上面的示例,expungeStaleEntry() 方法的入参slotToExpunge = 3

  • index为3的slot将清空

  • index为4的entry,其key为4,预期slot是4,无需任何操作

  • index为5的entry,其key为15,预期slot是5,无需任何操作

    if (h != i) { 
        tab[i] = null;
        while (tab[h] != null)
            h = nextIndex(h, len);
        tab[h] = e;
    }
    
  • 然后index为6的slot将清空

  • index为7的entry,其key为25,预期slot是5,与实际slot不匹配。

  • 执行以下代码,清空当前slot并将其前移:key为25的entry,将移动到index为6的slot

    if (h != i) { 
        tab[i] = null;
        while (tab[h] != null)
            h = nextIndex(h, len);
        tab[h] = e;
    }
    
  • 当index为8时,发现是个空slot,于是停止清理操作,并返回index = 8 (有博客说是7,自己坚信是8 😂)

  • 最后,整个ThreadLocalMap更新如下:

cleanSomeSlots()方法

  • cleanSomeSlots() 方法的代码如下:通过循环扫描,尽可能多的清理ThreadLocalMap中的过期entry

  • i表示已知的不会持有过期条目的位置,n用于扫描控制:如果不存在过期的entry,则执行 l o g ( 2 N ) log(2^N) log(2N)次扫描

  • 方法注释中说,这样的设计简单、快速且运行良好

    private boolean cleanSomeSlots(int i, int n) {
        boolean removed = false;
        Entry[] tab = table;
        int len = tab.length;
        do {
            i = nextIndex(i, len);
            Entry e = tab[i];
            if (e != null && e.get() == null) { // 遇到过期entry,需要重置n
                n = len;
                removed = true;
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);
        return removed;
    }
    
  • 对上面的ThreadLocalMap做一下更改,在首尾增加过期的entry

  • 执行完expungeStaleEntry() 方法返回的值为8,则扫描index = 9时,发现过期entry

  • 将n重置为table长度,从index = 9开始清理过期entry:index为9和0的slot都将被置为空,当index为1时,发现slot为空,停止清理操作

  • 最终,直到n变为0,都不会发现过期entry,cleanSomeSlots()方法执行结束

3.4.3 ThreadLocalMap的rehash方法

  • ThreadLocalMap的set() 方法结尾,如果清理完过期entry后,sz >= threshold依然成立,则需要对桶数组进行扩容,也就是rehash

    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
    
  • rehash之前仍然先清理一次过期entry,如果size > = 3/4 threshold,也就是size >= 1/2 table.length则进行扩容操作( threshold = 2/3 * table.length)

    private void rehash() {
        expungeStaleEntries();
    
        // Use lower threshold for doubling to avoid hysteresis
        if (size >= threshold - threshold / 4)
            resize();
    }
    
  • resize()方法如下,它会将通数值扩容2倍

    private void resize() {
        Entry[] oldTab = table;
        int oldLen = oldTab.length;
        int newLen = oldLen * 2;
        Entry[] newTab = new Entry[newLen];
        int count = 0;
        // 旧的桶数组中的entry移动到新的桶数组中
        // 对于过期entry,直接断开entry对value的引用
        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++;
                }
            }
        }
        // 更新threshold、size、table,旧的桶数组等待GC
        setThreshold(newLen);
        size = count;
        table = newTab;
    }
    

3.5 remove方法

  • 学习了如何get 和set 线程绑定的线程局部变量
  • 自然地,还想有一个方法支持解除线程局部变量与线程的绑定
  • ThreadLocal的remove() 提供这样的支持

3.5.1 ThreadLocal的remove方法

  • ThreadLocal的remove() 方法代码十分简单:获取当前线程的ThreadLocalMap,然后从map中清除对应的entry

    public void remove() {
        ThreadLocalMap m = getMap(Thread.currentThread());
        if (m != null)
            m.remove(this);
    }
    
  • 就如注释所说,remove() 方法可能会导致 initialValue()方法的多次调用

    • 通过remove() 方法解除了绑定,在尚未通过set() 方法重新绑定线程局部变量之前
    • 再次访问get() 方法,将会触发对ThreadLocal的 initialValue()方法的调用

3.5.2 ThreadLocalMap的remove方法

  • 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;
            }
        }
    }
    public void clear() {
        this.referent = null;
    }
    
  • 按照本人的构想,清除entry,超级简单:

    entry.key = null;
    entry.value = null;
    entry = null;
    
  • 实际上,JDK源码的实现十分精巧:

    • 首先,断开entry与key之间的弱引用,entry过期
    • 接着,通过expungeStaleEntry() 方法清除过期的entry
    • expungeStaleEntry() 方法不止会清除当前过期entry,还会清除到下一个空slot之间的所有过期entry;同时,还会对有效的entry进行rehash
  • 这样一来,remove当前entry,不仅清理了一波ThreadLocalMap中的过期entry,避免内存泄漏;还实现了有效entry的rehash,避免出现key重复的bug


参考链接:

4. ThreadLocal内存泄漏

  • 在笔者看来,与ThreadLocal有关的内存泄漏,更多的是value无法及时回收所带来的内存泄漏
  • 因为value存储本地变量,一般会包含大量数据,占用内存较大
  • 而ThreadLocal类型的key,虽然被多个线程引用,但其实际只有对象,内存占用可以忽略不计

4.1 一个有趣的现象

  • ThreadLocal的三个重要方法:get()、set()、remove(),最终都会调用expungeStaleEntry() 方法清理过期entry
  • 清理过期entry的终极目标: 断开entry对value的强引用,避免value带来的内存泄漏
  • get方法:执行ThreadLocalMap.getEntryAfterMiss() 方法查找entry时,若发现key为null,则调用expungeStaleEntry() 方法清理过期entry
  • set方法:执行ThreadLocalMap.set() 方法查找entry时
    • 若发现key为null,则执行replaceStaleEntry() → \rightarrow cleanSomeSlots() → \rightarrow expungeStaleEntry() ;
    • 若最终在空slot处插入entry,执行cleanSomeSlots() → \rightarrow expungeStaleEntry() 清理一波过期entry,然后根据size和threshold的关系决定是否rehash
    • ThreadLocalMap在rehash之前,也会先执行expungeStaleEntry() 清理一波过期entry;最后size >= 1/2 threshold,才会真正的resize
  • remove方法:先断开entry对key的弱引用,然后调用expungeStaleEntry() 完成后续的清理工作

4.2 内存泄漏的分析

key为强引用

  • 若entry中的key为强引用,ThreadLocal对象的生命周期将和关联的线程一样长
  • 尤其是线程池中的worker线程,其生命周期可能会非常长
  • 即使不存在外部强引用,线程内部对ThreadLocal对象的强引用链Thread --> ThreaLocalMap --> Entry --> key(ThreadLocal对象)将一直存在
  • entry不过期,关联的value无法及时回收,因此存在内存泄漏的风险

key为弱引用

  • 针对上述问题,ThreadLocalMap的Entry,其key是对ThreadLocal对象的弱引用,value是对线程局部变量的强引用

  • 若ThreadLocal对象不存在外部强引用,ThreadLocal对象将在下一次gc时被回收

  • ThreadLocal对象被回收后,entry中的key将变成null,entry过期

  • 后续调用ThreadLocal的get()、set()、remove()方法,均会清理当前线程ThreadLocalMap中的过期entry,避免内存泄漏

key为弱引用的局限性

  • 局限性:key设计为弱引用,只是降低了内存泄漏的概率,并不能完全避免内存泄漏
  • 若无无过期entry清理机制,假设线程一直持续运行,entry对value的强引用链Thread ---> ThreaLocalMap --> Entry --> value将一直存在,value无法被GC,存在内存泄漏的风险
  • 对此 ,JDK源码采取了补救措施:
    • 在ThreadLocal的get()、set()和remove()方法中调用expungeStaleEntry() 方法,清理key为null的entry,避免value内存泄漏
  • 这样的补救措施并不完美,考虑这样的场景:
    • 无其他强引用指向ThreadLocal、ThreadLocal成功被GC,但后续的执行中,线程不再调用ThreadLocal的get()、set()和remove()方法
    • 此时,即使ThreadLocalMap中存在过期entry,value也无法被GC,存在内存泄漏泄漏的风险

完美的解决方法

  • 主动调用ThreadLocal的remove()方法,实现 Entry --> keyEntry --> valueThreaLocalMap --> Entry三大引用链的断开,完美回收整个entry,避免内存泄漏
  • PS:主动回收资源,比自动回收更靠谱。

4.4 ThreadLocal对象定义为private static

  • 还记得ThreadLocal类的注释中,建议将ThreadLocal对象定义为private static

    ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).

  • 定义为private属性比较好理解:
    • 成员变量的权限控制,避免外部类访问ThreadLocal、从而访问ThreadLocal中存储的value
    • 这些value一般是不能被其他类访问的,如注释中提到的user ID、Transaction ID
  • static的作用:
    • 定义为static对象,ThreadLocal将与类具有相同的生命周期
      • 虽然这样将使得key为弱引用的设计派不上用场,但随时可以调用与类有相同生命周期的ThreadLocal对象的remove方法,以避免value的内存泄漏
      • 舍去一个ThreadLocal实例的内存空间占用,可以回收多个线程中的value,值得!
    • 同时,避免Per Thread - Per Instance,符合Per Thread语义
      • 定义为static,每个类一个ThreadLocal对象,而非每个实例一个ThreadLocal对象
      • 既符合我们对ThreadLocal的定义,还能避免资源浪费或内存泄露

解决办法:

  • ThreadLocal对象被定义为private static,使得其强引用将一直存在,需要主动调用remove方法断开引用链,避免value的内存泄漏

举一个例子

  • 通过ThreadLocal为每个查询线程关联一个数据库连接,查询线程来自于CachedThreadPool,也就是线程池中的worker
  • worker从阻塞队列获取任务超时(60秒)后,将退出线程池,等待垃圾回收
  • 而该Java应用的堆内存很大,终止的worker可能需要很长一段时间才会被垃圾回收,这时数据库连接将逐渐累积
  • 同时,如果只是单纯回收数据库连接占用的内存,底层的一些资源实际并未释放,将加剧连接连接累积的情况
  • 最理想的方法:
    • 重写ThreadLocal的remove方法,增加主动释放数据连接的代码
    • worker从线程池退出时,主动调用ThreadLocal对象的remove方法
  • 其实,对于一般线程,就是在run方法中增加finally语句块,并在finally语句块中执行remove操作
  • 但是线程池的封装比较完善,无法找到更新worker的突破口
  • 自己的解决办法:
    • 存储绑定了数据库连接的线程,定时任务判断线程状态
    • 如果线程结束,则主动调用调用ThreadLocal对象的remove方法释放数据库连接

4.5 内存泄漏的总结

  • ThreadLocal存在的内存泄漏问题,虽然有key为弱引用、set/get/remove等方法中主动清理过期entry等措施避免内存泄漏
  • 但这些措施都无法完美解决内存泄漏的问题,手动调用remove方法才是王道 😂

参考链接

5. 总结

5.1 知识点总结

ThreadLocal如何实现线程局部变量与线程的映射?

  • 方案一:ThreadLocal内部维护一个map,线程作为key,线程局部变量作为value;多线程写map的线程安全问题、不主动remove带来的内存泄漏问题
  • 方案二:Thread内部维护一个map,ThreadLocal作为key,线程局部变量作为value;不存在多线程写的问题、也存在内存泄漏的问题
  • JDK的实现:采用第二种方案,会画图、会口述

ThreadLocalMap

  • Entry的数据结构:对key为弱引用,对value为强引用
  • 一个slot存放一个entry,采用开放定址法解决哈希冲突
  • 一些属性:桶数组初始大小为16,容量阈值为 2/3的桶数组长度,2倍扩容(保证桶数组长度为 2 N 2^N 2N

ThreadLocal

  • 成员变量:

    • threadLocalHashCode:当前ThreadLocal对象自身的hashCode
    • 静态变量nextHashCode:下一个ThreadLocal对象的hashCode,使用CAS操作进行计算,每次计算hashCode都增加 0x61c88647
  • get方法:

    • 访问当前线程的ThreadLocalMap,如果map为空或不在对应的Entry,则使用initialValue()返回的value作为默认值
    • ThreadLocalMap的getEntry()查找entry:fast path机制:直接命中 + getEntryAfterMiss()
  • set方法:

    • 通过ThreadLocalMap的set() 方法实现新建entry:key存在,更新value;发现过期entry,替代过期entry;均不满足,在空slot直接新建entry,清理过期entry、视情况决定是否进行rehash
    • rehash操作关键:resize,扩容两倍,直接将旧桶数组中的有效entry放到新桶数组,过期entry,直接清理
    • 执行replaceStaleEntry()方法替换过期entry:① 当前entry放到staleSlot;② 存在其他的过期entry,则通过cleanSomeSlots()进行清理
  • remove方法

    • 通过ThreadLocalMap的remove() 方法实现entry清理:通过遍历确定entry,然后清理entry
    • entry的清理:断开key的弱引用、expungeStaleEntry()清理过期entry
  • 注意事项: 为每个线程绑定的value必须是不同的对象,否则还是存在数据共享,无法达到ThreadLocal线程隔离的目的:Java并发编程之ThreadLocal详解

内存泄漏的问题

  • key为强引用,key的生命周期和线程一样长,存在key(ThreadLocal)的内存泄漏
  • key为弱引用,不存在外部强引用时,key可以被GC,避免key的内存泄漏
  • 新的问题,value的内存泄漏;get、set、remove方法,主动清理过期entry避免value的内存泄漏;若没有ThreadLocal对象访问线程的ThreadLocalMap,也存在value的内存泄漏
  • 主动调用ThreadLocal的remove方法,完美解决内存泄漏的问题

5.2 关于线程隔离和线程安全

5.2.1 ThreadLocal如何实现线程隔离?

  1. 线程与线程局部变量绑定
    • 每个Thread都有一个自己的ThreadLocalMap,Entry.key为ThreadLocal对象,value为线程局部变量
    • 从而,使得每个线程都有自己的线程局部变量,实现了线程与线程局部变量绑定,
  2. 权限控制,只能通过ThreadLocal访问当前线程的ThreadLocalMap
    • 首先,Thread类中ThreadLocalMap对象(threadLocals)为包访问权限
    • 其次,ThreadLocalMap是ThreadLocal中具有包访问权限的静态内部类
    • 用户代码中,即使get到了ThreadLocalMap也不允许访问
    • 只能通过ThreadLocal的get、set、remove方法,访问当前线程的ThreadLocalMap
  3. 也就是说,JDK源码将线程局部变量的访问接口收拢到了ThreadLocal中,同时这些接口只能访问当前线程的ThreadLocalMap,从而实现了线程隔离的特性

5.2.2 线程隔离等于线程安全?

  • 最开始,本人一度认为只能通过ThreadLocal修改当前线程的线程局部变量的值,无法在其他线程并发修改
  • 本人对反射的使用并不熟练,认为就算是反射也无法打破线程安全
  • 甚至很佩服JDK源码的变成人员:我靠,不仅线程隔离,还线程安全啊,完美的实现!!!

行不通的反射思路

  • 通过getDeclaredField获取Thread中的threadLocals字段,即ThreadLocalMap
  • 通过field.get()方法获取该字段的值,这时必须把值转为ThreadLocalMap对象,才能继续执行后续的操作
  • 而ThreadLocalMap是ThreadLocal类中具有包访问权限的静态内部类,无法使用类似(String) field.get(obj)的强制类型转换
  • 我连一个ThreadLocalMap的Class对象都获取不到,set方法也没办法反射获取 😢

值得关注的现象

  • 如果本人要是直到这样一个现象,思路肯定就不会这么受局限了

  • 下面的代码,尝试获取value的具体类型,原本以为都会返回java.lang.Object

    JSONObject jsonObject = new JSONObject();
    jsonObject.put("name", "张三");
    jsonObject.put("age", 24);
    for (String key : jsonObject.keySet()) {
        Object value = jsonObject.get(key);
        System.out.println(key + "对应的value类型:" + value.getClass().getName());
    }
    
  • 明明获取的是Object类型的value,通过value.getClass().getName()返回却是一个具体的类型

  • 本人菜鸟一只,说不清具体的原因,但基本能肯定这跟Java的继承分与多态不开关系

正确的反射实现

  • 通过反射打破ThreadLocal的线程安全

    public class Test {
        private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
    
        public static void main(String[] args) throws InterruptedException {
            // 为主线程绑定线程局部变量
            Thread mainThread = Thread.currentThread();
            threadLocal.set("value");
            System.out.println("反射前,线程局部变量的值:" + threadLocal.get());
            new Thread(() -> {
                try {
                    // 反射获取threadLocals字段并开放访问权限
                    Class threadClass = Thread.class;
                    Field threadLocals = threadClass.getDeclaredField("threadLocals");
                    threadLocals.setAccessible(true);
                    // 反射获取主线程threadLocals的内容
                    Object map = threadLocals.get(mainThread);
    
                    // 反射获取ThreadLocalMap的set方法并开放访问权限
                    Method method = map.getClass().getDeclaredMethod("set", ThreadLocal.class, Object.class);
                    method.setAccessible(true);
    
                    // 调用set方法,修改线程局部变量的值
                    method.invoke(map, threadLocal, "new value");
                } catch (NoSuchFieldException | InvocationTargetException | IllegalAccessException | NoSuchMethodException e) {
                    e.printStackTrace();
                }
            }).start();
            // 休眠一段时间
            TimeUnit.SECONDS.sleep(1);
            System.out.println("反射后,线程局部变量的值:" + threadLocal.get());
        }
    }
    
  • 感谢博客:反向理解ThreadLocal,或许这样更容易理解

5.3 参考链接

  • 在学习ThreadLocal的过程中,发现了很多有用的博客,自己也是看得眼花缭乱
  • 反而搞得自己一头雾水:我应该参考哪个博客?我应该先讲解哪个知识点?😂
  • 最后:
    • 着重看2到3篇博客,总结出学习要点;
    • 根据自己的习惯,梳理出学习路径;
    • 每个知识点的学习:阅读看源码,结合博客,进行归纳总结

参考链接:

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
ThreadLocal是一种特殊的变量存储类,它允许每个线程拥有自己的副本变量,从而避免了线程之间的共享变量冲突。在Java中,ThreadLocal通常用于存储线程局部数据,即每个线程都有自己的数据副本,而不会受到其他线程的影响。 要使用ThreadLocal,首先需要创建一个ThreadLocal对象,并使用其set()方法将数据存储在当前线程的副本中。例如: ```java ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); threadLocal.set(42); ``` 在这个例子中,我们创建了一个ThreadLocal对象threadLocal,并将其初始化为空值。然后,我们使用set()方法将整数值42存储在当前线程的副本中。 接下来,我们可以通过get()方法从当前线程获取存储在该ThreadLocal对象中的值。例如: ```java int value = threadLocal.get(); ``` 这个例子中,我们从当前线程的副本中获取了存储在threadLocal中的值,并将其存储在变量value中。由于每个线程都有自己的副本,因此我们可以通过这种方式在不同的线程之间传递数据。 需要注意的是,ThreadLocal中的数据存储在每个线程的本地内存中,因此如果一个线程修改了存储在ThreadLocal中的值,它不会影响其他线程中的副本。这意味着ThreadLocal通常用于存储需要在多个线程之间隔离的数据。 除了set()和get()方法外,ThreadLocal还提供了remove()方法来删除当前线程中的数据副本。此外,还可以使用getAndSet()方法来获取当前线程中的值并设置新的值。 总之,ThreadLocal是一种非常有用的工具,它允许每个线程拥有自己的数据副本,从而避免了共享变量之间的冲突。它通常用于需要隔离不同线程之间的数据的情况。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值