原文链接
本文对ThreadLocal实现线程间分别存储数据,进行了深层次的探索,源码采用Android SDK 28版本进行分析。
本文内容主要包括三部分:
- ThreadLocal是什么
- ThreadLocal使用
- ThreadLocal源码分析
1. ThreadLocal是什么
首先贴官方描述:
This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its get or set method) has its own.
大概意思是,该类提供线程局部变量。 这些变量和普通变量不同,因为每个线程(通过其get或set方法)操作的都是线程自己的。
通过对官方描述的理解,我们知道ThreadLocal记录的变量跟线程相关,其他线程无法获取和修改该线程记录的变量。此特性在Handler中得到了很好的应用,帮助Handler实现了消息的跨线程通信,后续系列文章会详细分析如何借助该特性实现跨线程通信的。
ThreadLocal针对一个线程只能记录一个变量,但是一个线程内可以通过多个ThreadLocal来记录多个变量,在记录多个变量的时候,因为其存储方式,有可能会存在hash冲突的问题,后续结合源码我们进一步分析,如何解决hash冲突问题,下面我们来实际验证下。
2. ThreadLocal使用
public static void main(String[] args) {
sThreadLocal.set("Jon");
new Thread(new OneRunnable(), "SubThread").start();
String name = sThreadLocal.get();
System.out.println(Thread.currentThread().getName() + ", name: " + name);
}
private static class OneRunnable implements Runnable {
@Override
public void run() {
sThreadLocal.set("jaymzyang");
String name = sThreadLocal.get();
System.out.println(Thread.currentThread().getName() + ", name: " + name);
}
}
输出内容:
main, name: Jon
SubThread, name: jaymzyang
在main线程中我们设置"Jon",在SubThread线程中我们设置"jaymzyang",分别在对应线程下打印get值,我们发现在main线程中输出"Jon",在SubThread线程中输出"jaymzyang",所以ThreadLocal是会根据线程记录值的,如果在main线程中再次调用set方法,只会将main线程内的sThreadLocal变量内的值改为新值。
3. ThreadLocal源码分析
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 T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
结合源码发现线程通过threadLocals参数持有ThreadLocalMap对象。
- set方法:
- 如果线程未持有map时,则通过createMap创建一个ThreadLocalMap对象存储在线程中threadLocals变量中,构造map时将存储的值传递给ThreadLocalMap构造方法,value被存储到map对象中。
- 如果线程持有map时,则通过线程获取到所持有的ThreadLocalMap对象,然后将value存储到map对象中。
- get方法:
- 通过线程获取到所持有的ThreadLocalMal对象,然后查询存储在map中的value。
3.1 ThreadLocalMap是如何实现存储?
ThreadLocalMap从名称看应该是Map类型的数据结构,但是并没有继承自Map接口。接着分析发现,map在其内部维护了一个默认大小为16的Entry数组,Entry继承自WeakReference<ThreadLocal<?>>,采用key-value结构模型,key为ThreadLocal类型并存入WeakReference对象中,因此key为弱引用类型,易被GC回收,后续分析根此相关的泄漏问题。
3.1.1 ThreadLocalMap构造方法
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);
}
首先初始化一个容量为16的Entry数组,然后用key即thradLocal对象的hash值计算存储在数组中的索引位置,接着创建一个Entry对象,将key和value作为构造参数传入,数组大小加1,最后是setThreshold(INITIAL_CAPACITY),该方法是计算扩容因子,即当数组内元素达到数组大小的2/3时,会对数组进行扩容。
setThreshold方法:
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
这里解释下为什么是在达到数组长度的2/3时进行扩容,主要是hash冲突的原因:
- 通过hash计算索引时会存在hash冲突的问题,当数组容量较大时,hash冲突的概率降低
3.1.2 ThreadLocalMap 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;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
if (k == key) {
e.value = value;
return;
}
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
tab[i] = new Entry(key, value);
int sz = ++size;
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
根据key进行hash计算,得到在数组中的索引,如果下标中的Entry元素的key和要修改的key是同一个ThreadLocal对象,将新的value设置进去e.value = value,设置新值结束。
如果key不相同,则存在hash冲突,ThreadLocalMap处理hash冲突的方式为线性探测法,继续探测下一个索引的entry元素,判断key是否和查找到的entry.key相同,相同则将新的value设置进去,设置新值结束;否则表示当前数组中没有存储该值,新建Entry元素,存储到table中table[i] = new Entry(key, value)。
set流程如下:
在set数据时,会涉及到hash冲突,清理泄漏数据,扩容等操作
1. hash冲突处理
ThreadLocalMap的线性探测法和HashMap的链地址法,都是处理hash冲突的方式,线性探测法是发生hash冲突时,顺序地到存储区间中寻找存储位置,直到找到合适的位置。链地址法是在索引下标处建立一个链表结构,将新的数据插入链表中。
假设序列为"47, 34, 13, 12, 52, 38, 33, 27, 3",哈希数组表长为11,采用对11取模的hash算法进行存储。
Hash(47) = 47 % 11 = 3
Hash(34) = 34 % 11 = 1
Hash(13) = 13 % 11 = 2
...
Hash(3) = 3 % 11 = 3
由于47已经存储到下标为3的位置,因此3需要进行线性探测,直到找到空缺的位置7,前面的位置均被hash算法或探测占用。
利用线性探测法处理hash冲突之后的表格如下:
哈希地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 33 | 34 | 13 | 47 | 38 | 27 | 3 | 52 |
线性探测法优缺点:
- 算法简单
- 容易产生聚集现象,效率低
2. 清理泄漏数据
因为Entry是继承自WeakReference<ThreadLoacl<?>>类型,其key为ThreadLoacl<?>类型被保存到WeakReference对象中,如果在key没有被外部强引用时,根据GC规则会对key进行回收,如果创建ThreadLocal的线程一直在运行,则Entry对象中的value一直不能被回收,从而导致内存泄漏。
如何清理导致泄漏的value?
结合源码发现在set操作时,如果key == null,会调用replaceStaleEntry方法。
replaceStaleEntry方法:
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
...
if (k == key) {
e.value = value;
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// Start expunge at preceding stale entry if it exists
if (slotToExpunge == staleSlot)
slotToExpunge = i;
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
...
}
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;
}
最终在expungeStaleEntry中会对key为null的entry对象清理,然后重新rehash排列数组中的entry对象。
在源码中搜索expungeStaleEntry发现remove方法和getEntry方法当key == null时,都会调用到expungeStaleEntry操作,所以我们可以通过调用set、get方法,自动检测该threadLocal作为key的对象是否已被GC回收,如果回收,则将该threadLocal作为key的entry清理掉,或者手动调用remove来清理不需要的threadLocal对象,防止出现内存泄漏。
良好的code习惯:
try {
sThreadLocal.get();
...
} finally {
//使用完毕后回收掉
sThreadLocal.remove();
}
3. 扩容
在set填充数据时,一般很少用到,一个线程可以当记录到10个ThreadLocal对象记录的数据时,才会扩容,根据初始化table数据容量为16,达到扩容因子即容量的2/3大小时,会对数据进行扩容和rehash操作。
rehash方法:
private void rehash() {
expungeStaleEntries();
// Use lower threshold for doubling to avoid hysteresis
if (size >= threshold - threshold / 4)
resize();
}
expungeStaleEntries()实际内部是expungeStaleEntry()操作,先进行key为null的数据进行清理,然后通过resize方法来实现扩容操作。
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++;
}
}
}
setThreshold(newLen);
size = count;
table = newTab;
}
resize会进行双倍扩容,然后将原数据重新通过hash算法计算放到新数组中,最后设置新的扩容因子和数组元素数量。
3.2 ThreadLocalMap getEntry方法
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
return getEntryAfterMiss(key, i, e);
}
getEntry方法查询ThreadLocalMap中存储的已threadLocal为key的元素,如果查到会返回该entry对象;如果未找到会调用getEntryAfterMiss方法继续查找处理。
getEntryAfterMiss方法:
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;
}
在getEntryAfterMiss方法中,如果查到key相同的元素,在返回;如果该元素的key为null,则调用之前分析的方法expungeStaleEntry将该entry清理,防止出现内存泄漏,如果未找到则返回null。
结语:ThreadLocal是一个数据结构,根据线程来存储变量,一个ThreadLocal只能保存一个T类型的变量,使用完毕后养成良好的习惯,通过调用remove来清理,防止因为GC回收掉key,而value无法被清理,出现内存泄漏问题。