threadlocal存连接对象的目的_深入解读ThreadLocal

ad1bca13b6aac5377b9d4dd8bdd244fb.png
阅读本文大概需要6.8分钟

1、前言

在Java多线程模块中,ThreadLocal是经常被提问到的一个知识点,提问的方式多种多样,只有理解透彻了,才能回答的游刃有余。以下介绍基于JDK1.8进行。

2、定义

从名字我们可以看出ThreadLocal叫做线程局部变量,意思是ThreadLocal在每个线程中都创建了一个变量的副本,不同线程拥有的副本互不影响。

使用场景

①、在进行对象跨层传递的时候,可以避免多次传递,打破层次间的约束;

②、线程间数据隔离;

③、进行事务操作,用于存储线程事务信息;

④、数据库连接,Session会话管理。

3、用法

既然ThreadLocal的作用是每一个线程创建一个副本,我们使用一个例子来验证一下:

 1 public class Demo {
 2
 3    private static ThreadLocal<Integer> var = new ThreadLocal<>();
 4
 5    public static void main(String[] args) {
 6
 7        Thread t1 = new Thread(()->{
 8            var.set(20);
 9            System.out.println(Thread.currentThread().getName() + ":设置var值为20");
10            for (int i = 0; i < 3; i++) {
11                System.out.println(Thread.currentThread().getName() + ":获取var值为" + var.get());
12                try {
13                    Thread.sleep(1000);
14                } catch (InterruptedException e) {
15                    e.printStackTrace();
16                }
17            }
18        }, "Thread1");
19
20        Thread t2 = new Thread(()->{
21            var.set(15);
22            for (int i = 0; i < 3; i++) {
23                System.out.println(Thread.currentThread().getName() + ":获取var值为" + var.get());
24                try {
25                    Thread.sleep(1000);
26                } catch (InterruptedException e) {
27                    e.printStackTrace();
28                }
29            }
30        }, "Thread2");
31
32        t1.start();
33        t2.start();
34    }
35 }

创建两个线程,线程t1设置var值为20,线程t2设置var值为15,分别输出var值,运行结果如下:

c30e35c3eefb87d2ed55780d6a23feec.png

根据结果可以看出,不同线程保存的变量副本是互不影响的。

4、源码分析

set()方法解读
1 public void set(T value) {
2    Thread t = Thread.currentThread();  // 当前线程
3    ThreadLocalMap map = getMap(t);  // 获取Map
4    if (map != null)
5        map.set(this, value);  // map不为空,直接设置值
6    else
7        createMap(t, value);  // map为空,创建map
8 }

代码很简单

①、获取当前线程,并获取当前线程的ThreadLocalMap实例(从getMap(Thread t)中很容易看出来)。

②、如果获取到的map实例不为空,调用map.set()方法,否则调用createMap(t, value)实例化map。

我们来看下map.set(this, value)方法的具体实现:

 1 private void set(ThreadLocal<?> key, Object value) {
 2
 3    // We don't use a fast path as with get() because it is at
 4    // least as common to use set() to create new entries as
 5    // it is to replace existing ones, in which case, a fast
 6    // path would fail more often than not.
 7
 8    Entry[] tab = table;
 9    int len = tab.length;
10    // 计算key的索引值 
11    int i = key.threadLocalHashCode & (len-1);
12
13    // 根据获取到的索引进行循环,如果当前索引上的table[i]不为空,在没有return的情况下,
14    // 就使用nextIndex()获取下一个(线性探测法)
15    for (Entry e = tab[i];
16         e != null;
17         e = tab[i = nextIndex(i, len)]) {
18        ThreadLocal<?> k = e.get();
19
20        // table[i]上key不为空,并且和当前key相同,更新value
21        if (k == key) {
22            e.value = value;
23            return;
24        }
25
26        // table[i]上的key为空,说明被回收了(弱引用)。
27        // 这个时候说明该table[i]可以重新使用,用新的key-value将其替换,并删除其他无效的entry
28        if (k == null) {
29            replaceStaleEntry(key, value, i);
30            return;
31        }
32    }
33
34    // 找到为空的插入位置,插入值,在为空的位置插入需要对size进行加1操作
35    tab[i] = new Entry(key, value);
36    int sz = ++size;
37    // cleanSomeSlots用于清除那些e.get()==null,也就是table[index] != null && table[index].get()==null
38    // 这种数据key关联的对象已经被回收,所以这个Entry(table[index])可以被置null。
39    // 如果没有清除任何entry,并且当前使用量达到了负载因子所定义(长度的2/3),那么进行rehash()
40    if (!cleanSomeSlots(i, sz) && sz >= threshold)
41        rehash();
42 }

在插入过程中,根据 ThreadLocal 对象的索引值,定位到 table 中的位置 i,过程如下:

①、如果当前索引上的table[i]为空,那么正好,就初始化一个 Entry 对象放在位置 i 上;②、不巧,位置 i 已经有 Entry 对象了,如果这个 Entry 对象的 key 正好是即将设置的 key,则更新 Entry 中的 value值。如果Entry对象的key为null,则说明该table[i]可以重新使用,用新的key-value将其替换,并删除其他无效的entry;

③、很不巧,位置 i 的 Entry 对象,和即将设置的 key 没关系,那么只能找下一个空位置;

1 private static final int HASH_INCREMENT = 0x61c88647;
2 private final int threadLocalHashCode = nextHashCode();
3
4 private static int nextHashCode() {
5    return nextHashCode.getAndAdd(HASH_INCREMENT);
6 }

每个 ThreadLocal 对象都有一个 hash 值 threadLocalHashCode,每初始化一个 ThreadLocal对象,hash 值就增加一个固定的大小 0x61c88647。关于这个值和斐波那契散列有关,其原理这里不再深究,感兴趣可自行搜索,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry[] table中。

ThreadLocalMap使用线性探测法来解决哈希冲突,线性探测法的地址增量di = 1, 2, ... , m-1,其中,i为探测次数。该方法一次探测一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。

按照上面的描述,可以把table看成一个环形数组

先看一下线性探测相关的代码,从中也可以看出来table实际是一个环:

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

可以发现,set()方法如果冲突严重的话,效率会很低。

get()方法解读
 1 public T get() {
 2    // 跟set方法类似,获取对应线程中的ThreadLocalMap实例
 3    Thread t = Thread.currentThread();
 4    ThreadLocalMap map = getMap(t);
 5    if (map != null) {
 6        ThreadLocalMap.Entry e = map.getEntry(this);
 7        if (e != null) {
 8            @SuppressWarnings("unchecked")
 9            T result = (T)e.value;
10            return result;
11        }
12    }
13    // 为空返回初始化值
14    return setInitialValue();
15 }

代码也很简单

①、获取当前线程,并获取当前线程的ThreadLocalMap实例(从getMap(Thread t)中很容易看出来)。

②、如果获取到的map实例不为空,调用map.getEntry(this)获取Entry对象,否则调用setInitialValue()实例化map。

我们来看下getEntry方法:

 1 private Entry getEntry(ThreadLocal<?> key) {
 2    // 根据key计算索引,获取entry
 3    int i = key.threadLocalHashCode & (table.length - 1);
 4    Entry e = table[i];
 5    if (e != null && e.get() == key)
 6        return e;
 7    else
 8        // 因为用的是线性探测,所以往后找还是有可能能够找到目标Entry的
 9        return getEntryAfterMiss(key, i, e);
10 }
 1  /**
 2   * 通过计算出来的key找不到对应的value时使用这个方法.
 3  */
 4 private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
 5    Entry[] tab = table;
 6    int len = tab.length;
 7
 8    while (e != null) {
 9        ThreadLocal<?> k = e.get();
10        if (k == key)
11            return e;
12        if (k == null)
13            // 清除无效的entry
14            expungeStaleEntry(i);
15        else
16            // 基于线性探测法向后扫描
17            i = nextIndex(i, len);
18        e = tab[i];
19    }
20    return null;
21 }
remove()方法解读
 1 public void remove() {
 2    ThreadLocalMap m = getMap(Thread.currentThread());
 3    if (m != null)
 4        m.remove(this);
 5 }
 6
 7 private void remove(ThreadLocal<?> key) {
 8    Entry[] tab = table;
 9    int len = tab.length;
10    // 计算索引
11    int i = key.threadLocalHashCode & (len-1);
12    // 进行线性探测,查找正确的key
13    for (Entry e = tab[i];
14         e != null;
15         e = tab[i = nextIndex(i, len)]) {
16        if (e.get() == key) {
17            // 调用weakrefrence的clear()清除引用
18            e.clear();
19            // 连续段清除
20            expungeStaleEntry(i);
21            return;
22        }
23    }
24 }
25
26 public void clear() {
27    this.referent = null;
28 }

remove()在有上面了解后可以说极为简单了,就是找到对应的table[],调用weakrefrence的clear()清除引用,然后再调用expungeStaleEntry()进行清除。

5、细节解读

ThreadLocalMap 是何时初始化的

①、第一次调用set()方法时,如果ThreadLocalMap为null,则会调用createMap(t, value) 方法对ThreadLocalMap进行初始化。

 1 void createMap(Thread t, T firstValue) {
 2    t.threadLocals = new ThreadLocalMap(this, firstValue);
 3 }
 4
 5 ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
 6    // 初始化一个大小 16 的 Entry 数组
 7     // 表的大小始终为 2 的幂次
 8    table = new Entry[INITIAL_CAPACITY];
 9    // 计算 key 的 的 hash 
10    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
11    table[i] = new Entry(firstKey, firstValue);
12    size = 1;
13    // 设定扩容阈值
14    setThreshold(INITIAL_CAPACITY);
15 }
16
17 private void setThreshold(int len) {
18    threshold = len * 2 / 3;
19 }

关于& (INITIAL_CAPACITY - 1),这是取模的一种方式,对2的幂作为模数取模,用此代替%(2^n),这也就是为啥Entry的容量必须为2的幂。

②、调用get()方法时,如果ThreadLocalMap为null,则会调用setInitialValue() 方法对ThreadLocalMap进行初始化,最终其实也是调用了createMap(t, value) 方法。

 1 private T setInitialValue() {
 2    // 获取初始化值,默认为null(如果没有子类进行覆盖)
 3    T value = initialValue();
 4    Thread t = Thread.currentThread();
 5    ThreadLocalMap map = getMap(t);
 6    // 不为空不用再初始化,直接调用set操作设值
 7    if (map != null)
 8        map.set(this, value);
 9    else
10        // 第一次初始化,createMap在上面有介绍过
11        createMap(t, value);
12    return value;
13 }
14
15 protected T initialValue() {
16    return null;
17 }
replaceStaleEntry 替换无效Entry
 1 private void replaceStaleEntry(ThreadLocal<?> key, Object value,
 2                                       int staleSlot) {
 3    Entry[] tab = table;
 4    int len = tab.length;
 5    Entry e;
 6
 7    /**
 8     * 根据传入的无效entry的位置(staleSlot),向前扫描
 9     * 一段连续的entry(这里的连续是指一段相邻的entry并且table[i] != null),
10     * 直到找到一个无效entry,或者扫描完也没找到
11     */
12    int slotToExpunge = staleSlot;  // 之后用于清理的起点
13    for (int i = prevIndex(staleSlot, len);
14         (e = tab[i]) != null;
15         i = prevIndex(i, len))
16        if (e.get() == null)
17            slotToExpunge = i;
18
19    /**
20     * 向后扫描一段连续的entry
21     */
22    for (int i = nextIndex(staleSlot, len);
23         (e = tab[i]) != null;
24         i = nextIndex(i, len)) {
25        ThreadLocal<?> k = e.get();
26
27        /**
28         * 如果找到了key,将其与传入的无效entry替换,也就是与table[staleSlot]进行替换
29         */
30        if (k == key) {
31            e.value = value;
32
33            tab[i] = tab[staleSlot];
34            tab[staleSlot] = e;
35
36            // 如果向前查找没有找到无效entry,则更新slotToExpunge为当前值i
37            if (slotToExpunge == staleSlot)
38                slotToExpunge = i;
39            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
40            return;
41        }
42
43        /**
44         * 如果向前查找没有找到无效entry,并且当前向后扫描的entry无效,则更新slotToExpunge为当前值i
45         */
46        if (k == null && slotToExpunge == staleSlot)
47            slotToExpunge = i;
48    }
49
50    /**
51     * 如果没有找到key,也就是说key之前不存在table中
52     * 就直接最开始的无效entry——tab[staleSlot]上直接新增即可
53     */
54    tab[staleSlot].value = null;
55    tab[staleSlot] = new Entry(key, value);
56
57    /**
58     * slotToExpunge != staleSlot,说明存在其他的无效entry需要进行清理。
59     */
60    if (slotToExpunge != staleSlot)
61        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
62 }
cleanSomeSlots 启发式地清理slot
 1/**
 2 * 启发式的扫描清除,扫描次数由传入的参数n决定
 3 *
 4 * @param i 从i向后开始扫描(不包括i,因为索引为i的Slot肯定为null)
 5 * @param n 控制扫描次数,正常情况下为 log2(n) ,
 6 *          如果找到了无效entry,会将n重置为table的长度len,进行段清除。
 7 *          map.set()调用的时候传入的是元素个数,replaceStaleEntry()调用的时候传入的是table的长度len
 8 */
 9 private boolean cleanSomeSlots(int i, int n) {
10    boolean removed = false;
11    Entry[] tab = table;
12    int len = tab.length;
13    do {
14        i = nextIndex(i, len);
15        Entry e = tab[i];
16        if (e != null && e.get() == null) {
17            // 重置n为len
18            n = len;
19            removed = true;
20            // 依然调用expungeStaleEntry来进行无效entry的清除
21            i = expungeStaleEntry(i);
22        }
23    } while ( (n >>>= 1) != 0);  // 无符号的右移动,可以用于控制扫描次数在log2(n)
24    return removed;
25 }

正常情况下如果 log n 次扫描没有发现无效 slot,函数就结束了。但是如果发现了无效的 slot,将 n 置为 table 的长度 len,做一次连续段的清理,再从下一个空的 slot 开始继续扫描。

这个函数有两处地方会被调用,一处是map.set()会被调用,另外个是在替换无效slot时replaceStaleEntry()会被调用,区别是前者传入的 n 为元素个数,后者为 table 的容量。

expungeStaleEntry 连续段清除
 1/**
 2 * 连续段清除
 3 * 根据传入的staleSlot,清理对应的无效entry——table[staleSlot],
 4 * 并且根据当前传入的staleSlot,向后扫描一段连续的entry(这里的连续是指一段相邻的entry并且table[i] != null),
 5 * 对可能存在hash冲突的entry进行rehash,并且清理遇到的无效entry.
 6 *
 7 * @param staleSlot key为null,需要无效entry所在的table中的索引
 8 * @return 返回下一个为空的solt的索引。
 9 */
10 private int expungeStaleEntry(int staleSlot) {
11    Entry[] tab = table;
12    int len = tab.length;
13
14    // 清理无效entry,置空
15    tab[staleSlot].value = null;
16    tab[staleSlot] = null;
17    // size减1,置空后table的被使用量减1
18    size--;
19
20    // Rehash until we encounter null
21    Entry e;
22    int i;
23    /**
24     * 从staleSlot开始向后扫描一段连续的entry
25     */
26    for (i = nextIndex(staleSlot, len);
27         (e = tab[i]) != null;
28         i = nextIndex(i, len)) {
29        ThreadLocal<?> k = e.get();
30        // 如果遇到key为null,表示无效entry,进行清理
31        if (k == null) {
32            e.value = null;
33            tab[i] = null;
34            size--;
35        } else {
36            // 如果key不为null,计算索引
37            int h = k.threadLocalHashCode & (len - 1);
38            /**
39             * 计算出来的索引h,与其现在所在位置的索引i不一致,置空当前的table[i]
40             * 从h开始向后线性探测到第一个空的slot,把当前的entry挪过去
41             */
42            if (h != i) {
43                tab[i] = null;
44
45                while (tab[h] != null)
46                    h = nextIndex(h, len);
47                tab[h] = e;
48            }
49        }
50    }
51    // 下一个为空的solt的索引
52    return i;
53 }

从 staleSlot 开始遍历,将无效 key(弱引用指向对象被回收)清理,即对应 entry 中的 value置为 null,将指向这个 entry 的 table[i]置为 null,直到扫到空 entry。

另外,在过程中还会对非空的 entry 作 rehash。

可以说这个函数的作用就是从 staleSlot 开始清理连续段中的 slot(断开强引用,rehash slot等)

rehash

先进行全量清理,如果清理后现有元素个数超过负载,那么进行扩容。

 1 private void rehash() {
 2    // 进行一次全量清理
 3    expungeStaleEntries();
 4
 5    /**
 6     * threshold = 2/3 * len
 7     * 所以threshold - threshold / 4 = 1en/2
 8     * 这里主要是因为上面做了一次全清理所以size减小,需要进行判断。
 9     * 判断的时候把阈值调低了
10     */
11    if (size >= threshold - threshold / 4)
12        resize();
13 }

全量清理

 1/**
 2 * 全清理,清理所有无效entry
 3 */
 4 private void expungeStaleEntries() {
 5    Entry[] tab = table;
 6    int len = tab.length;
 7    for (int j = 0; j < len; j++) {
 8        Entry e = tab[j];
 9        if (e != null && e.get() == null)
10            // 使用连续段清理
11            expungeStaleEntry(j);
12    }
13 }

扩容,因为需要保证 table 的容量 len 为 2 的幂,所以扩容即扩大 2 倍

 1/**
 2 * 扩容,扩大为原来的2倍(这样保证了长度为2的幂)
 3 */
 4 private void resize() {
 5    Entry[] oldTab = table;
 6    int oldLen = oldTab.length;
 7    int newLen = oldLen * 2;
 8    Entry[] newTab = new Entry[newLen];
 9    int count = 0;
10
11    for (int j = 0; j < oldLen; ++j) {
12        Entry e = oldTab[j];
13        if (e != null) {
14            ThreadLocal<?> k = e.get();
15            // 虽然做过一次清理,但在扩容的时候可能会又存在key==null的情况
16            if (k == null) {
17                // 这里试试将e.value设置为null
18                e.value = null; // Help the GC
19            } else {
20                // 同样使用线性探测来设置值
21                int h = k.threadLocalHashCode & (newLen - 1);
22                while (newTab[h] != null)
23                    h = nextIndex(h, newLen);
24                newTab[h] = e;
25                count++;
26            }
27        }
28    }
29    // 设置新的阈值
30    setThreshold(newLen);
31    size = count;
32    table = newTab;
33 }
为什么 ThreadLocalMap 的 Key 是弱引用

如果是强引用,ThreadLocal 将无法被释放内存。

因为如果这里使用普通的 key-value 形式来定义存储结构,实质上就会造成节点的生命周期与线程强绑定,只要线程没有销毁,那么节点在 GC 分析中一直处于可达状态,没办法被回收,而程序本身也无法判断是否可以清理节点。

弱引用是 Java 中四档引用的第三档,比软引用更加弱一些,如果一个对象没有强引用链可达,那么一般活不过下一次 GC。当某个ThreadLocal 已经没有强引用可达,则随着它被垃圾回收,在 ThreadLocalMap 里对应的 Entry的键值会失效,这为 ThreadLocalMap 本身的垃圾清理提供了便利。关于Java中引用类型分类,可以参考 Java中四种引用类型 这篇文章。

ThreadLocalMap原理
 1 public class ThreadLocal<T> {
 2    static class ThreadLocalMap {
 3        static class Entry extends WeakReference<ThreadLocal<?>> {
 4            /** The value associated with this ThreadLocal. */
 5            Object value;
 6
 7            Entry(ThreadLocal<?> k, Object v) {
 8                super(k);
 9                value = v;
10            }
11        }
12        ...
13    }
14    ...
15 }

ThreadLocalMap是一个类似 HashMap 的数据结构,但是并没实现 Map 接口。内部初始化了一个大小为16 的 Entry 数组,Entry 对象用来保存每一个 key-value 键值对,只不过这里的 key 永远都是 ThreadLocal 对象。

75315e6f1a0ed784f47b309803c947a8.png

当调用 ThreadLocal 对象的 set() 方法时,会把ThreadLocal 对象自己当做 key,当前线程对应的变量作为值,存放进 ThreadLoalMap 中。

ThreadLoalMap 的 Entry 是继承 WeakReference,和 HashMap 很大的区别是,Entry 中没有next 字段,所以就不存在链表的情况了。

内存泄漏问题

上图中我们可以看到,Entry对象的key是弱引用,当ThreadLocal被垃圾回收时,由于ThreadLocalMap是与线程绑定的,也即对应的Entry不会被回收,这时就会出现一个现象:ThreadLocalMap的key没了,但是value还存在,久而久之就会导致内存泄漏。

既然存在内存泄露的隐患,自然有应对的策略,在上面的源码分析中,我们可以看到,在调用 ThreadLocal 的 get()、set()方法时都有清除 ThreadLocalMap 中 key 为 null 的 Entry 对象的操作,这样对应的 value 就没有 GC Roots可达了,下次 GC 的时候就可以被回收。但get(),set()方法也有清理不完全的情况,所以使用完 ThreadLocal 之后,需要调用 remove() 方法进行清理。

JDK 建议将 ThreadLocal 变量定义成 private static 的,这样的话 ThreadLocal 的生命周期就更长,由于一直存在 ThreadLocal 的强引用,所以 ThreadLocal 也就不会被回收,也就能保证任何时候都能根据 ThreadLocal 的弱引用访问到 Entry 的 value 值。

6、总结

①、每个Thread维护着一个ThreadLocalMap的引用,变量副本存储在线程自己的ThreadLocalMap中;

②、ThreadLocalMap是ThreadLocal的内部类,用Entry来存储key-value,键值为ThreadLocal对象,value为线程变量;

③、ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值