从源码看 ThreadLocal 内存泄漏问题
内存泄漏
不了解概念谈问题就是耍流氓,我们先看看内存泄漏的定义:
内存泄漏(Memory Leak)是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费。
用Java语言来讲就是堆内存中有数据无法被垃圾回收器回收。我们知道JVM是通过引用计数法
和可达性分析
来判断一个对象是否存活的,即一个对象被强引用链连接则为存活。
为了弄清ThreadLocal为什么会内存泄漏,首先要知道它的内存结构。如图所示:
通过源码会发现,ThreadLocal并不承载数据,真正的数据被存放在Thread对象中的ThreadLocalMap中,而ThreadLocal像一个管家,负责帮我们初始化、存取及销毁数据。
我们创建一个局部ThreadLocal变量,方法体结束后,红色引用被切断。Entry中的key为ThreadLocal的弱引用,于是不存在强引用的ThreadLocal对象变为可回收。JVM执行垃圾回收后,该对象被回收,Entry中的key变成null。
但是由于当前线程可能依然存活(main线程或线程池管理的线程),因此蓝色和橙色引用无法切断,Model对象无法被回收,于是造成了内存泄漏。开发Java语言的大神们一定想到了这一点,看看他们是怎么做的吧。
数据清除计划
通读一遍ThreadLocalMap源码(其实就是扫了一遍方法名),便会留意到expungeStaleEntry
方法。
// (尝试)清除陈旧条目
private int expungeStaleEntry(int staleSlot) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 将指定槽置为空,即打断橙色引用
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 从下一个槽开始,重hash直到遇见null
ThreadLocal.ThreadLocalMap.Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) { // 是陈旧条目,清除
e.value = null;
tab[i] = null;
size--;
} else { // 非陈旧条目,重hash
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;
}
我们从expungeStaleEntry
方法向上追溯,最终发现getEntry
set
remove
三个方法直接或间接调用了它,对应了ThreadLocal中的get
set
remove
,因此只有这三个方法被显示调用时,才有机会清除掉无效数据。
应用场景
1、临时数据
当我们需要给一个深层方法传值时,可以将数据保存到ThreadLocal,另一个方法再取出数据,从而避免参数多次传递。这时我们必须做手动清除。
var cache = new ThreadLocal<>();
try {
cache.set("hello world");
// call some method
} finally {
cache.remove();
}
MyBatis-PageHelper
插件就使用了这种方法。
同样也可以用于实现某些特殊的DSL,如:
sql.query(() -> {
select("*");
from("tableName");
where("1 = 1");
})
建议ThreadLocal
实现AutoCloseable
接口以便利用try with resource
语法。(手动狗头)
2、轻量级工具对象
这是一个更常见的需求,将一些轻量级的工具对象(如DateTimeFormatter
)存放到静态ThreadLocal
中,由于这类对象的使用贯穿整个应用的生命周期,并占用极少的内存,因此无需清除它们。
附:岁月静好
由于不懂算法,当我在读getEntry
方法时产生了一个困惑。
private ThreadLocal.ThreadLocalMap.Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
ThreadLocal.ThreadLocalMap.Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
private ThreadLocal.ThreadLocalMap.Entry getEntryAfterMiss(ThreadLocal<?> key, int i, ThreadLocal.ThreadLocalMap.Entry e) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
(错误的理解)假设有如下场景,我创建了三个threadLocal
对象,恰好它们都对应0号槽,根据插入规则,这三个对象会占据0,1,2槽。这时我remove掉threadLocal1,会产生如下结果。
# | slot | entry | key |
---|---|---|---|
i -> | 0 | null | |
1 | -> | threadLocal2 | |
2 | -> | threadLocal3 |
此时要获得threadLocal2时,getEntry
方法计算它的槽i = 0
,同时发现该位置为空e = null
,于是调用getEntryAfterMiss
继续查找。getEntryAfterMiss
方法发现e为空,直接返回null,threadLocal2和threadLocal3就这样丢失了。显然Java代码里不会有这么明显的错误,如果有,那一定是我打开的方式不对。
于是我针对性的查看有哪些地方将槽置为空(即tab[slog] = null
),发现只有三处,并且全在expungeStaleEntry
里。于是我仔细分析了Rehash部分代码做了什么,原来是它将后面的entry移动到正确的位置上。
现在我们来重新回顾一下删除流程。
private void remove(ThreadLocal<?> key) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
删除threadLocal1的过程一
- 计算得出槽
i = 0
- 槽非空,且key相等,则调用
expungeStaleEntry(i)
- 获得下一个槽,重复第2步
private int expungeStaleEntry(int staleSlot) {
ThreadLocal.ThreadLocalMap.Entry[] tab = table;
int len = tab.length;
// 将指定槽置为空,即打断橙色引用
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 从下一个槽开始,重hash直到遇见null
ThreadLocal.ThreadLocalMap.Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) { // 是陈旧条目,清除
e.value = null;
tab[i] = null;
size--;
} else { // 非陈旧条目,重hash
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;
}
删除threadLocal1的过程二
- 清空当前槽
staleSlot
- 获得下一个槽
i
- 如果
i
槽为空,则退出 - 如果此Entry的
key
为空,即threadLocal
对象已被垃圾回收,则清空该槽(解决正文中的内存泄漏问题) - 如果此Entry的
key
不为空,计算它的槽h
- 如果当前槽
i
和Entry的槽h
不一致,说明这个Entry是被临时安置在这里的,尝试将它移回槽h
(或后面的空槽) - 获得下一个槽
i
,重复第3步
删除前
# | slot | entry | key |
---|---|---|---|
0 | -> | threadLocal1 | |
1 | -> | threadLocal2 | |
2 | -> | threadLocal3 |
删除后
# | slot | entry | key |
---|---|---|---|
0 | -> | threadLocal2 | |
1 | -> | threadLocal3 | |
2 | null |
于是getEntry
便可正确获取threadLocal2和threadLocal3了。
总结起来一个槽为空只有如下可能:
- 这个槽从来没被用过
- 所有应当放在这个槽的Entry都被清理了
到这里,困扰我多时的问题终于豁然开朗。就是这么一个仅有寥寥几个方法的ThreadLocal
,它的背后是两个大神绞尽脑汁写下的算法,不知道他们写完这几百行代码掉了多少根头发。又有多少科学家费尽心血研究出各种算法,才有我们现在各种开箱即用的工具。
这让我想起来最近疫情期间流行的一句话:哪有什么岁月静好,不过是有人替你负重前行。
同样也是有我们这样一代代程序猿,贡献了青春,贡献了头发,才有一个个软件问世,让用户点点鼠标,按按屏幕,就能解决衣食住行。我想这就是编程的魅力吧。
来源:oschina
链接:https://my.oschina.net/u/580483/blog/4270382