《吊打面试官系列》从源码剖析ThreadLocal的来龙去脉
一、引言
今天博主的一个朋友出去面试,问到了 ThreadLocal 的原理。朋友这半年一直在CURD,没有了解过具体的原理实现
直接被面试官问晕了,然后面试也 GG 了
回来告诉了博主,博主替他整理了一份 ThreadLocal 的源码剖析,希望也对你们有所帮助!
二、概念
ThreadLocal
的英文字面意思为 “本地线程”,实际上 ThreadLocal
代表的是线程的本地变量,可能将其命名为 ThreadLocalVariable
更加容易让人理解。
ThreadLocal
如何做到为每个线程存有一份独立的本地值呢?
一个 ThreadLocal
实例可以形象地理解为一个 Map
(早期版本的 ThreadLocal
是这样设计的)。
当工作线程 Thread
实例向本地变量保持某个值时,会以 “Key-Value对”(即键-值对)的形式保存在 ThreadLocal
内部的Map中,其中 Key
为线程 Thread
实例,Value
为待保存的值。
当工作线程 Thread
实例从 ThreadLocal
本地变量取值时,会以 Thread
实例为 Key
,获取其绑定的 Value
。
一个ThreadLocal
实例内部结构的形象如下所示:
后续在 JDK8
中进行了优化
三、使用
public class ThreadLocalTest {
ThreadLocal<String> threadLocal = new ThreadLocal<String>();
public static void main(String[] args) {
ThreadLocalTest threadLocalTest = new ThreadLocalTest();
threadLocalTest.showTwoThread();
}
public void showTwoThread() {
new Thread(new Runnable() {
public void run() {
threadLocal.set("Thread1");
System.out.println("I am " + threadLocal.get());
}
}).start();
new Thread(new Runnable() {
public void run() {
threadLocal.set("Thread2");
System.out.println("I am " + threadLocal.get());
}
}).start();
}
}
上述是我们 ThreadLocal
的一个使用 Demo
最终结果输出:
I am Thread1
I am Thread2
由此可以看到,我们在线程中使用了 ThreadLocal
做到了线程之间彼此隔离的作用。
四、源码解析
1. set 方法
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 通过当前线程获取 ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 验证当前的ThreadLocalMap是否为空
if (map != null){
// 若不为空,则直接插入数据即可
map.set(this, value);
} else{
// 若为空,则需要创建 ThreadLocalMap
createMap(t, value);
}
}
// 根据当前的Thread创建ThreadLocalMap并赋予初始值firstValue
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
从这一步我们可以看出,如果当前并没有创建过 ThreadLocalMap
,则会去第一次创建:
1.1 初始化设值
// 初始化ThreadLocalMap
// 1) 创建底层数据存储的Entry数组table
// 2) 根据hashCode计算出来所在数组的下标i执行赋值操作
// 3) 初始化代表table中元素个数size的值
// 4) 初始化阈值threshold
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
// 初始化table,默认数值为16
table = new Entry[INITIAL_CAPACITY];
// hash获取下标
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
// 创建待插入的对象并放入数据
table[i] = new Entry(firstKey, firstValue);
// 调整容量
size = 1;
// 调整负载系数
setThreshold(INITIAL_CAPACITY);
}
但如果当前的 ThreadLocalMap
不是第一次创建,则会执行 map.set(this, value)
1.2 非初始化设值
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 首先,我们要先知晓,Entry对象是【弱引用】对象
// 注意这里循环的条件是e != null,这个很重要,它采用的就是上面讲的开放地址法。
// 这里遍历的逻辑是,先通过hash找到数组下标,然后寻找相等的ThreadLocal对象,找不到就往下一个index找。
// --------------------------------------------------------------------------------------------
// 有三种情况会跳出循环:
// 1) 找到了相同key的ThreadLocal对象,然后更新value值;
// 2) 找到了数组中的一个元素Entry,但是key=null,说明虚引用是可被GC回收的状态。
// 3) 一直往数组下一个index查找,直到下一个index对应的元素为null;
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
// 拿到当前的ThreadLocal对象
ThreadLocal<?> k = e.get();
// 如果相同的ThreadLocal,直接更新即可
if (k == key) {
e.value = value;
return;
}
// 当前的key=null,则表示虚引用的ThreadLocal是被GC回收的状态
if (k == null) {
// 1) 向前找到第一个空闲的key下标为 first
// 2) 向后找到第一个空闲的key下标为 last
// 3) 设置当前的key-value
// 4) 【expungeStaleEntry】清除first~last之间的陈旧的Entry,直接将value置为null
replaceStaleEntry(key, value, i);
return;
}
}
// 走到这里就说明下标为i的位置上,是没有元素的,所以可以直接将新建的Entry元素插入到i这个位置
tab[i] = new Entry(key, value);
int sz = ++size;
// cleanSomeSlots:存在陈旧的Entry且已经被清除
if (!cleanSomeSlots(i, sz) && sz >= threshold){
rehash();
}
}
// 循环获取下标
// 0-1-2-3-0-1-2-3
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
private void rehash() {
// 清除陈旧的key
expungeStaleEntries();
// 扩容
// 1) 创建一个长度是当前table的2倍
// 2) 遍历旧的table数组,依次获得里面的Entry元素
// 2-1) 计算Entry在新的table数组中应该插入的位置
// 2-2) 如果下标h已经被占用了,那么就向后查找空位,直到找到空闲的位置为止,插入进去。
// 2-3) 如果下标h没有被占用,那么就插入到h的位置上
// 2-4) count++
// 3) 根据新数组的长度,更新阈值threshold
// 4) 针对新的数组,更新全局变量size和table
if (size >= threshold - threshold / 4){
resize();
}
}
1.3 流程图
2. get方法
public T get() {
// 拿到当前线程
Thread t = Thread.currentThread();
// 通过当前线程获取 ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 如果 ThreadLocalMap 不为空的情况下
if (map != null) {
// 得到当前的Entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
// 初始化(和Set一样的流程)
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
// 获取下标
int i = key.threadLocalHashCode & (table.length - 1);
// 获取键值
Entry e = table[i];
// 如果不为空且当前key相等
if (e != null && e.get() == key)
return e;
else
//
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
// 取当前的table
Entry[] tab = table;
int len = tab.length;
// 循环遍历当前entry
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到即返回
if (k == key)
return e;
if (k == null)
// 清理陈旧的key
expungeStaleEntry(i);
else
// 遍历下一个
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
3. remove方法
public void remove() {
// 拿到当前线程的ThreadLocalMap
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null){
m.remove(this);
}
}
private void remove(ThreadLocal<?> key) {
// 获取当前key的所属下标
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 循环找出当前的key
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
// 若找到引用置为空并清理陈旧的entry
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
4. 开放地址法
当我们看到这里,就发现这个 ThreadLocalMap 有些“奇怪”,它并没有按照我们之前在学习 HashMap
的 链式 方式去解决哈希冲突,即:数组+链表。它其实使用的是一种叫做 “开放地址法” 作为解决哈希冲突的一种方式。
什么是开放地址法呢?
开放地址法的基本思想就是:一旦发生了冲突,那么就去寻找下一个空的地址;那么只要表足够大,空的地址总能找到,并将记录插入进去。
ThreadLocalMap
和 HashMap
的区别是什么呢?
HashMap:
- 数据结构是数组+链表
- 通过链地址法解决
hash
冲突的问题 - 里面的
Entry
内部类的引用都是强引用
ThreadLocalMap:
- 数据结构仅仅是数组
- 通过开放地址法来解决
hash
冲突的问题 Entry
内部类中的key
是弱引用,value
是强引用
链地址法和开放地址法的优缺点是什么呢?
开放地址法
- 容易产生堆积问题,不适于大规模的数据存储。
- 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
- 删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
链地址法
- 处理冲突简单,且无堆积现象,平均查找长度短。
- 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
- 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
- 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
ThreadLocalMap采用开放地址法原因是什么?
ThreadLocal
往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小)。
采用开放地址法简单的结构会更节省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也比较低。
5. 清理方式
5.1 探测式清除
这个清除主要是通过这个方法:expungeStaleEntry
private int expungeStaleEntry(int staleSlot) {
// 获取数据
Entry[] tab = table;
int len = tab.length;
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
Entry e;
int i;
// 遍历Table,这里是一直向后遍历,直到遇到为null(也就是没用过的)为止
for (i = nextIndex(staleSlot, len); (e = tab[i]) != null; i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 赋予空值
if (k == null) {
e.value = null;
tab[i] = null;
size--;
} else {
// 重新hash
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;
}
5.2 启发式清除
从上面探测式清除我们可以得到一个结论:探测式清除并不能完全清除我们table的所有陈旧数据
这个时候需要 启发式清除 的出场:
// i:探测式清除的返回地址
// n:table的总容量
private boolean cleanSomeSlots(int i, int n) {
boolean removed = false;
Entry[] tab = table;
int len = tab.length;
do {
// 下一个
i = nextIndex(i, len);
Entry e = tab[i];
// 如果这个已经被GC掉了
if (e != null && e.get() == null) {
n = len;
removed = true;
// 由当前地址去进行探测式清除
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
看完说实话,实现有点操蛋,接着人家探测式清除外包了一层,就换了一个名字了
假设:m = n>>>=1 ,也就是 2 的 m 次幂等于 n
当连续 m
次没有去进行清除操作,则默认当前 table
中没有垃圾
例如:数组长度是16,那么2^4=16,也就是连续4次没有过期Entry,即 m = logn/log2(n为数组长度)
五、内存泄露
public class ThreadLocalForOOM {
/**
* -Xms50m -Xmx50m
*/
static class OOMObject {
private Long[] a = new Long[2 * 1024 * 1024];
}
final static ThreadPoolExecutor pool = new ThreadPoolExecutor(5, 5, 1, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
final static ThreadLocal<OOMObject> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
for (int i = 0; i < 50; i++) {
int finalI = i;
pool.execute(() -> {
threadLocal.set(new OOMObject());
System.out.println("oom object--->" + finalI);
OOMObject oomObject = threadLocal.get();
System.out.println("oomObject---->" + oomObject);
// threadLocal.remove(); // 记得remove 防止内存泄露,此时一定要在使用完remove
});
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
我们运行上述代码,会出现 OOM
异常:Exception in thread "pool-1-thread-4" java.lang.OutOfMemoryError: Java heap space
我们在使用完 threadLocal
之后,需要及时的进行 remove
删除,加上这句就OK了。
六、总结
又是一篇大工程的文章结束了
记得校招的时候,对于 ThreadLocal
的认知只停留在线程隔离,但并未真正的去剖析其源码是怎么做到线程隔离的
我们通过讲解 set
、get
、remove
等三大方法源码,体会到了整个的运行流程
而其 table扩容
、清除方式
、解决hash冲突
的方式也令人感到眼前一亮,读完还是有收获的
那么如何证明你真的理解了 ThreadLocal
呢,我这里出个经典的题目,大家可以想一下:请你聊一下 ThreadLocal
的 set
过程?
如果你能看到这,那博主必须要给你一个大大的鼓励,谢谢你的支持!
下期是 reentrantlock
源码文章,这个是 Java 层面的,应该还好,哈哈哈哈
我是爱敲代码的小黄,独角兽企业的Java开发工程师,CSDN博客专家,Java领域新星创作者,喜欢后端架构和中间件源码。
我们下期再见。
#Java##面试##实习##如何判断面试是否凉了#