概述
ThreadLocal 提供一种访问某个变量的特殊方式: 访问到的变量属于当前线程,它保证每个线程的变量相互隔离,而同一个线程在任何时候、任何地点都能获取属于本线程的变量。如果要使用 ThreadLocal,通常定义为 private statis
类型。ThreadLocal 适用在多线程的场景范围。
ThreadLocal 用途可总结为两点:
- 保存线程上下文信息,在任意时刻、任意地点可以获取安全的变量值。
- 线程安全,不需要额外的锁保证变量的一致性。
ThreadLocal 在开源项目中的应用有哪些?
- Spring 事务管理。源码详见 org.springframework.transaction.support.TransactionSynchronizationManager。
- 日志框架中的 MDC 利用 ThreadLocal 实现。
局限性:
ThreadLocal 无法解决共享对象的更新问题。因此建议使用 static
修饰,这个变量是针对一个线程内所有操作共享的,所以设置为静态变量,所有此类实例共享此静态变量,也就是说在类第一次被使用时装载,只分配一块存储空间,所有此类的对象(只要是这个线程内定义的)都可以操控这个变量。(引用阿里巴巴编程规范)。
ThreadLocal Demo
// 创建一个「ThreadLocal」对象
private static final ThreadLocal<String> THREAD_LOCAL_NAME =
ThreadLocal.withInitial(() -> Thread.currentThread().getName());
public static void main(String[] args) {
// 从「ThreadLocal」获取本地线程缓存变量值
System.out.println(THREAD_LOCAL_NAME.get());
for (int i = 0; i < 10; i++) {
// 打印
new Thread(() -> System.out.println("threadName: " + THREAD_LOCAL_NAME.get()), "thread-" + i).start();
}
}
// Result
// main
// threadName: thread-0
// threadName: thread-1
// threadName: thread-2
// threadName: thread-3
// threadName: thread-4
// threadName: thread-5
// threadName: thread-6
// threadName: thread-7
// threadName: thread-8
// threadName: thread-9
ThreadLocal API
ThreadLocal API 并不复杂,核心的 API 就只有三个,分别是 get()、set() 和 remove()。ThreadLocal 支持泛型参数,因此,你不需要强转返回值。
/**
* 利用供应者{@link java.util.function.Supplier}返回的结果初始化「ThreadLocal」
* 每个线程一创建,就会通过「Supplier」设置不同的初始值。如果你不想饿汉创建的话,也可以下放到方法
* 内创建,通过 get() 判断是否为空,如果为空则创建一个新对象并通过set()方法存入「ThreadLocal」
* 如果通过此API设置一个初始值,经过remove()之后还能获取到初始值
* @param supplier 供应者
* @param <S>
* @return
*/
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier);
/**
* 获取值
* @return
*/
public T get();
/**
* 设置值
* @param value
*/
public void set(T value);
/**
* 移除值
*/
public void remove();
InheritableThreadLocal 是 TheadLocal 的一个子类,提供一种线程之间的属性继承关系。比如线程 A 创建线程 B,线程 B 可以拥有访问线程 A 创建的所有的 ThreadLocal 变量。
ThreadLocalMap
ThreadLocalMap 底层采用数组存储数据,内部 Entry 继承 WeakReference 弱引用对 ThreadLocal 进行包装。当 ThreadLocal 外部没有任何强引用指向它时,GC 就会将其回收。但是这会导致内存泄漏,后面再说。ThreadLocalMap 所定义的变量解释如下:
// java.lang.ThreadLocal.ThreadLocalMap
/**
* ThreadLocalMap 是一个定制的hash map,只适用于维护本地线程值。
* 在ThreadLocal类之外不导出任何操作。
* 该类是包私有的,允许在类 Thread 中声明字段。
* Entry继承「WeakReference」类使之成为弱引用,目的是帮助处理大且存活时间长的对象。
* 但是,由于没有使用引用队列,因此只有在空闲内存不足的情况下对象才会被移除。
*/
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;
}
}
/**
* 数组初始容量大小,默认值: 16
*/
private static final int INITIAL_CAPACITY = 16;
/**
* 数组长度必须是2的次幂
*/
private Entry[] table;
/**
* table数组中已存储的数量
*/
private int size = 0;
/**
* 扩容阈值,默认值:0
*/
private int threshold;
}
ThreadLocalMap 是一种使用线性探测法实现的哈希表,它是如何解决哈希冲突呢?
我们先认识一下线性探测法。
线性探测法
用大小为 M 的数组保存 N 个键值对,其中 M>N。我们需要依靠数组中的空位解决碰撞冲突。基于这种策略的所有方法被统称为开放地址散列表。开放地址散列表中最简单的方法叫做线性探测法: 当碰撞发生时(当一个键的散列值已经被另一个不同的键占用),我们直接检测散列表的下一个位置(将索引加一)。这样的线性探测可能会产生三种情况:
- 命中,该位置的键和被查找的键相同;
- 未命中,键为空(该位置没有键);
- 继续查找,该位置的键和被查找的键不同。
我们用散列函数找到键在数组中的索引,检查其中的键和被查找的键是否相同。如果不同则继续查找(将索引增大,达到数组结尾时折回数组的开头),直到找到该键或者遇到一个空元素。开放地址散列表的核心思想是与其将内存用作链表,不如将它们作为在散列表的空元素。这此空元素可以作为查找结束的标志(以上引用自算法第4版)。
线性探测是计算机程序解决散列表冲突时所采取的一种策略。散列表这种数据结构用于保存键值对,并且能通过给出的键来查找表中对应的值。线性探测这种策略是在1954年由Gene Amdahl, Elaine M. McGraw,和 Arthur Samuel 所发明,并且最早于1963年由Donald Knuth对其进行分析。 当散列函数对一个给定值产生一个键,并且这个键指向散列表中某个已经被另一个键值对所占用的单元时,线性探测用于解决此时产生的冲突:查找散列表中离冲突单元最近的空闲单元,并且把新的键插入这个空闲单元。同样的,查找也同插入如出一辙:从散列函数给出的散列值对应的单元开始查找,直到找到与键对应的值或者是找到空单元。线性探测能够提供高性能的原因是因为它的良好的引用局部性,然而它与其他解决散列冲突的策略相比对于散列函数的质量更为敏感。 引用维基百科
线性探测算法示意图
开放地址类的散列表的性能依赖公式 α = N/M,表示已被占用空间的比例,一般低于 1/8 到1/2 之间会有较好的性能。
还有一个值得注意的是删除操作,我们不能直接将该键的位置置为 NULL,否则位于此位置之后的元素无法被查找。因此,我们需要将被删除键的右侧所有的键重新插入散列表中。
ThreadLocalMap 如何实现开放地址列表
每个 ThreadLocal 在初始化时都会有一个 threadLocalHashCode 值,而这个值是通过静态方法 nextHashCode() 得到的,这个方法很有趣,每增加一个 ThreadLocal 对象,Hash 值就会固定增加一个魔术值 HASH_INCREMENT(0x61c88647)。实验证明,通过累加魔术值 0x61c88647 生成的 threadLocalHashCode 与 2 的次幂取模,得到的结果可以较为均匀地分布在 2 的次幂大小的数组中(据说与斐波那契散列法以及黄金分割有关)。相关源码解析如下,图文配合理解使用更佳。
下图解释在方法 replaceStaleEntry()
中有一步是当 key 相等时,需要与 NULL 的Entry 互换。
ThreadLocal#set
ThreadLocalMap#set
ThreadLocalMap#replaceStaleEntry
expungeStaleEntry
cleanSomeSlots
// java.lang.ThreadLocal#set
/**
* 向「ThreadLocal」添加值
*/
public void set(T value) {
// #1 获取当前线程
Thread t = Thread.currentThread();
// #2 从「Thread」对象中获取「ThreadLocalMap」对象
ThreadLocalMap map = getMap(t);
if (map != null)
// #3 添加值
map.set(this, value);
else
// #4 创建并添加
createMap(t, value);
}
// java.lang.ThreadLocal#getMap
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
// java.lang.ThreadLocal.ThreadLocalMap#set
/**
* 向「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;
// 因为Java规定「len」的值必须是2的次幂,即2^N,
// len-1=>低位有连续N个1。
// key.threadLocalHashCode & (len-1)=>获取threadLocalHashCode的低N位
// 这样就能获得均匀的Hash分布(与斐波那契散列法以及黄金分割有关)
// 详见 https://www.javaspecialists.eu/archive/Issue164-Why-0x61c88647.html
int i = key.threadLocalHashCode & (len-1);
// tab[i] != null 情况,向右找到一个空位存放当前值
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
// 判断「ThreadLocal」是否相等,相等则覆盖,否则继续向下查找匹配
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
// k==null&tab[i] !=null,表明key已经被GC(回顾弱引用相关知识)
if (k == null) {
// 使用新值替换旧值
replaceStaleEntry(key, value, i);
return;
}
}
// tab[i] == null
// 新创建「Entry」对象添加到数组中
tab[i] = new Entry(key, value);
int sz = ++size;
// 如果有NULL的Entry被清除且现存Entry数量超过了阈值,那么就需要重新HASH了
// cleanSomeSlots: 试探性的扫描tab[]数组,寻找key==NULL的Entry并清除,避免内存泄漏
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
// 对tab[]数组重新HASH
rehash();
}
}
/**
* 用新值替换旧值,这里我们需要清楚认识到当前tab[staleSlot]状态是
* e = tab[staleSlot] != null && e.get() == null
*/
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).
// 从staleSlot位置开始向前查找需要清除的tab节点i(e != null & e.get() == null)
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
// 与前面遍历方向相反,从位置「staleSlot」开始向后遍历,寻找清除的节点i(e != null & e.get() == null)
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.
// 若在NULL节点后面找到和「key」相等的节点,则与NULL节点互换
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 第一个「for」循环是向左查找「NULL」节点,这里会判断 slotToExpunge==staleSlot
// 若为「true」表示没有找到,所以从「i」开始清理
// 若为「false」表示有找到「NULL」节点,所以保持不变,从「slotToExpunge」开始清理「NULL」节点
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.
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);
}
/**
* 此方法目的是清理「NULL」节点。范围是从「staleSlot」到下一个「NULL」节点为止。
* staleSlot指向的节点此时为「NULL」节点。
* 明白一点,在长度不变的情况下,任意值的真实索引值>=Hash(任何值)。
* ① 从「staleSlot」开始向后遍历,找到NULL节点
* ② 清理这段tab[] 数组的NULL节点数据并重新HASH
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// #1 首先将「staleSlot」节点清除
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// #2 向右遍历每个节点,会遇到下面几种情况
// ① tab[nextId]==null,跳出循环并返回nextId
// ② tab[nextId]!=null & k==null,找到「NULL」 节点并清理
// ③ tab[nextId]!=null & k!=null,正常节点,需要重新进行HASH确定新的位置
Entry e;
int i;
for (i = nextIndex(staleSlot, len);(e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
// 当「ThreadLocal==null」时,则清除当前tab[i]
e.value = null;
tab[i] = null;
size--;
} else {
// 不为NULL,需要进行重新HASH
int h = k.threadLocalHashCode & (len - 1);
// 序号与之前不匹配,则重新找到空闲节点
if (h != i) {
// 重置节点i
tab[i] = null;
// 找到NULL的Entry存放数据
while (tab[h] != null) {
h = nextIndex(h, len);
}
// 重新引用对象
tab[h] = e;
}
}
}
// 空闲节点索引值(tab[i]==null)
return i;
}
/**
* 试探性扫描tab数组。并非是线性增长(+1),清理次数为log2(n)。
* 这个方法将会在添加一个新元素或者其他Entry节点被清理后调用。
*/
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// #1 获取下一个节点
i = nextIndex(i, len);
Entry e = tab[i];
// #2 遇到需要清理的Entry
if (e != null && e.get() == null) {
n = len;
//
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0); // n=n/2
return removed;
}
/**
* 如果超过长度,从0开始,否则+1
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
/**
* 获取前向节点。i=i-1
*/
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
}
ThreadLocalMap 的源码还是非常值得阅读的,里面的对性能的把控非常到位。通过局部性全清理和跳跃性探测清理互相配合,平衡性能。
ThreadLocal 内存泄漏 wiki
弱引用 是指当一个对象仅仅被弱引用指向,而没有任何强引用指向时,如果这里GC运行,那么这个对象就会被回收,不论当前内存空间是否足够。
ThreadLocalMap使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有任何强引用指向时,那么这个 ThreadLocal 势必会被 GC 回收。因此,就会出现 key 为 null 的Entry(注意,key 为 null 和 tab[i] ==null 在这里是不同的概念),就没有办法访问这些 key 为 null 的 Entry 的 value。如果当前线程还未结束,这些 key==null 的 Entry 就会一直存在强引用链,从而无法回收造成内存泄漏。
如何避免 ThreadLocal 内存泄漏
虽然 TheadLocal 在执行 ThreadLocal#set() 或 ThreadLocal#get() 方法时会清除 ThreadLocalMap 中 key==NULL 的 Entry 对象,从而避免内存泄漏。但最佳实践还是应该每次使用完 ThreadLocal,调用它的 remove() 方法清除数据,而不是 set(null),这可能会导致内存泄漏。
我的公众号
参考
- Java ThreadLocal(jenkov.com)
- 手撕ThreadLocal(匠心零度)
- 深入分析 ThreadLocal 内存泄漏问题
- 算法(第4版)
- 拉勾教育-Netty核心原理剖析与RPC实战