java随笔系列-ThreadLocal

/ java随笔系列 / 没有评论 / 76浏览

前言

ThreadLocal的作用是为线程提供了独享的变量方式,解决单独线程在多个函数,组件等公共变量获取的复杂程度。

ThreadLocal本身代码并不是很多,却有很多值得我们去学习研究的地方

  1. 一个特殊的hash数字,可以极大的减少存储数据时,key的hash碰撞
  2. 使用开放定址法的线性探测解决hash碰撞问题
  3. 如何解决弱引用作为key时的内存泄漏问题

源码分析

整体代码篇幅并不是太长,会着重分析几个特殊的地方

UML类图设计

9DC2142C-347F-40BC-BF9A-D1EE5F989178.png

原理分析

  1. ThreadLocal,ThreadLocalMap,Entry基本使用

看下,ThreadLocalMap,Entry和ThreadLocal部分源代码。

public class ThreadLocal<T> {
    //数据的hashcode值
    private final int threadLocalHashCode = nextHashCode();
    //原子
    private static AtomicInteger nextHashCode =new AtomicInteger();
    //一个黄金比例数字
    private static final int HASH_INCREMENT = 0x61c88647;
    //下一个hashcode值
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }
    ...
}
 static class ThreadLocalMap {
        //默认容器的大小
        private static final int INITIAL_CAPACITY = 16;
        //数据存储容器
        private Entry[] table;
        //当前数据个数
        private int size = 0;
        //数据个数达到多少时,需要进行扩容
        private int threshold; // Default to 0
    ...
}
     
//数据实体   
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

简单分析一下ThreadLocal基本使用逻辑

  1. 实例ThreadLocal对象,存储一个数据
  2. ThradLocal先检查当前线程是否有ThradLocalMap对象,没有则在当前Thread对象建立一个。
  3. 以ThreadLocal和value生成entry对象,存入到当前线程的ThrealLocalMap中。
  4. 以后的修改,删除,取出等操作都会以当前线程的ThreadlocalMap为基础。

整体逻辑看起来似乎很简单,那么我们通过几个问题来引出下文。

  1. 采取什么容器存储数据
  2. 怎样解决线程池存储数据时,内存泄漏的问题。

threadLocal的hashCode值的产生和使用(忽略碰撞情况)

Threadlocal通过静态变量nextHashCode控制产生一个hashcode值,然后把该值和threadlocalMap中容器的大小减一做个与运算,就可以产生一个小于容器大小的数字,并且,会发现这个数字会恰好均匀分布。最后用这个数字的作为容器的下标进行存储数据。

容器大小必须是2^n幂。自己写了个测试代码,很神奇

private static int special_number = 0x61c88647;

@Test
public void testSpecialNumber() {
    hashTest(16);
    hashTest(8);
    hashTest(32);
}
public void hashTest(int n) {
        for (int i = 0; i < n; i++) {
            int hashcode = special_number * i + special_number;
            System.out.print(hashcode & (n - 1));
            System.out.print(" ");
        }
        System.out.println();
}
    
输出结果:
7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 6 5 4 3 2 1 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 

当然,虽然使用这个特殊数字极大的避免了hash碰撞的概率,但是还是有极低的概率发生hash碰撞的,一旦发生碰撞,该怎么解决呢?ThreadLocal设计者采用了不同于HashMap的链表的方法,而是开放定址法-线性探测再散列

解决hash冲突的方法有开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列),再哈希法,链表(hashMap采用的),建立一个公共溢出区

原理-就是在发生碰撞的时候,依次需找下个null的地址。


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

    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1); //计算出容器下标
    /*
    * for语句代表容器中,该下标数据已经存在了。需要进行处理
    *
    **/
    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是null,则代表,发生过gc,弱引用被回收了,需要进行替换操作。同时会进行防止内存泄漏的操作
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    //如果走到这里,有两种可能,一种是开始的位置,不存在任何数据,一种是开始的数据存在数据,同时不满足for语句条件,直到某个位置下标数据为null。所以直接在容器下标i出设置数据。(此时的i可能已经不是最开始的值了。)
    tab[i] = new Entry(key, value);
    int sz = ++size;
    
    //防止内存泄漏进行的操作。
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

使用弱引用做为存储键

ThreadLocal之所以会发生内存泄漏,是由于ThreadLocalMap的生命周期是根随整个Thread的生命周期的。所以一旦我们在线程池里使用ThreadLocal方法,那么就会造成线程被线程池回收了,但是ThreadLocalMap没有被回收,在上次线程的使用过程中存入的数据将无法访问了。(只有ThreadLocal实例对象本身才可以访问到存入ThreadLocalMap中的数据)

所以为了解决没有手动调用remove方法造成内存泄漏的情况,ThreadLocal设计者采用弱引用方式。 Entry继承弱引用。成员变量value是数据是强引用类型。但是这里有一点需要注意,弱引用指向的对象可以回收,但是Entry本身实例并不会进行回收,同时,他的成员变量value也就不会回收,那么一旦发生弱引用指向对象被回收那么还是会造成内存泄漏。设计者为了避免这个情况 在ThreadLocalMap进行各类操作的时候,会遍历容器,凡是entry的key为null,需要把entry所在的位置,也设为null,释放entry的value

我们深入看下几个函数


/**
* 尝试查找回收的数据,这个是在添加新数据或者删除就数据时调用
* i 扫描开始位置,此位置不含有过期数据
* n scan control: {@code log2(n)} 数据被扫描,直到陈旧的数据被找到。
*/
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false; 是否删除过数据
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);//因为i不含有陈旧数据,直接跳过
        Entry e = tab[i];//容器i的条目数据
        if (e != null && e.get() == null) {//entry的key被回收了,也就是需要清除的数据
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);//为什么这样清除啊?似乎是和容器的hash有点关系~
    return removed;
}

/**
* 回收弱引用被回收的数据。考虑hash碰撞的情况
* staleSlot 回收的容器条目位置
*
*/
private int expungeStaleEntry(int staleSlot) {
    //回收处理
    Entry[] tab = table;
    int len = tab.length;
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    
    // Rehash until we encounter null。
    //
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);//会寻找从staleSlot开始位置连续的非空的条目,一方面可以清除内容泄漏的条目,一方面处理hash碰撞的                                    //条目
        (e = tab[i]) != null;
        i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {//如果下一个条目也被回收了,继续回收处理
            e.value = null;
            tab[i] = null;
            size--;
        } else {//如果下一个条目没有被回收,就需要考虑是否存在hash碰撞的情况,因为如果存入数据发生hash
                //碰撞,我们是默认寻找下一个容器位置,此时如果碰撞的位置没有数据了或者被删除回收了,那么此时这个后来
                //发生碰撞的数据需要放回到正常的位置。
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                //需要考虑多次碰撞的情况,所以需要从正常的h位置开始查找,直到找到合适的位置。
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
                }
        }
    }
    return i;
}

/**
* 遍历所有数据,处理所有被gc回收的弱引用数据
*/
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);
    }
}

/**
* 重新进行hash。多了个容器大小的操作
*/
private void rehash() {
    expungeStaleEntries();
    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

/*
* 修改容器大小
**/
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; // 顺便处理下gc引起的内存泄漏
            } else {
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)//解决hash冲突
                    h = nextIndex(h, newLen);
                    newTab[h] = e;
                    count++;
            }
        }
    }
    setThreshold(newLen);
    size = count;
    table = newTab;
}

通过代码,我们的得知,处理内存泄漏的方式,仅是遍历容器,把被回收的弱引用Entry所在位置设置为null,但是如果某些数据发生过hash碰撞,要重新调整entry的在容器中的位置

我用几张图,来展示函数expungeStaleEntry的执行情况

1535444509832.jpg

结论

本文只是着重的分析了下,hash的均匀分布,hash冲突解决思路,内存泄漏等问题,可能有不完善的地方,我会定期的修改的

ThreadLocal使用起来很简单,但是不得不佩服设计者们的巧妙设计,hashcode均匀分布,解决hash冲突,防止内存泄漏,通过Thread的ThreadLocaMap绑定数据等,都体现了其完整性。所以jdk的代码才是我们最应该深入了解研究的代码,里面有很多我们没有察觉但是却很精妙的内容。