Java - ThreadLocal原理

前言

Java开发者想必有一个类听得比较多,也就是ThreadLocal。关于它的话题也是比较多的:

  • 内存泄漏。
  • 线性安全。
  • 弱引用。

那么本文就来探究一下。

一. ThreadLocal的原理

要知道ThreadLocal的原理,我们首先应该去了解它的一个简单的用法。ThreadLocal用起来并不复杂,就是一个get、set罢了。

1.1 ThreadLocal 案例

我们看一个案例:

public class Test {
    public static final ThreadLocal<String> THREAD_LOCAL = new ThreadLocal<>();

    @org.junit.Test
    public void test() {
        THREAD_LOCAL.set("Hello");
        String s = THREAD_LOCAL.get();
        System.out.println(Thread.currentThread().getName() + ", " + THREAD_LOCAL.get());

        new Thread(() -> {
            THREAD_LOCAL.set("Hello2");
            THREAD_LOCAL.set("Hello3");
            THREAD_LOCAL.set("Hello4");
            System.out.println(Thread.currentThread().getName() + ", " + THREAD_LOCAL.get());
        }).start();

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + ", " + THREAD_LOCAL.get());
        }).start();
    }
}

程序运行结果如下:
在这里插入图片描述

从这个结果来看,我们可以暂时做出以下结论:

  • 同一个ThreadLocal可以被多个线程使用。并且存储的对象和当前线程绑定。互相不干扰。
  • ThreadLocal在同一个线程里面只会存储一个对象。

1.2 ThreadLocal 元素插入源码分析

我们从 set 函数开始分析:

public class ThreadLocal<T> {
	public void set(T value) {
	    // 拿到当前的线程
        Thread t = Thread.currentThread();
        // 根据当前线程拿到一个 ThreadLocalMap 实例
        ThreadLocalMap map = getMap(t);
        // 如果ThreadLocalMap实例不为空,塞入一个值
        if (map != null)
            map.set(this, value);
        else
        	// 否则创建一个ThreadLocalMap并将值放入其中
            createMap(t, value);
    }
    
	ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
}

我们可以发现,ThreadLocalMap实例来自于Thread对象中的threadLocals属性。我们来看下:

public class Thread implements Runnable {
	ThreadLocal.ThreadLocalMap threadLocals = null;
}

好巧不巧的是,从Thread源码中可以发现,ThreadLocalMapThreadLocal的一个内部类:

public class ThreadLocal<T> {
	// 无参构造,什么也没有做
	public ThreadLocal() { }

	static class ThreadLocalMap {
		static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

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

从源码我们可以知道:

  1. 我们在使用ThreadLocal的时候,肯定会new一个对象出来。但是ThreadLocal的无参构造什么也没有做。
  2. 我们在使用ThreadLocal存储对象的时候,并不是将对象存储在ThreadLocal本身,而是ThreadLocalMap中。
  3. 那么自然而然的,第一次的时候,ThreadLocalMap肯定也是nullThreadLocal构造函数并没有初始化ThreadLocalMap)。因此ThreadLocalMap实例对象需要被创建。因此会走createMap

1.2.1 ThreadLocalMap的创建

我们来继续分析createMap函数:

createMap(t, value);
↓↓↓↓↓
void createMap(Thread t, T firstValue) {
	// this就是当前的ThreadLocal对象
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

因此,ThreadLocalMap实际上就是当前线程Thread的一个全局变量threadLocals。并且我们可以初步判断出来,ThreadLocal中存储的是当前线程的一个本地变量。

  • 为什么是当前线程?因为set操作的时候,调用了Thread t = Thread.currentThread();函数。
  • 数据存储哪了?数据存储到ThreadLocalMap中,而ThreadLocalMap绑定于当前线程 t 中。

ThreadLocalMap的初始化和HashMap的很多地方有几分相似。

// 第一次创建ThreadLocalMap的时候,调用的构造函数
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
	// 初始化Entry数组,大小16
    table = new Entry[INITIAL_CAPACITY];
    // 和数组长度取模,计算索引
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 往对应哈希槽中塞数据
    table[i] = new Entry(firstKey, firstValue);
    // 当前的元素个数是1
    size = 1;
    // 设置阈值为16
    setThreshold(INITIAL_CAPACITY);
}

只不过和HashMap不同的是,Entry类并不具备链表结构。因此它是一个单一的、没有嵌套结构的对象。 这也是为什么,在同一个线程下,ThreadLocal中存储的对象只有一个了。

那么接下来就来看下具体的元素插入动作吧。如果ThreadLocalMap已经被创建出来了,那么就会走ThreadLocalMap.set()函数:

private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = table;
    int len = tab.length;
    // 计算当前ThreadLocal作为key时,应该将元素插入到哪一个槽下
    int i = key.threadLocalHashCode & (len-1);
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
		// 如果发现存在相同的ThreadLocal对象,那么将值进行覆盖
        if (k == key) {
            e.value = value;
            return;
        }
		// 如果此时ThreadLocal为null,说明此时的ThreadLocal虚引用可以被GC回收
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
	// 此时代表下标为i的位置上,没有元素,将新的Entry插入到里面
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 超过阈值了,扩容等。
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

我们分几个点来讲解。

1.2.2 开放地址法

首先第一个就是源码中的for循环,跳出循环有这么几种条件:

  • 如果是相同的ThreadLocal对象,此时旧值被更新为新值。
  • 如果ThreadLocalnull,说明当前对象可被回收。
  • 否则就是不断地在Entry数组中寻找,直到某个下标对应的元素为null。然后跳出循环。

前面我们说过ThreadLocal并不像HashMap那样,为了解决哈希冲突,采用数组+链表的形式来存储。 其次Entry本身就不具备链表的结构。那么在遇到哈希冲突的时候,是如何解决的呢?就是所谓的开放地址法。

注意:这里的哈希冲突,发生在不同的ThreadLocal实例之间,因为相同的ThreadLocal实例,value值直接被替代

遇到哈希冲突了,有两个方向可供选择:

  • 如果对应位置的keynull:说明它处于可被回收的状态,那么进行替换操作。
  • 否则:那就往后遍历其他的Entry元素,直到满足上述循环跳出条件为止,否则一直循环。

第一个我们好理解,竟然这个位置都是null了,那么我们就用它就好了。那么第二点是怎么实现的?我们可以看for循环中的这么一截代码:

e = tab[i = nextIndex(i, len)]
↓ ↓ ↓ ↓ ↓ ↓
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

很简单,就是取当前下标的下一个元素,如果超过了数组长度,就继续从头开始找。

那么开放地址法的思路也就比较明确了:一旦发生了哈希冲突,那么就去寻找下一个空的地址。

紧接着,我们再来看下,代码中的替换操作。

1.2.3 元素替换和过期元素清除操作

我们都知道的是,ThreadLocal在进行元素插入的时候,会清除Map中所有Keynull的值。而这部分的核心代码就在本小节讲解。

replaceStaleEntry(key, value, i);
↓ ↓ ↓ ↓ ↓ ↓
// 这里的key是新的ThreadLocal实例。value是新的值。staleSlot是待清除的一个元素下标
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 先以当前待删除元素作为一个中轴
    int slotToExpunge = staleSlot;
    // 从当前中轴,往左边遍历,找到最外侧key为null的元素(过期元素)。
    for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;
	// 以当前待删除元素作为中轴,往后遍历,同样也找到最外侧为null的过期元素
    for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
		// 如果碰巧找到与当前新key相同的Entry操作,那么执行清理操作,直接返回。
        if (k == key) {
        	// 新值替换旧值
            e.value = value;
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // 意思是,当前待删除元素的左侧,没有需要被清理的元素,那么自然而然的slotToExpunge的值就是staleSlot本身
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            // 旧数据的清理操作
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 往前遍历的时候,找不到旧值,并且有空位置,那么往后遍历尝试找旧值
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // 将新的值赋值到当前待清除节点上
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 如果有其他过期的对象,需要清理它
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

总结下就是:

  1. 当前待清理位置下标是staleSlot
  2. staleSlot该位置,分别向前和向后寻找第一个keynull的元素。
  3. 然后进行元素的清理操作。

那么接下来我们需要看下元素的清除操作,我们可以发现,代码里面,有段代码被两个地方同时引用到:

cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

我们先看下expungeStaleEntry函数:同时我们需要明确的是,传入的参数slotToExpunge下标指的是最左侧的一个过期元素的下标。

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

    // 首先清除该位置上对应的引用关系。
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;// 数组元素-1

    // 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();
        // 如果对应key为null,那么直接删除对应槽中的元素,并且元素个数-1
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
        	// 如果不为null,说明对应的元素还没有过期。这里简单来说,就是让后面的元素向前移动
            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;
}

这里我们可以看到元素清理的一个过程,其中涉及到两个重要的部分:

  • 从当前位置往后遍历,将对应的待删除元素引用设置为null(删除的方式)。
  • 将后面不为null的元素往前移动,(有一种内存碎片整理的味道)。

那么expungeStaleEntry这个函数就已经是删除的一个实际执行者了,那外层的cleanSomeSlots又是干啥的呢?我们来看下源码:

// 它的返回值是布尔类型。代表旧值的Entry是否被删除
private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    // log2N的复杂度,清除一些null的Entry
    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;
}

这个i = expungeStaleEntry(i);在下面set函数里面的最后部分用到了:

if (!cleanSomeSlots(i, sz) && sz >= threshold)

结合cleanSomeSlots函数的寓意,就是说此时table数组中基本上不包含过期值了,并且元素数量已经到达了阈值,可以进行rehash操作。有什么好处呢?

避免了table数组由于存在大量过期Entry而导致rehash的情况发生。

1.3 ThreadLocal 元素获取源码分析

public T get() {
	// 获取当前线程
    Thread t = Thread.currentThread();
    // 根据当前线程拿到一个ThreadLocalMap 
    ThreadLocalMap map = getMap(t);
    if (map != null) {
    	// 因为ThreadLocal是Key,也就是这里的this。根据Key拿到对应的槽位
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 几乎用不到
    return setInitialValue();
}

我们看下getEntry的源码:

private Entry getEntry(ThreadLocal<?> key) {
	// 计算下标
    int i = key.threadLocalHashCode & (table.length - 1);
    // 拿到对应的Entry对象
    Entry e = table[i];
    // 如果当前下标找到了就直接返回,
    if (e != null && e.get() == key)
        return e;
    else
    	// 否则做进一步的寻找操作
        return getEntryAfterMiss(key, i, e);
}
↓↓↓↓↓
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;
	// 就是遍历table数组,看看是否有相同的key,找到了直接返回
    while (e != null) {
        ThreadLocal<?> k = e.get();
        if (k == key)
            return e;
        // 如果寻找的途中,发现了Key为null的,那就进行垃圾回收
        if (k == null)
            expungeStaleEntry(i);
        else
        	// 就是下标往后推
            i = nextIndex(i, len);
        e = tab[i];
    }
    // 如果实在找不到,返回null
    return null;
}

总结下就是:

  1. 拿到当前ThreadLocal对应的存储下标。对应如果有值就直接返回
  2. 如果没找到,那么尝试在table数组中继续查找是否有相同的key。即二次查找
  3. 二次查找过程中,遇到垃圾,那么就回收。最后找不到的话就返回null

最后来看下元素的删除操作。

1.4 ThreadLocal 元素删除源码分析

public void remove() {
   ThreadLocalMap m = getMap(Thread.currentThread());
   if (m != null)
       m.remove(this);
}

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1);
    // 遍历table数组,发现相同的key就进行删除操作,并进行垃圾回收。
    for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

可见remove函数不仅会删除当前的ThreadLocal实例,而且还要看是否有相同的Key需要进行垃圾的处理。

二. 原理总结

2.1 对于元素插入的总结和思考

首先,为什么ThreadLocal用于一个线程变量的使用呢? (线程和变量绑定)

  1. 从源码上看,ThreadLocal存储数据的时候,底层是存储于ThreadLocalMap中的。
  2. ThreadLocalMap在第一次ThreadLocal.set()的时候被初始化。同时它和当前线程绑定。
  3. 因为他就是当前线程对象中的threadLocals属性。
  4. 从而做到变量和线程的一个对应。

为什么同一个ThreadLocal只能存储一个值?还有什么其他发现吗?

  1. ThreadLocalMap中,存储的Key就是ThreadLocal实例,而value就是你要存储的值。
  2. 但是如果发现Map中存在相同的Key,也就是同一个ThreadLocal的话,会进行值的替换操作。因为底层的数据结构Entry也不具备链表结构。并且采用开放地址法来解决哈希冲突问题。
  3. 同一个线程,ThreadLocalMap的大小初始化是16,也就是说,ThreadLocal可以存在多个。即多个一对一关系。 但不能存在一对多的关系。

ThreadLocal在插入元素的时候,是如何做到元素清理的?

  1. 首先,清理的本质都是将对应元素置为null
  2. 当前元素的待插入下标我们记为staleSlot(下图的下标5)。以他为轴,分别向两侧寻找最远的一个元素KeyThreadLocalnull)为null的下标。分别记为N1N2。(下图的下标2和8)
  3. 找到N1之前和N2之后的最近的空位置(元素为空),记为M1M2。(下图的下标0和9)
  4. 那么本次插入过程就会将[M1,M2]范围内的过期元素进行回收。
    在这里插入图片描述

具体的回收动作:

  1. 从回收区间的左侧开始往右遍历。遇到空值,将对应元素置为null
  2. 遇到非空置,向数组前移动。

为什么ThreadLocalMap要使用开放地址法,而不是拉链法?

开放地址法:一旦发生了哈希冲突,那么就去寻找下一个空的地址。

  • ThreadLocal存储的数据量往往比较小,同时Key是弱引用,会被垃圾回收,因此数据量更小。
  • 在第一点的前提下,开放地址法的结构存储会更省空间。

为什么在清理数据的时候,还要把非空元素向前移动呢?(很重要)

  1. 因为ThreadLocal采用开放地址法。不断地循环数组,找到第一个非空元素作为当前元素插入的地址。
  2. 如果说在元素清理阶段,不把非空元素向前移动,那么就会存在元素之间存在null(即断层)的情况。结合第一点,会导致某个null值后面的元素访问不到的情况。

2.3 内存溢出的总结和思考

我们从源码上可以看到,Key为弱引用。但是Key并不是导致内存泄漏的一个原因。内存泄漏的本质原因还是在于对应的value没有被删除掉。

我们同样可以看到,我们调用ThreadLocalgetset方法的时候,都会调用到expungeStaleEntry这个函数。而它的功能就是用来做垃圾回收的。

那么为什么Key要作为弱引用?结合ThreadLocal的一个使用场景:

  1. Key作为弱引用,那么Key被回收的概率也就比较大。ThreadLocal每次插入和获取元素的时候,会清理过期的数据,也就是Keynull的数据。就保证ThreadLocalMap的大小不会太大。
  2. 如果key使用强引用,当引用的ThreadLocal的对象被回收了,但是ThreadLocalMap还持有ThreadLocal的强引用,只要没有手动删除,ThreadLocal不会被回收,从而导致Entry内存泄漏。

我们可以发现,调用getset方法,都会帮助ThreadLocal去进行垃圾清理。相反,如果没有调用就可能面临着内存溢出的风险。即ThreadLocalMap中可能存在null-->值这样的键值对。导致对应的value不会被回收。

因此我们可以养成良好的编码习惯,在调用结束之前,调用ThreadLocal.remove()函数。将对应Keyvalue设置为null

  • 一般情况下,单线程,理论上不用强制remove,因为线程停掉之后,ThreadLocal也跟着没了。两者生命周期是一致的。
  • 但是在多线程情况下,一定要用remove。因为线程在结束任务之后,并不会被销毁,而是返回线程池里面等待任务的调度。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Java中的ThreadLocal是一种线程本地存储机制,它可以让每个线程都拥有自己的变量副本,从而避免了线程安全问题。ThreadLocal的实现原理是通过为每个线程创建一个独立的变量副本来实现的。具体来说,每个Thread对象都有一个ThreadLocalMap,当创建一个ThreadLocal的时候,就会将该ThreadLocal对象添加到该Map中,其中键就是ThreadLocal,值可以是任意类型。当需要获取ThreadLocal的值时,ThreadLocal会首先获取当前线程,然后从当前线程的ThreadLocalMap中获取对应的值。由于每个线程都有自己的ThreadLocalMap,因此不同线程之间的变量互不干扰。 以下是一个简单的示例代码,演示了如何使用ThreadLocal实现线程本地存储: ```java public class ThreadLocalDemo { private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>() { @Override protected Integer initialValue() { return 0; } }; public static void main(String[] args) { Runnable task = new Runnable() { @Override public void run() { int num = threadLocal.get(); for (int i = 0; i < 5; i++) { num++; threadLocal.set(num); System.out.println(Thread.currentThread().getName() + " : " + threadLocal.get()); } } }; Thread t1 = new Thread(task, "Thread-1"); Thread t2 = new Thread(task, "Thread-2"); t1.start(); t2.start(); } } ``` 输出结果为: ``` Thread-1 : 1 Thread-1 : 2 Thread-1 : 3 Thread-1 : 4 Thread-1 : 5 Thread-2 : 1 Thread-2 : 2 Thread-2 : 3 Thread-2 : 4 Thread-2 : 5 ```

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值