0
点赞
收藏
分享

微信扫一扫

ThreadLocal源码、InheritableThreadLocal与内存泄露,这一篇给你捋顺了

倚然君 2022-10-21 阅读 174


ThreadLocal,可以理解为线程局部变量。同一份变量在每一个线程中都保存一份副本,线程对该副本的操作对其他线程完全是不可见的,是封闭的。

一、ThreadLocal简单示例

public class Main {

private static ThreadLocal<Integer> tl = new ThreadLocal<>();

public static void main(String[] args) {

tl.set(1);

Thread t = new Thread(() -> {
tl.set(2);
System.out.println("子线程:" + tl.get());
});
t.start();

System.out.println("主线程:" + tl.get());

}
}

最终的输出如下:

ThreadLocal源码、InheritableThreadLocal与内存泄露,这一篇给你捋顺了_数组

 可以看出,各个线程内的ThreadLocal互不干扰,每个线程也只能访问自己独有的ThreadLocal变量。

那么ThreadLocal的结构是怎么样的呢?

二、ThreadLocal的结构

Thread、ThreadLocal与ThreadLocalMap的关系图如下:

ThreadLocal源码、InheritableThreadLocal与内存泄露,这一篇给你捋顺了_数组_02

 从上面的结构图我们可以看出:

(1)每个Thread内部都有一个ThreadLocalMap,可以理解为简单版的HashMap。

(2)map的key是ThreadLocal类型的,而value的类型则是ThreadLocal的泛型类型。在本例中,value是Intege类型的。

在我刚学ThreadLocal的时候,我觉得他应该是这样设计的:

ThreadLocal里面有一个map容器,key是线程id或线程名称,value是副本的值,简单又好理解。那为什么jdk不这样设计呢(当然早期就是这样设计的)?

在jdk8中,map被放入到了Thread中,ThreadLocal更像是一个工具类。

那么,jdk8这样设计的好处是什么呢?

(1)如果map被放到ThreadLocal中,那么map的大小取决于线程数量。当线程数特别多的时候,势必会影响到map的查找、插入与扩容的效率。而在jdk8中,map的大小取决于ThreadLocal的数量,这个数量是可控的,一般不可能声明出那么多的ThreadLocal。

(2)在早期的设计中,当线程消亡时,需要在每一个关联的ThreadLocal的map中做一些清理工作,比较麻烦。而在jdk8中,线程消亡时,内部的map容器也随之消亡。

三、ThreadLocal有哪些方法

先从比较简单的set与get方法说起

有关ThreadLocalMap的方法,我们将会在下个章节进行梳理。

set方法

public void set(T value) {
//获取当前的操作线程
Thread t = Thread.currentThread();
//获取线程内部的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
//如果map不为空,则直接将(k:当前ThreadLocal实例,v:副本值)放入进map中
map.set(this, value);
else
//如果map为空,则创建该线程的ThreadLocalMap,并将(k,v)放入进map中
createMap(t, value);
}

ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}

可以看到,Thread内部的ThreadLocalMap是懒加载的,只有在第一次使用的时候,才会创建map。

get方法

public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
//如果map不为空,则获取键为该ThreadLocal对象的Entry实例
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}

private T setInitialValue() {
//获取初始值,默认是null
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
return value;
}

protected T initialValue() {
return null;
}

看的出,get方法同样会触发map的初始化。

remove方法

public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//最终还是调用ThreadLocalMap的方法,移除key为当前ThreadLocal的Entry
m.remove(this);
}

好家伙,ThreadLocal工具人的身份石锤了。

核心的代码都在ThreadLocalMap中,他是ThreadLocal内的一个静态内部类。

四、ThreadLocalMap探究

ThreadLocalMap的结构

static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;

Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}

ThreadLocalMap没有直接使用HashMap,而是一个经过定制化的map。map中的每一项都是一个Entry,key是对ThreadLocal的一个弱引用(这个后面会再解释)。

成员变量

//初始容量,必须是2的整数次方
private static final int INITIAL_CAPACITY = 16;

//Entry数组。其长度也必须是2的整数次方
private Entry[] table;

//数组中不为null的Entry个数
private int size = 0;

//扩容阈值,当size≥threshold时,就会发生扩容。默认为0,会在构造方法中重新设置
private int threshold;

基本方法

//设置扩容阈值为表长度的2/3
private void setThreshold(int len) {
threshold = len * 2 / 3;
}

//下一个索引,当索引为len-1时,下一个索引为0
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}

//上一个索引,当索引为0时,上一个索引为len-1
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}

ThreadLocalMap不同于HashMap,HashMap使用链地址法解决冲突,而ThreadLocalMap使用线性探测法。即当前下标存在冲突时,检查下一个下标是否存在冲突,你可以把数组看成一个环。

构造方法

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
//初始化一个容量为16的table
table = new Entry[INITIAL_CAPACITY];
//计算当前ThreadLocal的下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
//设置扩容阈值为16的2/3,即10
setThreshold(INITIAL_CAPACITY);
}

在计算ThreadLocal的下标的时候,用到了

int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)

生成哈希值的时候,用到了以下代码:

private final int threadLocalHashCode = nextHashCode();

//使用AtomicInteger类型是保证加法的原子操作
private static AtomicInteger nextHashCode =
new AtomicInteger();

//该魔数使得在该table上散列均匀,这里不细究其原理
private static final int HASH_INCREMENT = 0x61c88647;

//返回下一个哈希值,仅仅是在当前值的基础上再加上魔数
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}

在计算下标的时候,使用到了& (INITIAL_CAPACITY - 1),这里和HashMap是一样的算法(有关HashMap的连环问,可以参考我的这篇文章​​HashMap夺命连环问​​)。

前面说过,Entry数组的容量必须是2的整数次方,那么在这样的前提下,hashCode%len是和hashCode&(len-1)相等的,而位运算更加的快速。

例如len=16,len-1的二进制为01111,即将最高位变为0,小于16的部分全为1。那么hashCode&(len-1)之后,hashCode中≥16的位全部被与为0,小于16的被保留了下来,从而达到对容量取余相同的效果。

getEntry方法

这个就是ThreadLocal.get方法调用的底层逻辑

private Entry getEntry(ThreadLocal<?> key) {
//计算下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
//找到则返回
if (e != null && e.get() == key)
return e;
else
//利用线性探测法继续寻找
return getEntryAfterMiss(key, i, e);
}


private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;

//只要Entry不为null,就一直寻找。如果为null,说明真的找不到了
while (e != null) {
ThreadLocal<?> k = e.get();
if (k == key)
return e;
if (k == null)
//key为null,说明ThreadLocal已经被回收,那么回收其value
expungeStaleEntry(i);
else
//寻找i的下一个下标
i = nextIndex(i, len);
//将e设置为下一个Entry
e = tab[i];
}
return null;
}

expungeStaleEntry方法

即清理那些key为null的Entry

private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;

//断开对value的强引用
tab[staleSlot].value = null;
//断开对Entry的强引用
tab[staleSlot] = null;
size--;

Entry e;
int i;
//从staleSlot的下一个位置开始,直到遇到为null的Entry结束
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
if (k == null) {
//断开对value与Entry的强引用
e.value = null;
tab[i] = null;
size--;
} else {
//如果当前的Entry不为null,则进行重新散列
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方法有两个作用:

(1)从staleSlot位置开始,在遇到空Entry之前,清理当前位置的Entry

(2)如果当前Entry不为空,则进行重新散列。重新散列后的位置不为空Entry的话,则选择下一个下标。

明明清理空Entry就行了,为什么需要对非空Entry还要再做一次重新散列呢?
是为了下一次get的时候,避免遇到空Entry需要执行expungeStaleEntry方法。

expungeStaleEntry方法可以理解为清理某一段数组,遇到null就停下来了,并不是全量清理。

set方法

private void set(ThreadLocal<?> key, Object value) {
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) {
//替换当前失效的Entry
replaceStaleEntry(key, value, i);
return;
}
}

tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
//当没清理到任何数据且size≥阈值的时候,进行扩容
rehash();
}

replaceStaleEntry与cleanSomeSlots方法,我们不继续深入了,两个方法的主要想法依然是去清理无效Entry,即key为null的Entry。

rehash方法

private void rehash() {
//该方法对于table上每一个Entry,都执行了expungeStaleEntry方法
//可以理解为整体清理
expungeStaleEntries();

//threshold - threshold / 4 =3/4*threshold
//默认的threshold =2/3*len,因此只要size>=1/2*len,即占了一半之后,就考虑扩容
//为什么不按传统的size>=threshold来考虑扩容呢?
//因为执行一次全部清理后,依然还占有一半容量,那么就说明冲突可能会趋于严重,不如早点执行扩容操作。
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);
}
}


private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
//扩容为原来的两倍
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;

//将旧位置的Entry重新计算下标放入新table中
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null; // Help the GC
} else {
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}

setThreshold(newLen);
size = count;
table = newTab;
}

可以看得出来,在ThreadLocal的get与set方法中,都会去检查Entry的key是否为null,如果为null的话,会进行一些局部的清理工作。

当需要进行扩容时,会进行一次整体清理。

五、InheritableThreadLocal是什么鬼

ThreadLocal是用于线程之间隔离的,但是InheritableThreadLocal可以使得子线程去自动拷贝来自父线程的副本数据。

简单的例子:

public static void main(String[] args) {
InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();

threadLocal.set(1);
System.out.println("父线程的副本值:" + threadLocal.get());

new Thread(() -> System.out.println("子线程的副本值:" + threadLocal.get())).start();
}

两个线程的副本值都是1,说明子线程确实自动拷贝了父线程的副本值。

原理很简单:

InheritableThreadLocal继承了ThreadLocal,

重写了childValue方法,直接返回了传入参数值。因为InheritableThreadLocal默认不对原值进行转换,如果我们需要对原值进行转换的话,可以重写该方法。

重写了getMap方法,返回当前线程的inheritableThreadLocals,也是ThreadLocalMap类型。createMap则是懒加载该inheritableThreadLocals。

protected T childValue(T parentValue) {
return parentValue;
}

ThreadLocalMap getMap(Thread t) {
return t.inheritableThreadLocals;
}

void createMap(Thread t, T firstValue) {
t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
}

说白了,当前的副本不在threadLocals存了,而是存在了inheritableThreadLocals中。

接着看Thread的构造方法:

public Thread(Runnable target) {
init(null, target, "Thread-" + nextThreadNum(), 0);
}

private void init(ThreadGroup g, Runnable target, String name,
long stackSize) {
init(g, target, name, stackSize, null, true);
}

private void init(ThreadGroup g, Runnable target, String name,
long stackSize, AccessControlContext acc,
boolean inheritThreadLocals) {
if (name == null) {
throw new NullPointerException("name cannot be null");
}

this.name = name;

Thread parent = currentThread();
...省略无关代码
//inheritThreadLocals为true,父线程的inheritableThreadLocals也不为空
if (inheritThreadLocals && parent.inheritableThreadLocals != null)
//则利用父线程的inheritableThreadLocals去创建子线程的inheritableThreadLocals
this.inheritableThreadLocals =
ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
/* Stash the specified stack size in case the VM cares */
this.stackSize = stackSize;

/* Set thread ID */
tid = nextThreadID();
}

接着进入createInheritedMap方法:

static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
return new ThreadLocalMap(parentMap);
}

private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];

//将parentTable上key不为null的Entry复制到当前table上
for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
if (key != null) {
//key.childValue返回e.value
Object value = key.childValue(e.value);
Entry c = new Entry(key, value);
int h = key.threadLocalHashCode & (len - 1);
while (table[h] != null)
h = nextIndex(h, len);
table[h] = c;
size++;
}
}
}
}

不过需要注意的是,如果在拷贝之后,父线程再进行set的话,子线程肯定是感知不到的。

六、ThreadLocal与内存泄漏

什么是内存泄露?

大白话讲,就是我自己创建的对象,在一系列操作后,我访问不到该对象了,我认为它已经被回收掉了,但该对象却一直存在与内存中。

那什么是内存溢出呢?

内存溢出是在有限的堆内存中(当然内存溢出的区域不止这一块)申请了大量的对象,造成oom的情况。

那两者的区别呢?

内存泄露比较严重的时候会导致内存溢出,如果每次gc后,堆内存都不能下降到一个比较低的占用量,那么可以使用jmap dump堆内存,再使用MAT找出导致内存泄漏的对象。

我们以一开头的例子来画出运行时的堆栈图:

ThreadLocal源码、InheritableThreadLocal与内存泄露,这一篇给你捋顺了_强引用_03

 为什么这里的key保持着对ThreadLocal的一个弱引用呢?保持强引用行不行?

假设这里的key保持对ThreadLocal的强引用,则当我的程序用不到该ThreadLocal时,我手动执行了tl=null,此时1号线断开,而这里的5号线是实线,5号线没有断开,因此ThreadLocal对象无法被回收掉,一直存在于内存中,造成内存泄露。

看来,这里的弱引用,能够保证用不到的ThreadLocal被回收掉。

弱引用就能完全防止内存泄露了吗?

由上面的分析,弱引用能够防止释放不掉ThreadLocal引起的内存泄露。但是,却不能防止释放不掉Integer引起的内存泄露。首先,执行tl=null,则1号线断开,GC到来时,5号线断开,此时ThreadLocal被回收掉了,这个key被置为了null,可是这个key对应的value强引用着Integer对象,该Integer无法在用户代码中访问到了,但却依然存在于内存中,造成内存泄露。

既然依然存在着内存泄露,那么JDK团队是怎么解决的呢?

从上文的源码分析来看,ThreadLocal中的get()、set()方法,不是单纯地去做获取、设置的操作。在它们的方法内部,依然会遍历该Entry数组,删除所有key为null的Entry,并将相关的value置为null,从而够解决因释放不掉value而引起的内存泄露。

有这些get()、set()方法,就能完全地防止内存泄漏吗?

但我们手动将tl置为null后,就已经没法调用这些get()、set()方法了。所以,预防内存泄露的最佳实践是,在使用完ThreadLocal后,先调用tl.remove(),再调用tl=null。tl.remove()能够使得ThreadLocalMap删除该ThreadLocal所在的Entry,以及将value置为null,tl=null使得ThreadLocal对象真正地被回收掉。
 

其实内存泄露的问题,核心在于ThreadLocal与Thread的生命周期不一致

有两种情况:

(1)ThreadLocal的生命周期长于Thread,此时的Thread销毁后,内部的ThreadLocalMap也逐渐销毁,这种情况是不会发生内存泄露的。

(2)在线程池相关的场景下,ThreadLocal的生命周期是明显短于Thread的。当ThreadLocal被置为null,而又没在其之前调用remove时,内存泄露就开始了,一直持续到Thread销毁。

还有哪些内存泄露的场景呢?怎么去解决呢?

【1】长生命周期的对象持有短生命周期对象的引用,就很有可能造成内存泄露。

长生命周期的对象往往和整个程序的生命周期相同,若是当它们持有短生命周期的对象的引用,尽管短对象不再被使用,也无法被垃圾回收器回收,因为垃圾回收器无法回收被强引用所关联的对象。

解决方案:

(1)像一些静态的集合类,它们的生命周期和整个程序相同,尽管放入集合中的元素不再需要,就算将元素强行置为null,但由于集合类持有它们的引用,这些元素占据的空间也得不到释放,那么在必要的时候,我们可以将集合类对象类型的变量置为null。

(2)单例模式中,单例与整个程序的生命周期一致,如果单例对象持有其他短对象的引用,也很容易造成内存泄露,这还得靠我们谨慎编码。

(3)又或是数据库连接对象(长生命周期),ResultSet与Statement对象(短生命周期),连接不再被使用时,需要调用其close()方法,释放长对象与短对象。同理,需要显式调用close()方法的长生命周期的对象还有Socket、IO流、Session等。

【2】非静态的外部类会隐式地持有外部类的一个强引用

在Android中,如果在Activity内声明一个非静态的内部类,那么只要该内部类没有被回收的话,那么外部类Activity就无法被回收,Activity所关联的视图和资源也不会被回收,这样的内存泄露比较严重。

解决方案:

(1)将非静态内部类改为静态内部类,静态内部类是属于类的,因此不会依赖于外部类的实例,从而不持有外部类实例的引用。

(2)显式地声明非静态内部类持有外部类的一个弱引用,被弱引用关联的对象,在下一次垃圾回收器活动时,就会被回收。

七、ThreadLocal的使用场景

ThreadLocal一个典型的场景就是,我们需要在某个线程内保存全局可流通的属性,避免参数传递的麻烦。

大可以使用拦截器,将请求的token信息解析成用户属性,放在ThreadLocal中,之后在该线程执行的任何地方都可以获取到用户属性。

不会真有人在不知道ThreadLocal的时候,一直把HttpServletRequest当作方法的常用参数吧?不会吧不会吧

当然,线程池以及异步程序中是不建议使用ThreadLocal的,你永远不知道你拿到的副本到底是哪个Thread的遗产。

此外,java8中的并行流也不是不建议使用ThreadLocal的,有关对并行流的介绍,可以移步我的另外一篇文章​​谈谈并行流parallelStream​​

举报

相关推荐

JQuery这一篇就够了

Promise这一篇就够了

0 条评论