前言
上篇文章简单介绍了 ThreadLocal 的用法以及与 Thread 之间的关系,这篇文章开始讨论下上篇文章提到的 ThreadLocalMap ,这个类就是专门用于存储我们需要存的値,是 ThreadLocal 思想的核心类,所以大家打起精神来跟我一起探讨。
ThreadLocalMap 的概念
ThreadLocalMap 顾名思义:就是一个key-value
对的散列表,key
就是我们的 ThreadLocal 对象,値为我们需要存储的値,其中 key
是弱引用类型,即内存不足时会被 GC 掉,这点是为了应对空间很大且保存时间很长的场景,但是如果使用不当极有可能造成 OOM 。ThreadLocalMap 不是一个普通的散列表,而是一个环形的散列表,解决冲突采用的是 线性探测法。
ThreadLocalMap 类定义
平平无奇的文字无法描述 ThreadLocalMap 类的巧妙定义,因此我们直接膜拜一下大神的代码:
static class ThreadLocalMap {
/**
* 散列表的中的条目 继承了 WeakReference ,
* 将条目中的键变为弱引用。因此 null 键则表示不再引用该键了,
* 该条目称为 ‘过期的条目’,这个条目会在一定时机进行清除
*/
static class Entry extends WeakReference<ThreadLocal<?>> {
/** 与ThreadLocal 关联的値 */
Object value;
Entry(ThreadLocal<?> k, Object v) {
// key 为弱引用
super(k);
value = v;
}
}
/**
* 初始容量,必须为2 的幂次方
*/
private static final int INITIAL_CAPACITY = 16;
/**
* 散列表,根据需要调整大小
* 表的长度,必须为2的幂次方
*/
private Entry[] table;
/**
* 表中条目的个数
*/
private int size = 0;
/**
* 调整表大小的阈值
*/
private int threshold;
}
大家可以看下👆的代码,根据我的注释自己理解下,在这里就不赘述了。
ThreadLocal:: set
一个正常的数据结构都有增改删查,按照顺序我们先来介绍下增改
即set
方法,下面我们来看下它的源码:
/**
* 设置与健关联的值
*
* @param key ThreadLocal 对象
* @param value 要设置的值
*/
private void set(ThreadLocal<?> key, Object value) {
//开启一个新的引用引用散列表
Entry[] tab = table;
int len = tab.length;
//获取 key 对应的下标
int i = key.threadLocalHashCode & (len-1);
//根据线性探测法寻找key
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
//若找到与之相等的则直接替换值即可
if (k == key) {
e.value = value;
return;
}
//若该位置的 key 为null ,如上文所说该位置的条目为一个过期的条目,
// 需要替换它,并且执行一些清除操作
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
//如果没有找到则新建一个条目
tab[i] = new Entry(key, value);
int sz = ++size;
//启发性地清理过期条目 然后判断size 是否达到阈值 是否需要重新hash
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
根据👆的代码注释,已经知道 set
方法的执行流程:①通过 ThreadLocal 对象的
threadLocalHashCode &(散列表的长度-1) 获取 key
在散列表中的位置 i ,② 通过线性探测法寻找 key
是否已存在,若存在,判断原先的 key 是否为空,若不为空则直接替换,若原先的 key 为空,则说明为过期的条目需要替换它并且执行一些清除操作。③如果 key
不存在,则直接新建一个条目即可④启发性地清理一些槽并判断是否需要重hash。
下面我们来看下 replaceStaleEntry
方法,该方法只是替换了过期条目那么简单吗,下面我们用代码来见证下吧:
/**
* 用指定键的项替换set操作期间遇到的过时项。
* 在value参数中传递的值存储在条目中,无论指定键的条目是否已经存在
* 还有另外一个副作用就是:删除位于两个空槽之间的所有过期条目
* @param key 指定的健
* @param value 与给定健关联的值
* @param staleSlot 搜索过期条目的第一个过期条目索引
*/
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
//搜索一下位于staleSlot 之前 的过期索引
//slotToExpunge 有两个结果:①staleSlot,说明在staleSlot 之前的 run 没有过期条目
// 不等于staleSlot ,说明staleSlot之前有过期条目
int slotToExpunge = staleSlot;
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 向后寻找与key 相等的条目
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
//如果找到对应的key
if (k == key) {
//将e的值替换为我们要设置的值
e.value = value;
//把当前 i 位置的条目置为过期条目
tab[i] = tab[staleSlot];
//并将e 赋给staleSlot
tab[staleSlot] = e;
// 若staleSlot 之前有过期条目,则从slotToExpunge 寻找,
// 否则从当前i 开始清理过期条目
if (slotToExpunge == staleSlot)
slotToExpunge = i;
//启发性地清理过期条目,在清理之前清理从slotToExpunge到下一个为null之间的过期条目,并重新hash
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
//如果在向后扫描时没有找到过时的条目,那么在扫描密钥时看到的第一个过时条目就是在运行时仍然存在的第一个过时条目。
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
//如果key 没有找则新建一个条目放到staleSlot 的位置
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
//如果找到了其他过期的条目则清除他们
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
上面的注释还是比较清晰的,但是为了增强理解,我们还是理下流程:①搜索 staleSlot
之前到空槽的之间过期条目,搜索到的下标定为 staleToExpunge
(可能等于 staleSlot
说明之前没有过期条目)②从 staleSlot
之后寻找 key
对应的条目,如果找到目标条目 则将目标条目的值替换为要设置的值,并且将目标条目替换到staleSlot
,然后判断 staleSlot
之前有无过期条目,如果没有则从目标条目位置开始清理,否则从 staleToExpunge
开始清理。如果没有找到 key
对应的条目则新建一个条目放到 staleSlot
位置处。③ 若找到了其他过期条目则清除他们。
下面我们来看看 ThreadLocalMap 中的核心函数 expungeStaleEntry
,该方法的参数为 第一个过期条目的索引staleSlot
,该函数从该 staleSlot
开始清理过期条目,并重新hash 没有清理的条目,直到为空结束。ThreadLocalMap 的 set、get 方法都会用到的方法,我们来看下它的源码:
/**
* 通过重新哈希位于staleSlot和下一个空槽之间的任何可能碰撞的条目,
* 删除陈旧的条目。
* @param staleSlot 已知key 为null 的下标
* @return staleSlot之后的下一个空槽的索引
* (所有在staleSlot和这个槽之间的都将被清除).
*/
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 清除在staleSlot 位置上的条目
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
//重新哈希,直到遇到null
Entry e;
int i;
for (i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
//遇到 键为 为null 的,删除掉
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;
}
这里我就不过多赘述了,该方法执行的操作是比较单一的
下面我们来看看 启发性清理函数 cleanSomeSlots
,该方法中会调用 expungeStaleEntry
,我们可以把它理解成 expungeStaleEntry
方法的复数形式,下面我们来看看源码:
/**
* 启发式地扫描一些单元格,寻找陈旧的条目。
* 这是在添加新元素或删除另一个陈旧元素时调用的。
* 它执行对数数量的扫描,
* 以平衡无扫描(快速但保留垃圾)和与元素数量成比例的扫描次数,
* 这将找到所有垃圾,但会导致一些插入花费O(n)时间。
*
* @param i 一个已知不是过期条目的位置,从i 之后扫描
*
* @param n 扫描控制:{@code log2(n)}单元格被扫描,除非找到一个过时的条
* 目,在这种情况下,{@code log2(table.length)-1}额外的单元格被扫描。当从
* 插入调用时,这个参数是元素的数量,但是当从replaceStaleEntry调用时,它
* 是表长度。(注意:所有这些都可以通过加权n来改变,而不是直接使用log n。
* 但 是这个版本简单、快速,而且似乎工作得很好。)
*
* @return 如果删除了任何陈旧的条目,则返回true
*/
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];
//找到过期条目,就调用expungeStaleEntry 方法删除
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
//对n 进行左移1 位
} while ( (n >>>= 1) != 0);
return removed;
}
cleanSomeSlots
方法从i 处开始 扫描 log2n 次,来清理过期条目,为什么扫描 log2n 次,为了平衡不扫描和扫描整个元素个数,真正清理的函数还是expungeStaleEntry
,cleanSomeSlots
有个副作用就是会导致 set
方法的时间复杂度位O(n)。
ThreadLocaLMap::getEntry
我们来探讨下 getEntry
方法会执行哪些操作,直接上源码:
/**
* 获取与key关联的条目。
* 此方法本身只处理快速路径:直接命中现有键。
* 否则,它就会转接到“事后”。
* 这是为了最大限度地提高直接命中的性能而设计的,
* 部分原因是通过使这种方法易于线性化。
* @param key ThreadLocal 对象
* @return 与key 关联的条目,或者为null
*/
private Entry getEntry(ThreadLocal<?> key) {
//计算key 应该在散列表的位置
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
//若该位置不为空且key 刚好相等则返回条目e
if (e != null && e.get() == key)
return e;
else
//否则基于线性探测法找到 与key相关的条目
return getEntryAfterMiss(key, i, e);
}
getEntry
方法还是比较简单的,先根据 ThreadLocal 类型的健就算出 条目的下标 i ,然后若 i 处的条目 e 不为空且 key
等于 条目 e 的健,则直接返回条目 e.否则 基于线性探测法去找 key
关联的条目。下面我们来看下getEntryAfterMiss
方法:
/**
* 基于线性探测法找到key 对应的条目,
* 顺便清理遇到的过期条目
* @param key ThreadLocal 对象
* @param i 键的哈希码的散列表索引
* @param e i 处的条目
* @return 与key 相关联的条目,也可能为null
*/
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length;
while (e != null) {
ThreadLocal<?> k = e.get();
// 如果找到 相等的健就返回 对应的条目
if (k == key)
return e;
//遇到过期条目则删除
if (k == null)
expungeStaleEntry(i);
else
i = nextIndex(i, len);
e = tab[i];
}
//找不到就返回空
return null;
}
ThreadLocalMap::remove
下面我们来看下 删除 的方法,remove(ThreadLocal<?> key)
,该方法删除 key
对应的条目,顺便删除 key
在散列表中索引之后直到空槽之前的过期条目。来看下源码:
/**
* 删除key 对应的条目
*/
private void remove(ThreadLocal<?> 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)]) {
if (e.get() == key) {
e.clear();
//删除从i 开始到空槽之间的过期条目
expungeStaleEntry(i);
return;
}
}
}
总结
通过对 ThreadLocalMap 核心源码的探讨,我们有个比较细致的理解,知道该散列表的 key 引用为弱类型,但是 value 引用
却为强引用,这就可能导致OOM了,哪钟场景下会导致OOM呢,大家可以在评论区写出来一起探讨哦,希望大家有收获。