Java
开发者想必有一个类听得比较多,也就是ThreadLocal
。关于它的话题也是比较多的:
那么本文就来探究一下。
要知道ThreadLocal
的原理,我们首先应该去了解它的一个简单的用法。ThreadLocal
用起来并不复杂,就是一个get、set
罢了。
我们看一个案例:
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
在同一个线程里面只会存储一个对象。我们从 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
源码中可以发现,ThreadLocalMap
是ThreadLocal
的一个内部类:
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;
}
从源码我们可以知道:
ThreadLocal
的时候,肯定会new
一个对象出来。但是ThreadLocal
的无参构造什么也没有做。ThreadLocal
存储对象的时候,并不是将对象存储在ThreadLocal
本身,而是ThreadLocalMap
中。ThreadLocalMap
肯定也是null
(ThreadLocal
构造函数并没有初始化ThreadLocalMap
)。因此ThreadLocalMap
实例对象需要被创建。因此会走createMap
。我们来继续分析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();
}
我们分几个点来讲解。
首先第一个就是源码中的for
循环,跳出循环有这么几种条件:
ThreadLocal
对象,此时旧值被更新为新值。ThreadLocal
为null
,说明当前对象可被回收。Entry
数组中寻找,直到某个下标对应的元素为null
。然后跳出循环。前面我们说过ThreadLocal
并不像HashMap
那样,为了解决哈希冲突,采用数组+链表的形式来存储。 其次Entry
本身就不具备链表的结构。那么在遇到哈希冲突的时候,是如何解决的呢?就是所谓的开放地址法。
(注意:这里的哈希冲突,发生在不同的ThreadLocal
实例之间,因为相同的ThreadLocal
实例,value
值直接被替代)
遇到哈希冲突了,有两个方向可供选择:
key
是null
:说明它处于可被回收的状态,那么进行替换操作。Entry
元素,直到满足上述循环跳出条件为止,否则一直循环。第一个我们好理解,竟然这个位置都是null
了,那么我们就用它就好了。那么第二点是怎么实现的?我们可以看for
循环中的这么一截代码:
e = tab[i = nextIndex(i, len)]
↓ ↓ ↓ ↓ ↓ ↓
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
很简单,就是取当前下标的下一个元素,如果超过了数组长度,就继续从头开始找。
那么开放地址法的思路也就比较明确了:一旦发生了哈希冲突,那么就去寻找下一个空的地址。
紧接着,我们再来看下,代码中的替换操作。
我们都知道的是,ThreadLocal
在进行元素插入的时候,会清除Map
中所有Key
为null
的值。而这部分的核心代码就在本小节讲解。
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);
}
总结下就是:
staleSlot
。staleSlot
该位置,分别向前和向后寻找第一个key
为null
的元素。那么接下来我们需要看下元素的清除操作,我们可以发现,代码里面,有段代码被两个地方同时引用到:
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
的情况发生。
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;
}
总结下就是:
ThreadLocal
对应的存储下标。对应如果有值就直接返回table
数组中继续查找是否有相同的key
。即二次查找null
。最后来看下元素的删除操作。
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
需要进行垃圾的处理。
首先,为什么ThreadLocal
用于一个线程变量的使用呢? (线程和变量绑定)
ThreadLocal
存储数据的时候,底层是存储于ThreadLocalMap
中的。ThreadLocalMap
在第一次ThreadLocal.set()
的时候被初始化。同时它和当前线程绑定。threadLocals
属性。为什么同一个ThreadLocal
只能存储一个值?还有什么其他发现吗?
ThreadLocalMap
中,存储的Key
就是ThreadLocal
实例,而value就是你要存储的值。Map
中存在相同的Key
,也就是同一个ThreadLocal
的话,会进行值的替换操作。因为底层的数据结构Entry也不具备链表结构。并且采用开放地址法来解决哈希冲突问题。ThreadLocalMap
的大小初始化是16,也就是说,ThreadLocal
可以存在多个。即多个一对一关系。 但不能存在一对多的关系。ThreadLocal
在插入元素的时候,是如何做到元素清理的?
null
。staleSlot
(下图的下标5)。以他为轴,分别向两侧寻找最远的一个元素Key
(ThreadLocal
为null
)为null
的下标。分别记为N1
和N2
。(下图的下标2和8)N1
之前和N2
之后的最近的空位置(元素为空),记为M1
和M2
。(下图的下标0和9)[M1,M2]
范围内的过期元素进行回收。具体的回收动作:
null
。为什么ThreadLocalMap
要使用开放地址法,而不是拉链法?
开放地址法:一旦发生了哈希冲突,那么就去寻找下一个空的地址。
ThreadLocal
存储的数据量往往比较小,同时Key
是弱引用,会被垃圾回收,因此数据量更小。为什么在清理数据的时候,还要把非空元素向前移动呢?(很重要)
ThreadLocal
采用开放地址法。不断地循环数组,找到第一个非空元素作为当前元素插入的地址。null
(即断层)的情况。结合第一点,会导致某个null
值后面的元素访问不到的情况。我们从源码上可以看到,Key
为弱引用。但是Key
并不是导致内存泄漏的一个原因。内存泄漏的本质原因还是在于对应的value
没有被删除掉。
我们同样可以看到,我们调用ThreadLocal
的get
和set
方法的时候,都会调用到expungeStaleEntry
这个函数。而它的功能就是用来做垃圾回收的。
那么为什么Key
要作为弱引用?结合ThreadLocal
的一个使用场景:
Key
作为弱引用,那么Key
被回收的概率也就比较大。ThreadLocal
每次插入和获取元素的时候,会清理过期的数据,也就是Key
为null
的数据。就保证ThreadLocalMap
的大小不会太大。key
使用强引用,当引用的ThreadLocal
的对象被回收了,但是ThreadLocalMap
还持有ThreadLocal
的强引用,只要没有手动删除,ThreadLocal
不会被回收,从而导致Entry
内存泄漏。我们可以发现,调用get
和set
方法,都会帮助ThreadLocal
去进行垃圾清理。相反,如果没有调用就可能面临着内存溢出的风险。即ThreadLocalMap
中可能存在null-->值
这样的键值对。导致对应的value
不会被回收。
因此我们可以养成良好的编码习惯,在调用结束之前,调用ThreadLocal.remove()
函数。将对应Key
的value
设置为null
。
remove
,因为线程停掉之后,ThreadLocal
也跟着没了。两者生命周期是一致的。remove
。因为线程在结束任务之后,并不会被销毁,而是返回线程池里面等待任务的调度。