• Java11 ThreadLocal的remove()方法源码分析


    1. ThreadLocal实现原理

    本文参考的java 版本是11。

    在讲述ThreadLocal实现原理之前,我先来简单地介绍一下什么是ThreadLocal。ThreadLocal提供线程本地变量,每个线程拥有本地变量的副本,各个线程之间的变量相互独立。在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。以下英文描述来源于ThreadLocal类的注释:

    This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.

    下面我就来说一说ThreadLocal是如何做到线程之间的变量相互独立的,也就是它的实现原理。每一个线程都有一个对应的Thread对象,而Thread类有一个ThreadLocalMap类型变量threadLocals和一个内部类ThreadLocal。这个threadLocals的key就是ThreadLocal的引用,而value就是当前线程在key所对应的ThreadLocal中存储的值。当某个线程需要获取存储在自己线程Thread的ThreadLocal变量中的值时,ThreadLocal底层会获取当前线程的Thread对象中的Map集合threadLocals,然后以ThreadLocal作为key,从threadLocals中查找value值。这就是ThreadLocal实现线程独立的原理。

    ThreadLocal通俗理解就是线程的私有变量,用于保证当前线程对其修改和读取。

    2. ThreadLocal堆栈分析

    Entry继承自WeakReference(弱引用,生命周期只能存活到下次GC前),但只有Key是弱引用类型的,Value并非弱引用。

    在《Java 8 ThreadLocal 源码解析》一文,我们知道每个thread中都存在一个map,它的类型是ThreadLocal.ThreadLocalMap。map中的Entry是ThreadLocalMap的静态内部类,继承自WeakReference,其key为一个ThreadLocal实例,使用弱引用(弱引用,生命周期只能存活到在下次 JVM 垃圾收集时被回收前),而其value却使用了强引用。在ThreadLocal的整个生命周期中,都存在这些引用。ThreadLocal堆栈结构示意图如下图所示,实线代表强引用,虚线代表弱引用:

    图2 ThreadLocal堆栈结构示意图

    从上面的结构图,我们可以窥见ThreadLocal的核心机制: 

    1. 每个Thread线程内部都有一个ThreadLocalMap。
    2. ThreadLocalMap里面存储线程本地对象(key)和线程的变量副本(value)
    3. 线程运行时,初始化ThreadLocal对象,存储在Heap,同时线程运行的栈区保存了指向该实例的引用,也就是图中的Thread Local Ref。
    4. 当调用ThreadLocal的set/get函数时,虚拟机根据当前线程的引用也就是Current Thread Ref找到其在堆区的实例,然后查看其对应的ThreadLocalMap实例是否被创建,若没有,则创建并初始化。
    5. ThreadLocalMap实例化之后,就可以将当前ThreadLocal对象作为key,进行存取操作。
    6. 当弱引用key被GC回收时,强引用value不被自动回收,有可能导致内存泄漏。

     

    通过如上4和5的分析,我们得知对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了对副本的隔离,互不干扰。

    ThreadLocalMap因为使用了弱引用,所以为了便于描述,我们把entry的状态区分为三种:即有效(key和value均未回收),无效(key已回收但是value未回收)和空(entry==null)。

    为什么ThreadLocalMap需要Entry数组呢?

    之所以用数组,是因为开发过程中,一个线程可以拥有多个TreadLocal以存放不同类型的对象,但是他们都将放到当前线程的ThreadLocalMap里,所以需要以数组的形式来存储。

    3. remove方法

    remove方法主要是为了防止内存溢出和内存泄露,使用的时机一般是在线程运行结束之后使用,也就是run()方法结束之后。下面介绍一下内存泄漏和内存溢的基本概念:

    内存泄露(Memory Leak):是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
    内存溢出(Out Of Memory,简称OOM):是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于系统能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。

    简单来说,内存泄露就是创建了太多的ThreadLocal变量,然后呢,又没有及时的释放内存;内存溢出可以理解为创建了多个ThreadLocal变量,然后又给她们分配了占用内存比较大的对象,使得多个线程累计占用太多内存,导致系统出现内存溢出。

     remove()

         public void remove() {
             // 获取ThreadLocalMap对象,此对象在ThreadLocal中是一个静态内部类
             ThreadLocalMap m = getMap(Thread.currentThread());
            // 如果存在的话,调用方法remove,看②
             if (m != null) {
                 m.remove(this);
             }
         }

    ②remove(ThreadLocal<?> key)

    /**
    * Remove the entry for key.
    */
    private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;// 获取长度
    // 通过key的hash值找到当前key的位置
    int i = key.threadLocalHashCode & (len-1);
    // 遍历,直到找到Entry中key为当前对象key的那个元素
    for (Entry e = tab[i];
    e != null;
    e = tab[i = nextIndex(i, len)]) {
    if (e.get() == key) {
    e.clear(); // 清除对象的引用
    expungeStaleEntry(i); // 去除陈旧的对象键值对(相当于帮派清理门户,就是将没用的东西清理出去)
    return;
    }
    }
    }

    ③clear

    public void clear() {
    this.referent = null; // 将引用指向null
    }

    ④ expungeStaleEntry

    看这个方法之前,需要明确全局变量size是什么,size是键值对的个数,定义如下:

    /**
     * The number of entries in the table.
     */
    private int size = 0;

    函数expungeStaleEntry是ThreadLocal中的核心清理函数,它做的事情大致如下:从staleSlot开始遍历,清理无效entry并且将此entry置为null,直到扫到空entry。另外,在遍历过程中还会对非空的entry作rehash,可以说她的作用就是从staleSlot开始清理连续段中的slot(断开强引用,rehash slot等)。

    private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    
    // expunge entry at staleSlot
    // 因为entry对应的ThreadLocal已经被回收,value设为null,显式断开强引用
    tab[staleSlot].value = null; 
    tab[staleSlot] = null; // 将整个键值对清除
    size--; // 数量减一
    
    // Rehash until we encounter null 直到遇到null,然后rehash操作
    Entry e;
    int i;
    // 从当前的staleSlot后面的位置开始,直到遇到null为止
    for (i = nextIndex(staleSlot, len);
    (e = tab[i]) != null;
    i = nextIndex(i, len)) {
    // 获取键对象,也就是map中的key对象
    ThreadLocal<?> k = e.get();
    // 如果为null,直接清除值和整个entry,数量size减一
    if (k == null) {
    e.value = null;
    tab[i] = null;
    size--;
    } else { 
    // k不为null,说明当前key未被GC回收,弱引用还存在
    // 此时执行再哈希操作
    int h = k.threadLocalHashCode & (len - 1);
    if (h != i) { // 如果不等的话,表明与之前的hash值不同这个元素需要更新
    tab[i] = null; // 将这个地方设置为null
    
    // Unlike Knuth 6.4 Algorithm R, we must scan until
    // null because multiple entries could have been stale.
    while (tab[h] != null) // 从当前h位置找一个为null的地方将当前元素放下
    h = nextIndex(h, len);
    tab[h] = e;
    }
    }
    }
    return i; // 返回的是第一个entry为null的下标
    }

    这段源码提及了Knuth高德纳的著作TAOCP(《计算机程序设计艺术》)的6.4章节(散列)中的R算法。R算法描述了如何从使用线性探测的散列表中删除一个元素。R算法维护了一个上次删除元素的index,当在非空连续段中扫到某个entry的哈希值取模后的索引还没有遍历到时,会将该entry挪到index那个位置,并更新当前位置为新的index,继续向后扫描直到遇到空的entry。

    正是因为ThreadLocalMap的entry有三种状态,所以不能完全采用高德纳原书的R算法。

    因为expungeStaleEntry函数在扫描过程中还需要对无效slot清理,并将它转为空entry,如果直接套用R算法,可能会出现具有相同哈希值的entry之间断开(中间有空entry)。

     

    关键节点梳理:

    1)删除staleSlot处的值value和entry。

    2)对从staleSlot位置到下一个为空的slot之间碰撞的entry进行rehash。

    碰撞的判断:h = k.threadLocalHashCode & (len - 1) 不等于当前的索引i,所以从h处向后线性探测查找空的slot插入。

    3)删除从staleSlot位置到下一个为空的slot之间所有无效的entry。

    4. ThreadLocal内存泄露

    我们从前面两个章节可以得知在Thread运行时,线程的一些局部变量和引用使用的内存属于Stack(栈)区,而普通的对象是存储在Heap(堆)区。由于ThreadLocalMap的key是弱引用而Value是强引用,这就导致了一个问题:ThreadLocal在没有外部对象强引用且发生GC时弱引用Key会被回收,而我们往里面放的value对于【当前线程->当前线程的threadLocals(ThreadLocal.ThreadLocalMap对象)->Entry数组->某个entry.value】这样一条强引用链是可达的,因此value不会被回收。如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,滴水成河,最终造成系统发生内存泄露。

    所以得出一个结论就是只要这个线程对象被GC回收,就不会出现内存泄露,但在ThreadLocal设为null和线程结束这段时间内,线程对象不会被回收,就会发生我们认为的内存泄露。

    Java为了降低内存泄露的可能性和风险,在ThreadLocal的get和set方法中都自带一套自我清理的机制,以清除线程ThreadLocalMap里所有无效的entry。为了避免内存泄漏,我们需要养成良好的编程习惯,使用完ThreadLocal之后,及时调用remove方法,显示地设置Entry对象为null。

    ThreadLocal<String> threadLocal = new ThreadLocal<String>();
    try {
        threadLocal.set("业务数据");
        // TODO 其它业务逻辑
    } finally {
        threadLocal.remove();
    }

    当使用static ThreadLocal的时候,会延长ThreadLocal的生命周期,那也可能导致内存泄漏。因为,static变量在类未加载的时候,它就已经加载,当线程结束的时候,static变量不一定会回收。那么,比起普通成员变量使用的时候才加载,static的生命周期加长将更容易导致内存泄漏危机。

    5.为什么使用弱引用

    为避免占用空间较大或生命周期较长的数据常驻于内存引发一系列问题,类ThreadLocalMap中有关英文原文描述如下:

    To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.

    由于ThreadLocalMap的生命周期跟Thread一样长,使用弱引用可以多一层保障:弱引用不会导致内存泄漏,无效entry在ThreadLocalMap调用set,get和remove函数的时候会被清除。

    6. ThreadLocal内存泄漏案例分析

    案例一

    首先,设置-Xms100m -Xmx100m,然后,使用如下的代码

    public class ThreadlocalApplication {
        // 线程私有变量,和当前线程绑定,所以各个线程对其的改变不会被其他线程读取到到
    public static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
    
    public static void main(String[] args) {
        SpringApplication.run(ThreadlocalApplication.class, args);
        ExecutorService exec = Executors.newFixedThreadPool(99);
        for (int i = 0; i < 1000; i++) {
            exec.execute(() -> {
                threadLocal.set(new byte[1024 * 1024]);
                try {
                    TimeUnit.MILLISECONDS.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    threadLocal.remove();
                }
            });
        }
    }
    }

    运行上面的代码没有抛出任何异常,但是若将 threadLocal.remove() 注释掉再执行,就会出现内存泄漏的问题,原因是1m的数组没有被及时回收,这也从侧面证明了手动 remove() 的必要性。

    案例二

    下面我们用代码来验证一下,

    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.atomic.AtomicInteger;
    
    /**
     * TODO
     *
     * @author Wiener
     * @date 2020/10/27
     */
    public class ThreadPoolProblem {
        private static ThreadLocal<AtomicInteger> sequencer = new ThreadLocal<AtomicInteger>() {
            @Override
            protected AtomicInteger initialValue() {
                return new AtomicInteger(0);
            }
        };
    
        static class BarTask implements Runnable {
            @Override
            public void run() {
                AtomicInteger s = sequencer.get();
                int initial = s.getAndIncrement();
                // 期望初始为0
                System.out.println(initial);
            }
        }
    
        public static void main(String[] args) {
            //线程池线程数设置为2,线程池中线程数超过2时,将复用已创建的2条线程
            ExecutorService executor = Executors.newFixedThreadPool(2);
            // 创建四条线程
            executor.execute(new BarTask());
            executor.execute(new BarTask());
            executor.execute(new BarTask());
            executor.execute(new BarTask());
            executor.shutdown();
        }
    }

    对于在线程池中执行异步任务BarTask而言,我们翘首以待的初始值应该始终是0,但如下图所示的程序执行结果却和期望值大相径庭:

            由此可见,第二次执行异步任务时期望的结果就不对了,为什么呢?因为线程池里面的线程都是复用的,在线程在执行下一个任务时,其ThreadLocal对象并不会被清空,修改后的值带到了下一个任务。那怎么办呢?下面提供有几种解决思路:

    l  第一次使用ThreadLocal对象时,总是先调用set设置初始值,或者如果ThreadLocal重写了initialValue方法,先调用remove

    l  使用完ThreadLocal对象后,总是调用其remove方法。

    l  使用自定义的线程池,执行新任务时总是清空ThreadLocal

     

    按照道理一个线程使用完,ThreadLocalMap是应该要被清空的,但是现在线程被复用了。

      

    7. 小结 

    本文讨论了ThreadLocal实现原理和内存泄漏相关的问题。首先,介绍了ThreadLocal的实现原理。其次,撸了撸remove函数的源码。然后,基于remove函数分析了ThreadLocal内存泄露的问题。最后,给出导致内存泄漏的两个案例,帮助各位读者进一步熟悉ThreadLocal

    作为Josh BlochDoug Lea两位大师之作,ThreadLocal源码所使用的算法与技巧很优雅。在开发过程中,如果ThreadLocal运用得当,可以提高代码复用率。但也要注意过度使用ThreadLocal很容易加大类之间的耦合度与依赖关系。

    Reference

     

    https://www.jianshu.com/p/1a5d288bdaee

    https://www.cnblogs.com/onlywujun/p/3524675.html

    https://www.cnblogs.com/micrari/p/6790229.html

    https://www.cnblogs.com/kancy/p/10702310.html

    https://cloud.tencent.com/developer/article/1333298

     

  • 相关阅读:
    TouTiao开源项目 分析笔记19 问答内容
    TouTiao开源项目 分析笔记18 视频详情页面
    TouTiao开源项目 分析笔记17 新闻媒体专栏
    TouTiao开源项目 分析笔记16 新闻评论
    TouTiao开源项目 分析笔记15 新闻详情之两种类型的实现
    TouTiao开源项目 分析笔记14 段子评论
    计算机专业大学课程学习路线图
    Windows GitLab使用全过程
    2017年最后一天
    生成6个1~33之间的随机整数,添加到集合,并遍历集合
  • 原文地址:https://www.cnblogs.com/east7/p/13893633.html
Copyright © 2020-2023  润新知