一、简介
ThreadLocal是java中提供的一个意在处理多线程对于共享变量访问操作冲突问题的一个工具,其实可以认为是处理多线程并发问题的一个工具。我们知道java中的内存交互方式是java的JMM(java内存模型)模型所规范的,每个线程都有属于自己的工作内存,且线程之间工作内存之间是互相隔离的,每个线程不可以直接操作主存,都需要从主存读取信息到工作内存,然后同步存入主存,并且在计算机底层的高速缓存和主存之间交互方式也类似于此,由此可能会产生多线程关于共享变量的问题(参考文章JUC-volatile关键字作用)。
一般针对多线程并发问题的处理方向有以下两种方式方式,第一种类似于synchronized或者lock这种保证同一时刻只有具有资格的线程才能操作共享资源,来保证并发安全;另外是本文中即将介绍的ThreadLocal这种本地变量的方式,每个线程维护了自己的共享变量的副本,不影响其他线程该变量的变化来避免线程之间共享变量并发安全问题。
本质上来讲前者是以时间换空间的方式,后者是以空间换时间的方式,并且后者方式的使用是需要特定情景的,需要这个共享变量是无状态的,因为副本只会在线程期间作用。二者之间各有各的特点,可以针对自己业务场景的需要选择合适的处理并发问题的方式。接下来让我们详细的讲解Threadlocal工具。
二、使用方式
ThreadLocal工具从jdk版本1.2就已经提供了,是由著名的并发大神Doug Lea和集合框架的大神Josh Bloch共同创造的,意在通过以空间方式解决无状态共享变量并发问题,也可以说是提供了一种新思路。从名字可以看出来,“Local”就是每个线程都维护了共享变量的一份副本,线程内如何操作都只是操作自己内部的副本,不会影响到其他线程,可以看到如下实例:
public class ThreadLocalTest {
private static ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>();
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
threadLocal.set(11);
System.out.println(Thread.currentThread().getName()+"---"+threadLocal.get());
}
});
thread.start();
System.out.println("begin");
threadLocal.set(10);
System.out.println(Thread.currentThread().getName()+"---"+threadLocal.get());
}
}
使用的时候,声明ThreadLocal变量,因为是使用泛型,可以指定你想要存储的对象类型,更加方便灵活。可以看到在类中有个静态成员变量threadLocal,使用泛型Integer,在主线程中起了一个子线程操作了该成员变量,主线程后续也操作了该变量,通过在两个线程分别打印日志查看threadLocal所存储的值。结果如下:
各自线程打印出各自设置的值,或许这个例子,并不能证明Threadlocal维护了变量的副本,通过下面的源码讲解,就会理解这个输出结果的必然性。
三、源码讲解
本次讲解的ThreadLocal是基于jdk1.8版本的,大致的类结构如下,可以看到其实还是比较简单的,对外暴露的方法只有几个,大致就是构造方法、remove()、set()、get()以及initialValue()基本方法:
存储容器?如果要掌握ThreadLocal,那么就必须掌握它的一个静态内部类ThreadLocalMap,该类实例是存储变量的实际容器对象,内部还有一个静态内部类Entry,以key-value的形式存储数据,key是ThreadLocal实例对象,value就是我们想要存储的值对象。
在ThreadLocalMap内部维护了一个初始长度16的Entry的数组table,并且通过维护一个临界值来进行数组扩容,除此之外,维护了一个size字段,记录table数组中Entry节点的数量,提供了ThreadLocalMap构造方法,以及对Entry的操作私有操作方法。
在我们的Thread类中,维护了一个ThreadLocal.ThreadLocalMap类型的引用,如下:
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
从这个引用逻辑可以大致看出,ThreadLocal是为线程和存储对象(ThreadLocalMap)之间建立了一个映射关系,提供了操作储存信息的一些api,也就是针对本线程的ThreadLocalMap进行操作。
ThreadLcoal set()方法?接下来我们从方法的入口开始,也就是set()方法,源码如下:
public void set(T value) {
Thread t = Thread.currentThread();//获取当前线程
ThreadLocalMap map = getMap(t);//获取当前线程
if (map != null)
map.set(this, value);//设置值
else
createMap(t, value);//初始化map,并设置值
}
1.获取当前线程,并根据当前线程获取线程引用的ThreadLocalMap对象;
2.如果map不为空,则进行set方法,入参是this(当前ThreadLocal对象),value想要存储的值;
3.如果map为空,则进行map的创建工作,并把当前存储的值存入;
下面看下当map非空时的set方法:
private void set(ThreadLocal<?> key, Object value) {
// We don't use a fast path as with get() because it is at
// least as common to use set() to create new entries as
// it is to replace existing ones, in which case, a fast
// path would fail more often than not.
Entry[] tab = table;//获取该ThreadLocalMap内部的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();
}
每一个Thread都会引用一个ThreadLocalMap实例,每一个ThreadLocalMap实例创建的时候,都化初始化一个默认初始长度为16的Entry节点类型的table数组,因此map非空的情况下,数组也是经过初始化的,内部set方法流如下:
1.获取当前ThreadLocalMap实例内的table数组,获取数组的长度;
2.哈希魔数和数组长度做位运算,获取本次存储的数组下标;
3.从本次下边位置开始循环,获取Entry节点,如果该下标节点不为null,则获取该节点的key,也就是ThreadLocal对象;
4.判断本次存储的ThreadLocal和该下标的key是否相同,若相同,则将value更新,返回;
5.如果不相同,则判断该下标的key是否为null,如果为null,则走replace流程,如果不是null,则进行下一节点继续判断;
6.若在循环过程中,不为空的节点,始终没有key相同,且key不为null,直到遇到空的节点,跳出循环;
7.跳出循环后,则在这个为空的节点下标处新建一个Entry,维护size,然后进行数组整理以及根据临界值决定扩容;
当map为空的时候,会走创建map的过程,代码如下:
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
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);
}
为当前线程创建一个ThreadLocalMap实例,包含了table数组的初始化,以及本次的值存储过程:
1.table初始化,长度是INITIAL_CAPACITY,默认是16;
2.根据数组默认值长度和哈希魔数位运算得到本次数组存储的下标位置;
3.新建Entry节点,存入table的下标位置,维护size=1,更新扩容值;
到此,本次存储值到ThreadLocal结束。
ThreadLocal get()方法?下面我们跟踪一下get()方法的执行流程:
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);//获取线程关联的map实例
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
1.获取线程关联的ThreadLocalMap实例对象,如果map为null,则走初始化方法逻辑并返回,初始化方法可以进行重写;
2.如果map不为空,则到map获取当前ThreadLocal对象的Entry对象节点;
3.Entry不为null,返回Entry的value;
因为前面我们看到,放入的过程中可能会因为哈希冲突放入下一个几点,我们看下map.getEntry(this)方法:
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);
}
根据哈希魔数和数组长度位运算得到预算的数组下标,如果该下标的key刚好等于key,则返回该Entry节点;如果该下边为null或者该下标存储的key与本次key不相等,则进入方法getEntryAfterMiss(key, i, e),代码如下:
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;
}
前面set()方法解决hash冲突的办法是进行数组下一位置判断,因此获取的时候也要遵循这一规则,循环获取不等于null的节点,判断key值,遇到k为null的节点进行数组整理,如果e为null则跳出循环,返回null。
ThreadLocal核心方法就是上面提到的get和set方法,方法的逻辑也是比较简单的,因此最上面提到的程序运行结果,我们可以知道结果的必然性。
四、关注点
在ThreadLocal中有几个值得我们关注的地方,比如它的hash方式,它可能出现内存泄漏的问题以及线程复用等问题,下面我们描述下这几种情况的含义。
1.内存泄漏
上面提到ThreadLocal使用自定义方式实现内部map数组,没有使用java提供的集合中提供的工具。在ThreadLocalMap中的Entry静态内部类是一个继承了弱引用的类,因此Entry的key,也就是对ThreadLocal对象的是弱引用,当一个对象没有任何强引用,只剩下弱引用的时候,gc就会把这个对象进行回收,哪怕此时堆内存是充足的,下面是Entry的类结构:
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
为什么使用弱引用?考虑到当该ThreadLocal对象,无其他强引用之后,因为ThreadLocalMap的key强引用导致了ThreadLocal对象无法被回收,为了不影响ThreadLocal对象正常的生命周期,把key设置为弱引用,因此不会影响ThreadLocal对象正常的生命周期。
反过来考虑到当ThreadLocal对象被回收之后,ThreadLocalMap实例中的一个key引用变成了null,此时key所对应的object已经变成了不可到达的了,但是此时object无法被gc回收,因此可以认为此时发生了所谓的内存泄漏。ThreadLocal对这种情况做了处理,在常用的几个操作方法中,如果遇到key为null的Entry节点,则会进行整理,避免内存泄漏。比如上述get以及set方法中使用到的expungeStaleEntry()方法进行整理,清除key为null的节点。
2.线程复用
上面我们已经剖析了ThreadLocal内部的执行逻辑,可以知道线程局部变量如果不主动处理的话,会在线程存活期间一直有效,当我们的线程生命周期是可控或者局部变量的一直存活不会对我们产生影响的时候,可以选择不考虑这种情况,但是如果线程复用使得线程不会死亡的时候,就需要考虑Threadlocal所管理的线程局部变量是否需要处理,是本次使用完立即清除掉,还是鉴于对堆内存的考虑,之后长时间不会在使用,需要释放内存。无论是出于哪种情况的考虑,如果是线程池所支持的线程复用,还是一些容器例如tomcat使得业务线程的服用,我们最好在本线程使用结束后,主动清除该值,以免造成业务错误,或者内存浪费。可以使用remove()方法进行清除:
private void remove(ThreadLocal<?> key) {
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)]) {
if (e.get() == key) {
e.clear();
expungeStaleEntry(i);
return;
}
}
}
可以看到remove()方法的大致逻辑,找到下标,循环获取key的entry,找到后调用clear()方法,使entry引用为null,然后走整理清除方法。
s3.哈希魔数
ThreadLocalMap中是自定义实现的一个Entry节点类型的table数组,来作为存储的map结构使用,提到map我们就会想到hash方式确定数组位置,在ThreadLocal中使用变量threadLocalHashCode和数组长度做位运算得到本次数组存储的下标位置,这个变量关联了一个比较神奇的变量数字,HASH_INCREMENT=0x61c88647,该变量使用static和final修饰,是一个类终量,每当我们新建一个ThreadLocal实例的时候,threadLocalHashCode都会增加HASH_INCREMENT步长。
假设在一个线程内,有两个ThreadLocal,那么这两个ThreadLocal的threadLocalHashCode相差HASH_INCREMENT,当和数组长度进行hash取数组下边的时候,能最大的散列位置,尽可能避免hash冲突。这个数字是个斐波那契数列,也是一个黄金分割的比值数。
五、使用场景
当我们理解ThreadLocal的执行过程之后,很容易知道它适合的使用场景,它的工作方式是线程内保存共享变量的副本,因此考虑到此功能,适合在线程期间其有效作用范围的业务场景,比如session的存储,数据库连接的保持。
结合我本次使用ThreadLocal使用场景,使用了spring容器管理的单例的bean,编写了一些有单独处理逻辑的handle类,可复用性比较高,每个handler维护了一个指向下一个handle类,在上层使用策略模式,通过设置每个handle的下一个处理类,组装handle类执行的串,这个过程中我们可以看到有隐藏的并发危险,加上一个策略设置A->B->C,而另外一个策略设置A->B->D,我们知道线程之间变量不可见,都是刷到主存中,若此时下一个策略运行中,B节点获取下一个运行handle的时候,从主存读取到的是上一个策略刷进主存的handle类C,这时就会发生业务错误。
上面问题处理方式还是比较多的,你可以不使用spring来管理这些handle类,组装策略的时候新建handle,这样不会共享一个实例,也不会有问题。我这边是选择了使用ThreadLocal来存储每一个handle的下一个handle的指针,这样在线程内部互不干扰,就可以按照策略设置好的handle串进行处理了。
六、资源地址
文档:《Thinking in java》jdk1.8版本源码