前言

ThreadLocal用于多线程环境下每个线程存储和获取线程的局部变量,这些局部变量与线程绑定,线程之间互不影响。本篇文章将对ThreadLocal的运用和原理进行学习。

正文

一. ThreadLocal的运用

以一个简略比如对ThreadLocal的运用进行说明。

通常,ThreadLocal的运用是将其声明为类的私有静态字段,如下所示。

public class ThreadLocalLearn {
    private static final ThreadLocal<String> threadLocal = new ThreadLocal<>();
    public void setThreadName(String threadName) {
        threadLocal.set(threadName);
    }
    public String getThreadName() {
        return threadLocal.get();
    }
}

ThreadLocalLearn类具有一个声明为private staticThreadLocal目标字段,每个线程经过ThreadLocalLearn提供的setThreadName() 办法存放线程名,经过getThreadName() 办法获取线程名。

二. ThreadLocal的原理

首先剖析一下ThreadLocalset() 办法,其源码如下所示。

public void set(T value) {
    // 获取当时线程
    Thread t = Thread.currentThread();
    // 获取当时线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null)
        // 以ThreadLocal目标为键,将value存到当时线程的ThreadLocalMap中
        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);
}

由上面源码可知,ThreadLocalset() 办法实践上是ThreadLocal以本身目标为键,将value存放到当时线程的ThreadLocalMap中。每个线程目标都有一个叫做threadLocals的字段,该字段是一个ThreadLocalMap类型的目标。ThreadLocalMap类是ThreadLocal类的一个静态内部类,用于线程目标存储线程独享的变量副本。

ThreadLocalMap实践上并不是一个Map,关于ThreadLocalMap是怎么存储线程独享的变量副本,将在后一末节进行剖析。下面再看一下ThreadLocalget() 办法。

public T get() {
    // 获取当时线程
    Thread t = Thread.currentThread();
    // 获取当时线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 以ThreadLocal目标为键,从当时线程的ThreadLocalMap中获取value
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            T result = (T) e.value;
            return result;
        }
    }
    // 假如当时线程没有ThreadLocalMap,则创立ThreadLocalMap,并以ThreadLocal目标为键存入一个初始值到创立的ThreadLocalMap中
    // 假如有ThreadLocalMap,但获取不到value,则以ThreadLocal目标为键存入一个初始值到ThreadLocalMap中
    // 回来初始值,且初始值一般为null
    return setInitialValue();
}
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;
}

由上面源码可知,ThreadLocalget() 办法实践上是ThreadLocal以本身目标为键,从当时线程的ThreadLocalMap中获取value

经过剖析ThreadLocalset()get()办法可知,ThreadLocal能够在多线程环境下存储和获取线程的局部变量,本质是将局部变量值存放在每个线程目标的ThreadLocalMap中,因而线程之间互不影响。

三. ThreadLocalMap的原理

ThreadLocalMap本身不是Map,但是能够实现以key-value的方式存储线程的局部变量。与Map类似,ThreadLocalMap中将键值对的联系封装为了一个Entry目标,EntryThreadLocalMap的静态内部类,源码如下所示。

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

Entry承继于WeakReference,因而Entry是一个弱引证目标,而作为键的ThreadLocal目标是被弱引证的目标。

首先剖析ThreadLocalMap的结构函数。ThreadLocalMap有两个结构函数,这儿只剖析签名为ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)的结构函数。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 创立一个容量为16的Entry数组
    table = new Entry[INITIAL_CAPACITY];
    // 运用散列算法核算第一个键值对在数组中的索引
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 创立Entry目标,并存放在Entry数组的索引对应方位
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    // 依据Entry数组初始容量巨细设置扩容阈值
    setThreshold(INITIAL_CAPACITY);
}

ThreadLocalMap的散列算法为将ThreadLocal的哈希码与Entry数组长度减一做相与操作,因为Entry数组长度为2的幂次方,因而上述散列算法本质是ThreadLocal的哈希码对Entry数组长度取模。经过散列算法核算得到初始键值对在Entry数组中的方位后,会创立一个Entry目标并存放在数组的对应方位。最后依据公式:len * 2 / 3核算扩容阈值。

由上述剖析可知,创立ThreadLocalMap目标时便会初始化存放键值对联系的Entry数组。现在看一下ThreadLocalMapset() 办法。

// 调用set()办法时会传入一对键值对
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 经过散列算法核算键值对的索引方位
    int i = key.threadLocalHashCode & (len-1);
    // 遍历Entry数组
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        // 获取当时Entry的键
        ThreadLocal<?> k = e.get();
        // 当时Entry的键与键值对的键持平(即指向同一个ThreadLocal目标),则更新当时Entry的value为键值对的值
        if (k == key) {
            e.value = value;
            return;
        }
        // 当时Entry的键被废物收回了,这样的Entry称为陈腐项,则依据键值对创立Entry并替换陈腐项
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 此刻i标明遍历Entry数组时遇到的第一个空槽的索引
    // 程序运行到这儿,说明遍历Entry数组时,在遇到第一个空槽前,遍历过的Entry的键与键值对的键均不持平,一起也没有陈腐项
    // 此刻依据键值对创立Entry目标并存放在索引为i的方位(即空槽的方位)
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        // Entry数组中键值对数量大于等于阈值,则触发rehash()
        // rehash()会先遍历Entry数组并删去陈腐项,假如删去陈腐项之后,键值对数量还大于等于阈值的3/4,则进行扩容
        // 扩容后,Entry数组长度应该为扩容前的两倍
        rehash();
}
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

set() 办法中,首先经过散列算法核算键值对的索引方位,然后从核算得到的索引方位开端往后遍历Entry数组,一向遍历到第一个空槽停止。在遍历的过程中,假如遍历到某个Entry的键与键值对的键持平,则更新这个Entry的值为键值对的值;假如遍历到某个Entry而且这个Entry被判定为陈腐项(键被废物收回的Entry目标),那么履行铲除陈腐项的逻辑;假如遍历遇到空槽了,但没有发现有键与键值对的键持平的Entry,也没有陈腐项,则依据键值对生成Entry目标并存放在空槽的方位。

set() 办法中,需求铲除陈腐项时调用了replaceStaleEntry() 办法,该办法会依据键值对创立Entry目标并替换陈腐项,一起触发一次铲除陈腐项的逻辑。replaceStaleEntry() 办法的实现如下所示。

// table[staleSlot]为陈腐项
// 该办法实践便是从索引staleSlot开端向后遍历Entry数组直到遇到空槽,假如找到某一个Entry的键与键值对的键持平,那么将这个Entry的值更新为键值对的值,并将这个Entry与陈腐项交换方位
// 假如遇到空槽也没有找到键与键值对的键持平的Entry,则直接将陈腐项铲除,然后依据键值对创立一个Entry目标存放在索引为staleSlot的方位
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;
    int slotToExpunge = staleSlot;
    // 从索引为staleSlot的槽位向前遍历Entry数组直到遇到空槽,并记录遍历时遇到的最后一个陈腐项的索引,用slotToExpunge标明
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;
    // 从索引为staleSlot的槽位向后遍历Entry数组
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        // 假如遍历到某个Entry的键与键值对的键持平
        if (k == key) {
            // 将遍历到的Entry的值更新
            e.value = value;
            // 将更新后的Entry与索引为staleSlot的陈腐项交换方位
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;
            // 假如向前遍历Entry数组时没有发现陈腐项,那么这儿将slotToExpunge的值更新为陈腐项的新方位的索引
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            // expungeStaleEntry(int i)能够铲除i方位的陈腐项,以及从i方位的槽位到下一个空槽之间的一切陈腐项
            // cleanSomeSlots(int i, int n)能够从i方位开端向后扫描log2(n)个槽位,假如发现了陈腐项,则铲除陈腐项,并再向后扫描log2(table.length)个槽位
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }
        // 假如遍历到的Entry是陈腐项,而且向前遍历Entry数组时没有发现陈腐项,则将slotToExpunge的值更新为当时遍历到的陈腐项的索引
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }
    // 从索引为staleSlot的槽位向后遍历Entry数组时,直到遇到了空槽也没有找到键与键值对的键持平的Entry
    // 此刻将staleSlot方位的陈腐项直接铲除,并依据键值对创立一个Entry目标存放在索引为staleSlot的方位
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);
    // 一开端时,staleSlot与slotToExpunge是持平的,一旦staleSlot与slotToExpunge不持平,标明从staleSlot方位向前或向后遍历Entry数组时,发现了除staleSlot方位的陈腐项之外的陈腐项
    // 此刻需求铲除这些陈腐项
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

replaceStaleEntry() 中调用了两个关键办法,expungeStaleEntry(int i) 能够铲除i方位的陈腐项,以及从i方位的槽位到下一个空槽之间的一切陈腐项;cleanSomeSlots(int i, int n) 能够从i方位开端向后扫描log2(n) 个槽位,假如发现了陈腐项,则铲除陈腐项,并再向后扫描log2(table.length) 个槽位。其实现如下。

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    // 删去staleSlot方位的陈腐项
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;
    // 从staleSlot方位开端往后遍历Entry数组,直到遍历到空槽
    // 假如遍历到陈腐项,则铲除陈腐项
    // 假如遍历到非陈腐项,则将该Entry从头经过散列算法核算索引方位
    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;
}
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为Entry数组长度,然后铲除扫描到的陈腐项到下一个空槽之间的一切陈腐项,最后从空槽的方位向后再扫描log2(table.length)个槽位
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ((n >>>= 1) != 0);
    return removed;
}

因为replaceStaleEntry() 办法中对应了很多种情况,因而单纯依据代码不能很直观的了解ThreadLocalMap是怎么铲除陈腐项的,所以下面结合图进行学习。这儿默许Entry数组长度为16。

场景一:Entry数组槽位散布如下所示。

详解ThreadLocal

staleSlot向前遍历时,会将slotToExpunge值置为2,从staleSlot向后遍历时,因为索引为6的Entry目标的键与键值对的键持平,因而会更新这个Entry目标的值,并与staleSlot方位(索引为4)的陈腐项交换方位。交换方位后,Entry数组槽位散布如下所示。

详解ThreadLocal

因而最后会触发一次铲除陈腐项的逻辑。先铲除slotToExpunge到下一个空槽之间的一切陈腐项,即索引2和索引6的槽位的陈腐项会被铲除;然后从空槽的下一个槽位,往后扫描log2(16) = 4个槽位,即顺次扫描索引为8,9,10,11的槽位,并在扫描到索引为10的槽位时发现陈腐项,此刻铲除索引10槽位到下一个空槽之间的一切陈腐项,即索引10槽位的陈腐项会被铲除,再然后从空槽的下一个槽位往后扫描log2(16) = 4个槽位,即顺次扫描索引为13,14,15,0的槽位,没有发现陈腐项,扫描完毕,并回来true,标明扫描到了陈腐项并铲除了。

场景二:Entry数组槽位散布如下所示。

详解ThreadLocal

staleSlot向前遍历时,直到遇到空槽停止,也没有陈腐项,因而向前遍历完毕后,slotToExpungestaleSlot持平。向后遍历到索引5的槽位时,发现了陈腐项,因为此刻slotToExpungestaleSlot持平,因而将slotToExpunge置为5。继续向后遍历,因为索引为6的Entry目标的键与键值对的键持平,因而会更新这个Entry目标的值,并与staleSlot方位(索引为4)的陈腐项交换方位。交换方位后,Entry数组槽位散布如下所示。

详解ThreadLocal

因而最后会触发一次铲除陈腐项的逻辑,铲除逻辑与场景一相同,这儿不再赘述。

场景三:Entry数组槽位散布如下所示。

详解ThreadLocal

staleSlot向前遍历时,会将slotToExpunge值置为2,从staleSlot向后遍历时,直到遇到空槽停止,也没有发现键与键值对的键持平的Entry,因而会将索引为staleSlot的槽位的陈腐项直接铲除,并依据键值对创立一个Entry目标存放在索引为staleSlot的方位。staleSlot槽位的陈腐项被铲除后的槽位散布如下所示。

详解ThreadLocal

之后铲除陈腐项的逻辑与场景一相同,这儿不再赘述。

实践场景下可能不会呈现上述的槽位散布,这儿仅仅举个比如,对replaceStaleEntry() 办法的履行流程进行说明。

下面再看一下getEntry() 办法。

private Entry getEntry(ThreadLocal<?> key) {
    // 运用散列算法核算索引
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        // 假如Entry数组索引方位的Entry的键与key持平,则回来这个Entry
        return e;
    else
        // 没有找到key对应的Entry时会履行getEntryAfterMiss()办法
        return getEntryAfterMiss(key, i, e);
}
// 该办法一边遍历Entry数组寻觅键与key持平的Entry,一边铲除陈腐项
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)
            return e;
        if (k == null)
            expungeStaleEntry(i);
        else
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}

无论是set() 仍是getEntry() 办法,一旦发现了陈腐项,便会触发铲除Entry数组中的陈腐项的逻辑,这是ThreadLocal为了避免产生内存走漏的维护机制。

四. ThreadLocal怎么避免内存走漏

已知,每个线程有一个ThreadLocalMap字段,ThreadLocalMap中将键值对的联系封装为了一个Entry目标,EntryThreadLocalMap的静态内部类,其实现如下。

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

当正常运用ThreadLocal时,虚拟机栈和堆上目标的引证联系能够用下图标明。

详解ThreadLocal

因而Entry是一个弱引证目标,key引证的ThreadLocal为被弱引证的目标,value引证的目标(上图中的Object)为被强引证的目标,那么在这种情况下,key引证的ThreadLocal不存在其它引证后,在下一次废物收回时key引证的ThreadLocal会被收回,避免了ThreadLocal目标的内存走漏。key引证的ThreadLocal被收回后,此刻这个Entry就成为了一个陈腐项,假如不对陈腐项做铲除,那么陈腐项的value引证的目标就永远不会被收回,也会产生内存走漏,所以ThreadLocal采用了线性探测来铲除陈腐项,然后避免了内存走漏。

总结

合理运用ThreadLocal能够在多线程环境下存储和获取线程的局部变量,而且将ThreadLocalMap中的Entry规划成了一个弱引证目标,能够避免ThreadLocal目标的内存走漏,一起也采用了线性探测办法来铲除陈腐项,避免了Entry中的值的内存走漏,不过仍是建议在每次运用完ThreadLocal后,及时调用ThreadLocalremove() 办法,及时开释内存。