likes
comments
collection
share

深入浅出Handler(二)ThreadLocal

作者站长头像
站长
· 阅读数 13

前言

上一篇文章我们提到Handler和Looper时,提到了ThreadLocal,在线程间数据处理时,我们需要解决的一个问题就是数据隔离和数据同步更新。而Handler机制中MessageQueue以及Looper都是和当前线程绑定的。ThreadLocal正如它的名字一样,线程本地,那这种存储涉及到锁,遇到并发时又是如何解决的呢?今天我们就来探讨一下ThreadLocal的使用与原理。

ThreadLocal概述

ThreadLocal通过提供线程局部变量,来达到数据隔离的目的。当每一个线程访问自身的局部变量时,都有自己独立的副本。从下图可以看出,Threadlocal通过ThreadLocalMap让每个线程都能获取自己线程内部的私有变量。

深入浅出Handler(二)ThreadLocal

public class ThreadLocalTest {
    //会出现内存泄漏的问题,下文会描述
    private static ThreadLocal<String> threadLocal = new ThreadLocal<>();

    public static void main(String[] args) {
        threadLocal.set("主线程main");
        new Thread(new A()).start();
        new Thread(new B()).start();
        System.out.println(threadLocal.get());
    }

    static class A implements Runnable {
        @Override
        public void run() {
            threadLocal.set("线程A");
            System.out.println(threadLocal.get());
        }
    }

    static class B implements Runnable {
        @Override
        public void run() {
            threadLocal.set("线程B");
            System.out.println(threadLocal.get());
        }
    }
}

在上述代码中,我们在主线程中设置了threadLocal的值为主线程main,在线程A中设置为线程A,在线程B中设置为线程B,在没有加锁的情况下却区分了相应的线程。

ThreadLocal原理

从上面的流程图我们可以看到ThreadLocal主要是利用ThreadLocalMap实现数据隔离,那么我们主要来看下set与get方法的具体实现以及ThreadLocalMap源码

ThreadLocal的set方法

在使用ThreadLocal时,我们会调用ThreadLocal的set(T value)方法对线程中的私有变量进行设置.

public void set(T value) {
    //拿到当前线程
    Thread t = Thread.currentThread();
    //拿到当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null)
    //设置key为当前的ThreadLocal对象,并赋值
        map.set(this, value);
    else
    //创建新的ThreadLocalMap对象并设值
        createMap(t, value);
}


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


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


可以看到,当调用set(T value)方法时,方法内部会获取当前线程的ThreadLocalMap,获取后再进行判断,如果不为空,调用set方法(key为当前的ThreadLocal对象,value为当前赋的值).

如果为空,则会先创建ThreadLocalMap再进行赋值。

接下来我们来看下ThreadLocalMap的相关代码

ThreadLocalMap

ThreadLocalMap是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 instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID). For example, the class below generates unique identifiers local to each thread. A thread's id is assigned the first time it invokes ThreadId.get() and remains unchanged on subsequent calls.

翻译过来大致是这样的

这个类提供线程局部变量。这些变量与普通变量的区别在于,每个访问一个变量的线程(通过它的get或set方法)都有自己的、独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,它们希望将状态与线程(例如,用户ID或事务ID)相关联。

例如,下面的类生成每个线程本地的唯一标识符。线程的id在第一次调用threaddid .get()时被分配,并在后续调用中保持不变。

这种与线程生命周期进行绑定的局部变量,不仅解决了存储空间的问题,而且提高了访问的速度。

接下来我们来看下ThreadLocalMap的代码

static class ThreadLocalMap {

    //可以看到存储的Entry数组,key是弱引用
    static class Entry extends WeakReference<ThreadLocal<?>> {
        /** The value associated with this ThreadLocal. */
        Object value;

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

   
    //table的初始容量
    private static final int INITIAL_CAPACITY = 16;

    //table数组用于存储数据
    private Entry[] table;

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

    //负载因子,用于数组扩容
    private int threshold; // Default to 0

    //设置负载因子,默认是数组长度的2/3
    private void setThreshold(int len) {
        threshold = len * 2 / 3;
    }

    ...
    
    //第一次放入value时
    ///初始化Entry数组
    //设置负载因子
    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是一个哈希表,但是它与我们传统认知的HashMap的内部结构并不一样,它的内部仅仅维护了一个Entry[]数组。

并且Entry类中的key为弱引用。

ThreadLocalMap的set()方法

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

   
    //根据hash值计算位置
    
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);

    //判断当前位置是否有数据,如果key值相同,就替换,如果不同则找空位放数据
    //开放定址法:索引位置+1
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();

        if (k == key) {
            //1、key值相同,直接覆盖
            e.value = value;
            return;
        }

        //2、如果当前Entry对象对应的key值为Null,说明对应的ThreadLocal已经被回收了
        //1.entry存在,在这个过时位置的后面,所以需要置换到这个位置
        //2.entry不存在,直接放到这个位置
        则replaceStaleEntry
        if (k == null) {
            replaceStaleEntry(key, value, i);
            //因为是替换,所以size要么不变,要么减少
            return;
        }
    }

    //3、没找到已存在的,也没找到可以替换的,则需要新建
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
    //如果没有清除过时entry,并且超过阈值,则进行先尝试缩小,不行则扩容
        rehash();
}

第一种和第三种情况还比较好理解,为什么会有第二步这种判空的条件,我们从头到尾捋一遍

1、key值相同

这种情况比较好理解,直接进行覆盖操作。

深入浅出Handler(二)ThreadLocal

如果当前数组中,存在key值相同的情况,ThreadLocal内部是直接覆盖的。

2、当前位置对应的Entry的key值为null
深入浅出Handler(二)ThreadLocal

从图中可以看出,当我们添加新的Entry(key=19,value=200)时,由于数组中已经存在旧的Entry,并且key为Null(key=null,value=19),此时,方法会将新的Entry进行赋值,同时会将数组中所有key为Null的Entry置为null.

replaceStaleEntry

1、需要清除过时的Entry,

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

    //记录当前位置
    int slotToExpunge = staleSlot;
    //向前遍历找到第一个Entry为null的下标位位置
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
        //如果引用为空,更新slotToExpunge位置
            slotToExpunge = i;

    //往后遍历找到最后一个Entry为null的下标位置
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

       
       //如果获取key值相同,那么重新赋值
        if (k == key) {
            //更新对应entry的值
            e.value = value;
            //与无效的sloat进行交换
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            //如果第一个无效的slot与当前的slotToExpunge相等,赋值slotToExpunge
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            //从slotToExpunge开始做一次清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        //如果当前Entry的Key为Null,并且向前没有无效slot,则更新赋值slotToExpunge
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    //放置一个新的Entry
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    //如果有一个无效的slot,那么进行一次清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
cleanSomeSlots
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}
expungeStaleEntry
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    //删除value
    tab[staleSlot].value = null;
    //删除entry
    tab[staleSlot] = null;
    //size递减
    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;

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

其实对于ThreadLocal源码来说,比较难看懂的是expungeStaleEntry和cleanSomeSlots方法,因为它和我们传统理解上的hashmap采用链地址法解决散列冲突不太一样

而是用开放地址法去解决冲突

开放地址法

开放地址法:一旦发生了冲突,那么就去寻找下一个空的地址;那么只要表足够大,空的地址总能找到,并将记录插入进去.

所以在源码里看到寻找index的方法如下

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}


private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

因为线程本地变量的数据不会特别大(而且key又是弱引用又会被垃圾回收)

所以会及时让数据量更小。

而开放地址法会更节省空间,同时数据的查询效率也会提高。

但是由于弱引用+开放地址,我们在方法里需要做的是及时清理entry的key为Null的脏数据,以及及时的扩容。

另外值得注意的是

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

cleanSomeSlots的线性探测,循环次数是log2(N)--此时的N是需要插入的数组下标,没有选择扫描全部是为了性能的平衡。

当遇到脏数据时又会执行一次线性探测,此时的n被赋值为length.然后继续循环log2(N)次

彩蛋

我们知道,在数组中存储数据时我们希望做到的是元素尽量的散列均匀,避免出现冲突,链地址法解决冲突的方法是在数组的位置上加上了链表,开放地址法解决冲突的办法是继续寻找下一个不为空的地址。

持续解决冲突的算法无疑是效率很低的,链地址法用了四次扰动函数让散列更均匀。

在ThreadLocal给添加的元素计算位置时,我们可以看到很多关于这个数字的计算

private final int threadLocalHashCode = nextHashCode();


private static AtomicInteger nextHashCode =
    new AtomicInteger();


private static final int HASH_INCREMENT = 0x61c88647;
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);
}

0x61c88647也因此被称为魔数,为什么是这个数字呢?

0x61c88647


/**
 * The initial capacity -- MUST be a power of two.
 */
private static final int INITIAL_CAPACITY = 16;

/**
 * The table, resized as necessary.
 * table.length MUST always be a power of two.
 */
 //必须是2的N次方
private Entry[] table;

我们来验证一下 它的散列

private static final int HASH_INCREMENT = 0x61c88647;
public static void main(String[] args) {
    magicHash(16); //初始大小16
    magicHash(32); //扩容一倍
}

private static void magicHash(int size){
    int hashCode = 0;
    for(int i=0;i<size;i++){
        hashCode = i*HASH_INCREMENT+HASH_INCREMENT;
        System.out.print((hashCode & (size-1))+" ");
    }
    System.out.println();
}
7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 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 

Process finished with exit code 0

可以看到,该长度大小内的数组都均匀的散列在数组内了。

其实这个数字和斐波那契数列有关,0x61c88647对应的十进制是1640531527

Entry[] table 的大小必须是2的N次方(len = 2^N),那 len-1 的二进制表示就是低位连续的N个1, 那 key.threadLocalHashCode & (len-1) 的值就是 threadLocalHashCode 的低N位

至于为什么要用0x61c88647?

这个数是Integer有符号整数的0.618倍,既黄金比例,斐波拉契数列。

使用这个比例,可以使key在数组上被更均匀的分散。至于为什么,是一个复杂的数学问题.

可能存在的内存泄露

我们知道Threadlocal是被弱引用修饰的,如果ThreadLocal没有外部强引用,并且系统GC时,这个ThreadLocal会被回收.

那在Thread的ThreadLocalMap-Entry-value这条引用链将永远无法被回收,而造成内存泄漏

可以在上面的源码分析中看到,set(),get(),remove()方法都会探测性的清理key为null的记录。

当然我们也可以在使用完ThreadLocal之后,手动调用remove方法将引用链断掉。

Java引用类型

强引用 A a =new A();此时由于引用a强引用对象A;不会被GC

软引用 SoftReference 在内存不够时引用对象会被回收

弱引用 WeakReference 每次GC都会被回收

虚引用 PhantomReference 每次GC都会被回收

public class MyReferenceTest {

    static class A {

    }

    static class Entry {
        private A a;

        public A getA() {
            return a;
        }

        public Entry(A a) {
            this.a = a;
        }
    }

    static class WeakEntry extends WeakReference<A> {


        public WeakEntry(A referent) {
            super(referent);
        }
    }

    public static void main(String[] args) {

        A a1 = new A();
        A a2 = new A();


        Entry entry = new Entry(a1);
        WeakEntry weakEntry = new WeakEntry(a2);
        System.out.println(entry.getA());
        System.out.println(weakEntry.get());
        System.out.println("====gc==");

        a1 = null;
        a2 = null;

        System.gc();
        System.out.println(entry.getA());
        System.out.println(weakEntry.get());

    }
}
com.example.testapt.MyReferenceTest$A@6b71769e
com.example.testapt.MyReferenceTest$A@2752f6e2
====gc==
null
null
com.example.testapt.MyReferenceTest$A@6b71769e
null

可以看到,上述代码中用强引用和弱引用两种方式引用了对象a,将对象a置为null,在手动调用gc后,弱引用的方式已经被系统清理.

为了系统清理缓存和空间,ThreadLocalMap的Entry设计的引用是弱引用,当gc时,threadLocal如果已经没有一条引用链路可达,此时会被回收.

而此时entry.get就存在key为null的情况,由由于value是可达的,导致在垃圾回收时value可达而不被回收掉,但是value此时已经是一个无效的数值并且永远无法访问了,此时就出现了内存泄露。

总结

实际上,我们从ThreadLocal的源码中可以看到很多去清理脏数据的操作,无论是get()方法还是set()方法都会环形查找清理脏数据。所以当我们已经结束一个对象的使用时,可以自己手动remove去加快垃圾回收,避免内存溢出.