前言
ThreadLocal,稍微一深入问你一点细节,你能答出来么?估计很多人都答不上来,因为没有真正去了解过,如果你不熟悉这块,不如趁这次机会弄懂 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 内部结构
可以将 ThreadLocal 理解为一个容器,对外提供了 set/get 方法,用于保存/获取当前线程对应的 value,但是 ThreadLocal 并不是真正的容器,真正的容器是它的静态内部类ThreadLocalMap,ThreadLocalMap 内部通过一个 Entry 数组保存数据,结构如下图:
ThreadLocal 方法详解
ThreadLocal 中核心的就是set,get 方法,分别来看下实现。
set
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 实例对象。
get
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 内部是如何实现的?
ThreadLocalMap 方法详解
构造方法省略,无非就是初始化数组,初始化一些变量。
set
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中的拉链法,这里不存在链表,如果当前插槽被占用了的话,就继续查找下一个,直到碰到空闲位置。
getEntry
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);
}
复制代码
共两个步骤:
- 首先计算出key应该存放的位置,然后看这个位置有没有数据,有的话再看key是否相等,都符合的话,直接返回当前位置的元素
- 当前位置不存在数据,或者key不相等,那么可能之前没有set过,或者发生了位置碰撞,那就往后面的位置继续查找
getEntryAfterMiss
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已经失效的元素,则做一次清除,若最终没找到,返回空。
expungeStaleEntry
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,翻译就是,清除过期条目,做了两件事:
- 将key过期的元素清除
- 从staleSlot后一位开始扫描,直到遇到元素为null的位置,将这期间的所有key过期的元素清除,key没过期的元素进行rehash,重新安排它存储在本该存储的位置,如果本该存储的位置还没空出来,那就挨个往后寻找,找到第一个空闲的位置,就把元素安排进去
可用如下图表示:
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];
if (e != null && e.get() == null) {
n = len;
removed = true;
i = expungeStaleEntry(i);
}
} while ( (n >>>= 1) != 0);
return removed;
}
复制代码
循环清理数组中的key失效的元素,循环次数为第二个参数的对数级别次,为什么是对数级别次?这是在不扫描与全部扫描两个方案中做了均衡,就来个二分扫描吧。
那这个方法跟上面expungeStaleEntry有啥区别呢?expungeStaleEntry只能清除staleSlot到下一个为null的位置之间的失效元素,只有这么一段,可以理解为expungeStaleEntry是原子清除方法。
而这个方法就是将多个expungeStaleEntry方法综合起来,对数组进行全局扫描,清除,当然,不一定能将数组中失效的元素全部清除,因为在循环有一定的次数,从名字中也可以看出,【清除一部分失效元素】。可用如下图表示:
replaceStaleEntry
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方法中调用,具体的在注释中已经写清楚了。
可用下图表示:
resize
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;
}
复制代码
这个方法基本没有要说的,将数组扩容两倍。
rehash
private void rehash() {
// 清除全部的失效的元素
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
// 如果清除失效元素后,没有有效的压缩数组的数据量,那么判断当前元素个数是否超过阈值条件,超过的话,扩容
if (size >= threshold - threshold / 4)
resize();
}
复制代码
关于ThreadLocal作为key为什么被设计成弱引用
首先,设计成弱引用,肯定是为了help GC 的,使得 ThreadLocal 对象在不再需要使用的情况下,能够自动被 GC,我们可以对比下 HashMap 的设计。
HashMap 没有设计成弱引用key的形式(当然也有专门的弱引用设计WeakHashMap),但是它的键值对是设计成泛型的,也就是说你可以将key的泛型传入 WeakReference,这样也就达到了弱引用key的效果。
而 ThreadLocalMap 中呢?key 固定只能为 ThreadLocal 的类型,这样就失去的拓展的功能,从而要想实现自动 GC,就必须在内部再给 ThreadLocal 包一层弱引用。
可能会有这样的疑问,ThreadLocal 不是提供了remove 方法么,ThreadLocal 对象不再使用时,主动 remove,不就不会存在问题了?但是我想说的是,如果我们程序员无论在怎么复杂的逻辑下,都能保持不出错,能够保证一个不再使用的对象,没有一个强引用指向它,那还有内存泄漏这个概念么。正因为此,设计成弱引用是一种安全保险的方式。
关于ThreadLocalMap内存泄漏
关于为什么会出现内存泄漏,网上文章比比皆是,不作讨论,无非就是一个实例对象用不到了,但是却被一个强引用,引用着,不能被GC。
那么这里为什么会泄漏,是因为 ThreadLocal 对象作为 key 是被Entry弱引用着,ThreadLocal 对象随时可能被回收,那么 key 不就指向 null 了么,指向 null 的话,对应的 value 是无法访问到的,访问不到,又被强引用着,无法被GC,这就造成了泄漏。
那么这种情况怎么办?其实 ThreadLocalMap 中,expungeStaleEntry 方法replaceStaleEntry 方法中也都已经包含了将 key 为 null 的 value 置 null 的逻辑,在 set 和 get 的过程中,遍历时,碰到 key 为 null 的就会去执行清除操作,这样,在很大程度上避免了内存泄漏。
就算存在内存泄漏,在线程运行结束后,也都会释放掉。可能有人会有疑问,如果线程是长期存在的,或者是主线程,这种情况怎么办?可以在不再需要 value 的情况下,主动调用 remove 方法。
综上,有三重保险:
- 线程结束,自动全部释放
- set,get过程中,会有清除失效元素操作
- ThreadLocal提供了remove方法,可以主动清除
所以,无需担心内存泄漏。
android是如何保证一个线程对应一个Looper的
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。