在上期,介绍了ThreadLocal 的概述以及基本用法后,我们本期则来研究一下ThreadLocal是怎么实现以及内部工作原理。
ThreadLocal存放位置
首先来了解一下ThreadLocal是存放在内存中的哪个位置的,调用ThreadLocal的set
方法时,可以看到一个跟ThreadLocal
相似的一个类:ThreadLocalMap 。
ThreadLocalMap的底层数据结构
ThreadLocalMap的结构与日常使用的HashMap类似,底层都是使用数组进行存储。唯一不同的点就是底层的数组对象类型为了避免内存泄漏,继承了弱引用 。但是在使用不当的情况下,弱引用也无法解决内存泄漏的问题。
除了存储ThreadLocal的Entry数组之外,就只有size以及扩容的阈值了。
整个ThreadLocalMap的内部属性也是比较少的。
hash算法
我们想快速的在数组中找到该元素存放的位置的话,就需要一个公式的存在。传入一个固定的参数,返回的结果也是固定的,那么就再极短的时间内找到存放的位置。无需遍历整个数组,浪费时间了,这也是hash算法出现的原因。
在ThreadLocalMap中,使用的算法是:
java
int index = threadLocalHashCode & (tableLength - 1)
根据ThreadLocal的一个属性threadLocalHashCode
和数组的长度-1 进行与运算 ,计算出该元素应该存放的位置。
由于Entry并非为链式对象,所以计算出来的位置可能已经被占用了,此时ThreadLocal的操作就是从该位置往后找,直至找到一个空的位置进行存放。
ThreadLocalMap的扩容机制
一般来说,ThreadLocal的数量不会太多,要是真的使用过多那就有可能是程序在设计之初就有问题了。不过还是看一下ThreadLocalMap是怎么扩容的。
名词描述
有效的ThreadLocal:指Entry对象引用的ThreadLocal仍未被回收,不是null值
扩容路径
扩容机制由resize方法实现,该方法的唯一入口在set方法中。这个也合理,在新增或重新设置值时发现数组中有效的ThreadLocal数量超过阈值时,就进行扩容。
set() -> 当前大小超过阈值 -> rehash() -> 删除空的Entry对象后,仍超过阈值的四分之三 -> resize()
所以实际上扩容有两个前提
- 调用set方法 时数组中有效的ThreadLocal数量超过阈值
- 清理一波后,数组中有效的ThreadLocal数量仍超过阈值的四分之三
与HashMap的阈值75%不一样,ThreadLocalMap阈值为数组大小的三分之二。而且在超过阈值时,会针对数组进行一波清理,要是在清理过后,还是超过阈值的四分之三,才真正进行扩容。
扩容逻辑
- 创建一个新数组,长度为旧数组的两倍
- 遍历旧数组,将有效的ThreadLocal根据hash算法计算出新的位置并存放。
- 重新设置阈值以及数量等属性
ThreadLocal的生命周期
ThreadLocal从它的名字就能简单的得出一个结论:
ThreadLocal的生命周期一定是与线程息息相关的
与线程的关系
我们来回顾一下ThreadLocal的存放位置
ThreadLocal间接被线程引用了,所以在引用关系不被打破时,ThreadLocal的生命周期与线程的生命周期是一致的。
那么问题就变成了线程的生命周期有多长呢?
众所周知,只要线程中的代码片段还在执行的话,那么线程就会一直存在。
上面提到了线程和ThreadLocal是有引用关系的,那么这个引用关系在什么时候被打破了呢?
有一个关键点:Entry类继承了弱引用 并且ThreadLocal正是被引用的对象
所以在每次GC的时候垃圾回收器都会尝试将引用的对象(ThreadLocal)给回收掉。
有关Java中的引用原理可以去我的专栏《Java引用关系》了解一下
弱引用在处理引用对象的回收时,会先去看引用对象是否还在被使用。若是没有在使用,就会在GC的时候被回收了。
总结
ThreadLocal的生命周期分为两个阶段:
- 线程还在运行并且线程中变量仍在使用ThreadLocal,那么ThreadLocal就会一直存在
- 线程还在运行,但是线程上下文没有使用ThreadLocal了,那么ThreadLocal就会被回收。
在第2点中,虽然ThreadLocal没有被使用 并且被垃圾回收器回收掉 了,要是没有调用remove方法的话,就会出现内存泄漏的问题了。
内存泄漏问题及解决方案
在ThreadLocal的生命周期中提到了,没有显示调用remove方法时,会造成内存泄漏。那么我们来看一下为什么会产生内存泄漏呢?
问题
先提供一段会产生内存泄漏的问题代码:
java
public static void main(String[] args) {
new Thread(() -> {
while (true) {
ThreadLocal<int[]> threadLocal = new ThreadLocal<>();
threadLocal.set(new int[1 * 1024 * 1024]);
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}, "ThreadLocal").start();
}
说明一下这段代码的作用:
创建了一个新线程,在线程中,每隔1秒创建一个4M大小的数组并把数组放进ThreadLocal中。
产生原因
在代码运行过程中,把内存快照给dump下来然后导入到MAT中打开,我们就能得知ThreadLocal的存活情况了。
如何找到线程中的ThreadLocalMap 对象呢?
先点击工具栏上的小齿轮按钮,找到对应的线程。
就能在左边信息栏中找到线程中threadLocals属性了,紧接着用内存地址找到对应的ThreadLocalMap对象。
这是运行了一段时间后的ThreadLocalMap对象,可以看到已经存放了许多的Entry对象了。此时,我们随机选一个Entry对象来分析一下。
这是其中的一个Entry对象属性,可以看到value
是创建的int数组,但是referent
却是null。
说明ThreadLocal已经被垃圾回收器回收掉了 ,但是int数组还存在着。
回到之前ThreadLocal的存放位置的图中,此时ThreadLocal已经被回收了,但是Entry对象中还存在着value属性的引用关系 ,所以Entry对象无法被正常回收,出现了内存泄漏的问题。
初步总结一下:运行过程中,ThreadLocal对象被回收掉了,但是所存储的对象却没有被回收,出现了内存泄漏问题了。
但是,为什么上面的程序运行了很久,都没有出现OOM呢?
先说结论,那是因为我们每隔1秒就创建一个ThreadLocal对象,在调用set方法时,ThreadLocal内部条件达到后会进行数组的清理工作 ,致使程序在运行过程中没有出现OOM。
有关内部的清理逻辑,会开一篇新的文章进行说明。
解决方案
解决ThreadLocal的内存泄漏问题解决方案很简单,不再使用的时候及时调用remove方法即可。
针对一般Web程序来说,我们都会在拦截器Filter中进行ThreadLocal的初始化以及清空操作,只需要在初始化后,finally中调用remove方法就可以了。
java
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ThreadLocal<String> threadLocal = new ThreadLocal<>();
try {
chain.doFilter(request, response);
} finally {
threadLocal.remove();
}
}