Java11 ThreadLocal的remove()方法源码分析
1. ThreadLocal实现原理
本文参考的java 版本是11。
在讲述ThreadLocal实现原理之前,我先来简单地介绍一下什么是ThreadLocal。ThreadLocal提供线程本地变量,每个线程拥有本地变量的副本,各个线程之间的变量相互独立。在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不通的变量值完成操作的场景。以下英文描述来源于ThreadLocal类的注释:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own, independently initialized copy of the variable.
下面我就来说一说ThreadLocal是如何做到线程之间的变量相互独立的,也就是它的实现原理。每一个线程都有一个对应的Thread对象,而Thread类有一个ThreadLocalMap类型变量threadLocals和一个内部类ThreadLocal。这个threadLocals的key就是ThreadLocal的引用,而value就是当前线程在key所对应的ThreadLocal中存储的值。当某个线程需要获取存储在自己线程Thread的ThreadLocal变量中的值时,ThreadLocal底层会获取当前线程的Thread对象中的Map集合threadLocals,然后以ThreadLocal作为key,从threadLocals中查找value值。这就是ThreadLocal实现线程独立的原理。
ThreadLocal通俗理解就是线程的私有变量,用于保证当前线程对其修改和读取。
2. ThreadLocal堆栈分析
Entry继承自WeakReference(弱引用,生命周期只能存活到下次GC前),但只有Key是弱引用类型的,Value并非弱引用。
在《Java 8 ThreadLocal 源码解析》一文,我们知道每个thread中都存在一个map,它的类型是ThreadLocal.ThreadLocalMap。map中的Entry是ThreadLocalMap的静态内部类,继承自WeakReference,其key为一个ThreadLocal实例,使用弱引用(弱引用,生命周期只能存活到在下次 JVM 垃圾收集时被回收前),而其value却使用了强引用。在ThreadLocal的整个生命周期中,都存在这些引用。ThreadLocal堆栈结构示意图如下图所示,实线代表强引用,虚线代表弱引用:

图2 ThreadLocal堆栈结构示意图
从上面的结构图,我们可以窥见ThreadLocal的核心机制:
- 每个Thread线程内部都有一个ThreadLocalMap。
- ThreadLocalMap里面存储线程本地对象(key)和线程的变量副本(value)
- 线程运行时,初始化ThreadLocal对象,存储在Heap,同时线程运行的栈区保存了指向该实例的引用,也就是图中的Thread Local Ref。
- 当调用ThreadLocal的set/get函数时,虚拟机根据当前线程的引用也就是Current Thread Ref找到其在堆区的实例,然后查看其对应的ThreadLocalMap实例是否被创建,若没有,则创建并初始化。
- ThreadLocalMap实例化之后,就可以将当前ThreadLocal对象作为key,进行存取操作。
- 当弱引用key被GC回收时,强引用value不被自动回收,有可能导致内存泄漏。
通过如上4和5的分析,我们得知对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了对副本的隔离,互不干扰。
ThreadLocalMap因为使用了弱引用,所以为了便于描述,我们把entry的状态区分为三种:即有效(key和value均未回收),无效(key已回收但是value未回收)和空(entry==null)。
为什么ThreadLocalMap需要Entry数组呢?
之所以用数组,是因为开发过程中,一个线程可以拥有多个TreadLocal以存放不同类型的对象,但是他们都将放到当前线程的ThreadLocalMap里,所以需要以数组的形式来存储。
3. remove方法
remove方法主要是为了防止内存溢出和内存泄露,使用的时机一般是在线程运行结束之后使用,也就是run()方法结束之后。下面介绍一下内存泄漏和内存溢的基本概念:
内存泄露(Memory Leak):是指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
内存溢出(Out Of Memory,简称OOM):是指应用系统中存在无法回收的内存或使用的内存过多,最终使得程序运行要用到的内存大于系统能提供的最大内存。此时程序就运行不了,系统会提示内存溢出。
简单来说,内存泄露就是创建了太多的ThreadLocal变量,然后呢,又没有及时的释放内存;内存溢出可以理解为创建了多个ThreadLocal变量,然后又给她们分配了占用内存比较大的对象,使得多个线程累计占用太多内存,导致系统出现内存溢出。
remove()
public void remove() {
// 获取ThreadLocalMap对象,此对象在ThreadLocal中是一个静态内部类
ThreadLocalMap m = getMap(Thread.currentThread());
// 如果存在的话,调用方法remove,看②
if (m != null) {
m.remove(this);
}
}
②remove(ThreadLocal<?> key)
/**
* Remove the entry for key.
*/
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;// 获取长度
// 通过key的hash值找到当前key的位置
int i = key.threadLocalHashCode & (len-1);
// 遍历,直到找到Entry中key为当前对象key的那个元素
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
e.clear(); // 清除对象的引用
expungeStaleEntry(i); // 去除陈旧的对象键值对(相当于帮派清理门户,就是将没用的东西清理出去)
return;
}
}
}
③clear
public void clear() {
this.referent = null; // 将引用指向null
}
④ expungeStaleEntry
看这个方法之前,需要明确全局变量size是什么,size是键值对的个数,定义如下:
/**
* The number of entries in the table.
*/
private int size = 0;
函数expungeStaleEntry是ThreadLocal中的核心清理函数,它做的事情大致如下:从staleSlot开始遍历,清理无效entry并且将此entry置为null,直到扫到空entry。另外,在遍历过程中还会对非空的entry作rehash,可以说她的作用就是从staleSlot开始清理连续段中的slot(断开强引用,rehash slot等)。
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
// 因为entry对应的ThreadLocal已经被回收,value设为null,显式断开强引用
tab[staleSlot].value = null;
tab[staleSlot] = null; // 将整个键值对清除
size--; // 数量减一
// Rehash until we encounter null 直到遇到null,然后rehash操作
Entry e;
int i;
// 从当前的staleSlot后面的位置开始,直到遇到null为止
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
// 获取键对象,也就是map中的key对象
ThreadLocal<?> k = e.get();
// 如果为null,直接清除值和整个entry,数量size减一
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// k不为null,说明当前key未被GC回收,弱引用还存在
// 此时执行再哈希操作
int h = k.threadLocalHashCode & (len - 1);
if (h != i) { // 如果不等的话,表明与之前的hash值不同这个元素需要更新
tab[i] = null; // 将这个地方设置为null
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null) // 从当前h位置找一个为null的地方将当前元素放下
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i; // 返回的是第一个entry为null的下标
}
这段源码提及了Knuth高德纳的著作TAOCP(《计算机程序设计艺术》)的6.4章节(散列)中的R算法。R算法描述了如何从使用线性探测的散列表中删除一个元素。R算法维护了一个上次删除元素的index,当在非空连续段中扫到某个entry的哈希值取模后的索引还没有遍历到时,会将该entry挪到index那个位置,并更新当前位置为新的index,继续向后扫描直到遇到空的entry。
正是因为ThreadLocalMap的entry有三种状态,所以不能完全采用高德纳原书的R算法。
因为expungeStaleEntry函数在扫描过程中还需要对无效slot清理,并将它转为空entry,如果直接套用R算法,可能会出现具有相同哈希值的entry之间断开(中间有空entry)。
关键节点梳理:
1)删除staleSlot处的值value和entry。
2)对从staleSlot位置到下一个为空的slot之间碰撞的entry进行rehash。
碰撞的判断:h = k.threadLocalHashCode & (len - 1) 不等于当前的索引i,所以从h处向后线性探测查找空的slot插入。
3)删除从staleSlot位置到下一个为空的slot之间所有无效的entry。
4. ThreadLocal内存泄露
我们从前面两个章节可以得知在Thread运行时,线程的一些局部变量和引用使用的内存属于Stack(栈)区,而普通的对象是存储在Heap(堆)区。由于ThreadLocalMap的key是弱引用而Value是强引用,这就导致了一个问题:ThreadLocal在没有外部对象强引用且发生GC时弱引用Key会被回收,而我们往里面放的value对于【当前线程->当前线程的threadLocals(ThreadLocal.ThreadLocalMap对象)->Entry数组->某个entry.value】这样一条强引用链是可达的,因此value不会被回收。如果创建ThreadLocal的线程一直持续运行,那么这个Entry对象中的value就有可能一直得不到回收,滴水成河,最终造成系统发生内存泄露。
所以得出一个结论就是只要这个线程对象被GC回收,就不会出现内存泄露,但在ThreadLocal设为null和线程结束这段时间内,线程对象不会被回收,就会发生我们认为的内存泄露。
Java为了降低内存泄露的可能性和风险,在ThreadLocal的get和set方法中都自带一套自我清理的机制,以清除线程ThreadLocalMap里所有无效的entry。为了避免内存泄漏,我们需要养成良好的编程习惯,使用完ThreadLocal之后,及时调用remove方法,显示地设置Entry对象为null。
ThreadLocal<String> threadLocal = new ThreadLocal<String>();
try {
threadLocal.set("业务数据");
// TODO 其它业务逻辑
} finally {
threadLocal.remove();
}
当使用static ThreadLocal的时候,会延长ThreadLocal的生命周期,那也可能导致内存泄漏。因为,static变量在类未加载的时候,它就已经加载,当线程结束的时候,static变量不一定会回收。那么,比起普通成员变量使用的时候才加载,static的生命周期加长将更容易导致内存泄漏危机。
5.为什么使用弱引用
为避免占用空间较大或生命周期较长的数据常驻于内存引发一系列问题,类ThreadLocalMap中有关英文原文描述如下:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.
由于ThreadLocalMap的生命周期跟Thread一样长,使用弱引用可以多一层保障:弱引用不会导致内存泄漏,无效entry在ThreadLocalMap调用set,get和remove函数的时候会被清除。
6. ThreadLocal内存泄漏案例分析
案例一
首先,设置-Xms100m -Xmx100m,然后,使用如下的代码
public class ThreadlocalApplication {
// 线程私有变量,和当前线程绑定,所以各个线程对其的改变不会被其他线程读取到到
public static ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
SpringApplication.run(ThreadlocalApplication.class, args);
ExecutorService exec = Executors.newFixedThreadPool(99);
for (int i = 0; i < 1000; i++) {
exec.execute(() -> {
threadLocal.set(new byte[1024 * 1024]);
try {
TimeUnit.MILLISECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
threadLocal.remove();
}
});
}
}
}
运行上面的代码没有抛出任何异常,但是若将 threadLocal.remove() 注释掉再执行,就会出现内存泄漏的问题,原因是1m的数组没有被及时回收,这也从侧面证明了手动 remove() 的必要性。
案例二
下面我们用代码来验证一下,
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger; /**
* TODO
*
* @author Wiener
* @date 2020/10/27
*/
public class ThreadPoolProblem {
private static ThreadLocal<AtomicInteger> sequencer = new ThreadLocal<AtomicInteger>() {
@Override
protected AtomicInteger initialValue() {
return new AtomicInteger(0);
}
}; static class BarTask implements Runnable {
@Override
public void run() {
AtomicInteger s = sequencer.get();
int initial = s.getAndIncrement();
// 期望初始为0
System.out.println(initial);
}
} public static void main(String[] args) {
//线程池线程数设置为2,线程池中线程数超过2时,将复用已创建的2条线程
ExecutorService executor = Executors.newFixedThreadPool(2);
// 创建四条线程
executor.execute(new BarTask());
executor.execute(new BarTask());
executor.execute(new BarTask());
executor.execute(new BarTask());
executor.shutdown();
}
}
对于在线程池中执行异步任务BarTask而言,我们翘首以待的初始值应该始终是0,但如下图所示的程序执行结果却和期望值大相径庭:

由此可见,第二次执行异步任务时期望的结果就不对了,为什么呢?因为线程池里面的线程都是复用的,在线程在执行下一个任务时,其ThreadLocal对象并不会被清空,修改后的值带到了下一个任务。那怎么办呢?下面提供有几种解决思路:
l 第一次使用ThreadLocal对象时,总是先调用set设置初始值,或者如果ThreadLocal重写了initialValue方法,先调用remove。
l 使用完ThreadLocal对象后,总是调用其remove方法。
l 使用自定义的线程池,执行新任务时总是清空ThreadLocal。
按照道理一个线程使用完,ThreadLocalMap是应该要被清空的,但是现在线程被复用了。
7. 小结
本文讨论了ThreadLocal实现原理和内存泄漏相关的问题。首先,介绍了ThreadLocal的实现原理。其次,撸了撸remove函数的源码。然后,基于remove函数分析了ThreadLocal内存泄露的问题。最后,给出导致内存泄漏的两个案例,帮助各位读者进一步熟悉ThreadLocal。
作为Josh Bloch和Doug Lea两位大师之作,ThreadLocal源码所使用的算法与技巧很优雅。在开发过程中,如果ThreadLocal运用得当,可以提高代码复用率。但也要注意过度使用ThreadLocal很容易加大类之间的耦合度与依赖关系。
Reference
https://www.jianshu.com/p/1a5d288bdaee
https://www.cnblogs.com/onlywujun/p/3524675.html
https://www.cnblogs.com/micrari/p/6790229.html
https://www.cnblogs.com/kancy/p/10702310.html
https://cloud.tencent.com/developer/article/1333298
Java11 ThreadLocal的remove()方法源码分析的更多相关文章
- 并发编程学习笔记(8)----ThreadLocal的使用及源码分析
1. ThreadLocal的理解 ThreadLocal,顾名思义,就是线程的本地变量,ThreadLocal会为每个线程创建一个本地变量副本,使得使用ThreadLocal管理的变量在多线程的环境 ...
- Java split方法源码分析
Java split方法源码分析 public String[] split(CharSequence input [, int limit]) { int index = 0; // 指针 bool ...
- invalidate和requestLayout方法源码分析
invalidate方法源码分析 在之前分析View的绘制流程中,最后都有调用一个叫invalidate的方法,这个方法是啥玩意?我们来看一下View类中invalidate系列方法的源码(ViewG ...
- Linq分组操作之GroupBy,GroupJoin扩展方法源码分析
Linq分组操作之GroupBy,GroupJoin扩展方法源码分析 一. GroupBy 解释: 根据指定的键选择器函数对序列中的元素进行分组,并且从每个组及其键中创建结果值. 查询表达式: var ...
- 【Java】NIO中Selector的select方法源码分析
该篇博客的有些内容和在之前介绍过了,在这里再次涉及到的就不详细说了,如果有不理解请看[Java]NIO中Channel的注册源码分析, [Java]NIO中Selector的创建源码分析 Select ...
- HashMap主要方法源码分析(JDK1.8)
本篇从HashMap的put.get.remove方法入手,分析源码流程 (不涉及红黑树的具体算法) jkd1.8中HashMap的结构为数组.链表.红黑树的形式 (未转化红黑树时) (转 ...
- jQuery实现DOM加载方法源码分析
传统的判断dom加载的方法 使用 dom0级 onload事件来进行触发所有浏览器都支持在最初是很流行的写法 我们都熟悉这种写法: window.onload=function(){ ... } 但 ...
- jQuery.extend()方法和jQuery.fn.extend()方法源码分析
这两个方法用的是相同的代码,一个用于给jQuery对象或者普通对象合并属性和方法一个是针对jQuery对象的实例,对于基本用法举几个例子: html代码如下: <!doctype html> ...
- jQuery.clean()方法源码分析(一)
在jQuery 1.7.1中调用jQuery.clean()方法的地方有三处,第一次就是在我之前的随笔分析jQuery.buildFramgment()方法里面的,其实还是构造函数的一部分,在处理诸如 ...
- ThreadLocal应用场景以及源码分析
一.应用篇 ThreadLocal介绍 ThreadLocal如果单纯从字面上理解的话好像是“本地线程”的意思,其实并不是这个意思,只是这个名字起的太容易让人误解了,它的真正的意思是线程本地变量. 实 ...
随机推荐
- MySQL索引最左原则:从原理到实战的深度解析
MySQL索引最左原则:从原理到实战的深度解析 一.什么是索引最左原则? 索引最左原则是MySQL复合索引使用的核心规则,简单来说: "当使用复合索引(多列索引)时,查询条件必须从索引的最左 ...
- 【 Python 】补全fibersim 导出的xml语法
fibersim导出的xml文件中,node 和mesh部分的标签会缺失.即<R></R>变成了<R/>. 以下python脚本可以自动修正 # ********* ...
- C/C++显示类型转换的位拓展方式
最近用verilator写模块的tb,在这里卡了好久(测半天都是C++写的问题) 要点 变量从小位宽到大位宽显示类型转换(explicit cast)时的位拓展方式,取决于转换前变量的符号性. 倘若转 ...
- OpenHarmony 开源鸿蒙北向开发——hdc工具安装
hdc(OpenHarmony Device Connector)是为开发人员提供的用于设备连接调试的命令行工具,该工具需支持部署在 Windows/Linux/Mac 等系统上与 OpenHar ...
- Windows下Dll在Unity中使用的一般方式
Windows下Dll在Unity中使用的一般方式 Unity中虽然已经有广泛的库和插件,但是相较于C++的库生态而言,还是有一定的差距:因此本篇博文记录Windows下将C++函数打包成动态链接库在 ...
- 网页P图
此篇文章记录一段比较好玩的网页P图代码 1.在你要修改的网页上Fn + F12或者F12打开控制台,然后在console里输入这样一段代码,回车 document.designMode = 'on' ...
- 【Java】Java UDP 套接字编程乱码问题
零.发现问题 用Java写了个UDP收发程序,发现中文有问题! package socket; import java.io.IOException; import java.net.Datagram ...
- Netty源码—10.Netty工具之时间轮
大纲 1.什么是时间轮 2.HashedWheelTimer是什么 3.HashedWheelTimer的使用 4.HashedWheelTimer的运行流程 5.HashedWheelTimer的核 ...
- ShadowSql之借Dapper打通ORM最后一公里
ShadowSql专职拼写sql,要想做为ORM就需要借高人之手 我们要借的就是Dapper,Dapper以高性能著称,ShadowSql搭配Dapper就是强强联手 为此本项目内置了一个子项目Dap ...
- P5490 【模板】扫描线 & 矩形面积并 做题笔记
扫描线是一种很常用的 trick,用来计算矩形并周长.并面积.核心思路是使用标记永久化 + 线段树,直接引用朴素的做法,即从某一维度开始扫描并将经过的面积加和. 错误 upd 函数中的汇总不正确,要想 ...