ThreadLocal源码、InheritableThreadLocal与内存泄露,这一篇给你捋顺了

ThreadLocal,可以理解为线程局部变量。同一份变量在每一个线程中都保存一份副本,线程对该副本的操作对其他线程完全是不可见的,是封闭的。

一、ThreadLocal简单示例

public class Main {

    private static ThreadLocal<Integer> tl = new ThreadLocal<>();

    public static void main(String[] args) {

        tl.set(1);

        Thread t = new Thread(() -> {
            tl.set(2);
            System.out.println("子线程:" + tl.get());
        });
        t.start();

        System.out.println("主线程:" + tl.get());

    }
}

最终的输出如下:

 可以看出,各个线程内的ThreadLocal互不干扰,每个线程也只能访问自己独有的ThreadLocal变量。

那么ThreadLocal的结构是怎么样的呢?


二、ThreadLocal的结构

Thread、ThreadLocal与ThreadLocalMap的关系图如下:

 从上面的结构图我们可以看出:

(1)每个Thread内部都有一个ThreadLocalMap,可以理解为简单版的HashMap。

(2)map的key是ThreadLocal类型的,而value的类型则是ThreadLocal的泛型类型。在本例中,value是Intege类型的。

在我刚学ThreadLocal的时候,我觉得他应该是这样设计的:

ThreadLocal里面有一个map容器,key是线程id或线程名称,value是副本的值,简单又好理解。那为什么jdk不这样设计呢(当然早期就是这样设计的)?

在jdk8中,map被放入到了Thread中,ThreadLocal更像是一个工具类。

那么,jdk8这样设计的好处是什么呢?

(1)如果map被放到ThreadLocal中,那么map的大小取决于线程数量。当线程数特别多的时候,势必会影响到map的查找、插入与扩容的效率。而在jdk8中,map的大小取决于ThreadLocal的数量,这个数量是可控的,一般不可能声明出那么多的ThreadLocal。

(2)在早期的设计中,当线程消亡时,需要在每一个关联的ThreadLocal的map中做一些清理工作,比较麻烦。而在jdk8中,线程消亡时,内部的map容器也随之消亡。


三、ThreadLocal有哪些方法

先从比较简单的set与get方法说起

有关ThreadLocalMap的方法,我们将会在下个章节进行梳理。

set方法

    public void set(T value) {
        //获取当前的操作线程
        Thread t = Thread.currentThread();
        //获取线程内部的ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null)
            //如果map不为空,则直接将(k:当前ThreadLocal实例,v:副本值)放入进map中
            map.set(this, value);
        else
            //如果map为空,则创建该线程的ThreadLocalMap,并将(k,v)放入进map中
            createMap(t, value);
    }

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

可以看到,Thread内部的ThreadLocalMap是懒加载的,只有在第一次使用的时候,才会创建map。

get方法

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            //如果map不为空,则获取键为该ThreadLocal对象的Entry实例
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }
    
    private T setInitialValue() {
        //获取初始值,默认是null
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }

    protected T initialValue() {
        return null;
    }

看的出,get方法同样会触发map的初始化。

remove方法

     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             //最终还是调用ThreadLocalMap的方法,移除key为当前ThreadLocal的Entry
             m.remove(this);
     }

好家伙,ThreadLocal工具人的身份石锤了。

核心的代码都在ThreadLocalMap中,他是ThreadLocal内的一个静态内部类。


四、ThreadLocalMap探究

ThreadLocalMap的结构

        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

ThreadLocalMap没有直接使用HashMap,而是一个经过定制化的map。map中的每一项都是一个Entry,key是对ThreadLocal的一个弱引用(这个后面会再解释)。

成员变量

        //初始容量,必须是2的整数次方
        private static final int INITIAL_CAPACITY = 16;

        //Entry数组。其长度也必须是2的整数次方
        private Entry[] table;

        //数组中不为null的Entry个数
        private int size = 0;

        //扩容阈值,当size≥threshold时,就会发生扩容。默认为0,会在构造方法中重新设置
        private int threshold;

基本方法

        //设置扩容阈值为表长度的2/3
        private void setThreshold(int len) {
            threshold = len * 2 / 3;
        }
      
        //下一个索引,当索引为len-1时,下一个索引为0
        private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

        //上一个索引,当索引为0时,上一个索引为len-1
        private static int prevIndex(int i, int len) {
            return ((i - 1 >= 0) ? i - 1 : len - 1);
        }

ThreadLocalMap不同于HashMap,HashMap使用链地址法解决冲突,而ThreadLocalMap使用线性探测法。即当前下标存在冲突时,检查下一个下标是否存在冲突,你可以把数组看成一个环。

构造方法

        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            //初始化一个容量为16的table
            table = new Entry[INITIAL_CAPACITY];
            //计算当前ThreadLocal的下标
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
            table[i] = new Entry(firstKey, firstValue);
            size = 1;
            //设置扩容阈值为16的2/3,即10
            setThreshold(INITIAL_CAPACITY);
        }

在计算ThreadLocal的下标的时候,用到了

int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)

生成哈希值的时候,用到了以下代码:

    private final int threadLocalHashCode = nextHashCode();

    //使用AtomicInteger类型是保证加法的原子操作
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    //该魔数使得在该table上散列均匀,这里不细究其原理
    private static final int HASH_INCREMENT = 0x61c88647;

    //返回下一个哈希值,仅仅是在当前值的基础上再加上魔数
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

在计算下标的时候,使用到了& (INITIAL_CAPACITY - 1),这里和HashMap是一样的算法(有关HashMap的连环问,可以参考我的这篇文章HashMap夺命连环问)。

前面说过,Entry数组的容量必须是2的整数次方,那么在这样的前提下,hashCode%len是和hashCode&(len-1)相等的,而位运算更加的快速。

例如len=16,len-1的二进制为01111,即将最高位变为0,小于16的部分全为1。那么hashCode&(len-1)之后,hashCode中≥16的位全部被与为0,小于16的被保留了下来,从而达到对容量取余相同的效果。

getEntry方法

这个就是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;

            //只要Entry不为null,就一直寻找。如果为null,说明真的找不到了
            while (e != null) {
                ThreadLocal<?> k = e.get();
                if (k == key)
                    return e;
                if (k == null)
                    //key为null,说明ThreadLocal已经被回收,那么回收其value
                    expungeStaleEntry(i);
                else
                    //寻找i的下一个下标
                    i = nextIndex(i, len);
                //将e设置为下一个Entry
                e = tab[i];
            }
            return null;
        }

expungeStaleEntry方法

即清理那些key为null的Entry

        private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            //断开对value的强引用
            tab[staleSlot].value = null;
            //断开对Entry的强引用
            tab[staleSlot] = null;
            size--;
 
            Entry e;
            int i;
            //从staleSlot的下一个位置开始,直到遇到为null的Entry结束
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    //断开对value与Entry的强引用
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    //如果当前的Entry不为null,则进行重新散列
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {
                        //如果重新散列后,位置发生变动
                        tab[i] = null;

                        //一直找到一个空位置
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
        }

expungeStaleEntry方法有两个作用:

(1)从staleSlot位置开始,在遇到空Entry之前,清理当前位置的Entry

(2)如果当前Entry不为空,则进行重新散列。重新散列后的位置不为空Entry的话,则选择下一个下标。

明明清理空Entry就行了,为什么需要对非空Entry还要再做一次重新散列呢?
是为了下一次get的时候,避免遇到空Entry需要执行expungeStaleEntry方法。

expungeStaleEntry方法可以理解为清理某一段数组,遇到null就停下来了,并不是全量清理。

set方法

        private void set(ThreadLocal<?> key, Object value) {
            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)]) {
                ThreadLocal<?> k = e.get();
                //存在则更新
                if (k == key) {
                    e.value = value;
                    return;
                }
                
                if (k == null) {
                    //替换当前失效的Entry
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                //当没清理到任何数据且size≥阈值的时候,进行扩容
                rehash();
        }

replaceStaleEntry与cleanSomeSlots方法,我们不继续深入了,两个方法的主要想法依然是去清理无效Entry,即key为null的Entry。

rehash方法

        
        private void rehash() {
            //该方法对于table上每一个Entry,都执行了expungeStaleEntry方法
            //可以理解为整体清理
            expungeStaleEntries();

            //threshold - threshold / 4 =3/4*threshold 
            //默认的threshold =2/3*len,因此只要size>=1/2*len,即占了一半之后,就考虑扩容
            //为什么不按传统的size>=threshold来考虑扩容呢?
            //因为执行一次全部清理后,依然还占有一半容量,那么就说明冲突可能会趋于严重,不如早点执行扩容操作。
            if (size >= threshold - threshold / 4)
                resize();
        }

        //对每一个位置上都进行检查并清理
        private void expungeStaleEntries() {
            Entry[] tab = table;
            int len = tab.length;
            for (int j = 0; j < len; j++) {
                Entry e = tab[j];
                if (e != null && e.get() == null)
                    expungeStaleEntry(j);
            }
        }


        private void resize() {
            Entry[] oldTab = table;
            int oldLen = oldTab.length;
            //扩容为原来的两倍
            int newLen = oldLen * 2;
            Entry[] newTab = new Entry[newLen];
            int count = 0;

            //将旧位置的Entry重新计算下标放入新table中
            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++;
                    }
                }
            }

            setThreshold(newLen);
            size = count;
            table = newTab;
        }

可以看得出来,在ThreadLocal的get与set方法中,都会去检查Entry的key是否为null,如果为null的话,会进行一些局部的清理工作。

当需要进行扩容时,会进行一次整体清理。


五、InheritableThreadLocal是什么鬼

ThreadLocal是用于线程之间隔离的,但是InheritableThreadLocal可以使得子线程去自动拷贝来自父线程的副本数据。

简单的例子:

    public static void main(String[] args) {
        InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();

        threadLocal.set(1);
        System.out.println("父线程的副本值:" + threadLocal.get());

        new Thread(() -> System.out.println("子线程的副本值:" + threadLocal.get())).start();
    }

两个线程的副本值都是1,说明子线程确实自动拷贝了父线程的副本值。

原理很简单:

InheritableThreadLocal继承了ThreadLocal,

重写了childValue方法,直接返回了传入参数值。因为InheritableThreadLocal默认不对原值进行转换,如果我们需要对原值进行转换的话,可以重写该方法。

重写了getMap方法,返回当前线程的inheritableThreadLocals,也是ThreadLocalMap类型。createMap则是懒加载该inheritableThreadLocals。

    protected T childValue(T parentValue) {
        return parentValue;
    }
   
    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }

说白了,当前的副本不在threadLocals存了,而是存在了inheritableThreadLocals中。

接着看Thread的构造方法:

    public Thread(Runnable target) {
        init(null, target, "Thread-" + nextThreadNum(), 0);
    }

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize) {
        init(g, target, name, stackSize, null, true);
    }

    private void init(ThreadGroup g, Runnable target, String name,
                      long stackSize, AccessControlContext acc,
                      boolean inheritThreadLocals) {
        if (name == null) {
            throw new NullPointerException("name cannot be null");
        }

        this.name = name;

        Thread parent = currentThread();
        ...省略无关代码
        //inheritThreadLocals为true,父线程的inheritableThreadLocals也不为空
        if (inheritThreadLocals && parent.inheritableThreadLocals != null)
            //则利用父线程的inheritableThreadLocals去创建子线程的inheritableThreadLocals
            this.inheritableThreadLocals =
                ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
        /* Stash the specified stack size in case the VM cares */
        this.stackSize = stackSize;

        /* Set thread ID */
        tid = nextThreadID();
    }

接着进入createInheritedMap方法:

    static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
        return new ThreadLocalMap(parentMap);
    }

    private ThreadLocalMap(ThreadLocalMap parentMap) {
        Entry[] parentTable = parentMap.table;
        int len = parentTable.length;
        setThreshold(len);
        table = new Entry[len];
    
        //将parentTable上key不为null的Entry复制到当前table上
        for (int j = 0; j < len; j++) {
            Entry e = parentTable[j];
            if (e != null) {
                @SuppressWarnings("unchecked")
                ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
                if (key != null) {
                    //key.childValue返回e.value
                    Object value = key.childValue(e.value);
                    Entry c = new Entry(key, value);
                    int h = key.threadLocalHashCode & (len - 1);
                    while (table[h] != null)
                        h = nextIndex(h, len);
                    table[h] = c;
                    size++;
                }
            }
        }
    }

不过需要注意的是,如果在拷贝之后,父线程再进行set的话,子线程肯定是感知不到的。


六、ThreadLocal与内存泄漏

什么是内存泄露?

大白话讲,就是我自己创建的对象,在一系列操作后,我访问不到该对象了,我认为它已经被回收掉了,但该对象却一直存在与内存中。

那什么是内存溢出呢?

内存溢出是在有限的堆内存中(当然内存溢出的区域不止这一块)申请了大量的对象,造成oom的情况。

那两者的区别呢?

内存泄露比较严重的时候会导致内存溢出,如果每次gc后,堆内存都不能下降到一个比较低的占用量,那么可以使用jmap dump堆内存,再使用MAT找出导致内存泄漏的对象。

我们以一开头的例子来画出运行时的堆栈图:

 为什么这里的key保持着对ThreadLocal的一个弱引用呢?保持强引用行不行?

假设这里的key保持对ThreadLocal的强引用,则当我的程序用不到该ThreadLocal时,我手动执行了tl=null,此时1号线断开,而这里的5号线是实线,5号线没有断开,因此ThreadLocal对象无法被回收掉,一直存在于内存中,造成内存泄露。

看来,这里的弱引用,能够保证用不到的ThreadLocal被回收掉。

弱引用就能完全防止内存泄露了吗?

由上面的分析,弱引用能够防止释放不掉ThreadLocal引起的内存泄露。但是,却不能防止释放不掉Integer引起的内存泄露。首先,执行tl=null,则1号线断开,GC到来时,5号线断开,此时ThreadLocal被回收掉了,这个key被置为了null,可是这个key对应的value强引用着Integer对象,该Integer无法在用户代码中访问到了,但却依然存在于内存中,造成内存泄露。

既然依然存在着内存泄露,那么JDK团队是怎么解决的呢?

从上文的源码分析来看,ThreadLocal中的get()、set()方法,不是单纯地去做获取、设置的操作。在它们的方法内部,依然会遍历该Entry数组,删除所有key为null的Entry,并将相关的value置为null,从而够解决因释放不掉value而引起的内存泄露。

有这些get()、set()方法,就能完全地防止内存泄漏吗?

但我们手动将tl置为null后,就已经没法调用这些get()、set()方法了。所以,预防内存泄露的最佳实践是,在使用完ThreadLocal后,先调用tl.remove(),再调用tl=null。tl.remove()能够使得ThreadLocalMap删除该ThreadLocal所在的Entry,以及将value置为null,tl=null使得ThreadLocal对象真正地被回收掉。
 

其实内存泄露的问题,核心在于ThreadLocal与Thread的生命周期不一致

有两种情况:

(1)ThreadLocal的生命周期长于Thread,此时的Thread销毁后,内部的ThreadLocalMap也逐渐销毁,这种情况是不会发生内存泄露的。

(2)在线程池相关的场景下,ThreadLocal的生命周期是明显短于Thread的。当ThreadLocal被置为null,而又没在其之前调用remove时,内存泄露就开始了,一直持续到Thread销毁。

还有哪些内存泄露的场景呢?怎么去解决呢?

【1】长生命周期的对象持有短生命周期对象的引用,就很有可能造成内存泄露。

长生命周期的对象往往和整个程序的生命周期相同,若是当它们持有短生命周期的对象的引用,尽管短对象不再被使用,也无法被垃圾回收器回收,因为垃圾回收器无法回收被强引用所关联的对象。

解决方案:

(1)像一些静态的集合类,它们的生命周期和整个程序相同,尽管放入集合中的元素不再需要,就算将元素强行置为null,但由于集合类持有它们的引用,这些元素占据的空间也得不到释放,那么在必要的时候,我们可以将集合类对象类型的变量置为null。

(2)单例模式中,单例与整个程序的生命周期一致,如果单例对象持有其他短对象的引用,也很容易造成内存泄露,这还得靠我们谨慎编码。

(3)又或是数据库连接对象(长生命周期),ResultSet与Statement对象(短生命周期),连接不再被使用时,需要调用其close()方法,释放长对象与短对象。同理,需要显式调用close()方法的长生命周期的对象还有Socket、IO流、Session等。

【2】非静态的外部类会隐式地持有外部类的一个强引用

在Android中,如果在Activity内声明一个非静态的内部类,那么只要该内部类没有被回收的话,那么外部类Activity就无法被回收,Activity所关联的视图和资源也不会被回收,这样的内存泄露比较严重。

解决方案:

(1)将非静态内部类改为静态内部类,静态内部类是属于类的,因此不会依赖于外部类的实例,从而不持有外部类实例的引用。

(2)显式地声明非静态内部类持有外部类的一个弱引用,被弱引用关联的对象,在下一次垃圾回收器活动时,就会被回收。


七、ThreadLocal的使用场景

ThreadLocal一个典型的场景就是,我们需要在某个线程内保存全局可流通的属性,避免参数传递的麻烦。

大可以使用拦截器,将请求的token信息解析成用户属性,放在ThreadLocal中,之后在该线程执行的任何地方都可以获取到用户属性。

不会真有人在不知道ThreadLocal的时候,一直把HttpServletRequest当作方法的常用参数吧?不会吧不会吧

当然,线程池以及异步程序中是不建议使用ThreadLocal的,你永远不知道你拿到的副本到底是哪个Thread的遗产。

此外,java8中的并行流也不是不建议使用ThreadLocal的,有关对并行流的介绍,可以移步我的另外一篇文章谈谈并行流parallelStream

猜你喜欢

转载自blog.csdn.net/qq_33591903/article/details/119712216