很多朋友在面试时常常都会被面试官问到 ThreadLocal 的底层原理以及使用场景,或者是自己有使用该类,但是对其还是存在部分的疑惑,那么不妨看看我这篇文章。
1 Thread, ThreadLocal 类结构解析
Thread 类当中存在一个唯一绑定的 ThreadLocalMap 对象,在这个 map 当中就是我们实际存放数据的。
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal 是用于 ThreadLocalMap 的弱引用 key, 对于弱引用,我们都知道只能活到下一次 gc, 但是很多朋友在这里没有想明白,如果刚存入值,但是就发生了gc, 那么这个弱引用的 key 会被回收吗?答案是不会的,因为 map 虽然对 key 是弱引用,但是在外面, 还有线程对 ThreadLocal 对象的强引用啊, 那么这里可以得出的结论就是 ThreadLocal 是和线程当中对其强引用的占有多久,只有线程对其没有了强引用,那么其才会被 gc 。而 map 对象是和线程同生命周期的。讲解到这里大家都应该能够听明白吧。map 当中也是通过 key - value 实现的。并且 key 为弱引用实现。
public class ThreadLocal<T> {
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
}
}
2 ThreadLocal 源码解析
1 通过以下方式对 ThreadLocal 进行创建以及初始化定义,这里设置初始化定义的数据是很有必要的。虽然不设置也是可以运行的,但是后面操作获取数据可能会导致空指针异常,那么通过设置初始化的就可以避免。
ThreadLocal<String> local = ThreadLocal.withInitial(() -> "");
2 数据存入, 通过 ThreadLocal 对象就可以直接对数据进行存放操作。
local.set("hello world");
在方法当中,其第一步是获取到当前的线程对象,调用getMap(t) 方法, 获取和 Thread 进行唯一绑定的 ThreadLocalMap 对象。这里其 map 的生命周期是和线程是一样的,很多同学对这里的认识是可能有错误的,因为唯一对 map 有引用的只有 Thread 对象。
public void set(T value) {
Thread t = Thread.currentThread(); // 获取当前线程对象
ThreadLocalMap map = getMap(t); // 通过线程对象获取到 map
if (map != null) // 判断是否有 map
map.set(this, value); // 存在 map 放入值
else
createMap(t, value); // 不存在 先创建 后放入
}
这里就是通过线程获取到其绑定的唯一 map 方法。
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
没有 map 对象,进行创建操作。
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
通过创建数组方式创建桶数组,通过 hash 与运算得到其实际的插入数据的桶下标位置。
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY]; // 创建桶数组
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); // 计算第一个节点hash位置
table[i] = new Entry(firstKey, firstValue); // 放入
size = 1; // 大小设置
setThreshold(INITIAL_CAPACITY); // 扩容阈值
}
和HashMap 类似,通过 hash 计算桶位置,这里通过一个循环,找到不为 null, 也就是没有存放数据的位置,就可以退出循环,插入 entry 数据。
这个循环我详细的解释一下,通过 i 表示其需要查找的下标,如果存在 entry 不为空,进入查看,判断是否为 key 重复,key 重复做替换操作,但是下面还有一个判断条件, 也就是 k == null。 这里和内存泄露相关联了,因为 jdk 防止因为使用错误没有做删除,会导致 key 被回收,但是值还存在的情况所做的优化,发现了这种 key 已经被 gc 了的 entry, 那么可以帮助删除原来的 value, 并且可以占据该位置。而循环变换的条件是获取下一个节点,这里也就是解决 hash 冲突的方式了,和 HashMap 不一样,其没有使用链地址法,而是使用线性探测法。对于解决哈希冲突可以看看这篇博文 解决哈希冲突(四种方法)
而最后的代码也就是对扩容进行操作。其扩容策略是所有的空间都使用完毕了,超过其阈值了就会进行扩容。
private void set(ThreadLocal<?> key, Object value) {
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) { // 判断是否为key 替换
e.value = value;
return;
}
if (k == null) { // 没有 key 替换 内存泄露的问题
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value); // 直接遇到为空的桶位置 插入
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold) // 判断是否扩容
rehash();
}
以上就是对 ThreadLocal 当中获取数据的完整流程
3 数据获取
直接通过 get 方法就可以去获取数据值。
local.get();
和 get 一样,都是先获取当前线程,通过线程获取到 map。 再通过 ThreadLocal 作为 key 去获取到 entry 对象。最后做结果的返回。但是没有,就会返回设置的初始化的值,所有在我们使用 ThreadLocal 的时候应该先将其设置默认值,防止空指针。
public T get() {
Thread t = Thread.currentThread(); // 获取当前线程
ThreadLocalMap map = getMap(t); // 获取 map
if (map != null) { // 存在 map 才进行操作
ThreadLocalMap.Entry e = map.getEntry(this); // 通过 ThreadLocal 获取数据值
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value; // 结果返回
return result;
}
}
return setInitialValue(); // 没有 map 返回默认值
}
同样具体的查找都是先计算桶位置,第一次直接拿到就返回,没有能够拿到那么就线性探测。
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1); // 计算桶
Entry e = table[i];
if (e != null && e.get() == key) // 判断第一次是否就拿到了指定的 entry
return e;
else
return getEntryAfterMiss(key, i, e); // 通过线性探测查找
}
这个是线性探测的获取结果的方法,通过 entry 的 key 进行查找,key 如果找到了具体的值,那么说明确实找到了结果,返回,如果出现了 key 为空的情况,这里又出现了内存泄露的问题, 这里 jdk 也是做了优化,会将这种发生了内存泄露的 entry 进行清除。
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get(); // 获取 entry 的 key
if (k == key) // 找到结果 返回
return e;
if (k == null) // key 为空 entry 存在 内存泄露问题
expungeStaleEntry(i);
else
i = nextIndex(i, len); // 线性探测
e = tab[i];
}
return null;
}
4 手动清除 value
虽然 jdk 为我们做了防止内存泄露的优化,但是我们还是要手动对 value 进行清除,因为可能出现极端情况,jdk 的自己解决内存泄露没有发生。那么整个程序就可能出现危机。
local.remove();
相信有了以上的基础,这里的代码应该很清除了。
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread()); // 获取线程
if (m != null)
m.remove(this); // 清除
}
3 误区解读
1 很多人就对为什么会发生内存泄露,以及 key ,value, map 的生命周期没有一个明确的认识。
下面是我画的一个引用关系图,其中实线为强引用,虚线为弱引用。
ThreadLocal 作为弱引用的 key ,但是还收到了 Thread 的强引用,也就是说,当跳出了相应的调用栈,ThreadLocal 的生命周期就结束了。而 map 是一直被 Thread 强引用着,其生命周期就和线程生命周期一样。通过以上可以得出的结果,对于一般创建了很快就会死亡的线程, key 和 map 和 thread 线程生命周期基本一样,而在线程池当中一直不会死亡的线程,map 和 thread 一直会存活,而 ThreadLocal 是出了相应的调用栈,没有了线程对其的强引用, 其生命就结束了。所有对于线程池当中使用的 ThreadLocal 一定要手动清除 value 就是这样一个原因。
2 ThreadLocal 其的到底是一个什么作用呢?
ThreadLocal 很多人都会和线程安全等等联系起来,确实,这个我不否则,其确实是线程安全的,但是这并不是其正在的作用,其主要的作用是实现数据的私有化,hreadLocal为每一个线程都提供了变量的副本,使得每一个线程在某一时间访问到的并不是同一个对象,这样就隔离了多个线程对数据的数据共享,这样的结果无非是耗费了内存,也大大减少了线程同步所带来的性能消耗。并且实现了线程的隔离。
3 ThreadLocal 能够抗住高并发?
答案是不能的,ThreadLocal 设计的目的只是为了实现数据的线程私有,线程隔离作用,根本都不会产生线程安全问题,没有数据的共享,其如何来提高并发呢?如果要考虑高并发,可以多考虑硬件,cpu, 优化共享数据的同步访问,加入缓冲等等。所以别把 ThreadLocal 和并发考虑在一起了。
4 ThreadLocal 没有并发问题,这么好,是不是应该多使用?
答案也是不能的,合适即可,使用在一些需要的特定场景就够了,而不能一贯的使用,在数据量很小的情况下可能感觉其访问速度还很快。但是在大数据量的情况下,其性能就会很差了。首先其使用的解决哈希冲突方式为线性探测法,时间复杂度那可是 O(n) 呢,并且其扩容操作,是必须要将所有的容量都使用完毕才会进行扩容。
4 使用场景
1 一个用户一个线程操作,存放用户信息:用户登录令牌解密后的信息传递、用户权限信息、从用户系统中获取到的用户名。
2 做数据库的session 连接,将数据库连接和线程进行绑定。
3 对数据做隐式传递,一些情况下,方法被限定好了,我们要做参数的传递等等操作时,就可以放入 ThreadLocal 来传递数据。
5 总结
总体来说, ThreadLocal 是通过 Thread 和 ThreadLocalMap 来实现线程隔离的类,使用过程可能出现内存泄露问题,需要我们进行手动清除。了解了其底层查找方式后,也就明白了为什么不能大量使用,了解了其几个的生命周期之后,也就知道了为什么尽量不要在线程池当中使用 ThreadLocal。