作者:HuntX 链接:https://juejin.cn/post/6961801971615399950
ThreadLocal,稍微一深入问你一点细节,你能答出来么?估计很多人都答不上来,因为没有真正去了解过,如果你不熟悉这块,不如趁这次机会弄懂 ThreadLocal。读完会让你对 ThreadLocal 印象深刻,丛容面对 ThreadLocal 相关问题。
官方注释:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID).
这个类提供线程局部变量。这些变量与普通变量的不同之处在于,每个访问它们的线程(通过其get或set方法)都有自己的独立初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,希望将状态与线程关联(例如,用户ID或事务ID)。
可以这样理解:正常情况下,当我们定义出一个变量,可能会有多个线程来访问它,你需要给这个变量加上同步进制,以保证线程安全,此时多个线程访问的是同一个对象,这个对象是公共的,并不属于某个线程独享。但是在某些情况下,我们需要线程拥有自己独立的数据(比如 Looper ),与别的线程隔离开来,该如何做?第一反应是不是通过一个HashMap将线程与value对应起来,这样当某个线程想要取数据时,在 HashMap 中找到自己对应的 value 。ThreadLocal 提供了这种机制,但不是利用的 HashMap 去建立线程与 value 的对应关系,而是给每个线程提供了独立的变量副本,让线程自己去持有这个变量副本,这样就不必在外部的 HashMap 中维护线程与 value 的对应关系。
可以将 ThreadLocal 理解为一个容器,对外提供了 set/get 方法,用于保存/获取当前线程对应的 value,但是 ThreadLocal 并不是真正的容器,真正的容器是它的静态内部类ThreadLocalMap,ThreadLocalMap 内部通过一个 Entry 数组保存数据,结构如下图:
ThreadLocal 中核心的就是set,get 方法,分别来看下实现。
public void set(T value) {
// 获取当前调用这行代码的线程对象
Thread t = Thread.currentThread();
// 获取当前线程对象的成员变量 threadLocals
ThreadLocalMap map = getMap(t);
if (map != null)
// 将当前 ThreadLocal 与 value 组成 Entry,放入map
map.set(this, value);
else
// 初始化当前线程的 threadLocals 变量,并将数据放入
createMap(t, value);
}
简单一句话总结:往当前线程持有的 ThreadLocalMap 变量中放入数据,key 是当前ThreadLocal 实例对象。
public T get() {
// 获取当前调用这行代码的线程对象
Thread t = Thread.currentThread();
// 获取当前线程对象的成员变量 threadLocals
ThreadLocalMap map = getMap(t);
if (map != null) {
// 通过 key(ThreadLocal)获取对应的 Entry
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
// 返回真正的 value
return result;
}
}
// 如果没找到,返回 ThreadLocal 初始化时的 value,
// ThreadLocal 有个 initialValue 方法,可以实现它返回初始值
return setInitialValue();
}
总结一句话:从当前线程持有的 ThreadLocalMap 变量中获取数据,key 是当前 ThreadLocal 实例对象。
根据这两个方法的实现,我们可以看到,在 set,get 时,都是操作的当前线程持有的 ThreadLocalMap,不同线程对应不同的 Thread 对象,不同 Thread 对象对应 不同的 ThreadLocalMap 对象,所以这就起到了线程之间相互隔离的效果,就算 ThreadLocal 是同一个对象也无所谓,因为数据放到了不同的 ThreadLocalMap 中。
那么 ThreadLocalMap 内部是如何实现的?
构造方法省略,无非就是初始化数组,初始化一些变量。
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
// 从i往后查找
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已经失效,替换失效位置的元素
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 在数组中,没找到旧的值,将值存储到i位置上,i此时空闲的
tab[i] = new Entry(key, value);
int sz = ++size;
// 从i开始,以 sz --> 0的对数级别扫描,清除key失效的元素
if (!cleanSomeSlots(i, sz) && sz >= threshold)
// 如果一个都没有被清理,并且当前数组中的元素数量已经>=阈值,做一次rehash
// rehash里面包含了清理key失效的元素与扩容的逻辑
rehash();
}
将当前ThreadLocal对象与value组成Entry插入数组中,其中有两点需要注意,当遍历到失效的key时,需要做替换操作,最后需要做清除失效元素的操作。
注意,当发生插槽碰撞时,ThreadLocalMap 采用的是线性探测法,而不是HashMap中的拉链法,这里不存在链表,如果当前插槽被占用了的话,就继续查找下一个,直到碰到空闲位置。
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// 通过hash定位插槽,并且插槽上的key与参数传入的key相等,直接返回
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;
// 从i开始往后扫描
while (e != null) {
ThreadLocal<?> k = e.get();
// 找到了,返回
if (k == key)
return e;
// 当前元素k失效了,做一次清除
if (k == null)
expungeStaleEntry(i);
else
// 更新i
i = nextIndex(i, len);
e = tab[i];
}
// 没找到,返回空
return null;
}
从i开始,挨个往后查找,直到遇到null元素,如果找到,直接返回,若查找过程中发现了key已经失效的元素,则做一次清除,若最终没找到,返回空。
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 将staleSlot位置的entry清除,先把value引用断开,再将整个entry置null
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
// 其实本来应该到这里就结束了,因为已经将staleSlot位置的数据清除掉了,但是,没这么简单
// 下面,从staleSlot往后挨个扫描,清除key过期的entry,直到entry为null的位置
// 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) {
// 当前位置的key过期了,需要清除掉
e.value = null;
tab[i] = null;
size--;
} else {
// 当前位置没过期,通过当前key的threadLocalHashCode,计算出它应该存放的位置
int h = k.threadLocalHashCode & (len - 1);
// 如果计算出来的应该存放的位置跟当前i不同,也就是当前key本应该存储在h的位置
// 但是现在却存放在i的位置,因为可能存在冲突,当时set这个key的时候,发现
// 对应的位置已经有了元素,并且key不同,那只能寻找下一个不为null的位置存储了,
// 这时就会造成key计算出应该存放的位置跟实际存放的位置不相等
if (h != i) {
// 不相等怎么办?将当前i的位置元素清除,为什么要这么干?因为当前key本不应该存放
// 在i这个位置,现在就借这个机会,给当前key rehash一下,给它安排到它本应该
// 存放的位置h上,因为此时h的位置可能已经被清除了,空闲了下来,那么i位置是不是就要
// 清除掉,相当于把i位置上的值重新放到h上,然后将i清除
tab[i] = null;
// Unlike Knuth 6.4 Algorithm R, we must scan until
// null because multiple entries could have been stale.
// 将当前entry赋值到h位置,因为h位置上是有可能有元素的,如果有元素的话,就将h往后挪,直到
// tab[h]不为null
while (tab[h] != null)
h = nextIndex(h, len);
// 将当前元素放到本该对应的位置h上的好处就是,下次get的时候,可以快速定位,可以
// 直接从h位置上拿到值,当然,前提是第一次计算出来的h位置是空闲的,没有经过线性探测过,如果经过线性探测了,这个最终这个h也不是当前元素本该存放的位置
tab[h] = e;
}
}
}
return i;
}
expungeStaleEntry,翻译就是,清除过期条目,做了两件事:
可用如下图表示:
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];
if (e != null && e.get() == null) {
n = len;
removed = true;
// 碰到key失效的,就调用一次expungeStaleEntry
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);// 对数级别扫描
return removed;
}
循环清理数组中的key失效的元素,循环次数为第二个参数的对数级别次,为什么是对数级别次?这是在不扫描与全部扫描两个方案中做了均衡,就来个二分扫描吧。
那这个方法跟上面expungeStaleEntry有啥区别呢?expungeStaleEntry只能清除staleSlot到下一个为null的位置之间的失效元素,只有这么一段,可以理解为expungeStaleEntry是原子清除方法。
而这个方法就是将多个expungeStaleEntry方法综合起来,对数组进行全局扫描,清除,当然,不一定能将数组中失效的元素全部清除,因为在循环有一定的次数,从名字中也可以看出,【清除一部分失效元素】。可用如下图表示:
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
// 从staleSlot前一位开始向前扫描,直到遇到元素为null的位置
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
// 直到遇到元素为null,在这期间,如果key已经失效了,不断更新slotToExpunge
slotToExpunge = i;
// 从staleSlot后一位开始向后扫描,直到遇到元素为null的位置
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 如果当前扫描到的位置的key与我们参数传入的key(也就是需要set到数组的元素的key)相等
if (k == key) {
// 将当前扫描到的元素的value替换成传入的value(也就是需要set到数组的元素的value)
e.value = value;
// 将当前位置元素与staleSlot位置元素交换,为什么要交换?注释中说是维持hash表顺序
// 这么解释不好理解,什么叫维持hash表顺序?说下我的见解:
// 首先,这个方法在set时调用,走到这个方法,说明已经发生了碰撞,并且遇到了key失效的位置,那么基于线性探测法,
// 需要往后面查找能插入的位置,如果找到了与key相等的位置,那么新值替换旧值,那怎么解释交换?
// 看下staleSlot是什么?它是在线性探测给key寻找插槽时,碰到的第一个key失效的index,
// 但是此时我们找到的与key相等的位置还在staleSlot后面,与key最初计算出的插槽位置更远了,
// 反正staleSlot对应的位置元素已经失效了,跟staleSlot交换一下,不是能使key实际存放的插槽
// 与key最初计算出的本应该存放的插槽更近一点么,这样,下次get的时候,能少遍历几步,从而更快的访问到
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// 如果从staleSlot往前扫描时,没有发现key失效的元素,就把slotToExpunge重新赋值为i,
// 此时i位置上的元素的key已经失效了,因为上一步我们进行交换了
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 清除从slotToExpunge开始到下一个元素为null区间内的key失效的元素
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
// 返回,因为已经完成了key的插入了
return;
}
// 如果从staleSlot往前没有找到key失效的元素,并且当前位置的k失效了,更新slotToExpunge为当前位置
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 如果在数组中没有找到与key相等的元素,那就说明当前数组中没有key对应的老的值,也即之前没有set过,
// 就把key放入staleSlot位置上
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 上面的for循环中,有两个更新slotToExpunge的地方,第一个不用管,因为里面return了,
// 这里就是需要看第二个地方,可能会更新slotToExpunge,如果更新了,就做一次清除
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
这个方法从名字可以看出,替换失效的元素,但是它同时还会做清除元素的工作,这个方法在set方法中调用,具体的在注释中已经写清楚了。可用下图表示:
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; // Help the GC
} else {
// 重新计算插槽位置
int h = k.threadLocalHashCode & (newLen - 1);
while (newTab[h] != null)
h = nextIndex(h, newLen);
newTab[h] = e;
count++;
}
}
}
// 设置resize阈值
setThreshold(newLen);
size = count;
table = newTab;
}
这个方法基本没有要说的,将数组扩容两倍。
private void rehash() {
// 清除全部的失效的元素
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
// 如果清除失效元素后,没有有效的压缩数组的数据量,那么判断当前元素个数是否超过阈值条件,超过的话,扩容
if (size >= threshold - threshold / 4)
resize();
}
首先,设计成弱引用,肯定是为了help GC 的,使得 ThreadLocal 对象在不再需要使用的情况下,能够自动被 GC,我们可以对比下 HashMap 的设计。
HashMap 没有设计成弱引用key的形式(当然也有专门的弱引用设计WeakHashMap),但是它的键值对是设计成泛型的,也就是说你可以将key的泛型传入 WeakReference,这样也就达到了弱引用key的效果。
而 ThreadLocalMap 中呢?key 固定只能为 ThreadLocal 的类型,这样就失去的拓展的功能,从而要想实现自动 GC,就必须在内部再给 ThreadLocal 包一层弱引用。
可能会有这样的疑问,ThreadLocal 不是提供了remove 方法么,ThreadLocal 对象不再使用时,主动 remove,不就不会存在问题了?但是我想说的是,如果我们程序员无论在怎么复杂的逻辑下,都能保持不出错,能够保证一个不再使用的对象,没有一个强引用指向它,那还有内存泄漏这个概念么。正因为此,设计成弱引用是一种安全保险的方式。
关于为什么会出现内存泄漏,网上文章比比皆是,不作讨论,无非就是一个实例对象用不到了,但是却被一个强引用,引用着,不能被GC。
那么这里为什么会泄漏,是因为 ThreadLocal 对象作为 key 是被Entry弱引用着,ThreadLocal 对象随时可能被回收,那么 key 不就指向 null 了么,指向 null 的话,对应的 value 是无法访问到的,访问不到,又被强引用着,无法被GC,这就造成了泄漏。
那么这种情况怎么办?其实 ThreadLocalMap 中,expungeStaleEntry 方法replaceStaleEntry 方法中也都已经包含了将 key 为 null 的 value 置 null 的逻辑,在 set 和 get 的过程中,遍历时,碰到 key 为 null 的就会去执行清除操作,这样,在很大程度上避免了内存泄漏。
就算存在内存泄漏,在线程运行结束后,也都会释放掉。可能有人会有疑问,如果线程是长期存在的,或者是主线程,这种情况怎么办?可以在不再需要 value 的情况下,主动调用 remove 方法。
综上,有三重保险:
所以,无需担心内存泄漏。
static final ThreadLocal<Looper> sThreadLocal = new ThreadLocal<Looper>();
通过定义一个静态常量 sThreadLocal,这个常量全局唯一,不会被回收,当调用 prepare 时,会先通过当前 sThreadLocal 去获取一下,如果有直接抛异常,这就保证了,一个线程不能多次创建Looper对象,如果没有,new 一个 Looper 对象,放进去。
private static void prepare(boolean quitAllowed) {
if (sThreadLocal.get() != null) {
throw new RuntimeException("Only one Looper may be created per thread");
}
sThreadLocal.set(new Looper(quitAllowed));
}
ThreadLocal 的 set 方法的实现上面已经说明了,将创建的 Looper 对象与 threadLocal 对象组装成Entry,放到当前线程对应的 Thread 对象的 threadLocals 里:
ThreadLocal.ThreadLocalMap threadLocals = null;
那么当调用 Looper 的 myLooper 时,会调用 sThreadLocals 的 get 方法:
public static @Nullable Looper myLooper() {
return sThreadLocal.get();
}
ThreadLocal 的 get 方法的实现上面已经说明了,会从当前线程对应的 Thread 对象的 threadLocals 中取数据。
综上,不管 set 还是 get 都是将去操作当前线程的 threadLocals 对象,而不同的 Thread 对象对应不同的 threadLocals 对象,所以,各个线程的Looper对象是隔离的
各线程的 Looper 对象隔离,并且 Looper 对象不能多次创建,是不是就有了这个结论:一个线程对应个一个 Looper。