1. 序言
- 在上一篇博客中,从SimpleDateFormat的线程安全问题,引出了Java中解决线程安全的常用方法:不可变、互斥同步(锁)、非阻塞同步(CAS和原子变量)、无同步方案(栈封闭、ThreadLocal、可重入代码)
- 其中,ThreadLocal可以实现线程隔离,从而避免多线程访问时的资源竞争问题
- ThreadLocal也是面试时的高频知识点:
- 对ThreadLocal的理解、ThreadLocal的使用场景、内存泄漏及解决办法、ThreadLocalMap的实现等
1.1 ThreadLocal概述
JDK源码对ThreadLocal类的注释如下:
- ThreadLocal提供线程局部变量,使得每个线程都有自己的、独立初始化的变量副本
- ThreadLocal实例通常是类中的
private static
字段,用于将状态与线程相关联,如用户ID、事务ID - 只要线程处于活动状态并且ThreadLocal实例是可访问的,每个线程都将持有对线程局部变量副本的隐式引用
- 当线程终止,线程所绑定的线程局部变量都将被垃圾回收
对注释要点3的理解:
- 这与JDK如何实现线程局部变量与线程的绑定有关,线程局部变量就是ThreadLocalMap.Entry中的value
ThreadLocal的使用场景:
-
保存线程上下文信息,在需要的地方进行获取
- 实际开发中使用较少,框架中使用较多,如Spring的事务管理
- 一般,使用ThreadLocal管理数据库连接、Session会话等,保证每一个线程中使用的连接是同一个
-
保存上下文信息,优雅地进行参数传递
-
例如,类似责任链模式的代码中,需要在很多方法中传递context参数,后续的维护十分麻烦
-
若果ThreadLocal进行参数传递,整个代码将更加优雅
-
改造前的代码
-
改造后的代码
-
-
保证线程安全,避免同步操作带来的性能损耗.例如,SimpleDateFormat线程不安全的解决方案
局限性:
- ThreadLocal实现了线程隔离,自然也就无法解决多线程间共享对象的更新问题
参考链接
- 前2种场景和局限性:手撕面试题ThreadLocal!!!
- 参数传递的使用场景:阿里面试官问我ThreadLocal,我一口气给他说了四种!
- ThreadLocal进行参数传递的示例:Java面试必问:ThreadLocal终极篇 淦!
1.2 如何将线程局部变量与线程绑定?
- 从上面的概述可知,ThreadLocal使得线程拥有自己的、独立的变量副本
- 那么,ThreadLocal如何将线程局部变量与线程实现一对一绑定的呢?
错误的实现方案: ThreadLocal维护线程与线程局部变量的映射
- 很多人的第一想法应该是:
- ThreadLocal中维护一个Map,线程做key,线程局部变量做value,这就实现了二者之间的一一对应
- 线程通过 ThreadLocal 的 get() 方法获取实例时,只需要以线程为key,从 Map 中找出对应的实例即可
- 上面的设计,每个线程访问ThreadLocal前,需要向Map中添加一个映射;线程终止后,需要从Map中清除映射,否则容易造成内存泄漏
- 这样将会带来两个问题
(1)多线程写Map,要求ThreadLocal中的Map应该是线程安全的,例如使用ConcurrentHashMap。但也需要通过锁来保证线程安全,其性能将会受影响
(2)线程终止前,需要从所有包含该线程的ThreadLocal Map中清除映射,否则容易造成整个entry的内存泄漏 - 根据博客的描述,问题(1)是JDK未使用该方案的原因
正确的实现方案:线程维护ThreadLocal与线程局部变量的映射
- 上述实现方案,需要使用锁来保证多线程访问同一个Map的线程安全
- 如果由线程维护ThreadLocal与线程局部变量的映射:ThreadLocal为key,线程局部变量为value
- 线程访问自己内部的Map就不存在多线程写的问题,也就不需要锁
- 上述方案也存在内存泄漏的问题:如果线程长时间运行,Map中的映射将一直存在。若不主动删除无用映射,将存在内存泄漏的风险
- 博客中说,ThreadLocal不能被回收,笔者更倾向于整个映射不能被回收
- 后续JDK将映射(entry)中对key(ThreadLocal)的引用设计为弱引用,保证了ThreadLocal能被及时回收
- 同时也在set、get、remove等方法中,主动清理key为
null
的过期entry,以保证value的回收 - JDK源码对ThreadLocal的设计,其实是在尽量保证整个entry的回收,并非单独针对ThreadLocal的回收
参考文档:
- 两种映射方案的介绍:ThreadLocal到底是什么?它解决了什么问题?
2. JDK如何实现映射的?
2.1 threadLocals in Thread
-
阅读Thread类的源码,发现一个名为
threadLocals
的成员变量,它拥有包访问权限// 维护与该线程有关的线程局部变量 ThreadLocal.ThreadLocalMap threadLocals = null;
-
threadLocals 的类型为 ThreadLocal.ThreadLocalMap,ThreadLocalMap是ThreadLocal中的静态内部类,拥有包访问权限
static class ThreadLocalMap { ... }
-
由于Thread和ThreadLocal同属于
java.lang
包,所以在Thread类中能访问ThreadLocal的静态内部类ThreadLocalMap
2.2 ThreadLocalMap的数据结构
ThreadLocalMap的类注释如下:
- ThreadLocalMap是一个自定义的哈希表,用于维护线程局部变量(ThreadLocal与线程局部变量的映射)
- 在ThreadLocal之外,无法操作ThreadLocalMap: ThreadLocalMap是ThreadLocal的静态内部类,其所有方法都是private的
- ThreadLocalMap的entry,key使用弱引用,但没有使用引用队列。因此,在桶空间开始耗尽时,会主动清理陈旧的entry(key为null的entry)
- 如果弱引用关联了引用队列,则key被垃圾回收后,弱引用将进入引用队列
- 如果能从引用队列获取到该弱引用,则可以及时清理对应的value
ThreadLocalMap的Entry
- Entry的定义如下,key为ThreadLocal的弱引用,value为Object的强引用,存储线程局部变量
static class Entry extends WeakReference<ThreadLocal<?>> { /** The value associated with this ThreadLocal. */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }
ThreadLocalMap的成员变量
-
从成员变量可以看出,ThreadLocalMap采用桶数组的形式存储Entry
private static final int INITIAL_CAPACITY = 16; // resize后,table的长度必须为2^n private Entry[] table; // 桶数组中,entry的数目 private int size = 0; // 扩容的阈值,默认为0 private int threshold; // Default to 0 // 阈值后续会调整为容量的2/3 private void setThreshold(int len) { threshold = len * 2 / 3; }
-
与使用桶数组的HashMap不同,ThreadLocalMap的桶数组,一个位置只存放一个Entry
2.3 ThreadLocalMap如何解决哈希冲突的?
-
HashMap采用链地址法(桶 + 链表/红黑树)解决哈希冲突,而ThreadLocalMap采用开放定址法解决哈希冲突
-
开放定址法: 如果当前slot已有元素,则依次往后查找,直到某个slot为空,则将新的entry插入该slot
-
特殊情况: 如果slot不为空但entry过期,则直接替换,具体可以查看ThreadLocalMap的replaceStaleEntry() 方法
-
上图中信息有误:
hashCode % 4
,而非hashCode % 3
:与HashMap一样,ThreadLocalMap使用hashCode & (n - 1) = hashCode & 3
快速定址,,其实就是hashCode % 4
- 开放定址法,而非链地址法:链地址发是HashMap使用的桶 + 链表/红黑树的方法
2.4 Thread、ThreadLocal、ThreadLocalMap三者之间的关系
- 从对Thread和ThreadLocalMap的分析可知,Thread维护一个key为ThreadLocal、value为线程局部变量的Map
- 三者之间的关系如图所示,图示中使用了2个ThreadLocal,使得线程将包含2个线程局部变量
3. ThreadLocal
3.1 成员变量
- 说了这么久,ThreadLocal究竟是怎样的,想必大家都很好奇
- ThreadLcoal的成员变量十分简单,且都与计算ThreadLocal的哈希有关
public class ThreadLocal<T> { // 每个线程都包含一个基于线性探测的哈希表,ThreadLocal依靠该哈希表绑定到对应线程 // ThreadLocal对象是哈希表中的key,可以通过threadLocalHashCode进行检索 // 这是一种自定义的hashCode,具有很好的散列效果,仅在ThreadLocalMap中有效 private final int threadLocalHashCode = nextHashCode(); // 计算下一个ThreadLocal对象的哈希,初始值为0 // 通过加上哈希增量0x61c88647,可以计算得到一个新的hashCode private static AtomicInteger nextHashCode = new AtomicInteger(); // 固定的哈希增量 private static final int HASH_INCREMENT = 0x61c88647; // 使用原子类的加法,保证哈希计算的线程安全 private static int nextHashCode() { return nextHashCode.getAndAdd(HASH_INCREMENT); // 加法计算,返回旧值 } }
关于哈希增量0x61c88647
- 有博客说,这是一个神奇的数字,可以让哈希值均匀地分布到长度为 2 N 2^N 2N的哈希表中
- 参考链接:为什么ThreadLocalMap 采用开放地址法来解决哈希冲突?
- 确实如此,ThreadLocalMap中的桶数组
Entry[] table
初始长度为16,后续扩容也是按照2倍扩容 - 感兴趣的读者,可以上网查一下相关的内容
3.2 构造函数
-
ThreadLocal的构造函数只有一个,其实就是个默认构函数
public ThreadLocal() {}
-
除此之外,还有一个静态工具方法
withInitial()
,支持创建具有初始值的ThreadLocalpublic static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) { return new SuppliedThreadLocal<>(supplier); }
-
SuppliedThreadLocal的定义如下,它继承了ThreadLocal,重写了ThreadLocal的
initialValue()
方法- initialValue() 方法返回函数式编程接口
Supplier
中产生的值 - 这个值其实就是ThreadLocalMap中key(ThreadLocal)对应的初始value,也就是线程局部变量
static final class SuppliedThreadLocal<T> extends ThreadLocal<T> { private final Supplier<? extends T> supplier; SuppliedThreadLocal(Supplier<? extends T> supplier) { this.supplier = Objects.requireNonNull(supplier); } @Override protected T initialValue() { return supplier.get(); } }
- initialValue() 方法返回函数式编程接口
函数式接口Supplier
-
Supplier是一个函数式接口,其get() 方法没有入参,但会自动生产一个值并返回
-
如何生产这个值,由实现
Supplier
接口时的代码决定public static void main(String[] args) { // 返回一个随机数值字符串 String randomString = getRandomString(() -> { return RandomStringUtils.random(8, false, true); }); System.out.println(randomString); // 利用lambda表达式简写为 randomString = getRandomString(() -> RandomStringUtils.random(8, false, true)); System.out.println(randomString); } public static String getRandomString(Supplier<String> supplier) { return supplier.get(); }
-
执行结果如下:
withInitial() 方法使用示例
-
示例代码如下,通过withInitial() 方法创建ThreadLocal对象threadLocal
-
通过threadLocal .get() 方法,获取threadLocal 为当前线程绑定的线程局部变量
public static void main(String[] args) { ThreadLocal<String> threadLocal = ThreadLocal.withInitial(() -> { String prefix = "k8s-presto-"; return prefix + RandomStringUtils.random(2, false, true); }); // 打印main线程中, threadLocal对应的线程局部变量 System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get()); // 打印子线程中, threadLocal对应的线程局部变量 new Thread(() -> System.out.println(Thread.currentThread().getName() + ": " + threadLocal.get())).start(); }
-
执行结果如下
3.3 get方法
3.3.1 ThreadLocal的get()方法
- 关于如何实现get方法,直观想法非常简单、清晰
- 注意: 从本小节开始,若无特殊说明,value就是线程局部变量
直观想法
- 如果不关注权限修饰符,根据Thread、ThreadLocal、ThreadLocalMap之间的三者之间的关系,要想通过threadlocal这个key获取对应的value,最直观的想法是
- 以某种方式,获取Thread中的threadLocals,如
threadLocals = thread.getThreadLocals()
- 通过
value = threadLocals.get(threadlocal)
获取threadlocal对应的value
- 以某种方式,获取Thread中的threadLocals,如
- 包访问权限使得上述构想难以实现
-
首先,Thread类中,成员变量threadLocals为包访问权限(
default
),且未提供public的getter方法。因此,在用户代码中,不能从Thread获取其threadLocals -
就算提供了public的getter方法,get到的ThreadLocal.ThreadLocalMap也是包访问权限,不允许在用户代码中访问
-
其次,虽然ThreadLocal类提供了
getMap(Thread t)
方法用于获取线程的threadLocals,但该方法也是包访问权限ThreadLocalMap getMap(Thread t) { return t.threadLocals; }
-
- 可以说,精妙的包访问权限设计,使得只有在ThreadLocal或Thread类中,才能访问ThreadLocalMap
- JDK源码的实现更加精妙:在Thread类中定义threadLocals,在ThreadLocal中初始化并读写当前线程的threadLocals
JDK源码的实现
- 通过threadlocal对象获取线程绑定的value,由ThreadLocal类中的get()方法提供支持
- 获取当前线程的ThreadLocalMap
- 然后,将当前的threadlocal对象作为key,从ThreadLocalMap 中获取对应的value
- 若尚未绑定value,则将value初始化为
initialValue()
方法返回的
public T get() { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); // 获取当前线程的ThreadLocalMap if (map != null) { // 尝试从map中查找entry,获取对应的value ThreadLocalMap.Entry e = map.getEntry(this); if (e != null) { @SuppressWarnings("unchecked") T result = (T)e.value; return result; } } return setInitialValue(); // map为空或不存在对应的entry,则返回一个默认初始值 }
关于初始value
-
setInitialValue() 方法如下,无论是否需要新建ThreadLocalMap,最终都是将initialValue() 方法的返回值作为初始value
- JDK源码中,还对该方法有如下说明:它是set()方法的变种,用于设置初始value,避免由于用户重写set()方法
- 自己的理解:用户重写的set()方法可能并未真正的创建entry或设置value
private T setInitialValue() { // 将initialValue()的返回值作为初始value T value = initialValue(); Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) // 将initialValue()返回的值作为value map.set(this, value); else // map为null,构建map createMap(t, value); return value; } // 创建指定entry的ThreadLocalMap void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }
-
initialValue() 方法如下,默认返回
null
值。- 在尚未通过set() 方法为线程绑定value时,第一次通过get()方法获取value,将会调用initialValue() 方法,将其返回值作为value的初始值
- 若已经通过remove() 方法删除线程绑定的value,则紧随其后的get() 方法,也将调用initialValue() 方法
- initialValue() 方法默认返回
null
值,建议以匿名类的方式继承ThreadLocal、重写 initialValue() 方法,使得初始值非空protected T initialValue() { return null; }
-
这下,终于知道为什么withInitial() 方法在初始化ThreadLocal时,只重写了
initialValue()
方法。 -
因为,withInitial() 方法作用就是实现一个具有初始value的ThreadLocal对象
3.3.2 ThreadLocalMap的getEntry()方法
getEntry() 方法
-
获取key对应的entry,采用一种fast path机制:若直接命中,则直接返回对应的entry;否则,需要通过getEntryAfterMiss() 方法进行中继
private Entry getEntry(ThreadLocal<?> key) { int i = key.threadLocalHashCode & (table.length - 1); // 计算index,等同于hashCode % table.length Entry e = table[i]; if (e != null && e.get() == key) // 直接命中 return e; else // 否则,通过getEntryAfterMiss() 方法进行中继(继续查找) return getEntryAfterMiss(key, i, e); }
getEntryAfterMiss() 方法
-
传入key、index和首次获取到的entry,只要entry不为null,则获取entry对应的key进行判断
-
key相等,说明找到对应的entry(开放定址法导致entry并非处于期望的slot中)
-
key为null,说明该entry已经过期,通过
expungeStaleEntry()
方法进行处理; -
key不为null,说明位置已被其他entry占据,需要继续向后查找(这是因为ThreadLocalMap采用开放定址法)
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; // 清理过期的entry,方便及时回收value和entry // 清理完成后,继续从i开始比较,避免有效entry rehash后位于i这个slot if (k == null) expungeStaleEntry(i); else i = nextIndex(i, len); e = tab[i]; } return null; } private static int nextIndex(int i, int len) { return ((i + 1 < len) ? i + 1 : 0); }
expungeStaleEntry()方法
-
清理过期的entry和最近的空位置(
null slot
)之间的过期entry,有利于GC回收,避免内存泄漏(所谓过期entry,就是key为null
的entry) -
同时,将有效的entry放到新的、合适的位置
private int expungeStaleEntry(int staleSlot) { Entry[] tab = table; int len = tab.length; // 清理过期的entry tab[staleSlot].value = null; tab[staleSlot] = null; size--; // 对后续entry进行rehash,直到遇到空slot Entry e; int i; for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == null) { // 过期entry,继续清理 e.value = null; tab[i] = null; size--; } else { // 有效entry,rehash到合适的位置(补齐空slot) int h = k.threadLocalHashCode & (len - 1); // 期望的index与当前index不相等,说明是开放地址法移位导致的,需要将其放到最近的有效entry之后 if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; } } } return i; // 返回空slot的index }
- 也就是说,expungeStaleEntry() 方法的目标:清除当前过期entry到下一个空slot之间所有过期entry,并将有效entry进行rehash
- 后续的学习中,我们将会发现:ThreadLocal的get()、set()、remove() 方法,遇到key为`null的情况,都会调用expungeStaleEntry() 方法清理过期entry
3.3.3 get方法总结
ThreadLocal的get() 方法
- ThreadLocal的get() 方法,两个大的方向:
- ThreadLocalMap不为null,则通过ThreadLocalMap.getEntry(key) 获取对应的entry
- 如果map为空或未找到对应的entry,则添加新的entry。其中,value为initialValue() 方法返回的默认值
- ThreadLocalMap.getEntry(key)方法,采用fast path模式:
- 直接命中,则返回命中的entry;
- 否则,通过ThreadLocalMap.getEntryAfterMiss() 继续查找
ThreadLocalMap.expungeStaleEntry()方法
- ThreadLocalMap.expungeStaleEntry()方法,清理从index开始过期的entry,并对有效entry进行rehash
3.4 set方法
3.4.1 ThreadLocal的set方法
-
set()方法如下,可以为当前线程绑定一个线程局部变量
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) // 向已存在的map中添加值 map.set(this, value); else // 否则,需要新建ThreadLocalMap createMap(t, value); }
3.4.2 ThreadLocalMap的set方法
- ThreadLocal的set方法的关键还是在ThreadLocalMap的set()方法
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table; int len = tab.length; int i = key.threadLocalHashCode & (len-1); // 计算index // 开放定址法:对应的位置存在entry,则尝试下一个位置 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和value替代已经过期的entry replaceStaleEntry(key, value, i); return; } } // 找到空slot,新建entry tab[i] = new Entry(key, value); int sz = ++size; // 清理过期的entry,如果仍然超过阈值则需要扩容 if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash(); }
举一个例子
-
假设ThreadLocalMap的长度为10,布局如下:
-
新来的ThreadLocal的hashCode为15,应该放在index为5的slot。该slot中的entry已经过期,则执行如下代码:
if (k == null) { replaceStaleEntry(key, value, i); return; }
replaceStaleEntry() 方法
- replaceStaleEntry() 方法并非简单地使用新entry替换过期entry
- 而是从过期entry所在的slot(
staleSlot
)向前、向后查找过期entry,并通过slotToExpunge
标记过期entry最早的index - 最后,使用cleanSomeSlots() 方法从
slotToExpunge
开始清理过期entryprivate void replaceStaleEntry(ThreadLocal<?> key, Object value, int staleSlot) { Entry[] tab = table; int len = tab.length; Entry e; // slotToExpunge记录需要清理的index,始终指向最早的过期entry的索引 int slotToExpunge = staleSlot; // 从staleSlot的前一个位置开始,向前查找过期entry并更新slotToExpunge,直到遇到空slot for (int i = prevIndex(staleSlot, len); (e = tab[i]) != null; i = prevIndex(i, len)) if (e.get() == null) slotToExpunge = i; // 从staleSlot的后一个位置开始,向后查找 for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) { ThreadLocal<?> k = e.get(); if (k == key) { // 由于开放定址法,可能相同的key存放于预期的位置(staleSlot)之后 // 如果遇到相同的key,则更新value,并交换staleSlot与当前index的entry // 交换的原因:让有效的entry占据预期的位置(staleSlot),避免重复key的情况 e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; // 向前查找,未找到过期entry,更新slotToExpunge为当前index if (slotToExpunge == staleSlot) slotToExpunge = i; // 从slotToExpunge开始,清理一些过期entry cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; } // 向后查找,未找到过期entry,更新slotToExpunge为当前index if (k == null && slotToExpunge == staleSlot) slotToExpunge = i; } // 直到遇到空slot也未发现相同的key,则在staleSlot的位置新建一个entry tab[staleSlot].value = null; tab[staleSlot] = new Entry(key, value); // 存在过期entry,需要进行清理 if (slotToExpunge != staleSlot) cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); }
- 总体来说,过期entry所在的staleSlot是key瞄准的位置:
- 如果key已经存在,则需要将原entry更新、与staleSlot对应的过期entry交换,使其位于staleSlot这个位置
- 如果遇到了空slot,都未发现key相等的entry,说明key不存在 ⇒ \Rightarrow ⇒ 直接在在staleSlot这个位置新建entry
- 不管是哪种情况,只要发现过期entry,都需要通过cleanSomeSlots() 进行清理
- 而过期entry存在的判断条件为:
slotToExpunge != staleSlot
-
接着上面的示例:key为15,staleSlot为5,向前查找过期的entry,直到遇到空slot。
-
由于index为3的entry已过期,将执行如下代码更新slotToExpunge的值为3
if (e.get() == null) slotToExpunge = i;
-
key为15,staleSlot为5,向后查找。发现index为6的entry,其key与当前key相等,于是执行如下代码:
if (k == key) { e.value = value; tab[i] = tab[staleSlot]; tab[staleSlot] = e; if (slotToExpunge == staleSlot) slotToExpunge = i; cleanSomeSlots(expungeStaleEntry(slotToExpunge), len); return; }
-
首先,将index为6的entry的值更新为
new
,然后将index为6和staleSlot的entry进行交换
-
然后执行cleanSomeSlots() 方法,清理过期entry
为何需要staleSlot和key相等时的slot交换?
-
假设不进行交换,清理完过期entry后,ThreadLocalMap将如下图所示
-
后续如果再set哈希值为15的threadlocal对应的value为
new1
),发现期望的index为5的slot就是空的,于是直接插入新的entry
-
这时,ThreadLocalMap中,将存在两个entry的key为15,显然不符合map的定义
-
其实,expungeStaleEntry() 方法对有效entry的rehash,也是出于对这样的情况的考虑
expungeStaleEntry() 方法的执行
-
在调用cleanSomeSlots() 方法前,首先需要调用expungeStaleEntry() 方法清理过期的entry
-
通过之前的学习,expungeStaleEntry() 方法将清理从指定slot开始到下一个空slot之间所有的过期entry,并对有效的entry进行rehash
-
根据上面的示例,expungeStaleEntry() 方法的入参
slotToExpunge = 3
-
index为3的slot将清空
-
index为4的entry,其key为4,预期slot是4,无需任何操作
-
index为5的entry,其key为15,预期slot是5,无需任何操作
if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; }
-
然后index为6的slot将清空
-
index为7的entry,其key为25,预期slot是5,与实际slot不匹配。
-
执行以下代码,清空当前slot并将其前移:key为25的entry,将移动到index为6的slot
if (h != i) { tab[i] = null; while (tab[h] != null) h = nextIndex(h, len); tab[h] = e; }
-
当index为8时,发现是个空slot,于是停止清理操作,并返回
index = 8
(有博客说是7,自己坚信是8 😂) -
最后,整个ThreadLocalMap更新如下:
cleanSomeSlots()方法
-
cleanSomeSlots() 方法的代码如下:通过循环扫描,尽可能多的清理ThreadLocalMap中的过期entry
-
i
表示已知的不会持有过期条目的位置,n
用于扫描控制:如果不存在过期的entry,则执行 l o g ( 2 N ) log(2^N) log(2N)次扫描 -
方法注释中说,这样的设计简单、快速且运行良好
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) { // 遇到过期entry,需要重置n n = len; removed = true; i = expungeStaleEntry(i); } } while ( (n >>>= 1) != 0); return removed; }
-
对上面的ThreadLocalMap做一下更改,在首尾增加过期的entry
-
执行完expungeStaleEntry() 方法返回的值为8,则扫描index = 9时,发现过期entry
-
将n重置为table长度,从index = 9开始清理过期entry:index为9和0的slot都将被置为空,当index为1时,发现slot为空,停止清理操作
-
最终,直到n变为0,都不会发现过期entry,cleanSomeSlots()方法执行结束
3.4.3 ThreadLocalMap的rehash方法
-
ThreadLocalMap的set() 方法结尾,如果清理完过期entry后,
sz >= threshold
依然成立,则需要对桶数组进行扩容,也就是rehashif (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
-
rehash之前仍然先清理一次过期entry,如果
size > = 3/4 threshold
,也就是size >= 1/2 table.length
则进行扩容操作( threshold = 2/3 * table.length)private void rehash() { expungeStaleEntries(); // Use lower threshold for doubling to avoid hysteresis if (size >= threshold - threshold / 4) resize(); }
-
resize()方法如下,它会将通数值扩容2倍
private void resize() { Entry[] oldTab = table; int oldLen = oldTab.length; int newLen = oldLen * 2; Entry[] newTab = new Entry[newLen]; int count = 0; // 旧的桶数组中的entry移动到新的桶数组中 // 对于过期entry,直接断开entry对value的引用 for (int j = 0; j < oldLen; ++j) { Entry e = oldTab[j]; if (e != null) { ThreadLocal<?> k = e.get(); if (k == null) { e.value = null; // Help the GC } else { int h = k.threadLocalHashCode & (newLen - 1); while (newTab[h] != null) h = nextIndex(h, newLen); newTab[h] = e; count++; } } } // 更新threshold、size、table,旧的桶数组等待GC setThreshold(newLen); size = count; table = newTab; }
3.5 remove方法
- 学习了如何get 和set 线程绑定的线程局部变量
- 自然地,还想有一个方法支持解除线程局部变量与线程的绑定
- ThreadLocal的remove() 提供这样的支持
3.5.1 ThreadLocal的remove方法
-
ThreadLocal的remove() 方法代码十分简单:获取当前线程的ThreadLocalMap,然后从map中清除对应的entry
public void remove() { ThreadLocalMap m = getMap(Thread.currentThread()); if (m != null) m.remove(this); }
-
就如注释所说,remove() 方法可能会导致 initialValue()方法的多次调用
- 通过remove() 方法解除了绑定,在尚未通过set() 方法重新绑定线程局部变量之前
- 再次访问get() 方法,将会触发对ThreadLocal的 initialValue()方法的调用
3.5.2 ThreadLocalMap的remove方法
-
ThreadLocalMap的remove() 个方法如下
private void remove(ThreadLocal<?> key) { 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)]) { if (e.get() == key) { e.clear(); expungeStaleEntry(i); return; } } } public void clear() { this.referent = null; }
-
按照本人的构想,清除entry,超级简单:
entry.key = null; entry.value = null; entry = null;
-
实际上,JDK源码的实现十分精巧:
- 首先,断开entry与key之间的弱引用,entry过期
- 接着,通过expungeStaleEntry() 方法清除过期的entry
- expungeStaleEntry() 方法不止会清除当前过期entry,还会清除到下一个空slot之间的所有过期entry;同时,还会对有效的entry进行rehash
-
这样一来,remove当前entry,不仅清理了一波ThreadLocalMap中的过期entry,避免内存泄漏;还实现了有效entry的rehash,避免出现key重复的bug
参考链接:
- 图解ThreadLocalMap的清理操作:被大厂面试官连环炮轰炸的ThreadLocal (吃透源码的每一个细节和设计原理)
- 源代码剖析:ThreadLocal详解
4. ThreadLocal内存泄漏
- 在笔者看来,与ThreadLocal有关的内存泄漏,更多的是value无法及时回收所带来的内存泄漏
- 因为value存储本地变量,一般会包含大量数据,占用内存较大
- 而ThreadLocal类型的key,虽然被多个线程引用,但其实际只有对象,内存占用可以忽略不计
4.1 一个有趣的现象
- ThreadLocal的三个重要方法:get()、set()、remove(),最终都会调用expungeStaleEntry() 方法清理过期entry
- 清理过期entry的终极目标: 断开entry对value的强引用,避免value带来的内存泄漏
- get方法:执行ThreadLocalMap.getEntryAfterMiss() 方法查找entry时,若发现key为null,则调用expungeStaleEntry() 方法清理过期entry
- set方法:执行ThreadLocalMap.set() 方法查找entry时
- 若发现key为null,则执行replaceStaleEntry() → \rightarrow → cleanSomeSlots() → \rightarrow → expungeStaleEntry() ;
- 若最终在空slot处插入entry,执行cleanSomeSlots() → \rightarrow → expungeStaleEntry() 清理一波过期entry,然后根据size和threshold的关系决定是否rehash
- ThreadLocalMap在rehash之前,也会先执行expungeStaleEntry() 清理一波过期entry;最后
size >= 1/2 threshold
,才会真正的resize
- remove方法:先断开entry对key的弱引用,然后调用expungeStaleEntry() 完成后续的清理工作
4.2 内存泄漏的分析
key为强引用
- 若entry中的key为强引用,ThreadLocal对象的生命周期将和关联的线程一样长
- 尤其是线程池中的worker线程,其生命周期可能会非常长
- 即使不存在外部强引用,线程内部对ThreadLocal对象的强引用链
Thread --> ThreaLocalMap --> Entry --> key(ThreadLocal对象)
将一直存在 - entry不过期,关联的value无法及时回收,因此存在内存泄漏的风险
key为弱引用
-
针对上述问题,ThreadLocalMap的Entry,其key是对ThreadLocal对象的弱引用,value是对线程局部变量的强引用
-
若ThreadLocal对象不存在外部强引用,ThreadLocal对象将在下一次gc时被回收
-
ThreadLocal对象被回收后,entry中的key将变成
null
,entry过期 -
后续调用ThreadLocal的get()、set()、remove()方法,均会清理当前线程ThreadLocalMap中的过期entry,避免内存泄漏
key为弱引用的局限性
- 局限性:key设计为弱引用,只是降低了内存泄漏的概率,并不能完全避免内存泄漏
- 若无无过期entry清理机制,假设线程一直持续运行,entry对value的强引用链
Thread ---> ThreaLocalMap --> Entry --> value
将一直存在,value无法被GC,存在内存泄漏的风险 - 对此 ,JDK源码采取了补救措施:
- 在ThreadLocal的get()、set()和remove()方法中调用
expungeStaleEntry()
方法,清理key为null
的entry,避免value内存泄漏
- 在ThreadLocal的get()、set()和remove()方法中调用
- 这样的补救措施并不完美,考虑这样的场景:
- 无其他强引用指向ThreadLocal、ThreadLocal成功被GC,但后续的执行中,线程不再调用ThreadLocal的get()、set()和remove()方法
- 此时,即使ThreadLocalMap中存在过期entry,value也无法被GC,存在内存泄漏泄漏的风险
完美的解决方法
- 主动调用ThreadLocal的remove()方法,实现
Entry --> key
、Entry --> value
、ThreaLocalMap --> Entry
三大引用链的断开,完美回收整个entry,避免内存泄漏 - PS:主动回收资源,比自动回收更靠谱。
- 例如,文件流、网络流等,其Java对象占用的内存已被回收,但是关联的文件句柄、端口等系统资源并未被回收
- 下面的两个例子,由于未主动调用close(),导致了系统资源泄漏
4.4 ThreadLocal对象定义为private static
- 还记得ThreadLocal类的注释中,建议将ThreadLocal对象定义为
private static
ThreadLocal instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).
- 定义为private属性比较好理解:
- 成员变量的权限控制,避免外部类访问ThreadLocal、从而访问ThreadLocal中存储的value
- 这些value一般是不能被其他类访问的,如注释中提到的user ID、Transaction ID
- static的作用:
- 定义为static对象,ThreadLocal将与类具有相同的生命周期
- 虽然这样将使得key为弱引用的设计派不上用场,但随时可以调用与类有相同生命周期的ThreadLocal对象的remove方法,以避免value的内存泄漏
- 舍去一个ThreadLocal实例的内存空间占用,可以回收多个线程中的value,值得!
- 同时,避免Per Thread - Per Instance,符合Per Thread语义
- 定义为static,每个类一个ThreadLocal对象,而非每个实例一个ThreadLocal对象
- 既符合我们对ThreadLocal的定义,还能避免资源浪费或内存泄露
- 定义为static对象,ThreadLocal将与类具有相同的生命周期
解决办法:
- ThreadLocal对象被定义为
private static
,使得其强引用将一直存在,需要主动调用remove方法断开引用链,避免value的内存泄漏
举一个例子
- 通过ThreadLocal为每个查询线程关联一个数据库连接,查询线程来自于CachedThreadPool,也就是线程池中的worker
- worker从阻塞队列获取任务超时(60秒)后,将退出线程池,等待垃圾回收
- 而该Java应用的堆内存很大,终止的worker可能需要很长一段时间才会被垃圾回收,这时数据库连接将逐渐累积
- 同时,如果只是单纯回收数据库连接占用的内存,底层的一些资源实际并未释放,将加剧连接连接累积的情况
- 最理想的方法:
- 重写ThreadLocal的remove方法,增加主动释放数据连接的代码
- worker从线程池退出时,主动调用ThreadLocal对象的remove方法
- 其实,对于一般线程,就是在run方法中增加finally语句块,并在finally语句块中执行remove操作
- 但是线程池的封装比较完善,无法找到更新worker的突破口
- 自己的解决办法:
- 存储绑定了数据库连接的线程,定时任务判断线程状态
- 如果线程结束,则主动调用调用ThreadLocal对象的remove方法释放数据库连接
4.5 内存泄漏的总结
- ThreadLocal存在的内存泄漏问题,虽然有key为弱引用、set/get/remove等方法中主动清理过期entry等措施避免内存泄漏
- 但这些措施都无法完美解决内存泄漏的问题,手动调用remove方法才是王道 😂
参考链接
- 内存泄漏的分析:为何每次用完ThreadLocal都要调用remove()?
- 盗图:面试:为了进阿里,死磕了ThreadLocal内存泄露原因
- 线程池中的线程使用ThreadLocal更容易引发内存泄漏:使用ThreadLocal到底需不需要remove?
- 结合阿里编程规范讲解ThreadLocal,在finally语句中主动的remove为最佳实践:手撕面试题ThreadLocal!!!
5. 总结
5.1 知识点总结
ThreadLocal如何实现线程局部变量与线程的映射?
- 方案一:ThreadLocal内部维护一个map,线程作为key,线程局部变量作为value;多线程写map的线程安全问题、不主动remove带来的内存泄漏问题
- 方案二:Thread内部维护一个map,ThreadLocal作为key,线程局部变量作为value;不存在多线程写的问题、也存在内存泄漏的问题
- JDK的实现:采用第二种方案,会画图、会口述
ThreadLocalMap
- Entry的数据结构:对key为弱引用,对value为强引用
- 一个slot存放一个entry,采用开放定址法解决哈希冲突
- 一些属性:桶数组初始大小为16,容量阈值为 2/3的桶数组长度,2倍扩容(保证桶数组长度为 2 N 2^N 2N)
ThreadLocal
-
成员变量:
- threadLocalHashCode:当前ThreadLocal对象自身的hashCode
- 静态变量nextHashCode:下一个ThreadLocal对象的hashCode,使用CAS操作进行计算,每次计算hashCode都增加
0x61c88647
)
-
get方法:
- 访问当前线程的ThreadLocalMap,如果map为空或不在对应的Entry,则使用initialValue()返回的value作为默认值
- ThreadLocalMap的getEntry()查找entry:fast path机制:直接命中 + getEntryAfterMiss()
-
set方法:
- 通过ThreadLocalMap的set() 方法实现新建entry:key存在,更新value;发现过期entry,替代过期entry;均不满足,在空slot直接新建entry,清理过期entry、视情况决定是否进行rehash
- rehash操作关键:resize,扩容两倍,直接将旧桶数组中的有效entry放到新桶数组,过期entry,直接清理
- 执行replaceStaleEntry()方法替换过期entry:① 当前entry放到staleSlot;② 存在其他的过期entry,则通过cleanSomeSlots()进行清理
-
remove方法
- 通过ThreadLocalMap的remove() 方法实现entry清理:通过遍历确定entry,然后清理entry
- entry的清理:断开key的弱引用、expungeStaleEntry()清理过期entry
-
注意事项: 为每个线程绑定的value必须是不同的对象,否则还是存在数据共享,无法达到ThreadLocal线程隔离的目的:Java并发编程之ThreadLocal详解
内存泄漏的问题
- key为强引用,key的生命周期和线程一样长,存在key(ThreadLocal)的内存泄漏
- key为弱引用,不存在外部强引用时,key可以被GC,避免key的内存泄漏
- 新的问题,value的内存泄漏;get、set、remove方法,主动清理过期entry避免value的内存泄漏;若没有ThreadLocal对象访问线程的ThreadLocalMap,也存在value的内存泄漏
- 主动调用ThreadLocal的remove方法,完美解决内存泄漏的问题
5.2 关于线程隔离和线程安全
5.2.1 ThreadLocal如何实现线程隔离?
- 线程与线程局部变量绑定
- 每个Thread都有一个自己的ThreadLocalMap,Entry.key为ThreadLocal对象,value为线程局部变量
- 从而,使得每个线程都有自己的线程局部变量,实现了线程与线程局部变量绑定,
- 权限控制,只能通过ThreadLocal访问当前线程的ThreadLocalMap
- 首先,Thread类中ThreadLocalMap对象(
threadLocals
)为包访问权限 - 其次,ThreadLocalMap是ThreadLocal中具有包访问权限的静态内部类
- 用户代码中,即使get到了ThreadLocalMap也不允许访问
- 只能通过ThreadLocal的get、set、remove方法,访问当前线程的ThreadLocalMap
- 首先,Thread类中ThreadLocalMap对象(
- 也就是说,JDK源码将线程局部变量的访问接口收拢到了ThreadLocal中,同时这些接口只能访问当前线程的ThreadLocalMap,从而实现了线程隔离的特性
5.2.2 线程隔离等于线程安全?
- 最开始,本人一度认为只能通过ThreadLocal修改当前线程的线程局部变量的值,无法在其他线程并发修改
- 本人对反射的使用并不熟练,认为就算是反射也无法打破线程安全
- 甚至很佩服JDK源码的变成人员:我靠,不仅线程隔离,还线程安全啊,完美的实现!!!
行不通的反射思路
- 通过getDeclaredField获取Thread中的threadLocals字段,即ThreadLocalMap
- 通过field.get()方法获取该字段的值,这时必须把值转为ThreadLocalMap对象,才能继续执行后续的操作
- 而ThreadLocalMap是ThreadLocal类中具有包访问权限的静态内部类,无法使用类似
(String) field.get(obj)
的强制类型转换 - 我连一个ThreadLocalMap的Class对象都获取不到,set方法也没办法反射获取 😢
值得关注的现象
-
如果本人要是直到这样一个现象,思路肯定就不会这么受局限了
-
下面的代码,尝试获取value的具体类型,原本以为都会返回
java.lang.Object
JSONObject jsonObject = new JSONObject(); jsonObject.put("name", "张三"); jsonObject.put("age", 24); for (String key : jsonObject.keySet()) { Object value = jsonObject.get(key); System.out.println(key + "对应的value类型:" + value.getClass().getName()); }
-
明明获取的是Object类型的value,通过
value.getClass().getName()
返回却是一个具体的类型
-
本人菜鸟一只,说不清具体的原因,但基本能肯定这跟Java的继承分与多态不开关系
正确的反射实现
-
通过反射打破ThreadLocal的线程安全
public class Test { private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); public static void main(String[] args) throws InterruptedException { // 为主线程绑定线程局部变量 Thread mainThread = Thread.currentThread(); threadLocal.set("value"); System.out.println("反射前,线程局部变量的值:" + threadLocal.get()); new Thread(() -> { try { // 反射获取threadLocals字段并开放访问权限 Class threadClass = Thread.class; Field threadLocals = threadClass.getDeclaredField("threadLocals"); threadLocals.setAccessible(true); // 反射获取主线程threadLocals的内容 Object map = threadLocals.get(mainThread); // 反射获取ThreadLocalMap的set方法并开放访问权限 Method method = map.getClass().getDeclaredMethod("set", ThreadLocal.class, Object.class); method.setAccessible(true); // 调用set方法,修改线程局部变量的值 method.invoke(map, threadLocal, "new value"); } catch (NoSuchFieldException | InvocationTargetException | IllegalAccessException | NoSuchMethodException e) { e.printStackTrace(); } }).start(); // 休眠一段时间 TimeUnit.SECONDS.sleep(1); System.out.println("反射后,线程局部变量的值:" + threadLocal.get()); } }
5.3 参考链接
- 在学习ThreadLocal的过程中,发现了很多有用的博客,自己也是看得眼花缭乱
- 反而搞得自己一头雾水:我应该参考哪个博客?我应该先讲解哪个知识点?😂
- 最后:
- 着重看2到3篇博客,总结出学习要点;
- 根据自己的习惯,梳理出学习路径;
- 每个知识点的学习:阅读看源码,结合博客,进行归纳总结
参考链接:
-
最佳面试宝典(从实际问题的解决引出ThreadLocal、InheritableThreadLocal实现继承、remove避免内存泄漏等):Java面试必问:ThreadLocal终极篇 淦!
-
开放定址法、弱引用尽最大努力避免内存泄漏,比较适合面试看:一个ThreadLocal和面试官大战30个回合
-
几种常见的ThreadLocal(FastThreadLocal后续可以深入学习):阿里面试官问我ThreadLocal,我一口气给他说了四种!
-
InheritableThreadLocal:【Java并发编程】面试常考的ThreadLocal,超详细源码学习
-
其他:
-
后续:学习线程的退出机制、FastThreadLocal、InheritableThreadLocal等
-
路漫漫其修远兮啊