目录
ThreadLocal的简单使用
ThreadLocal 的数据结构
ThreadLocal的核心方法介绍
set
get
remove
ThreadLocalMap源码分析
ThreadLocalMap的内存泄漏问题
构造函数
getEntry方法
set方法
什么是ThreadLocal?
ThreadLocal类顾名思义可以理解为线程本地变量。也就是说如果定义了一个ThreadLocal,每个线程往这个ThreadLocal中读写是线程隔离,互相之间不会影响的。它提供了一种将可变数据通过每个线程有自己的独立副本从而实现线程封闭的机制。它大致的实现思路是怎样的?
Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,也就是说每个线程有一个自己的ThreadLocalMap。ThreadLocalMap有自己的独立实现,可以简单地将它的key视作ThreadLocal,value为代码中放入的值(实际上key并不是ThreadLocal本身,而是它的一个弱引用)。每个线程在往某个ThreadLocal里塞值的时候,都会往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。我们可以得知ThreadLocal 的作用是:提供线程内的局部变量,不同线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或者组件之间一些公共变量传递的复杂度。
一句话就是:线程并发下的数据隔离以及数据传递
先来看下简单的使用,比如下面的demo,我们开启五个线程,分别去设置每个线程自己的content ,再get
package com.cjian.threadlocal;
/**
* 需求:线程隔离
* 在多线程并发的场景下,每个线程中的变量都是相互独立的
* @description:
* @author: CJ
* @time: 2020/11/13 16:34
*/
public class ThreadLocalDemo {
private String content;
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println(Thread.currentThread().getName() + "---->" + demo.getContent());
}).start();
}
}
}
输出为:
多运行几次每次的结果会不相同,但是有一个共同点->不同线程之间的content乱了,如果想实现每个线程准确的获取自己设置的content值,应该怎么做呢?
我们首先想到的就是使用synchronized关键字,我没来试一下:
package com.cjian.threadlocal;
public class ThreadLocalDemo {
private String content;
public String getContent() {
return content;
public void setContent(String content) {
this.content = content;
}
public static void main(String[] args) {
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
synchronized (demo) {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println(Thread.currentThread().getName() + "---->" + demo.getContent());
}
}).start();
}
}
}
结果如我们所愿:
但是呢,问题来了,在高并发的情况下使用synchronized会导致程序的并发性降低,那还有其他办法吗?下面就是主角了:使用 ThreadLocal
我们 先来实现以下上面的demo:
package com.cjian.threadlocal;
public class ThreadLocalDemo {
ThreadLocal tl = new ThreadLocal<>();
private String content;
public String getContent() {
return tl.get();
}
public void setContent(String content) {
tl.set(content);
}
public static void main(String[] args) {
ThreadLocalDemo demo = new ThreadLocalDemo();
for (int i = 0; i < 5; i++) {
new Thread(() -> {
demo.setContent(Thread.currentThread().getName() + "的数据");
System.out.println(Thread.currentThread().getName() + "---->" + demo.getContent());
}).start();
}
}
}
运行结果:
虽然ThreadLocal 和synchronized都能解决多线程并发访问变量的问题,但两者是有区别的:
synchronized | ThreadLocal | |
原理 | 同步机制采用‘时间换空间’的的方式,只提供一份变量,让不同的线程排队访问 | 采用‘空间换时间’的方式,为每一个线程都提供了一份变量副本,从而实现同时访问时而互不干扰 |
侧重点 | 多个线程之间访问共享资源的同步 | 多线程中让每个线程之间的数据相互隔离 |
那么ThreadLocal 底层原理是什么呢?
如果我们不看源码的话,我们可能会猜测每个ThreadLocal都会创建一个Map,然后用线程作为map的key,要存储的局部变量作为value,JDK早期确实是这样设计的,但现在早不是了。
JDK1.8中:每个ThreadLocal 维护一个ThreadLocalMap,这个map的key是ThreadLocal实例本身,value是要存储的值:
这样设计的好处:
每个Map存储的Entry减少;当Thread销毁的时候ThreadLocalMap也会随之销毁,减少内存的使用
我们继续分析ThreadLocal的源码:
主要由下面四个方法:
//设置当前线程对应的ThreadLocal的值
public void set(T value) {
//获取当前线程
Thread t = Thread.currentThread();
//获取此线程中维护的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null)
//如果mapo不为空,设置值,注意this为当前调用set方法的threadlocal对象
map.set(this, value);
else
//当前线程不存在ThreadLocalMap ,则调用createMap进行ThreadLocalMap 的初始化
//并将当前线程t和value作为第一个Entry存放至ThreadLocalMap 中
createMap(t, value);
}
//获取当前线程Thread对应的ThreadLocalMap
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
总结一下set方法:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
//以当前threadlocal为key,获取对应的存储实体Entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
//如果e不为空,获取存储的value
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//初始化:有2种情况
//1)map不存在,表示此线程没有维护的ThreadLocalMap
//2)map存在,但是没有与当前threadlocal关联的entry
return setInitialValue();
}
//初始化
private T setInitialValue() {
//调用该方法获取初始化的值
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
//存在则设置值
map.set(this, value);
else
//当前线程不存在ThreadLocalMap,则对ThreadLocalMap 进行初始化,set方法中有分析
createMap(t, value);
//返回设置的value
return value;
}
//此方法可以被子类重写,通常采用匿名内部内的方式实现。
//返回当前线程对应的threadlocal的初始值。
//此方法的第一次调用发生在当线程通过get方法访问此线程的threadlocal值时,
//且当前线程未调用set方法,通常情况下,每个线程最多调用一次该方法
protected T initialValue() {
return null;
}
通过对get方法的分析可得到:如果在set之前执行get方法,则会返回默认值null,如果想改变该默认值,可通过子类去重写
总结一下get方法:
//删除当前线程中保存的threadlocal对应的实体entry
public void remove() {
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
//以当前threadlocal为空删除对应的实体entry
m.remove(this);
}
先分析一些简单的方法和属性:
static class ThreadLocalMap {
/**
* 自定义一个Entry类,并继承自弱引用
* 用来保存ThreadLocal和Value之间的对应关系
*
* 之所以用弱引用,是为了解决线程与ThreadLocal之间的强绑定关系
* 会导致如果线程没有被回收,则GC便一直无法回收这部分内容
*/
static class Entry extends WeakReference> {
Object value;
Entry(ThreadLocal> k, Object v) {
super(k);
value = v;
}
}
/**
* The initial capacity -- MUST be a power of two.
* Entry数组的初始化大小,必须是2的幂
*/
private static final int INITIAL_CAPACITY = 16;
/**
* The table, resized as necessary.
* table.length MUST always be a power of two.
* 数组
* 长度必须是2的N次幂
* 这个可以参考为什么HashMap里维护的数组也必须是2的N次幂
* 主要是为了减少碰撞,能够让保存的元素尽量的分散
* 关键代码还是hashcode & table.length - 1 前面的博文都有分析
*/
private Entry[] table;
/**
* The number of entries in the table.
* table里的元素个数
*/
private int size = 0;
/**
* The next size value at which to resize.
* 扩容的阈值
*/
private int threshold; // Default to 0
/**
* Set the resize threshold to maintain at worst a 2/3 load factor.
* 根据长度计算扩容的阈值
*/
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
/**
* 通过以下两个获取next和prev的代码可以看出,entry数组实际上是一个环形结构
*/
/**
* Increment i modulo len.
* 获取下一个索引,超出长度则返回0
*/
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
/**
* Decrement i modulo len.
* 返回上一个索引,如果-1为负数,返回长度-1的索引
*/
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
/**
* 构造一个包含firstKey和firstValue的map。
* ThreadLocalMap是惰性构造的,所以只有当至少要往里面放一个元素的时候才会构建它。
*/
ThreadLocalMap(ThreadLocal> firstKey, Object firstValue) {
// 初始化table的大小为16
table = new Entry[INITIAL_CAPACITY];
// 通过hashcode & (长度-1)的位运算,确定键值对的位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 创建一个新节点保存在table当中
table[i] = new Entry(firstKey, firstValue);
// 设置table内元素为1
size = 1;
// 设置扩容阈值
setThreshold(INITIAL_CAPACITY);
}
/**
* ThreadLocal本身是线程隔离的,按道理是不会出现数据共享和传递的行为的
* 这是InheritableThreadLocal提供了了一种父子间数据共享的机制
* @param parentMap the map associated with parent thread.
*/
private ThreadLocalMap(ThreadLocalMap parentMap) {
Entry[] parentTable = parentMap.table;
int len = parentTable.length;
setThreshold(len);
table = new Entry[len];
for (int j = 0; j < len; j++) {
Entry e = parentTable[j];
if (e != null) {
@SuppressWarnings("unchecked")
ThreadLocal
先来分析下弱引用
通过之前的博客强软弱虚引用,可知:弱引用在垃圾回收的时候是直接被回收的
为什么要使用弱引用呢?先来分析下如果使用强引用会是什么后果
总结:如果ThreaaLocalMap中Entry的key使用强引用,且不显式调用remove方法,是完全无法避免内存泄漏的
如果使用弱引用呢?
总结:如果ThreaaLocalMap中Entry的key使用弱引用,且不显式调用remove方法,也有可能发生内存泄漏
那value为啥不搞成弱引用,用完直接扔了多好?
不设置为弱引用,是因为不清楚这个
Value
除了map
的引用还是否还存在其他引用,如果不存在其他引用,当GC
的时候就会直接将这个Value干掉了,而此时我们的ThreadLocal
还处于使用期间,就会造成Value为null的错误,所以将其设置为强引用。
而为了解决这个valkue强引用的问题,ThreadLocalMap提供了一种清除机制,我们下面会分析
/**
* 构造一个包含firstKey和firstValue的map。
* ThreadLocalMap是惰性构造的,所以只有当至少要往里面放一个元素的时候才会构建它。
*/
ThreadLocalMap(ThreadLocal> firstKey, Object firstValue) {
// 初始化table的大小为16
table = new Entry[INITIAL_CAPACITY];
// 通过hashcode & (长度-1)的位运算,确定键值对的位置
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 创建一个新节点保存在table当中
table[i] = new Entry(firstKey, firstValue);
// 设置table内元素为1
size = 1;
// 设置扩容阈值
setThreshold(INITIAL_CAPACITY);
}
重点看一下上面构造函数中的int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
这一行代码。
ThreadLocal类中有一个被final修饰的类型为int的threadLocalHashCode,它在该ThreadLocal被构造的时候就会生成,相当于一个ThreadLocal的ID,而它的值来源于
private final int threadLocalHashCode = nextHashCode();
/*
* 生成hash code间隙为这个魔数,可以让生成出来的值或者说ThreadLocal的ID较为均匀地分布在2的幂大小的数组中。
*/
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode() {
return nextHashCode.getAndAdd(HASH_INCREMENT);
}
可以看出,它是在上一个被构造出的ThreadLocal的ID(threadLocalHashCode)的基础上加上一个魔数0x61c88647的。
了解:这个魔数的选取与斐波那契散列有关,0x61c88647对应的十进制为1640531527。斐波那契散列的乘数可以用(long) ((1L << 31) * (Math.sqrt(5) - 1))可以得到2654435769,如果把这个值给转为带符号的int,则会得到-1640531527。换句话说
(1L << 32) - (long) ((1L << 31) * (Math.sqrt(5) - 1))
得到的结果就是1640531527也就是0x61c88647。通过理论与实践,当我们用0x61c88647作为魔数累加为每个ThreadLocal分配各自的ID也就是threadLocalHashCode再与2的幂取模,得到的结果分布很均匀。
ThreadLocalMap使用的是线性探测法,均匀分布的好处在于很快就能探测到下一个临近的可用slot,从而保证效率。这就回答了上文抛出的为什么大小要为2的幂的问题。为了优化效率。(摘自其他博客)
线性探测法(解决hash冲突的)
在开放定址算法里,线性探测法是散列解决冲突的一种方法,当hash一个关键字时,发现没有冲突,就保存关键字, 如果出现冲突,则就探测冲突地址下一个地址,依次按照线性查找,直到发现有空地址为止,从而解决冲突。
HashMap里面截图hash冲突使用的是链表法:链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。在散列表中,每个位置对应一条链表,所有散列值相同的元素都放到相同位置对应的链表中
对于& (INITIAL_CAPACITY - 1)
,相信有过算法经验或是阅读源码(比如HashMap)较多的程序员,一看就明白,对于2的幂作为模数取模,可以用&(2n-1)来替代%2n,位运算比取模效率高很多。至于为什么,因为对2^n取模,只要不是低n位对结果的贡献显然都是0,会影响结果的只能是低n位。之前的博文有分析,传送门:HashMap的容量为什么是2的n次幂小记
可以说在ThreadLocalMap中,形如key.threadLocalHashCode & (table.length - 1)
(其中key为一个ThreadLocal实例)这样的代码片段实质上就是在求一个ThreadLocal实例的哈希值,只是在源码实现中没有将其抽为一个公用函数。
private Entry getEntry(ThreadLocal> key) {
//根据key这个ThreadLocal的ID来获取索引,也即哈希值
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
//对应的entry存在且未失效且弱引用指向的ThreadLocal就是key,则返回
if (e != null && e.get() == key)
return e;
else
//如果第一次找的不对,因为用的是线性探测,所以往后找还是有可能能够找到目标Entry的
return getEntryAfterMiss(key, i, e);
}
/*
* 调用getEntry未直接命中的时候调用此方法
*/
private Entry getEntryAfterMiss(ThreadLocal> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
// 基于线性探测法不断向后探测直到遇到空entry。
while (e != null) {
ThreadLocal> k = e.get();
// 找到目标
if (k == key) {
return e;
}
if (k == null) {
// 该entry对应的ThreadLocal已经被回收,调用expungeStaleEntry来清理无效的entry
expungeStaleEntry(i);
} else {
// 环形意义下往后面走
i = nextIndex(i, len);
}
e = tab[i];
}
return null;
}
/**
* 这个函数是ThreadLocal中核心清理函数,它做的事情很简单:
* 就是从staleSlot开始遍历,将无效(弱引用指向对象被回收)清理,即对应entry中的value置为null,将指向这个entry的table[i]置为null,直到扫到空entry。
* 另外,在过程中还会对非空的entry作rehash。
* 可以说这个函数的作用就是从staleSlot开始清理连续段中的slot(断开强引用,rehash slot等)
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 因为entry对应的ThreadLocal已经被回收,value设为null,显式断开强引用
tab[staleSlot].value = null;
// 显式设置该entry为null,以便垃圾回收
tab[staleSlot] = null;
size--;
Entry e;
int i;
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal> k = e.get();
// 清理对应ThreadLocal已经被回收的entry
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
/*
* 对于还没有被回收的情况,需要做一次rehash。
*
* 如果对应的ThreadLocal的ID对len取模出来的索引h不为当前位置i,
* 则从h向后线性探测到第一个空的slot,把当前的entry给挪过去。
*/
int h = k.threadLocalHashCode & (len - 1);
if (h != i) {
tab[i] = null;
while (tab[h] != null) {
h = nextIndex(h, len);
}
tab[h] = e;
}
}
}
// 返回staleSlot之后第一个空的slot索引
return i;
}
getEntry方法总结:
根据入参threadLocal的threadLocalHashCode对表容量取模得到下标index
看下流程图,方便记忆:
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();
// 找到对应的entry,设置值
if (k == key) {
//如果新增的key与k相同则表示,当前数组存在相同key的Entry了,此时只需要更新value
e.value = value;
return;
}
//根据key计算的索引值,进行线性搜索后找到的第一个Key为空的Entry
if (k == null) {
//擦除key为空的Entry,并设置key和value,逻辑复杂
replaceStaleEntry(key, value, i);
return;
}
}
//这里我们来分析一下for循环的条件:
//1.起始位置为 tab[i]:i = key.threadLocalHashCode & (len - 1);
//2.循环的条件为:线性探测到的下一个 entry!= null
//3.接着,继续使用nextIndex方法得到下一个位置(也就i=i+1的位置)如果在这个for循环里没有return,并且走完了(即线性探测到的下一个 entry == null),则继续执行下面的代码
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold) {
rehash();
}
}
/**
* 启发式地清理slot,
* i对应entry是非无效(指向的ThreadLocal没被回收,或者entry本身为空)
* n是用于控制控制扫描次数的
* 正常情况下如果log n次扫描没有发现无效slot,函数就结束了
* 但是如果发现了无效的slot,将n置为table的长度len,做一次连续段的清理
* 再从下一个空的slot开始继续扫描
*
* 这个函数有两处地方会被调用,一处是插入的时候可能会被调用,另外个是在替换无效slot的时候可能会被调用,
* 区别是前者传入的n为元素个数,后者为table的容量
*/
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// i在任何情况下自己都不会是一个无效slot,所以从下一个开始判断
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);
return removed;
}
private void rehash() {
// 做一次全量清理
expungeStaleEntries();
/*
* 因为做了一次清理,所以size很可能会变小。
* ThreadLocalMap这里的实现是调低阈值来判断是否需要扩容,
* threshold默认为len*2/3,所以这里的threshold - threshold / 4相当于len/2
*/
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) {
/*
* 个人觉得这里可以取返回值,如果大于j的话取了用,这样也是可行的。
* 因为expungeStaleEntry执行过程中是把连续段内所有无效slot都清理了一遍了。
*/
expungeStaleEntry(j);
}
}
}
/**
* 扩容,因为需要保证table的容量len为2的幂,所以扩容即扩大2倍
*/
private void resize() {
Entry[] oldTab = table;
int oldLen = oldTab.length;
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (int j = 0; j < oldLen; ++j) {
Entry e = oldTab[j];
if (e != null) {
ThreadLocal> k = e.get();
if (k == null) {
e.value = null;
} else {
// 线性探测来存放Entry
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null) {
h = nextIndex(h, newLen);
}
newTab[h] = e;
count++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
线性探测的过程中遇到key==null需要清除的entry时执行这个:
private void replaceStaleEntry(ThreadLocal> key, Object value,int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
//staleSlot是在set方法中通过key计算索引,经过线性探测,找到的第一个Key为null的Entry的所在位置,即:清除元素的开始位置
//从staleSlot位置反向搜索,因为Entry数组的设计是环形的,因此反向遍历可以遍历到最后一个Entry为null的位置
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);(e = tab[i]) != null;i = prevIndex(i,len))
if (e.get() == null)
// 用slotToExpunge记录最后一个key为null的索引位置
slotToExpunge = i;
//for循环执行完得到的是当前为空的entry的位置再往前探测到的第一个entry的key==null的位置
//从staleSolt向后搜索,直到遇见空的Entry为止
for (int i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i,len)) {
ThreadLocal> k = e.get();
//如果k与key相等,则将e的value设置为传入的value
if (k == key) {
e.value = value;
// 将i位置和staleSlot位置的元素对换,如此以来开始遍历的位置就是i位置了,减少了需要遍历的元素,提高遍历效率(staleSlot位置是要清除的元素)
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
//如果slotToExpunge与staleSlot相等,则staleSlot位置表示的第一个key为null的Entry也是slotToExpunge表示的最后一个key为null的Entry,
//即表示数组中只有一个key为null的Entry,上面已经将staleSlot位置的Entry放到了i位置,则此时清除的开始位置slotToExpunge应该为i
if (slotToExpunge == staleSlot)
slotToExpunge = i;
//从slotToExpunge位置开始清除key为空的Entry
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
//如果key为空,而根据slotToExpunge与staleSlot相等可以知道数组中只有一个key为空的Entry,所以此时开始清除的位置slotToExpunge就是当前遍历到的位置i
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 上面的遍历没有遇见空的Entry,则将staleSlot位置的value设为空,并且在此位置放入新的Entry对象
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 如果slotToExpunge不等于staleSlot表示,第一个key为空的Entry和最后一个key为空的Entry 不是同一个,也就是说Entry数组中存在多个Entry中key为空的对象,则从slotToExpunge位置开始清除
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
清理流程图:
set方法总结: