04. Thread Local

ThreadLocal

背景

ThreadLocal,很多地方叫做线程本地变量,也有些地方叫做线程本地存储,其实意思差不多。可能很多朋友都知道ThreadLocal为变量在每个线程中都创建了一个副本,那么每个线程可以访问自己内部的副本变量。

同步机制以“时间换空间”,由于每个线程在同一时刻共享对象只能被一个线程访问造成整体上响应时间增加,但是对象只占有一份内存,牺牲了时间效率换来了空间效率即“时间换空间”。而threadLocal,为每个线程都分配了一份对象,自然而然内存使用率增加,每个线程各用各的,整体上时间效率要增加很多,牺牲了空间效率换来时间效率即“空间换时间”。

首先我们可以看一个完整的例子:

public class ThreadLocalTest {

    public Integer getAfterSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        threadLocal.set(1);
        return threadLocal.get();
    }

    public Integer getWithoutSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        return threadLocal.get();
    }

    @Test
    public void test() {
        Assert.assertEquals(1, getAfterSet().intValue());
        Assert.assertNull(getWithoutSet());
    }
}

1. ThreadLocal 深入源码分析

ThreadLocal 被设计成一个,可以在同一个线程中存在多个ThreadLocal对象. 所以就存在了一个ThreadLocalMap, 用于维护相同线程中,多个Threadlocal实例。

img

对应上面的代码,我们一步一步的分析ThreadLocal的调用过程,首先分析threadLocal.set(1);

1.1 初始化和插入数据 threadLocal.set(1)

我们可以首先根据源码来分析一下:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);     --------- 1
    if (map != null)
        map.set(this, value);           --------- 3
    else
        createMap(t, value);            --------- 2
}

1.1.1 执行步骤

这个方法引出以下几个步骤:

  1. 通过当前线程去获得ThreadLocalMap
  2. 如果ThreadLocalMap实例不存在,就创建一个map, 并把value插入到map中
  3. 如果ThreadLocalMap实例存在,就把value插入到map中

1.1.2 获得ThreadLocalMap

我们可以通过方法getMap看出,ThreadLocalMap实例实际上是绑定在Thread类中的。

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}
public class Thread implements Runnable {
  /* ThreadLocal values pertaining to this thread. This map is maintained
   * by the ThreadLocal class.
   */
  ThreadLocal.ThreadLocalMap threadLocals = null;
...
}

而且在Thread类中,默认ThreadLocalMapnull, 只有当线程使用了ThreadLocal的时候,才会真正的创建ThreadLocalMap对象。

1.1.3 创建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);
}

我们一步一步地看待这段代码:

  1. ThreadLocalMap维护了一个Entry数组实例
  2. ThreadLocalMap使用ThreadLocal作为键值, ThreadLocal中的hash值是ThreadLocal中的一个属性
  3. 通过hash值在被hash之后,插入对应的位置

由此可以看出, threadLocalHashCode是一个递增的数字,每当生成一个新的ThreadLocal实例,就会使threadLocalHashCode递增加一。

public class ThreadLocal<T> {
  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);
 }
}

现在来对set方法进行总结一下:

通过当前线程对象thread获取该thread所维护的threadLocalMap,若threadLocalMap不为null,则以threadLocal实例为key,值为value的键值对存入threadLocalMap,若threadLocalMapnull的话,就新建threadLocalMap然后在以threadLocal为键,值为value的键值对存入即可。

1.1.4 插入数据到ThreadLocalMap

首先看源码怎么写:

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) {
            replaceStaleEntry(key, value, i);   //注意这里,很搞事情.
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

其实这里面的Hash表的算法更像我们在课堂上学到的hash算法,如果当前的hash所指示的数组所在地方已经被占用,就沿着当前表往下遍历,一直遍历到第一个为null的数组下标,然后把要插入的值插入到ThreadLocalMap

注意:不会发生数组所有的数据都被插入了,这里面的threshold会控制到达2/3之后就会扩容

接下来,我们可以看看ThreadLocal.get()是如何工作的。

1.2 获取线程副本threadLocal.get()

public T get() {
    Thread t = Thread.currentThread();
    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();
}

set相对应,我们在get的时候,通过map来查询结果,有可能map并不存在,也就是说没有执行set,就直接get

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;
}
protected T initialValue() {
    return null;
}

这个方法比较简单,就是初始化一个map,然后把空值传入进去. 这里面就涉及了完整的一个map的创建过程然后被Thread实例引用。

其实到这里,一个完整的ThreadLocal是如何为每个线程创建变量的副本的过程就已经很清晰了.

首先,在每个线程Thread内部有一个ThreadLocal.ThreadLocalMap类型的成员变量threadLocals,这个threadLocals就是用来存储实际的变量副本的,键值为当前ThreadLocal变量,value为变量副本(即T类型的变量)。

初始时,在Thread里面,threadLocals为空,当通过ThreadLocal变量调用get()方法或者set()方法,就会对Thread类中的threadLocals进行初始化,并且以当前ThreadLocal变量为键值,以ThreadLocal要保存的副本变量为value,存到threadLocals

然后在当前线程里面,如果要使用副本变量,就可以通过get方法在threadLocals里面查找。

1.3 实现思路

Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMapThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocalvalue为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。每个线程在往某个ThreadLocal里塞值的时候,都会往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。

img

一个ThreadLocal只能存储一个Object对象,如果需要存储多个Object对象那么就需要多个ThreadLocal

img

2. ThreadLocalMap详解

此处摘录自[这里][1], 如果想更深入理解,请前往阅读.

从上面的分析我们已经知道,数据其实都放在了threadLocalMap中,threadLocalgetsetremove方法实际上具体是通过threadLocalMapgetEntry,setremove方法实现的。如果想真正全方位的弄懂threadLocal,势必得在对threadLocalMap做一番理解。

2.1 Entry数据结构

Entry是一个以ThreadLocal为key,Object为value的键值对,另外需要注意的是这里的threadLocal是弱引用,因为Entry继承了WeakReference,在Entry的构造方法中,调用了super(k)方法就会将threadLocal实例包装成一个WeakReferenece。

img

注意上图中的实线表示强引用,虚线表示弱引用。

ThreadLocal 内存泄漏的原因

从上图中可以看出,hreadLocalMap使用ThreadLocal的弱引用作为key,如果一个ThreadLocal不存在外部强引用时,Key(ThreadLocal)势必会被GC回收,这样就会导致ThreadLocalMap中key为null, 而value还存在着强引用,只有thead线程退出以后,value的强引用链条才会断掉。

但如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:

Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value

永远无法回收,造成内存泄漏。

那为什么使用弱引用而不是强引用??

key 使用强引用

当hreadLocalMap的key为强引用回收ThreadLocal时,因为ThreadLocalMap还持有ThreadLocal的强引用,如果没有手动删除,ThreadLocal不会被回收,导致Entry内存泄漏。

key 使用弱引用

当ThreadLocalMap的key为弱引用回收ThreadLocal时,由于ThreadLocalMap持有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。当key为null,在下一次ThreadLocalMap调用set(),get(),remove()方法的时候会被清除value值。

2.2 散列表

在实际使用中,经常会出现多个关键字散列值相同的情况(被映射到数组的同一个位置),我们将这种情况称为散列冲突。为了解决散列冲突,主要采用下面两种方式: 分离链表法(separate chaining)开放定址法(open addressing)

2.2.1 分离链表法

分散链表法使用链表解决冲突,将散列值相同的元素都保存到一个链表中。当查询的时候,首先找到元素所在的链表,然后遍历链表查找对应的元素,典型实现为hashMap,concurrentHashMap的拉链法。

img

2.2.2 开放定址法

开放定址法不会创建链表,当关键字散列到的数组单元已经被另外一个关键字占用的时候,就会尝试在数组中寻找其他的单元,直到找到一个空的单元。探测数组空单元的方式有很多,这里介绍一种最简单的 – 线性探测法。线性探测法就是从冲突的数组单元开始,依次往后搜索空单元,如果到数组尾部,再从头开始搜索(环形查找)。

img 之所以采用不同的方式主要是因为:在 ThreadLocalMap 中的散列值分散的十分均匀,很少会出现冲突。并且 ThreadLocalMap 经常需要清除无用的对象,使用纯数组更加方便。

ThreadLocal的坑

1. 内存泄露

实际上,为了解决threadLocal潜在的内存泄漏的问题,Josh Bloch and Doug Lea大师已经做了一些改进。在threadLocal的set和get方法中都有相应的处理。下文为了叙述,针对key为null的entry,源码注释为stale entry,直译为不新鲜的entry,这里我就称之为“脏entry”。比如在ThreadLocalMap的set方法中:

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) {
            replaceStaleEntry(key, value, i);
            return;
        }
     }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

在该方法中针对脏entry做了这样的处理:

  1. 如果当前table[i]!=null的话说明hash冲突就需要向后环形查找,若在查找过程中遇到脏entry就通过replaceStaleEntry进行处理;
  2. 如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry;

expungeStaleEntry(int staleSlot)

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

    //清除当前脏entry
    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
        //2.往后环形继续查找,直到遇到table[i]==null时结束
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
      //3. 如果在向后搜索过程中再次遇到脏entry,同样将其清理掉
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
          //处理rehash的情况
            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;
}

清理掉当前脏entry后,并没有闲下来继续向后搜索,若再次遇到脏entry继续将其清理,直到哈希桶(table[i])为null时退出。因此方法执行完的结果为 从当前脏entry(staleSlot)位到返回的i位,这中间所有的entry不是脏entry。为什么是遇到null退出呢?原因是存在脏entry的前提条件是 当前哈希桶(table[i])不为null,只是该entry的key域为null。如果遇到哈希桶为null,很显然它连成为脏entry的前提条件都不具备。

cleanSomeSlots(int i, int n)

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

这个源码很简单, 就是向后循环查询,主要用于扫描控制(scan control),从while中是通过n来进行条件判断的说明n就是用来控制扫描趟数(循环次数)的。在扫描过程中,如果没有遇到脏entry就整个扫描过程持续log2(n)次,log2(n)的得来是因为n »>= 1,每次n右移一位相当于n除以2。如果在扫描过程中遇到脏entry的话就会令n为当前hash表的长度(n=len),再扫描log2(n)趟,注意此时n增加无非就是多增加了循环次数从而通过nextIndex往后搜索的范围扩大,示意图如下

img

现在对cleanSomeSlot方法做一下总结:

  • 从当前位置i处(位于i处的entry一定不是脏entry)为起点在初始小范围(log2(n),n为哈希表已插入entry的个数size)开始向后搜索脏entry,若在整个搜索过程没有脏entry,方法结束退出
  • 如果在搜索过程中遇到脏entryt通过expungeStaleEntry方法清理掉当前脏entry,并且该方法会返回下一个哈希桶(table[i])为null的索引位置为i。这时重新令搜索起点为索引位置i,n为哈希表的长度len,再次扩大搜索范围为log2(n’)继续搜索。

下面,以一个例子更清晰的来说一下,假设当前table数组的情况如下图。

这一段例子引用自这里[这里][8]

img

  • 如图当前n等于hash表的size即n=10,i=1,在第一趟搜索过程中通过nextIndex,i指向了索引为2的位置,此时table[2]为null,说明第一趟未发现脏entry,则第一趟结束进行第二趟的搜索。

  • 第二趟所搜先通过nextIndex方法,索引由2的位置变成了i=3,当前table[3]!=null但是该entry的key为null,说明找到了一个脏entry,先将n置为哈希表的长度len,然后继续调用expungeStaleEntry方法,该方法会将当前索引为3的脏entry给清除掉(令value为null,并且table[3]也为null),但是该方法可不想偷懒,它会继续往后环形搜索,往后会发现索引为4,5的位置的entry同样为脏entry,索引为6的位置的entry不是脏entry保持不变,直至i=7的时候此处table[7]位null,该方法就以i=7返回。至此,第二趟搜索结束;

  • 由于在第二趟搜索中发现脏entry,n增大为数组的长度len,因此扩大搜索范围(增大循环次数)继续向后环形搜索;

  • 直到在整个搜索范围里都未发现脏entry,cleanSomeSlot方法执行结束退出。

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;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        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.
        if (k == key) {
          //如果在向后环形查找过程中发现key相同的entry就覆盖并且和脏entry进行交换
            e.value = value;

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

            // Start expunge at preceding stale entry if it exists
            //如果在前面的向前查找过程中还未发现脏entry,那么就以当前位置作为cleanSomeSlots
            //的起点
            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.
        //与上面相同, 如果slotToExpunge  不同,就可以认定, 前面改过, 肯定比当前的要早
        //就不要替换, 如果相同,就说明前面没有改过,就替换掉.
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

我们细致的梳理一下,这个函数做了什么:

  1. 我们可以认定入参 staleSlot所指向的entry是一个脏entry, 这是必然的.

  2. 首先,向前查询,查验,在当前脏entry之前,是不是还存在脏entry, 当然, 不会无限查询,只会查询不是null的. 如果存在多个,就返回slot最小的那个. 记录下来这个值

  3. 向后查询, 还是以null结束.

  4. 如果在循环查询的过程中, 发现了要插入的key已经存在

    • 和staleslot对调,并且调整当前值. 然后删掉那个脏entry
    • 然后执行cleanSomeSlots(expungeStaleEntry(slotToExpunge), len) 返回
  5. 如果向后查询的过程中,key不存在

    • 把要插入的key-value, 插入staleSlot所在的entry.
    • 然后再次执行cleanSomeSlots(expungeStaleEntry(slotToExpunge), len)

其实也可以使用一些图标来形象地展示:

分为四种情况:

前向有脏entry 后向环形查找找到可覆盖的entry

img

这里,slotToExpunge的指向就是前面的脏entry.

前向有脏entry 后向环形查找未找到可覆盖的entry

img

这里,slotToExpunge的指向就是前面的脏entry.

前向没有脏entry 后向环形查找找到可覆盖的entry

img

这里,slotToExpunge的指向的就是向后循环所查找到的第一个脏entry.

前向没有脏entry 2.2后向环形查找未找到可覆盖的entry

img

这里,slotToExpunge的指向的就是向后循环所查找到的第一个脏entry.

为什么使用弱引用?

从文章开头通过threadLocal,threadLocalMap,entry的引用关系看起来threadLocal存在内存泄漏的问题似乎是因为threadLocal是被弱引用修饰的。

假设threadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉threadLocal实例的目的,但是因为threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误

假设Entry弱引用threadLocal,尽管会出现内存泄漏的问题,但是在threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。 从以上的分析可以看出,使用弱引用的话在threadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。

2. 关于到底能不能set之前get

有的博客会说get之前必须set,否则会报空指针异常. 这个理解是错误的,正如前面的例子,并不会报错,只是会返回一个null,读完代码,也可以发现这一点。

我们以这个代码为例,这里是不会报错的。

public class ThreadLocalTest {

    public Integer getAfterSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        threadLocal.set(1);
        return threadLocal.get();
    }

    public Integer getWithoutSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        return threadLocal.get();
    }

    @Test
    public void test() {
        Assert.assertEquals(1, getAfterSet().intValue());
        Assert.assertNull(getWithoutSet());
    }
}

但是如果代码改成这个样子, 把函数返回从包装类型变成基本数据类型,才会真正的报出NullPointerException异常。

public class ThreadLocalTest {

    public Integer getAfterSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        threadLocal.set(1);
        return threadLocal.get();
    }

    public int getWithoutSet() {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        return threadLocal.get();
    }

    @Test
    public void test() {
        Assert.assertEquals(1, getAfterSet().intValue());
        Assert.assertEquals(0, getWithoutSet());
    }
}

我们把代码反编译了之后,会发现

包装数据类型变成了以下字节码

public Integer getLong() {
    System.out.println(this.longLocal);
    return (Integer)this.longLocal.get();
}

简单数据类型变成了以下字节码

public int getLong() {
    System.out.println(this.longLocal);
    return (int)this.longLocal.get();
}

这个问题就已经不再属于ThreadLocal的范畴了,属于强制类型装换是怎么做的范畴了. 对null, 他们都是怎么做的: 首先,Java允许(Object)null的存在,只是这样做,依旧是返回null, 也就是没有任何的引用。 但是,Java不允许(int)null的存在,因为,int如果没有被赋值,会返回一个默认值,但是,null没有默认值,所以会在这里报错。 而不是什么ThreadLocal不允许先getset的操作, 请注意。

ThreadLocal 的使用场景

ThreadLocal 的使用场景非常多,比如说:

  • 用于保存用户登录信息,这样在同一个线程中的任何地方都可以获取到登录信息。
  • 用于保存数据库连接、Session 对象等,这样在同一个线程中的任何地方都可以获取到数据库连接、Session 对象等。
  • 用于保存事务上下文,这样在同一个线程中的任何地方都可以获取到事务上下文。
  • 用于保存线程中的变量,这样在同一个线程中的任何地方都可以获取到线程中的变量。