ThreadLocal源码探究 (JDK 1.8)

ThreadLocal类以前有了解过,看过一些文章,自觉得对其理解得比较清楚了。偶然刷到了一道关于ThreadLocal内存泄漏的面试题,竟然彻底不知道是怎么回事,痛定思痛,发现了解问题的本质仍是须要从源码看起。
ThreadLocal能够保存一些线程私有的数据,从而避免多线程环境下的数据共享问题。ThreadLocal存储数据的功能是经过ThreadLocalMap实现的,这是ThreadLocal的一个静态内部类。ThreadLocal源码加注释总共700多行,ThreadLocalMap就占据了接近400行,基本上理解了ThreadLocalMap也就理解了ThreadLocal。本文先简介ThreadLocalMap,而后从ThreadLocal的核心方法开始讲起,须要用到ThreadLocalMap的地方顺带一块儿介绍。面试

1.ThreadLocalMap简介

ThreadLocalMap本质上仍然是一个Map,具备普通Map的特色,当遇到hash冲突的时候,采用线性探测的方式来解决冲突,底层使用数组做为存储结构,它的主要字段以下:数组

  • INITIAL_CAPACITY:初始容量,默认是16
  • Entry[] table:存储键值对的数组,其大小是2的整数幂
  • size:数组内存储的元素个数
  • threshold:扩容阈值
    ThreadLocalMap底层数组保存的是Entry类型键值对,EntryThreadLocalMap的一个内部类,它是用来存储键值对的对象,值得关注的是Entry继承了WeakReference这个弱引用类,这意味着Entry的key引用的对象,在没有其余强引用的状况下,在下一次GC的时候就会被回收(注意:这里忽略了软引用,由于软引用是在即将由于内存不足而抛出异常的时候才会回收)。而且EntrykeyThreadLocal对象,经过其祖父类Reference的构造函数能够看到,key其实是被保存在referent字段中,Entry对象的get方法也是从Reference继承过来的,直接返回该referent字段。
static class Entry extends WeakReference<ThreadLocal<?>> {
        Object value;

        Entry(ThreadLocal<?> k, Object v) {
            super(k);
            value = v;
        }
    }
    
    //Entry的构造器调用了父类的构造,最终是经过Reference的构造器实现的
    Reference(T referent) {
        this(referent, null);
    }

    Reference(T referent, ReferenceQueue<? super T> queue) {
        this.referent = referent;
        this.queue = (queue == null) ? ReferenceQueue.NULL : queue;
    }
    
    //Reference的get方法
    public T get() {
        return this.referent;
    }

在对Entry有了初步了解后,如今来思考一下为何key要设计成弱引用呢?假设如今采有强引用来设计key,考虑以下代码:多线程

ThreadLocal<String> tl1 = new ThreadLocal<>();
    tl1.set("abc");
    tl1=null;

此时,相关的引用状况以下图:
函数

tl1虽然再也不引用堆上的ThreadLocal对象,可是线程的ThreadLocalMap里还保留着对该对象的强引用,要获取该对象就须要ThreadLocal对象做为key,可是这个key如今已是null了。也就是说,此时已经没有任何办法可以访问到堆上的TheradLocal对象,可是因为还有强引用的存在,致使这个对象没法被GC回收。这种状况显然不是咱们但愿看到的,所以Entrykey不能被设计为强引用。设计成弱引用是合理的,一旦外界的强引用被取消,就应当容许key所引用的对象被回收。this

2.ThreadLocal核心方法

  • get
    get方法用来获取存储在ThreadLocal中的元素,其源码以下:
public T get() {
        Thread t = Thread.currentThread();
        //获取当前线程内部的ThreadLocalMap对象
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //执行到这里的两种状况:1)map没初始化;2)map.getEntry返回null
        return setInitialValue();
    }
    
    //从这里能够看到,每一个Thread示例内部都有一个ThreadLocalMap类型的字段,线程局部变量就存在这个Map中
    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

    //ThreadLocalMap的getEntry方法
    private Entry getEntry(ThreadLocal<?> key) {
        //计算key位于哪一个桶
        int i = key.threadLocalHashCode & (table.length - 1);
        Entry e = table[i];
        if (e != null && e.get() == key)
            return e;
        //执行到这里的两种状况:1)e=null,即桶内没有存数据;2)桶内有数据,
        //但不是当前这个ThreadLocal对象的,说明产生了hash冲突,致使键值对被放到了其余位置
        else
            return getEntryAfterMiss(key, i, e);
    }

线程可以保存私有变量的缘由就在于其成员变量threadLocals,每一个线程都有这样的结构,互相不干扰。get方法的代码很简单,根据从线程内取到的ThreadLocalMap对象,若是ThreadLocalMap还没初始化,则先初始化;若是已完成初始化,调用其getEntry方法取元素,取不到的话,就会执行getEntryAfterMiss方法(ThreadLocal内部只在getEntry方法里调用了getEntryAfterMiss),先看看setInitialValue方法的逻辑:线程

private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        //map已经初始化,就将键值对存入底层数组
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }
    
    //默认的initialValue返回null,并且该方法是protected,目的显然是让子类进行重写
    protected T initialValue() {
        return null;
    }

setInitialValue的逻辑很简单,假如map没有初始化,执行createMap方法进行初始化,不然将当前ThreadLocal对象和null构形成一个新的Entry放入数组内。接下来看一下createMap的初始化逻辑:设计

//能够看到,初始化的过程就是对Thread内部变量threadLocals赋值的过程,用到了ThreadLocalMap的构造器
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
    
    //
    ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        //使用默认容量
        table = new Entry[INITIAL_CAPACITY];
        //计算位置,并初始化对应的桶
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        setThreshold(INITIAL_CAPACITY);
    }

    //ThreadLocalMap的扩容使用的是2/3做为加载因子,这点与HashMap等容器不一样
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

该方法经过ThreadLocalMap的构造器对内部数组进行初始化,并将对应的值添加到数组中。能够看到,ThreadLocalMap有容量的概念,但却没有办法指定其初始容量,在构造的时候使用固定值16做为初始容量,并且扩容阈值设置的是容量的2/3,这一点与HashMap等容器的作法不一样。
接下来看看getEntryAfterMiss方法的源码:指针

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
        Entry[] tab = table;
        int len = tab.length;

        while (e != null) {
            ThreadLocal<?> k = e.get();
            //找到key就直接返回
            if (k == key)
                return e;
            //注意这里:当发现key=null是时,说明其对应的ThreadLocal对象已被GC回收,
            //此时会经过expungeStaleEntry将一部分key为null的桶清空
            if (k == null)
                expungeStaleEntry(i);
            //走到这里说明存在hash冲突,当前桶被其余元素占了,使用nextIndex向后找一个位置
            else
                i = nextIndex(i, len);
            e = tab[i];
        }
        //若是e=null,在这里返回null
        return null;
    }
    
    /nextIndex的主要做用是:查找下一个桶,若是到达末尾,则从头开始
    private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
    }

在桶内的key=null时,会调用expungeStaleEntry方法,从命名能够看出,这个方法主要功能是将ThreadLocalMapkey=null的元素清理掉。下面是对这个方法的讲解:code

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

            // expunge entry at staleSlot
            //分别将Entry的键和值清空
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            //元素数量减1
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            //从当前下标的下一个位置开始遍历,清空key=null的桶,并更新hash冲突的元素的位置
            //循环终止条件:顺序向后遍历时,找到一个非空的桶则循环终止,所以这里只是做了局部清理
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //若是key = null,则清空该桶
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                    int h = k.threadLocalHashCode & (len - 1);
                    //h!=i说明当前元素是由于hash冲突,以前的桶被占了才放在了i这个桶内,
                    //那么就从其原来的位置h开始向后查找,找到第一个空桶,就把元素挪过去,
                    //目的是为了保证元素距离其正确位置最近,减小后续的查找成本
                    if (h != i) {
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            //返回值是第一个不为空的桶的下标
            return i;
        }

expungeStaleEntry的循环逻辑说明,在对失效元素进行清空时,不是清空全部失效的桶,而是从当前位置向后遍历,只要找到一个非空的桶,清理的过程就结束了。也就是说,这种清理会致使有一些过时失效的桶没法获得清理。对象

  • set
    介绍完get方法后,如今再来看看set方法的实现逻辑:
public void set(T value) {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        //map已建立则直接设值
        if (map != null)
            map.set(this, value);
        //map未建立,则建立map
        else
            createMap(t, value);
    }

来看看mapset方法是如何设值的:

private void set(ThreadLocal<?> key, Object value) {

        // We don't use a fast path as with get() because it is at
        // least as common to use set() to create new entries as
        // it is to replace existing ones, in which case, a fast
        // path would fail more often than not.

        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();
            
            //若是当前ThreadLocal对应的有值,则更新
            if (k == key) {
                e.value = value;
                return;
            }
            
            //若是k=null,说明对应的ThreadLocal对象已被GC回收,执行replaceStaleEntry的逻辑
            if (k == null) {
                //这里是replaceStaleEntry方法的惟一调用点
                replaceStaleEntry(key, value, i);
                return;
            }
        }
        //找到一个空位置,将值存进去
        tab[i] = new Entry(key, value);
        int sz = ++size;
        //若是调用cleanSomeSlots没有清理任何桶,而且达到了扩容阈值,就执行扩容逻辑
        if (!cleanSomeSlots(i, sz) && sz >= threshold)
            rehash();
    }

set的时候须要从对应的下标开始向后遍历,找到一个合适的位置将元素放进去,这里合适的位置是指:a)空桶;b)桶非空,可是key对应的ThreadLocal对象已被清理。在key已经被清理的状况下,会执行replaceStaleEntry方法的逻辑,接下来看看这个方法的代码:

private void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) {
        Entry[] tab = table;
        int len = tab.length;
        Entry e;

        // Back up to check for prior stale entry in current run.
        // We clean out whole runs at a time to avoid continual
        // incremental rehashing due to garbage collector freeing
        // up refs in bunches (i.e., whenever the collector runs).
        int slotToExpunge = staleSlot;
        //从staleSlot这个桶向前查找,遇到第一个空桶就中止
        for (int i = prevIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = prevIndex(i, len))
             //若是桶内的key=null,说明该桶能够被回收,将slotToExpunge变量指向这个桶
            if (e.get() == null)
                slotToExpunge = i;

        // Find either the key or trailing null slot of run, whichever
        // occurs first
        //从当前桶向后遍历
        for (int i = nextIndex(staleSlot, len);
             (e = tab[i]) != null;
             i = nextIndex(i, len)) {
            ThreadLocal<?> k = e.get();

            // If we find key, then we need to swap it
            // with the stale entry to maintain hash table order.
            // The newly stale slot, or any other stale slot
            // encountered above it, can then be sent to expungeStaleEntry
            // to remove or rehash all of the other entries in run.
            // k== key,说明这个ThreadLocal已经存在,可是距离正确的位置太远,须要对其位置进行更正
            if (k == key) {
                e.value = value;

                //交换两个桶内的元素,把i位置的元素放在距离其正确位置最近的桶内。
                //注意,replaceStaleEntry的惟一调用点出如今set方法内,此时staleSlot对应的桶的key=null,
                //我的推测这里不直接赋值tab[i]=null的缘由是让下一次expungeStaleEntry可以多清理一些空桶,
                //若是这里设置为null的话,下一次清理到这个位置就终止了
                tab[i] = tab[staleSlot];
                tab[staleSlot] = e;

                // Start expunge at preceding stale entry if it exists
                //下面的等式成立有两种状况:
                //1)staleSlot前一个桶就为空,此时上文中前向遍历的循环体会直接结束;
                //2)staleSlot前面的若干桶都不为空,且桶内的key!=null,即对应的ThreadLocal对象都没有被回收;
                //出现这两种状况的时候,都只能从i这个位置开始进行清理
                if (slotToExpunge == staleSlot)
                    slotToExpunge = i;
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                return;
            }

            // If we didn't find stale entry on backward scan, the
            // first stale entry seen while scanning for key is the
            // first still present in the run.
            //k!=null的时候,不能清理i这个桶;slotToExpunge != staleSlot时,
            //说明在i这个位置以前就已经有须要清理的桶了,不能更新slotToExpunge这个指针的值
            if (k == null && slotToExpunge == staleSlot)
                slotToExpunge = i;
        }

        // If key not found, put new entry in stale slot
        //执行到这里说明,直到遇到空桶,都没有在数组中找到key,就把新的键值对放在staleSlot的位置。
        tab[staleSlot].value = null;
        tab[staleSlot] = new Entry(key, value);

        // If there are any other stale entries in run, expunge them
        //slotToExpunge != staleSlot说明在其余位置找到须要清理的键值对,那么就从对应的位置清理
        if (slotToExpunge != staleSlot)
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
    }

针对代码注释中的内容,有几点须要强调一下。首先replaceStaleEntry这个方法惟一的调用点就是ThreadLocalMapset方法内部,并且在调用replaceStaleEntry的时候,参数staleSlot对应的桶内的Entry对象的key=null。其次,replaceStaleEntry的逻辑是从staleSlot这个桶开始,先前向遍历,找到第一个空桶就中止遍历,期间若是发现某个桶内的key=null,就将slotToExpunge指针指向这个桶,表示下文要从这个桶开始进行过时键清理。前向遍历结束以后开始后向遍历,找到当前的ThreadLocal对象所在的桶,将其位置更新,调用清理方法以后代码返回,不然就一直向后找直到遇到空桶。
下面对replaceStaleEntry方法的执行流程进行梳理。
假设在方法执行时,ThreadLocalMap的存储结构以下所示:

首先前向遍历,遍历到LL位置结束,因为在L位置Entry.key=null,因此设置slotToExpunge=L

接下来开始向后遍历,遍历到R1位置时,虽然Entry.key=null,可是因为slotToExpunge的值已经被修改,再也不对其进行赋值。代码接着遍历R2位置,在这里找到了key,所以将该位置的值与staleSlot位置进行交换,以下图:

以后执行expungeStaleEntry方法将LL位置清空,而后从L位置开始执行cleanSomeSlots的逻辑。
replaceStaleEntry方法内有两处用到了cleanSomeSlots方法,接下来对其进行介绍:

private boolean cleanSomeSlots(int i, int n) {
        boolean removed = false;
        Entry[] tab = table;
        int len = tab.length;
        //循环的逻辑是:从i的下一个位置开始找那些桶不空,可是桶内Entry对应的key=null的元素,
        //而后从这些元素开始向后进行清空。循环的过程当中会跳过空桶或者桶内元素的key!=null的桶,
        //循环的次数由n的大小决定,每次将n减半,直到减为0循环结束。
        do {
            i = nextIndex(i, len);
            Entry e = tab[i];
            if (e != null && e.get() == null) {
                n = len;
                removed = true;
                //注意:expungeStaleEntry方法的返回值是第一个不为空的桶的下标,循环的下一次会从这个下标开始遍历
                i = expungeStaleEntry(i);
            }
        } while ( (n >>>= 1) != 0);
        //若是清理了元素,则返回true,不然返回false
        return removed;
    }
  • rehash
    set方法内部最后几行,若是调用cleanSomeSlots没有清理任何桶,而且达到了扩容阈值,就执行扩容逻辑,这段逻辑在rehash方法中,来看看方法的实现逻辑:
private void rehash() {
        //清理全部的过时桶
        expungeStaleEntries();

        // Use lower threshold for doubling to avoid hysteresis
        //清理事后剩余元素达到threshold的0.75才进行扩容,回忆一下ThreadLocalMap的初始化过程,
        //初始化时threshold=2/3*初始容量,这里在判断是否要扩容时,是已threshold*0.75为标准
        if (size >= threshold - threshold / 4)
            resize();
    }
    //这个方法会从头开始遍历整个数组,每遇到一个ThreadLocal对象被回收的桶,就调用expungeStaleEntry方法向后清理一部分桶
    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;
        //注意这里并无对newLen做限制,也就是说有超限的可能,可是通常确定不会在线程内放这么多本地变量
        int newLen = oldLen * 2;
        Entry[] newTab = new Entry[newLen];
        int count = 0;
        
        //将原来的数据迁移到新的数组
        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;
    }
  • remove
    最后来看一下ThreadLocalremove方法,方法很简单,底层逻辑仍然是经过ThreadLocalMapremove实现的:
public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

     private void remove(ThreadLocal<?> key) {
        Entry[] tab = table;
        int len = tab.length;
        //计算key的位置
        int i = key.threadLocalHashCode & (len-1);
        for (Entry e = tab[i];
             e != null;
             e = tab[i = nextIndex(i, len)]) {
            //若是找到key,则调用Reference类的clear方法,将referent置为null,而后从该位置开始向后清理一部分过时键值对
            if (e.get() == key) {
                e.clear();
                expungeStaleEntry(i);
                return;
            }
        }
    }
    
    //Reference类的clear方法
    public void clear() {
        this.referent = null;
    }

3.内存泄漏问题

ThreadLocal的底层Entry数组的key是弱引用,意味着当ThreadLocal对象的全部强引用都被去除后,对应的ThreadLocal对象会被回收,此时Entrykey=null,可是value还维持着堆上数据的强引用,只要当前线程不退出,这个强引用会一直存在。为了尽量缓解这个问题,ThreadLocalgetsetremove方法都会清除一批过时数据,可是从本文的分析能够看出,这种清理只是部分清理,仍然可能遗漏掉部分数据。所以这三个方法只能在必定程度上缓解内存泄漏的问题,并不能避免。另外,若是线程在较长时间内都没有执行上述方法,那过时的数据只会更多。那些在一段时间内都没被清理的过时value对象仍会继续占用内存空间,这些未被清理的对象就是内存泄漏的源头。固然,过时的数据会在线程退出后所有销毁,可是当使用了线程池以后,线程用完会重复利用,并不会被销毁,这种内存泄漏问题就不得不考虑了。所以,好习惯是在ThreadLocal对象用完以后及时使用remove方法进行删除,从而避免内存泄漏问题。

相关文章
相关标签/搜索