文章目录
1 问题
如果我们在使用ThreadLocal的过程中发现有内存泄漏的情况,是不是这个内存泄漏跟Entry中使用弱引用的key有关系?下面我们先来复习下内存泄漏和弱引用相关的知识,在分析。
2 内存泄露
- Memory Overflow:内存溢出,内存不足(不够用了)。内存溢出(Out Of Memory,简称OOM)是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于能提供的最大内存。此时程序就运行不了,系统会提示内存溢出,有时候会自动关闭软件,重启电脑或者软件后释放掉一部分内存又可以正常运行该软件,而由系统配置、数据流、用户代码等原因而导致的内存溢出错误,即使用户重新执行任务依然无法避免
- Memory Leak:内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
3 弱引用
Java中的引用类型有4种类型:强、软、弱、虚。当前问题主要涉及强引用和弱引用。
- 强引用:就是我们最常见的普通对象引用,只要还有强引用执行一个对象,表示该对象还“活着”,不会被GC回收。
- 弱引用(WeakReference):垃圾回收器一旦发现了只具有弱引用的对象,不管当前内存空间如何,都会回收它的内存。
4 问题分析
4.1 key为强引用
我们来分析下如果ThreadLocalMap的Entry的key是强引用的情况,情况会如何呢?此时ThreadLocal的内存堆栈图如下4.1-1所示:
-
假设此时业务代码使用完ThreadLocal,ThreadLocal Ref被回收,如下图4.1-2所示
-
因为ThreadLocal Entry中的key强引用了ThreadLocal导致ThreadLocal对象无法被回收
-
在没有手动删除这个Entry以及当前线程运行的情况下,始终有强引用链,CurrentThread Ref->CurrentThread->ThreadLocalMap->Entry(entry中包括key引用和value),导致Entry内存泄露。
ThreadLocalMap Entry中的key如果使用强引用,无法完全避免内存泄漏。
4.2 key为弱引用
演示下key为弱引用的情况,如下图4.2-1所示:
- 假设在业务代码使用完ThreadLocal ref之后,ThreadLocal ref被回收
- 由于ThreadLocalMap的entry只持有ThreadLocal 的弱引用,没有其他强引用指向ThreadLocal的实例,所以ThreadLocal实例被回收,entry的key为null
- 但是在没有手动删除Entry和当前线程在运行的情况下,依然存在这强引用链CurrentRef->CurrentThread->ThreadLocalMap entry->value,而这个value不会在被访问到,导致value内存泄露
- ThreadLocalMap entry的key使用了弱引用,也有可能造成内存泄漏
4.3 内存泄漏的真正原因
以上两种情况,内存泄漏的发生跟ThreadLocalMap key是否使用弱引用没有必然联系,那么真正原因是什么呢?
- 没有手动删除entry
- 当前线程依然在运行
第一点如果在使用完ThreadLocal并调用其remove方法删除对应的Entry,可以避免内存泄漏。
第二点ThreadLocalMap 是Thread的一个属性,被当前线程引用,所以它的生命周期同Thread一样。如果在使用完ThreadLocal同时,线程结束运行,ThreadLocalMap也会被GC回收,根源上避免了内存泄露。
综上,THreadLocal内存泄漏的根源是:由于ThreadLocalMap的生命周期同Thread一样长,在使用完ThreadLocal后如果没有手动删除对应的Entry就会导致内存泄漏。
4.4 为什么Entry 的key使用弱引用
根据上面的分析,Entry的key无论是使用强弱引用,都可能导致ThreadLocal的内存泄漏。避免内存泄露的方法:
- 使用完ThreadLocal后调用remove方法,删除对应的Entry
- 使用完ThreadLocal时,Thread随之运行结束。
显然相对于第一种方式,第二种方式更不好控制,特别是在使用线程池的情况下,线程结束执行是不会被销毁的。
而我们只要在使用完ThreadLocal后,调用remove方法,无论Entry的key是强引用还是弱引用,都不会出现ThreadLocal内存泄露的情况,为什么我们还要使用弱引用呢?
根据我们之前对ThreadLocal源码的分析,ThreadLocalMap的set、getEntry方法中,都会对key为null的entry进行标记回收(非实时),即吧key为null对应entry的vualue置为null。这意味着,在当前线程依然运行的前提下,就算没有忘记调用remove方法,弱引用比强引用可以多一层保障:在ThreadLocal引用被回收之后,对应Entry中的key的弱引用ThreadLocal被回收,对应的value在下一次ThreadLocalMap调用set、get或者remove任一方法的时候会被清除,从而避免内存泄漏。
虽然key为弱引用为避免内存泄漏提供一层保障,但不是实时的,需要在下次调用ThreadLocalMap的相应的方法的时候才会被清除。所以在ThreadLocal使用完成后,强烈建议调用remove方法。
5 hash冲突的解决
5.1 hash计算
首先我们来看下ThreadLocalMap是如果计算hash的,构造方法如下:
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
table = new Entry[INITIAL_CAPACITY];
// 计算hash
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
}
- 我们在看下firstKey.threadLocalHashCode,源码如下:
private final int threadLocalHashCode = nextHashCode();
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
private static AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
这里通过定义一个AtomicInteger类型,每次获取当前值加上HASH_INCREMENT,这个值跟斐波那契数列(黄金分割数)有关,主要目的使哈希码均匀的分布在 2 n 2^n 2n的数组中,尽量避免hash冲突。
- & (INITIAL_CAPACITY - 1)
计算hash的时候采用hashcode&(size-1)的算法,这相当于hashcode%size 的一个更高效的实现。因此采用该算法,我们要求size必须是2的整次幂,这也能保证在索引不越界的前提下,使得hash冲突的次数减少。
关于hash、散列以及黄金分割数更深入的知识,本人目前没学习,不做讨论啊😅。
5.2 set()方法中的hash冲突解决
回顾下之前的ThreadLocalMap的set方法,源代码如下;
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;
int i = key.threadLocalHashCode & (len-1);
// 线性探测法查找元素
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
方法通过线性探测法查找目标key,如果计算hash位置有元素,但是key与目标key不相等,即产生了hash冲突,那么继续后向查找key为空的元素,替换该元素。如果在遍历到元素为空,但是即没有找到key相等的元素,也没有元素key为空的情况,直接在元素为空的位置插入新的Entry(key,value)。
后向获取索引nextIndex()方法,源代码如下:
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
即遍历到数组末尾的时候,会把索引置为0,从头开始,循环执行。
5.3 remove()中的hash冲突
看下源代码:
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.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
同set()一样,这里hash冲突之后,继续后向遍历,直到找到key和目标key相等的entry,回收清理或者元素为空。