目录
内存泄漏
我对java的内存泄漏理解是某个对象你访问不到他,但是GCRoots可达性分析可以访问到,所以这个对象既无法被使用,也无法被回收,这样一来就造成了这块内存空间被浪费
ThreadLocal的内存泄漏
强引用链路导致的内存泄漏
回顾一下这张图的引用关系,可以发现除了key对于threadLocal是弱引用关系,其余都是强引用。
如果threadLocal外部强引用被置为null(threadLocalInstance=null)的话,threadLocal实例就没有一条强引用链路可达,很显然在gc的时候势必会被回收,因此entry就存在key为null的情况,无法通过一个Key为null去访问到该entry的value。
然而从图中可以看出存在这样一条强引用链路:
Thread对象引用--->当前线程对象中的threadLocalMap引用--->threadLocalMap中的entry引用--->value引用--->value对象本身
这条强引用链路导致key为null的情况下entry和value都不能被回收,且外部无法访问到,因此就造成了内存泄漏。
当然,线程执行结束后,Thread对象引用会断掉,如果没有用static修饰的话threadLocal,threadLocalMap,entry都会被回收掉。可是,在实际使用中我们都是会用线程池去维护我们的线程,比如在Executors.newFixedThreadPool()时创建线程的时候,为了复用线程是不会结束的,所以threadLocal内存泄漏就值得我们关注。
ThreadLocal源码对内存泄漏的处理
set方法深入解析
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1); // 用key的hashCode计算槽位
// hash冲突时,使用开放地址法
// 因为独特和hash算法,导致hash冲突很少,一般不会走进这个for循环
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) { // key 相同,则覆盖value
e.value = value;
return;
}
if (k == null) { // key = null,说明 key 已经被回收了,进入替换方法
replaceStaleEntry(key, value, i);
return;
}
}
// 新增 Entry
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold) // 清除一些过期的值,并判断是否需要扩容
rehash(); // 扩容
}
关键点:
-
开放地址法:与我们常用的Map不同,java里大部分Map都是用链表发解决hash冲突的,而 ThreadLocalMap 采用的是开发地址法。而且处理哈希冲突的时候采用线性探测固定1的步长寻找下一个可用的地址。
- 探测过程中slot都不无效,并且顺利找到key所在的slot,直接替换即可
-
探测过程中发现有无效slot,调用replaceStaleEntry,效果是最终一定会把key和value放在这个slot,并且会尽可能清理无效slot。 在replaceStaleEntry过程中,如果找到了key,则做一个swap把它放到那个无效slot中,value置为新值。 在replaceStaleEntry过程中,没有找到key,直接在无效slot原地放entry
-
hash算法:hash值算法的精妙之处上篇已经讲了,均匀的 hash 算法使其可以很好的配合开方地址法使用。
-
过期值清理:关于过期值的清理,也是解决内存溢出的一种办法。
-
探测没有发现key,则在连续段末尾的后一个空位置放上entry,这也是线性探测法的一部分。放完后,做一次启发式清理,如果没清理出去key,并且当前table大小已经超过阈值了,则做一次rehash,rehash函数会调用一次全量清理slot方法也即expungeStaleEntries,如果完了之后table大小超过了threshold – threshold / 4,则进行扩容2倍
replaceStaleEntry
因为开发地址发的使用,导致 replaceStaleEntry 这个方法有些复杂,它的清理工作会涉及到slot前后的非null的slot。
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
// 向前找到第一个脏entry
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 找到 key 或者 直到 遇到null 的slot 才终止循环
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 如果在向后环形查找过程中发现key相同的entry就覆盖并且和脏entry进行交换
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 如果在查找过程中发现脏entry,那么就以该位置作为cleanSomeSlots的起点
if (slotToExpunge == staleSlot)
slotToExpunge = i;
//搜索脏entry并进行清理
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 如果向前未搜索到脏entry,则在查找过程遇到脏entry的话,后面就以此时这个位置
// 作为起点执行cleanSomeSlots
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
//如果在查找过程中没有找到可以覆盖的entry,则将新的entry插入在脏entry
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 如果还有其他过期的entries存在 run 中,则清除他们
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
cleanSomeSlots
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) {
n = len;
removed = true;
i = expungeStaleEntry(i); // 清除方法
}
} while ( (n >>>= 1) != 0); // n = n / 2, 对数控制循环
return removed;
}
remove和set方法都会调用cleanSomeSlots,该方法会试探性地扫描一些 entry 寻找过期的条目。它执行 对数 数量的扫描,是一种基于不扫描(快速但保留垃圾)
和所有元素扫描
之间的平衡。每次扫描到失效元素时就会扩大log2(length)个扫描元素。
此方法并没有真正的清除,只是找到了要清除的位置,而真正的清除在 expungeStaleEntry(int staleSlot) 里面。
expungeStaleEntry
这里是真正的清除,并且不要被方法名迷惑,不仅仅会清除当前过期的slot,还回往后查找直到遇到null的slot为止。开发地址法的清除也较难理解,清除当前slot后还有往后进行rehash。
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清除当前过期的slot
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash 直到 null 的 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) {
e.value = null;
tab[i] = null;
size--;
} else {
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;
}
- 清理当前脏entry,即将其value引用置为null,并且将table[staleSlot]也置为null。value置为null后该value域变为不可达,在下一次gc的时候就会被回收掉,同时table[staleSlot]为null后以便于存放新的entry;
- 从当前staleSlot位置向后环形(nextIndex)继续搜索,直到遇到哈希桶(tab[i])为null的时候退出;
- 若在搜索过程再次遇到脏entry,继续将其清除。
rehash
private void rehash() {
expungeStaleEntries();
// 在上面的清除过程中,size会减小,在此处重新计算是否需要扩容
// 并没有直接使用threshold,而是用较低的threshold (约 threshold 的 3/4)提前触发resize
if (size >= threshold - threshold / 4)
resize();
}
private void expungeStaleEntries() {
Entry[] tab = table;
int len = tab.length;
for (int j = 0; j < len; j++) {
Entry e = tab[j];
if (e != null && e.get() == null)
expungeStaleEntry(j);
}
}
使用者应该注意的地方
-
每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
-
在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
参考资料:
ThreadLocal内存泄漏真因探究:https://www.jianshu.com/p/a1cd61fa22da
一篇文章,从源码深入详解ThreadLocal内存泄漏问题:https://www.jianshu.com/p/dde92ec37bd1
Threadlocal 和 ThreadLocalMap 原理解析:https://blog.csdn.net/zhuzj12345/article/details/84333765