面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看
Posted JavaCaiy
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看相关的知识,希望对你有一定的参考价值。
前言
1.为什么用 ThreadLocal?
所谓并发,就是有限资源需要应对远超资源的访问。解决问题的方法,要么增加资源应对访问;要么增加资源的利用率。 所以,相信这年头做开发的多多少少,都会那么几个“线程二三招”、“用锁五六式”。 那所带来的就是多线程访问下的并发安全问题。 共享变量的访问域跨越了原始的单线程,进入了千家万户的线程眼里。谁都可以用,谁都可以改,那不就打起来了吗? 因此,防止并发问题的最好办法,就是不要多线程访问(这科技水平倒退二十年~)。ThreadLocal 顾名思义,将一个变量限制为“线程封闭”:对象只被一个线程持有、访问、修改。
2.那到底什么是 ThreadLocal?
ThreadLocal 如果做到线程封闭,那固然是独木难支。它必然携手 Thread 为广大 Javaer 带来福音。 ThreadLocal 自己不是存储者,它只是 Thread 的搬运工。独有变量必然是存在 Thread 中的。一般项目中多定义多个 ThreadLocal,那相应的 Thread 必然也需要存储那么多独有变量。 既然解决了线程之间的访问干扰,那一个线程的访问干扰自然就不在话下了。Thread 维护了一个 ThreadLocalMap,以“key-value”的形式存储了独有变量;以 ThreadLocal 实例为 key,精准获取。
3.ThreadLocal 需要考虑哪些问题?
如果线程死亡了,那 ThreadLocalMap、ThreadLocal 及独有变量都会被销毁。
但是现在避免线程的重复创建与销毁,线程使用完都是放回线程池。而如果没有手动移除 ThreadLocalMap 的元素,即使当前线程退出,ThreadLocal 已不被线程方法栈持有,也依然无法被回收,从而造成内存泄漏。 所以 ThreadLocalMap.Entry 的 key(也就是 ThreadLocal)实际是弱引用。当没有其他强引用时,只要发生 GC,就会被回收,相当于这个时候 key 为 null。
这又产生了一个问题,key 被回收了, entry 和 value 可还是强引用呢,怎么办? ThreadLocalMap 已经考虑了这种情况,再调用 set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录。 所以人家设计是没有问题的,如果发生内存泄漏都是用的不对。 建议使用完 ThreadLocal方法后,最好手动调用remove()方法。
4.ThreadLocal 还需要考虑哪些问题?
随着业务场景的复杂化,变量的线程封闭固然解决了访问的问题,但是也给线程传递带来了难度。 线程之间的协作,带来了变量在两个线程之间安全传递的需要。需要人为处理这种传递,需要三个步骤:
- 线程 1 取出变量;
- 线程 1 安全传递变量、ThreadLocal(其实一般选择共享)给线程 2,当心逃逸。
- 线程 2 放到当前线程的 ThreadLocal。 这个步骤是通用的,只要存在使用 hreadLocal并且需要线程传递时,必然少不了这三步。 JDK 为我们提供了“线程 2 是线程 1 创建出来时,独有变量传递给线程 2”的解决方法:InheritableThreadLocal,Thread 中也有专门为其服务的 ThreadLocalMap。
那我们明白,在线程池化的世面下,不会经常存在创建的场景,更多的是与已有线程的协作。 各家公司,其实也会为相关业务的 ThreadLocal 自研类库,去做到传递。 市面上解决通用场景的线程传递的类库就是 TransmittableThreadLocal。
源码解析
Thread
public Class Thread implements Runnable
//与此线程有关的 ThreadLocal 值。由 ThreadLocal 类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
// 与此线程有关的 InheritableThreadLocal 值。由 InheritableThreadLocal 类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
ThreadLocalMap 是 ThreadLocal 的内部类,是定制的 Map 实现。 初始值都为 null,只有当第一次调用对应ThreadLocal的 get 或 set 时,才会初始化。
ThreadLcoal
ThreadLcoal 只有一个默认的无参构造函数。实际的初始化逻辑,都在第一次调用 get 或 set 时。
get()
由于是类似懒加载的形式,所以 get 中涉及到ThreadLocalMap的创建以及初始值设置。
public T get()
Thread t = Thread.currentThread();
// 获取线程的 map, 为啥要抽取方法呢?就是为了扩展之前提到的 InheritableThreadLocal
ThreadLocalMap map = getMap(t);
if (map != null)
// 已经 set 过
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null)
@SuppressWarnings("unchecked")
// 走到这里没有 Entry 的情况:remove 以后
T result = (T)e.value;
return result;
// 未 set 过的第一次 get (map == null)
// 或 set 过, 但是 remove 了 (map != null && e == null)
return setInitialValue();
private T setInitialValue()
// 获取指定初始值, 默认是 null
// 可以通过 withInitial(Supplier<? extends S> supplier) 工厂方法来创建指定初始化值的 ThreadLocal
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
// ThreadLocalMap 未初始化
createMap(t, value);
if (this instanceof TerminatingThreadLocal)
// 处理一个特殊子类的逻辑
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
return value;
void createMap(Thread t, T firstValue)
t.threadLocals = new ThreadLocalMap(this, firstValue);
ThreadLocalMap getMap(Thread t)
return t.threadLocals;
指定初始值的工厂构造方法
// 如果以下情况下的第一次 get, 判断 map 的 entry 为 null下
// 1.从未 set 过;
// 2.remove 过后
protected T initialValue()
return null;
默认初始值是 null。 可以通过以下工厂方法,获取一个指定初始化逻辑的 ThreadLocal。
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier)
return new SuppliedThreadLocal<>(supplier);
static final class SuppliedThreadLocal<T> extends ThreadLocal<T>
private final Supplier<? extends T> supplier;
SuppliedThreadLocal(Supplier<? extends T> supplier)
this.supplier = Objects.requireNonNull(supplier);
@Override
protected T initialValue()
return supplier.get();
set() & remove()
set() 有点像 setInitialValue(),只不过一个是初始值,一个是指定值。
两个方法其实本身都简单,主要依赖于 ThreadLocalMap的操作。
public void set(T value)
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
public void remove()
ThreadLocalMap m = getMap(Thread.currentThread());
if (m != null)
m.remove(this);
ThreadLocalMap
- 这个类是 ThreadLocal 的内部类,是包私有的。
- key 的 hashcode 是自定义的增长值。
- key 是 WeakReference 的。
Entry
可以看到 key 就是 ThreadLocal,肯定不为空,但也是弱引用的。
也就是说,当 key 为 null 时,说明 ThreadLocal 已经被回收了,对应的 Entry 就应该被清除了。
static class Entry extends WeakReference<ThreadLocal<?>>
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v)
super(k);
value = v;
预设值
- 初始容量为 16,扩容翻倍。所以容量一定为 2 的 n 次幂。
- 负载因子是 2/3。
- 初始化时,应该是第一次设置值,或来源于 ThreadLocalMap。所以算得上饿汉式加载。
private static final int INITIAL_CAPACITY = 16;
private Entry[] table;
private int size = 0;
private int threshold; // Default to 0
private void setThreshold(int len)
threshold = len * 2 / 3;
构造函数
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)
table = new Entry[INITIAL_CAPACITY];
int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
table[i] = new Entry(firstKey, firstValue);
size = 1;
setThreshold(INITIAL_CAPACITY);
ThreadLocal
/**
* 人为设置的 hash code 分布. 对于在相同线程中使用连续构造的 ThreadLocal, 可以有效避免冲突.
* 因为是可以预见的场景, 仅在 ThreadLocalMap 中使用.
*/
private final int threadLocalHashCode = nextHashCode();
/**
* The next hash code to be given out. Updated atomically. Starts at
* zero.
*/
private static AtomicInteger nextHashCode = new AtomicInteger();
private static final int HASH_INCREMENT = 0x61c88647;
private static int nextHashCode()
return nextHashCode.getAndAdd(HASH_INCREMENT);
每个 ThreadLocal 对象都有一个 hash 值 threadLocalHashCode,每初始化一个 ThreadLocal 对象,hash 值就增加一个固定的大小 0x61c88647。这个东西比较讲究,有兴趣可以自行研究一下。
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;
// 开放定址法: 索引位置 + 1
e = tab[i = nextIndex(i, len)])
ThreadLocal<?> k = e.get();
if (k == key)
e.value = value;
return;
if (k == null)
// key 为空, 说明 对应的 ThreadLocal 已经回收了.
// 可以复用当前位置.
// 有两种情况:1\\. entry 存在, 在这个过时位置的后面. 所以需要置换到这个位置
// 2.不存在, 直接放到这个位置
replaceStaleEntry(key, value, i);
// 因为是替换, 所以size 要么不变,要么减少。
return;
// 没找到已存在的, 也没找到可以替换的过时. 则直接新建
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
// 如果没有清除过时 entry, 并且超过阈值. 则进行先尝试缩小,不行则扩容
rehash();
类中定义了两个方法用于开放定址法的查找:增量为 1。
private static int prevIndex(int i, int len)
return ((i - 1 >= 0) ? i - 1 : len - 1);
private static int nextIndex(int i, int len)
return ((i + 1 < len) ? i + 1 : 0);
replaceStaleEntry()
replaceStaleEntry() 比较复杂。一是需要清除过时 entry,二是开放定址法要保证所计算出的索引值后面的元素连续性。
所以,replaceStaleEntry() 会检查当前可替换位置的前后最近的两个空档之间所有的过时 entry。
其次,如果是 key 已存在过时位置的后面,那原有位置替换后会留出空档,需要后面的 entry 都往前挪一位(空档前的)。
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot)
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
// 1.往前查找第一个空档后的最小过时
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();
// 找到对应的 entry
if (k == key)
e.value = value;
// 2.将key 与原位置的过时替换
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
if (slotToExpunge == staleSlot)
// 3.如果前面都没有过时的话,那这个区间的第一个过时就是原来的staleSlot, 现在的 i
slotToExpunge = i;
// 4.清理过时, 挪移 entry
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
// 5.如果前面没有空槽, 且有新的过时, 则重新标记第一个过时.(因为staleSlot一定会被替换成不过时的,到时候就不是第一个过时点了)
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
// 6.直接替换
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// slotToExpunge == staleSlot, 说明当前区间只有这个过时, 已经被替换了, 所以不需要再进行清除
if (slotToExpunge != staleSlot)
// key 本不在, 且前或后存在其他的过时
// 7.清理过时, 挪移 entry
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
5、7 由于是清理过时,后面再详细说明。
区间是当前过时位置staleSlot前后第一个空位所组成的范围,即下图两个空白格子之间。
我们根据区间的不同情况,做了图例说明。
key 存在:
key 不存在:
rehash()
当 set() 完,数量到达阈值,是先尝试能不能删掉一些过时的。如果删无可删,或者删完之后达不到标准,则扩容。
注意的是,这个标准不是之前的 threshold,而是 3/4 threshold,避免滞后性。
private void rehash()
// 对整个数组进行扫描,清理.
// 而不像替换那步, 只扫描区间
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
private void resize()
Entry[] oldTab = table;
int oldLen = oldTab.length;
// 翻倍扩容
int newLen = oldLen * 2;
Entry[] newTab = new Entry[newLen];
int count = 0;
for (Entry e : oldTab)
if (e != null)
ThreadLocal<?> k = e.get();
if (k == null)
// 发现过时, 则抛弃
e.value = null; // Help the GC
else
// 重新 hash
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
setThreshold(newLen);
size = count;
table = newTab;
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);
expungeStaleEntry()
从上面的分析可以看到,该方法应用在 replaceStaleEntry 和 expungeStaleEntries。
replaceStaleEntry是对区间进行处理, expungeStaleEntries是对全数组。所以expungeStaleEntry(int)就是上述处理的一个子集。这样理解下来,就是清理指定位置到下一个空位之间的过时 entry,包含指定位置:[index, indexOf(first null))。
-
index 一定是一个过时元素的位置。
-
既然过时的会被清除,那中间就会留出空位。开放定址法是要求连续的,所以重新计算索引来放置。
-
注意:保留的 key 是重新计算索引, 而不是简单地往前挪一位。
-
这是因为清除区间的过时,是在某个 key 与运算出的起始索引之前。
-
而 key 刚好在这个索引上,简单往前挪一位,下次查找可能就找不到了。
-
因为要求连续性地从头遍历到尾,一旦中间出现空位,就找不到了。
private int expungeStaleEntry(int staleSlot)
Entry[] tab = table;
int len = tab.length;
// expunge entry at staleSlot
// 明确当前位置一定是过时的, 先直接清理掉
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// Rehash until we encounter null
Entry e;
int i;
// 开始遍历直到遇到第一个空位
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
// 因为前面留出空档, 所以后面的元素都要重新计算索引, 以望填补空位
int h = k.threadLocalHashCode & (len - 1);
if (h != i)
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
while (tab[h] != null)
h = nextIndex(h, len);
tab[h] = e;
// 返回遇到的第一个空位
return i;
cleanSomeSlots()
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];
// 没扫到任何过时,共扫描 log2(n) 个槽;
if (e != null && e.get() == null)
// 上述期间扫到过时,则将该区间遍历:
// 然后基于区间终点,重新扫描 log2(length);
// 如果扫到,重复上面;
// 如果一直重复,最终扫描了全数组。
n = len;
removed = true;
i = expungeStaleEntry(i);
while ( (n >>>= 1) != 0);
return removed;
cleanSomeSlots 一般在新增一个元素或删除另一个旧元素(不是 remove,而是 set 时刚好删掉另一个过时的后),进行扫描
以上是关于面试又被问懵了吗?不如把ThreadLocal拆开了揉碎看看的主要内容,如果未能解决你的问题,请参考以下文章
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
面试官:单核 CPU 支持 Java 多线程吗?为什么?被问懵了!
头铁面试官:一个小小的 System.out.println 硬是考了我半个小时,被问懵了。。