前言

ThreadLocal,简单翻译过来就是本地线程,但是直接这么翻译很难理解ThreadLocal的作用,如果换一种说法,可以称为线程本地存储。简单来说,就是ThreadLocal为共享变量在每个线程中都创建一个副本,每个线程可以访问自己内部的副本变量。这通常会比其它可选方法更简单、更快。例如,当在服务器上执行事务时,可能需要在多个方法和对象中访问事务上下文。ThreadLocal变量通常用于保存这种信息,以避免使用显式参数传递。

由于ThreadLocal的底层是ThreadLocalMap,这是一个静态内部类,用于存储每个线程的ThreadLocal变量。每个线程都有一个自己的ThreadLocalMap。ThreadLocalMap是一个定制的哈希映射,只用于维护线程本地值。它的键是ThreadLocal对象,值是线程本地变量的值。我们下面也会着重分析下这个静态内部类的源码。

简单用法

下面的例子中,我们创建了三个线程,每个线程都设定了自己的值,第三个线程会调用remove方法清除值。从结果我们可以看到每个线程的操作都是独立的,值并没有串掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ThreadLocalTest {
static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();

public static void main(String[] args) {
new Thread(() -> {
threadLocal.set(1);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("子线程的值1: " + threadLocal.get());
}).start();

new Thread(() -> {
threadLocal.set(2);
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("子线程的值2: " + threadLocal.get());
}).start();

new Thread(() -> {
threadLocal.set(3);
threadLocal.remove();
System.out.println("子线程的值3: " + threadLocal.get());
}).start();
}
}

程序结果:

子线程的值3: null
子线程的值2: 2
子线程的值1: 1

关键属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 用于作为ThreadLocal在ThreadLocalMap中的哈希值。每个ThreadLocal实例都有一个唯一的threadLocalHashCode,这个哈希值是在ThreadLocal实例创建时自动分配的。
private final int threadLocalHashCode = nextHashCode();

// 用于生成下一个ThreadLocal实例的threadLocalHashCode。每当创建一个新的ThreadLocal实例时,nextHashCode就会自动增加
private static AtomicInteger nextHashCode =
new AtomicInteger();

// 用于计算下一个threadLocalHashCode。它的值是0x61c88647,这是一个“黄金角度”的近似值,用于在哈希表中分布键
private static final int HASH_INCREMENT = 0x61c88647;

// 计算threadLocalHashCode的方法
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

构造器

1
2
3
4
5
6
7
// 无参构造器
public ThreadLocal() {
}
// 这个方法并不是构造器,但它的功能类似于一个构造器。它创建一个新的ThreadLocal变量,并使用一个Supplier来提供初始值。这个方法用于支持使用lambda表达式或方法引用来提供ThreadLocal的初始值。
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
return new SuppliedThreadLocal<>(supplier);
}

常用方法

设置属性

通过set方法来设置属性

1
2
3
4
5
6
7
8
9
10
public void set(T value) {
Thread t = Thread.currentThread();
// 获取线程的ThreadLocalMap变量
ThreadLocalMap map = getMap(t);
if (map != null) { // 如果ThreadLocalMap不为空,直接设置值
map.set(this, value);
} else { // 否则创建一个新的ThreadLocalMap
createMap(t, value);
}
}

访问属性

访问属性也是从ThreadLocalMap中获取。需要注意的是,我们可以指定未查询到数据时的返回值,默认是返回null,如果要返回其它值,只要重写initialValue()即可。所以我们在调用get()方法时并不是单纯的查询操作,在未查询到数据时它会有写的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public T get() {
Thread t = Thread.currentThread();
// 获取线程的ThreadLocalMap变量
ThreadLocalMap map = getMap(t);
if (map != null) {
// 获取Entry中的value值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 添加未查询到数据时的处理逻辑
return setInitialValue();
}

private T setInitialValue() {
// 可以重写initialValue()这个方法,以指定未查询到值时可以返回这个默认值。默认是返回null。
T value = initialValue();
// 下面逻辑和set基本是一致的。
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}

// 获取线程的ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

移除属性

从ThreadLocal中移除当前线程的值,主要操作也是在ThreadLocalMap中。

1
2
3
4
5
6
7
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null) {
// 操作也是在ThreadLocalMap中的
m.remove(this);
}
}

总结

总的来说,ThreadLocal自身的操作逻辑其实是很清晰的,它的逻辑基本都是维护在自己的静态内部类ThreadLocalMap中。

ThreadLocalMap解析

简介

ThreadLocalMap是维护在ThreadLocal类中的一个静态内部类,它用于存储每个线程的ThreadLocal变量。每个线程都有一个自己的ThreadLocalMap。ThreadLocalMap是一个定制的哈希映射,只用于维护线程本地值。它的键是ThreadLocal对象,值是线程本地变量的值。

关键属性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 默认容量是16,必须是2的倍数
private static final int INITIAL_CAPACITY = 16;

// 用于存储ThreadLocalMap的键值对
private Entry[] table;

// 数组中Entry的数量
private int size = 0;

// 决定何时扩容的阈值
private int threshold;

private void setThreshold(int len) {
threshold = len * 2 / 3;
}

构造器

这里注意这个点,ThreadLocalMap的存储容器为一个Entry类型的数组,默认容量为16。这里的寻址算法是ThreadLocal的Hash值和数组容量减去一后的值做“与”操作后得出来的值。

1
2
3
4
5
6
7
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}

关键方法

添加数据

添加数据调用的是set方法,这个方法会先根据当前ThreadLocal的hash值和数组长度-1的值计算这个ThreadLocal在表中的下标,然后从下标处开始遍历数组,如果最后一个元素也不匹配会从头开始遍历。

  1. 如果找到具有相同键的条目,它将用新值替换条目的值
  2. 如果找到过时的条目,就替换掉这个条目
  3. 如果下标处为null,构造一个Entry并放入到这个位置中。同时清理条目后再判断是否触发扩容操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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)]) {// 从下标处开始循环,nextIndex方法会保证循环到最后一个元素再从第一个元素开始遍历
if (e.refersTo(key)) {// 如果找到具有相同键的条目,它将用新值替换条目的值
e.value = value;
return;
}

if (e.refersTo(null)) {// 如果找到过时的条目,替换这个条目
replaceStaleEntry(key, value, i);
return;
}
}
// 否则就说明当前下标处没有Entry,直接放到这个位置就好
tab[i] = new Entry(key, value);
int sz = ++size; // 数量加1
// 清理过时坑位后表中数量还是大于扩容的阈值,就触发扩容操作
if (!cleanSomeSlots(i, sz) && sz >= threshold)
// 扩容
rehash();
}

作为延伸,我们看下ThreadLocalMap是如何替换指定位置过时条目的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// 这段代码的目的是在当前运行中查找之前的陈旧条目,目的是为了一次性清理掉整个运行中的所有陈旧条目,避免因为垃圾回收器批量释放引用而导致的连续的增量式哈希表重构
int slotToExpunge = staleSlot; // 最早的一个过时条目的下标
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.refersTo(null)) // 表示这个条目已被垃圾回收器回收
slotToExpunge = i;

// 从staleSlot的下一个位置开始,向后遍历哈希表,查找键或空槽
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// 如果找到了键,将其与陈旧条目进行交换,以保持哈希表的顺序
if (e.refersTo(key)) {
e.value = value;

tab[i] = tab[staleSlot];
tab[staleSlot] = e;

// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 清理陈旧的条目
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}

// 如果在向前遍历的过程中没有找到陈旧条目,那么在向后遍历的过程中找到的第一个陈旧条目就是运行中仍存在的第一个陈旧条目
if (e.refersTo(null) && slotToExpunge == staleSlot)
slotToExpunge = i;
}

// 如果没有找到键,将新的条目放在陈旧条目的位置
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);
}

接下来我们看下ThreadLocalMap的扩容机制:

其实只是一个简单的扩容机制,大概是构造一个大小是旧数组两倍的新数组,然后遍历就数组,并将其中的内容根据寻址算法重新计算其位置,然后放入即可。

1
2
3
4
5
6
7
8
private void rehash() {
// 清理过时条目,size可能会减少
expungeStaleEntries();

// 使用较低的阈值进行扩容,假设原先数组容量为16,那么此时threshold为10,threshold - threshold / 4的结果为8.如果清理了两个过时条目,那么此时size也等于8,也能触发扩容。
if (size >= threshold - threshold / 4)
resize();
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
 // 对数组进行两倍扩容
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2; // 构造一个原数组两倍大小的新数组
Entry[] newTab = new Entry[newLen];
int count = 0;

for (Entry e : oldTab) {
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++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

查询操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private Entry getEntry(ThreadLocal<?> key) {
// 通过寻址算法找到下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.refersTo(key))// 下标处有条目并且正好与key相同,返回这个条目
return e;
else // 否则,处理key不匹配的情况
return getEntryAfterMiss(key, i, e);
}
// 说明下标处条目为null,或者不是null但key值不想等
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 如果e == null,那么直接返回null。否则从给定的下表处开始向后遍历,直到遍历到空槽位为止。
while (e != null) {
if (e.refersTo(key)) // 如果匹配到了元素,返回这个条目
return e;
if (e.refersTo(null)) // 不走运,条目已经过时,就触发清理过时条目动作
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}

删除操作

还是先根据寻址算法找到数组的下标,然后从这个下标处开始往后遍历,直到找到条目位置,删除条目后又清理了下过时条目。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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.refersTo(key)) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}

内存泄漏问题

具体来说,可能出现内存泄漏的情况有两种:

  1. ThreadLocal对象没有被外部强引用,但是它在ThreadLocalMap中作为键存在。由于ThreadLocalMap的键是对ThreadLocal对象的弱引用,所以在下一次垃圾回收时,ThreadLocal对象会被回收,但是ThreadLocalMap中的对应的值(即变量副本)却无法被回收,因为它仍然被ThreadLocalMap强引用。
  2. 长生命周期的线程(例如线程池中的线程)持有ThreadLocalMap,而ThreadLocalMap中的键值对没有被及时移除。这种情况下,即使ThreadLocal对象被外部强引用,ThreadLocalMap中的值(即变量副本)也无法被回收,因为它们被长生命周期的线程持有的ThreadLocalMap强引用。

为了避免ThreadLocal导致的内存泄漏,我们需要注意以下几点:

  1. 每次使用完ThreadLocal后,都应该调用其remove方法,将不再使用的变量副本从ThreadLocalMap中移除。
  2. 避免在长生命周期的线程(例如线程池中的线程)中使用ThreadLocal,除非你能确保变量副本在不再使用后能被及时移除
  3. 避免ThreadLocal对象被无限期地强引用,例如将ThreadLocal对象存储在静态字段中