栏目分类:
子分类:
返回
名师互学网用户登录
快速导航关闭
当前搜索
当前分类
子分类
实用工具
热门搜索
名师互学网 > IT > 软件开发 > 后端开发 > Java

ThreadLocal学习

Java 更新时间: 发布时间: IT归档 最新发布 模块sitemap 名妆网 法律咨询 聚返吧 英语巴士网 伯小乐 网商动力

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实现了线程隔离,自然也就无法解决多线程间共享对象的更新问题

参考链接

  • 前2种场景和局限性:手撕面试题ThreadLocal!!!
  • 参数传递的使用场景:阿里面试官问我ThreadLocal,我一口气给他说了四种!
  • ThreadLocal进行参数传递的示例:Java面试必问: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中清除映射,否则容易造成内存泄漏
  • 根据博客的描述,问题(1)是JDK未使用该方案的原因

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

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

参考文档:

  • 两种映射方案的介绍:ThreadLocal到底是什么?它解决了什么问题?
2. JDK如何实现映射的? 2.1 Thread与ThreadLocalMap的
  • 阅读Thread类的源码,发现一个名为threadLocals的成员变量(线程局部变量简写为thread-local)

    // 维护与该线程有关的thread-local
    ThreadLocal.ThreadLocalMap threadLocals = null;
    
  • threadLocals 的类型为 ThreadLocal.ThreadLocalMap,ThreadLocalMap是ThreadLocal中的静态内部类

  • ThreadLocalMap拥有包访问权限,因此可以在同一个包的Thread类中定义ThreadLocalMap类型的threadLocals 字段

    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)

ThreadLocalMap的Entry

  • Entry的定义如下,key为ThreadLocal且为弱引用,value为Object类型的线程局部变量
    static class Entry extends WeakReference> {
        
        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 & (n - 1) = hashCode & 3,其实就是hashCode % 4
2.4 Thread、ThreadLocal、ThreadLocalMap三者之间的关系
  • 从对Thread和ThreadLocalMap的分析可知,Thread维护一个key为ThreadLocal、value为线程局部变量的Map
  • 三者之间的关系如图所示,图示中使用了2个ThreadLocal,使得线程将包含2个线程局部变量
3. ThreadLocal 3.1 成员变量
  • 说了这么久,ThreadLocal究竟是怎样的,想必大家都很好奇
  • ThreadLcoal的成员变量十分简单,且都与计算ThreadLocal的hashCode有关
    public class ThreadLocal {
        // 每个线程都包含一个基于线性探测的哈希表,ThreadLocal依靠该哈希表绑定到对应线程
        // ThreadLocal对象是哈希表中的key,可以通过threadLocalHashCode进行检索
        // 这是一种自定义的hashCode,具有很好的散列效果,仅在ThreadLocalMap中有效
        private final int threadLocalHashCode = nextHashCode();
         // 计算下一个ThreadLocal对象的hashCode,初始值为0
         // 通过加上哈希增量0x61c88647,可以计算得到一个新的hashCode
        private static AtomicInteger nextHashCode =
            new AtomicInteger();
        // 固定的hashCode增量
        private static final int HASH_INCREMENT = 0x61c88647;
        // 使用原子类的加法,保证hashCode计算的线程安全
        private static int nextHashCode() {
            return nextHashCode.getAndAdd(HASH_INCREMENT); // 加法计算,返回旧值
        }
    }
    

关于哈希增量0x61c88647

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

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

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

    • initialValue() 方法返回函数式编程接口Supplier中产生的值
    • 这个值其实就是ThreadLocalMap中key(ThreadLocal)对应的初始value,也就是线程局部变量
    static final class SuppliedThreadLocal extends ThreadLocal {
    
        private final Supplier supplier;
    
        SuppliedThreadLocal(Supplier 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 supplier) {
        return supplier.get();
    }
    
  • 执行结果如下:

withInitial() 方法使用示例

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

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

    public static void main(String[] args) {
        ThreadLocal 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,最直观的想法是
    • 调用threadLocals = thread.getThreadLocals(),获取线程中ThreadLocalMap类型的成员变量threadLocals
    • 通过 value = threadLocals.get(threadlocal)获取threadlocal对应的value
  • 包访问权限使得上述构想难以实现
    • 首先,Thread类中,成员变量threadLocals为包访问权限(default)。在用户自定义类中,不能直接通过thread.threadLocals进行访问
    • 其次,虽然Thread类提供了getter方法(getMap())用于获取threadLocals,但是这也是一个具有包访问权限的成员方法。同样,在用户自定义的类中,也无法直接访问
      ThreadLocalMap getMap(Thread t)
      

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回收,避免内存泄漏;

  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(entry过期)的情况,都会调用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;
            }
        }
    }
    
  • 按照本人的构想,清除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


参考链接:

  • 图解ThreadLocalMap的清理操作:被大厂面试官连环炮轰炸的ThreadLocal (吃透源码的每一个细节和设计原理)
  • 源代码剖析:ThreadLocal详解
4. ThreadLocal内存泄漏 4.1 一个有趣的现象
  • ThreadLocal的三个重要方法:get、set、remove,最终都会调用expungeStaleEntry() 方法清理过期entry
  • 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导致的内存泄漏

key为强引用

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

key为弱引用

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

  • 若ThreadLocal对象不存在外部强引用,GC以后,ThreadLocal对象被垃圾回收

  • ThreadLocal对象被回收后,entry的key将变成null,整个entry变得不可用

4.3 value导致的内存泄漏

key为弱引用的局限性

  • 局限性:key设计为弱引用,只是降低了内存泄漏的概率,并不能完全避免内存泄漏
  • 局限性的原因分析
    • 如果线程一直持续运行,entry对value的强引用链Thread ---> ThreaLocalMap --> Entry --> value将一直存在
    • entry中的value无法访问,也无法及时回收,将发生内存泄漏

value内存泄漏的补救措施

  • 为了降低value内存泄漏的风险,ThreadLocal主动在get、set和remove方法中调用expungeStaleEntry() 方法,清理key为null的entry,避免value内存泄漏
  • 但是,这也不是一个十全十美的方法,考虑这样的场景:
    • 线程在后续的执行中,没有ThreadLocal对象执行get、set或remove方法
    • 线程的ThreadLocalMap中的过期entry将无法被清理,value的强引用链将一直存在,内存泄漏也将随之发生

完美的解决方法

  • 主动调用ThreadLocal的remove方法,实现 Entry --> key、Entry --> value、 ThreaLocalMap --> Entry三大引用链的断开,避免内存泄漏的问题
  • 自己手动斩断,总比别人小心翼翼地修修补补更加靠谱 
4.4 ThreadLocal对象定义为private static
  • 还记得ThreadLocal类的注释中,建议将ThreadLocal对象定义为private static
  • 定义为private属性比较好理解:成员变量的权限控制,避免外部类更新ThreadLocal对象
  • 定义为static对象,一旦类被加载,对ThreadLocal对象的强引用将一直存在
  • static的作用:
    • 虽然这样将使得key为弱引用的设计派不上用场,但随时可以通过ThreadLocal对象调用remove方法,完美地避免内存泄漏
    • 这里主要是指value的内存泄漏,舍去一个ThreadLocal,可以回收多个线程中的value
    • 同时,将ThreadLocal对象定义为static,可以保证单例,避免浪费存储空间
      • ThreadLocal对象作为map的key,在一个线程中只会使用一次;
      • 多个线程,完全可以使用相同的ThreadLocal对象作为key,而不会发生冲突

解决办法:

  • 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方法才是王道 

参考链接

  • 内存泄漏的分析:为何每次用完ThreadLocal都要调用remove()?
  • 盗图:面试:为了进阿里,死磕了ThreadLocal内存泄露原因
  • 线程池中的线程使用ThreadLocal更容易引发内存泄漏:使用ThreadLocal到底需不需要remove?
  • 结合阿里编程规范讲解ThreadLocal,在finally语句中主动的remove为最佳实践:手撕面试题ThreadLocal!!!
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方法,完美解决内存泄漏的问题

ThreadLocal如何实现线程隔离?

  • 线程局部变量与线程的绑定:
    • 每个Thread都有一个自己的ThreadLocalMap,该map的Entry以ThreadLocal对象作为key,线程局部变量作为value
    • 从而,实现了线程局部变量与线程的绑定,保证每个线程都有自己的、独立的线程局部变量
  • ThreadLocal对ThreadLocalMap的访问机制:
    • 由于Thread类中ThreadLocalMap对象包访问权限的问题,用户无法访问一个线程的线程局部变量
    • 线程局部变量的访问是依靠ThreadLocal对象实现的
    • ThreadLocal的get、set、remove方法都会访问当前线程的ThreadLocalMap,它将自己作为key,对相应的Entry进行操作
    • JDK源码收拢了线程局部变量的访问接口,并在ThreadLocal的接口中限定访问当前线程的ThreadLocalMap,维护了线程隔离的特性
5.2 参考链接
  • 在学习ThreadLocal的过程中,发现了很多有用的博客,自己也是看得眼花缭乱
  • 反而搞得自己一头雾水:我应该参考哪个博客?我应该先讲解哪个知识点?
  • 最后:
    • 着重看2到3篇博客,总结出学习要点;
    • 根据自己的习惯,梳理出学习路径;
    • 每个知识点的学习:阅读看源码,结合博客,进行归纳总结

参考链接:

  • 最佳面试宝典(从实际问题的解决引出ThreadLocal、InheritableThreadLocal实现继承、remove避免内存泄漏等):Java面试必问:ThreadLocal终极篇 淦!

  • 开放定址法、弱引用尽最大努力避免内存泄漏,比较适合面试看:一个ThreadLocal和面试官大战30个回合

  • 几种常见的ThreadLocal(FastThreadLocal后续可以深入学习):阿里面试官问我ThreadLocal,我一口气给他说了四种!

  • InheritableThreadLocal:【Java并发编程】面试常考的ThreadLocal,超详细源码学习

  • 其他:

    • Java 并发 - ThreadLocal详解
    • Java面试必问,ThreadLocal终极篇
    • Java基础进阶之ThreadLocal详解
    • Java 之 ThreadLocal 详解
    • 【Java笔记】ThreadLocal的学习和理解
  • 后续:学习线程的退出机制、FastThreadLocal、InheritableThreadLocal等

  • 路漫漫其修远兮啊

转载请注明:文章转载自 www.mshxw.com
我们一直用心在做
关于我们 文章归档 网站地图 联系我们

版权所有 (c)2021-2022 MSHXW.COM

ICP备案号:晋ICP备2021003244-6号