简单的ThreadLocal

560 阅读10分钟

ThreadLocal

ThreadLocal 线程私有变量,只有在同一个线程内可以共享。

实际场景

在实际生活中,有很多场景都在使用,如保存请求的用户信息到ThreadLocal中,保证在后续流程中都可以从ThreadLocal中获取用户的信息。

PageHelper插件中的PageHelper静态方式调用,把分页参数存到ThreadLocal中,然后执行sql之前用自定义的插件从Threadlocal中取出来,在sql中添加分页参数

PageHelper.startPage(1, 10);
list = countryMapper.selectIf(param1);

Dubbo 添加隐式参数,当然Dubbo的方式没有那么的直接,在消费者方会把值设置到RpcInvocation#attachments 中,在服务提供方,会通过ContextFilter 时,把这些参数都设置到Threadlocal中去,方便在后续的流程中使用

// 消费者方 设置参数
RpcContext.getContext().setAttachment("index","1");
// 服务者方 获取参数
RpcContext.getContext().getAttachment("index");

ThreadLocal 的组成

结合下面的图来看,ThreadLocal是保存在一个ThreadLocalMap中,作为ThreadLocalMap的一个key,而这个map是保存在Thread中 java.lang.Thread#threadLocals。 可以理解为ThreadLocal只是一个操作工具,不保存实际的数据。因此get() 方法很好理解,也可以看到在源码中,ThreadLocalMap是懒加载,只有调用过ThreadLocal的相关方法时,才会去thread中设置一个ThreadLocalMap对象

	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;
            }
        }
        // 懒加载
        return setInitialValue();
	}
	
    // 给thread中创建ThreadLocalMap,或创建一个value为null的entry
	private T setInitialValue() {
        T value = initialValue();
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
        return value;
    }


此处也是解答了在后面ThreadLocalMap 中对key使用弱引用,假如使用的都强应用,那么在threadlocal对象销毁时,threadlocal却还存在在map中key的引用,导致无法被回收,会造成内存泄露。

而使用了弱引用以后,key被回收了,但是value依旧存在引用关系无法被回收,因此threadlocal在使用后,需要保证remove来保证value及时被回收,防止内存泄露。除了手动的回收,在内部get和set的一些方法中,会同步去检查这个value,若发现不使用了,也会去回收掉。

上面所述的2点,都是处于线程复用的情况下,线程不会回收,那么如果前面的Threadlocal一直存在的话,是会造成内存泄露的。在目前的互联网环境下,在系统中基本上线程都是复用的,不然会出现早期的C10K问题。

ThreadLocalMap

ThreadLocalMap 哈希冲突解决

采用hash的数据结构来存储,那么哈希冲突是一定要解决的。 解决哈希冲突的方法有4种:

  • 链式地址法(HashMap的哈希冲突解决方法)

  • 开放地址方法 (ThreadLocalMap)

  • 建立公共溢出区

  • 二次哈希

在 ThreadLocalMap 中,使用的是开放地址中的线性探测,就是按顺序决定值时,如果某数据的值已经存在,则在原来值的基础上往后加一个单位,直至不发生哈希冲突。在最初的hash中,所得的结果是下面的表达式,得到在数组中的下表

 i =  hashcode & (size - 1)

开放地址的方法,不像链式地址发,对于哈希冲突希望是劲量的去避免,那么我们可以在hashcode中做一下操作,让他得到的i是尽可能的离散不发生冲突,此时出现了一个魔数,0x61c88647

	private final int threadLocalHashCode = nextHashCode();

    
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    
    private static final int HASH_INCREMENT = 0x61c88647;

    
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

每次新增一个Threadlocal对象,hashcode就增加0x61c88647,该值是2^32 * 黄金分割比,黄金比例为(Math.sqrt(5) - 1)/2 保留3位小数为0.618

黄金分割比,当斐波那契数列的n趋于无限大时,n与n-1的比例无限接近于(Math.sqrt(5) - 1)/2,至于为什么这个数可以保证离散程度最大,可以去网上看数学大佬的资料,这里就不阐述了,主要是我看不懂。

做一个实验

		// 假设容器的容量为16,然后按照hashcode的递增方式
		int a = 0x61c88647;
        for(int i = 0;i<16 ;i++){
           int h =  a & (16 -1 );
            System.out.print(h+",");
            a = a+0x61c88647;
        }
        // 最后输出如下
        // 7,14,5,12,3,10,1,8,15,6,13,4,11,2,9,0

在上诉的实验中,确实离散度控制的很好,没有冲突。

ThreadLocalMap get

更具key获取value时,主要在ThreadLocalMap.Entry e = map.getEntry(this);

简单的分为几个步骤
1、更具hash得到在数组上的位置,然后查看key是否匹配,如匹配就返回
2、如不匹配则按顺序向后遍历数组,查到与其相等的key返回
3、在遍历的过程中,如遇到entry的key为null的情况,说明改位置已经被gc回收,需要调整该位置后面的元素,把元素的位置调整一下。在ThreadLocalMap的其他方法中,都会存在这一的情况,帮助清理无用的entry,用来最大可能的保证不存在内存泄露

1
	private Entry getEntry(ThreadLocal<?> key) {
    		// 获取key原始的位置i
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            // 
            if (e != null && e.get() == key)
                return e;
            else
            // 当原始的位置i上的元素与key不匹配时,向后查找
                return getEntryAfterMiss(key, i, e);
        }
2
 	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)
                // 找到对应的key就返回
                    return e;
                if (k == null)
                // 找到一个key为null的entry,处理过期的entry
                    expungeStaleEntry(i);
                else
                    i = nextIndex(i, len);
                e = tab[i];
            }
            return null;
        }
3

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

            // expunge entry at staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                if (k == null) {
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {
                //这里主要的作用是由于采用了开放地址法,所以删除的元素是多个冲突元素中的一个,需要对后面的元素作
            //处理,可以简单理解就是让后面的元素往前面移动
                    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;
        }

在get中,在当前位置找不到key会往下查询,这一来看,好像不用移动后面废弃的entry也是没有问题的,但是实际上移动解决了2个问题,一个是内存泄露问题,一个是get的效率问题。所以整体来看expungeStaleEntry是非常有必要的

ThreadLocalMap set

set主要看 ThreadLocalMap#set分为如下几个情况
1、 如果key原来存在,则替换原来的value
2、如果entry中的key已经是null了,就会替换他
3、插入完毕后,会清理整个table 并且与判断是否需要扩容

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();

                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;
            // 清理table  并判断是否需要扩容
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }
 private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;

            //这里采用的是从当前的staleSlot 位置向前面遍历,i--
            //这样的话是为了把前面所有的的已经被垃圾回收的也一起释放空间出来
            //(注意这里只是key 被回收,value还没被回收,entry更加没回收,所以需要让他们回收),
            //同时也避免这样存在很多过期的对象的占用,导致这个时候刚好来了一个新的元素达到阀值而触发一次新的rehash
            int slotToExpunge = staleSlot;
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len))
                 //slotToExpunge 记录staleSlot左手边第一个空的entry 到staleSlot 之间key过期最小的index
                if (e.get() == null)
                    slotToExpunge = i;

            // 这个时候是从数组下标小的往下标大的方向遍历,i++,刚好跟上面相反。
            //这两个遍历就是为了在左边遇到的第一个空的entry到右边遇到的第一空的 entry之间查询所有过期的对象。
            //注意:在右边如果找到需要设置值的key(这个例子是key=15)相同的时候就开始清理,然后返回,不再继续遍历下去了
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();
                //说明之前已经存在相同的key,所以需要替换旧的值并且和前面那个过期的对象的进行交换位置,
                //交换的目的下面会解释
                if (k == key) {
                    e.value = value;

                    tab[i] = tab[staleSlot];
                    tab[staleSlot] = e;

                    //这里的意思就是前面的第一个for 循环(i--)往前查找的时候没有找到过期的,只有staleSlot
                    // 这个过期,由于前面过期的对象已经通过交换位置的方式放到index=i上了,
                    // 所以需要清理的位置是i,而不是传过来的staleSlot
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                        //进行清理过期数据
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

                // 如果我们在第一个for 循环(i--) 向前遍历的时候没有找到任何过期的对象
                // 那么我们需要把slotToExpunge 设置为向后遍历(i++) 的第一个过期对象的位置
                // 因为如果整个数组都没有找到要设置的key 的时候,该key 会设置在该staleSlot的位置上
                //如果数组中存在要设置的key,那么上面也会通过交换位置的时候把有效值移到staleSlot位置上
                //综上所述,staleSlot位置上不管怎么样,存放的都是有效的值,所以不需要清理的
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            // 如果key 在数组中没有存在,那么直接新建一个新的放进去就可以
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);

            // 如果有其他已经过期的对象,那么需要清理他
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }

代码比较长,主要是执行了2个过程,一个是把过期的entry和有效的进行交换,保证在原始的位置上是一个有效的值,然后进行清理过期的操作。假设我们现在有 key 13 23 分别位于 i 3 4的位置上,此时i= 3的位置上,entry过期了,然后没有进行交换,那么下一个23进来的时候,看到i = 3 的位置上不存在值,就会放到 i= 3 上面,此时 i = 3 和 4 都是key为23的entry,发送了错误。

ThreadLocalMap rehash

扩容,ThreadLocalMap 的扩容阈值为2/3 ,同时每次扩容也是2倍。保证是2的N次幂。
相比于其他的扩容不同,当ThreadLocalMap扩容时,需要2次判断,第一次是超过了阈值,然后触发清理过期entry的操作,清理后,再次比较 是否大于 threshold(阈值) - threshold / 4,最后才会触发扩容

private void rehash() {
            expungeStaleEntries();

            // Use lower threshold for doubling to avoid hysteresis
            if (size >= threshold - threshold / 4)
                resize();
        }

        /**
         * Double the capacity of the table.
         新建一个数组来扩容
         */
        private void resize() {
            Entry[] oldTab = table;
            int oldLen = oldTab.length;
            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;
        }

ThreadLocalMap remove

remove ,操作比较简单。

	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;
            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;
                }
            }
        }

总结

ThreadMap的get set remove rehash 由于线性开放的原因,都会设计到遍历的操作,因此在线程副本中还是尽量不要存放太多的东西,同时在这些方法中,都会去附带清理过期的entry,帮助gc回收空间,防止内存泄露。虽然系统有这么多的兜底方案,但是在使用中,还是需要靠我们手动remove来注意,防止内存泄露的发送