ThreadLocal在java.lang包中,其主要作用是提供一个和线程绑定的变量环境,即通过ThreadLocal在一个线程中存储了一个变量之后,再在另一个线程中使用同一个ThreadLocal对象设置值,第二个线程内设置的值不会将第一个线程内设置的值覆盖,并且在同一个线程中可以获取之前设置的值。如下是一个ThreadLocal的使用示例:
public class ThreadLocalExample {
public static void main(String[] args) {
ThreadLocal<String> threadLocal = new MyThreadLocal<>();
Runnable task1 = () -> {
threadLocal.set("task1");
sleep(2);
threadLocal.get();
};
Runnable task2 = () -> {
sleep(1);
threadLocal.set("task2");
sleep(2);
threadLocal.get();
};
Thread thread1 = new Thread(task1);
Thread thread2 = new Thread(task2);
thread1.start();
thread2.start();
}
private static void sleep(int seconds) {
try {
TimeUnit.SECONDS.sleep(seconds);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
private static final class MyThreadLocal<T> extends ThreadLocal<T> {
@Override
public T get() {
T result = super.get();
System.out.println(Thread.currentThread().getName() + " invoke get method, result is " + result);
return result;
}
@Override
public void set(T value) {
System.out.println(Thread.currentThread().getName() + " invoke set method, value is " + value);
super.set(value);
}
}
}
如下是上述代码的执行结果:
Thread-0 invoke set method, value is task1
Thread-1 invoke set method, value is task2
Thread-0 invoke get method, result is task1
Thread-1 invoke get method, result is task2
可以看到,Thread-0首先往ThreadLocal中设置了一个值,接着Thread-1也设置了一个值,但是Thread-1并没有将Thread-0设置的值覆盖,因为接下来Thread-0从ThreadLocal中获取的值还是其先前设置的值,并且Thread-1获取的值也是其先前设置的值。
在项目中,借助于ThreadLocal我们可以编写出一些非常优雅的代码,并且实现一些类似于缓存的功能,比如如下util类:
public class ParamUtil {
private static final ThreadLocal<Map<String, Object>> params = new ThreadLocal<>();
public static void clear() {
params.remove();
}
public static void setParam(String key, Object obj) {
Map<String, Object> paramsMap = params.get();
if (null == paramsMap) {
paramsMap = new HashMap<>();
params.set(paramsMap);
}
paramsMap.put(key, obj);
}
public static <T> T getParam(String key) {
Map<String, Object> paramMap = params.get();
if (paramMap == null) {
return null;
}
@SuppressWarnings("unchecked") T result = (T) paramMap.get(key);
return result;
}
}
在ParamUtil中,我们声明了一个ThreadLocal类型的变量params,其存储的是一个Map<String, Object>类型的数据,也就是我们实际存储的数据是放在这个map中的,这里的Map使用HashMap即可,因为ThreadLocal针对每个线程都是保存有其存储的变量的一个副本,因而针对每个线程其都有一个Map对象,也就不存在并发的问题,如下是该util类的使用示例:
@Service
public class MlsOrgServiceImpl implements MlsOrgService {
@Autowired
private MlsOrgDao mlsOrgDao;
@Override
public MlsOrgInfo getMlsOrg(Long id) {
MlsOrgInfo mlsOrgInfo = ParamUtil.getParam("mlsOrgInfo");
if (null == mlsOrgInfo) {
mlsOrgInfo = mlsOrgDao.getByMlsOrgId(id);
ParamUtil.setParam("mlsOrgInfo", mlsOrgInfo);
}
return mlsOrgInfo;
}
}
这里在service方法中直接从ParamUtil获取缓存的数据,如果存在,则直接返回,如果不存在,则从dao从查询,并且将其设置到ParamUtil中。这里需要说明的是,通过这种方式进行缓存有三个优点:
- 缓存实效性较好。因为用户的一次请求的时间非常短,因而该缓存只会在这一次请求中有效,实时更改数据库中的数据对后续的请求都是生效的;
- 重复调用时效果明显。当需要缓存的信息在请求中需要经常用到的时候该缓存的效果将非常明显;
- 可以跨多层调用。在Java web项目中,相较于将数据缓存在request中,ThreadLocal可以跨多个层(controller,service等)进行缓存,而request一般只用于controller层中;
这里需要说明一点是,使用ThreadLocal进行缓存的时候,由于其是和线程绑定的,而服务端框架中,不会每次请求都新建一个线程进行处理,而是有空余线程时则复用该线程处理新的请求,因而这种缓存方式需要在每次请求开始时(比如Java web中的拦截器)对ThreadLocal中存储的数据进行清理,这样可以避免当前请求中获取到了之前某次请求中缓存的数据。
通过上述示例可以看出,ThreadLocal主要有两个方法:get和set方法。get方法用于获取其存储的值,set方法则将数据存储在ThreadLocal中。ThreadLocal在底层维护了一个Map对象,其键是一个ThreadLocal对象,而值则为该ThreadLocal对象中存储的值,在调用ThreadLocal的get和set方法的时候实际上底层调用的是该map对象的对应方法。并且ThreadLocal实现将数据与线程绑定的方式则主要是将这个Map对象实例保存在每个Thread对象中,如下所示Thread类中的Map对象的声明:
ThreadLocal.ThreadLocalMap threadLocals = null;
首先我们看看ThreadLocal的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);
}
可以看到,每次调用set方法时,其都会首先获取当前执行该set方法的线程,然后获取该线程中保存的ThreadLocalMap实例,如果该map不为空,则将当前ThreadLocal和其值设置到该map中,否则创建一个ThreadLocalMap实例,然后将当前设置的值初始化到该map中,并且还会将该实例设置到当前线程的ThreadLocalMap实例中。如下是getMap(Thread)和createMap(Thread, T)方法的实现:
ThreadLocalMap getMap(Thread t) {
return t.threadLocals;
}
void createMap(Thread t, T firstValue) {
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
在讲解ThreadLocalMap.set(ThreadLocal, T)方法之前,我们首先看看ThreadLocalMap的数据结构及其存储方式:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
private static final int INITIAL_CAPACITY = 16;
private Entry[] table;
private int size = 0;
private int threshold;
private void setThreshold(int len) {
threshold = len * 2 / 3;
}
private static int nextIndex(int i, int len) {
return ((i + 1 < len) ? i + 1 : 0);
}
private static int prevIndex(int i, int len) {
return ((i - 1 >= 0) ? i - 1 : len - 1);
}
}
这里列出了ThreadLocalMap的主要属性以及其余比较简单的方法。可以看出,ThreadLocalMap底层和HashMap类似,是使用一个Entry类型的数组来存储键值对的,但是不同的是其Entry是继承自WeakReference的,值为Object类型的一个属性。WeakReference的作用是其可以存储一个引用,如果在其他位置(比如某个全局或局部变量)没有存储相同的引用,那么Java垃圾回收机制则会对该引用对象进行回收。这里使用WeakReference的用意则在于线程是可消逝的,那么当其消逝之后和其绑定的ThreadLocal对象如果没有引用到则可以被垃圾回收机制回收。回过头来,ThreadLocalMap中的另外几个属性的意义如下:INITIAL_CAPACITY表示Entry数组的默认初始化长度,size存储了当前键值对的数量,threshold存储了当前Entry中最多存储的键值对数目,超过该数目时就会对当前ThreadLocalMap进行rehash()操作,通过setThreshold(int)方法可以看出,当前Map的默认负载银子是2/3。
在ThreadLocalMap中,其Entry只有两个属性:键和值。相较于HashMap,其在当前Entry中保存有一个Entry类型的next指针,即使用一个单向链表的方式来解决hash冲突。ThreadLocalMap的Entry由于只有两个属性,因而其解决冲突的方式不是使用单向链表的方式,并且由于每个Entry对象也是一个WeakReference实例,这也导致其不能使用单项链表的方式解决冲突。这里ThreadLocalMap使用的是线性探测再散列法解决hash冲突的,即当一个键映射到某个槽位之后,其会先检查该槽位是否存储有值,如果有值则检查数组的下一位是否有值,如此查找直到找到一个没有存储数据的槽位将当前键值对存储其中。这里实际探测时,如果发生冲突,其还会检查当前槽位的Entry的键是否为null,因为其可能被垃圾回收机制给回收,如果为空则将当前键值对存储于该槽位中,并且还会对从该槽位到后续第一个为空的槽位的没有被回收的键值对进行再散列,并且清除已经被回收的键值对,这样做的目的有助于减少hash表中键值对的数量,减少发生冲突的概率和rehash的次数。
下面我们通过ThreadLocalMap.set()方法具体看看其是如何存储键值对的。如下是set()方法的具体实现:
private void set(ThreadLocal<?> key, Object value) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1); // 对数组长度取余计算将存储的槽位
// 从计算得到的槽位开始依次往后遍历,直到找到对应的键或者是遇到了null槽位
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// 如果k == key说明之前存在该键及其值,那么直接替换其值为新的值即可
if (k == key) {
e.value = value;
return;
}
// 如果k为空,说明当前entry的键已经被垃圾回收机制回收了,那么将要设置的键值对替换当前键值对
// 并且对其后没有被回收的键值对进行再散列
if (k == null) {
replaceStaleEntry(key, value, i);
return;
}
}
// 在for循环中没有找到当前key对应的值,说明之前没有设置相同键的键值对
// 此时i指向的是冲突槽位之后第一个为空的槽位,那么在该槽位上新建一个entry存储当前键值对
tab[i] = new Entry(key, value);
int sz = ++size;
// cleanSomeSlots的作用是选择性的对一些槽位进行检测,如果其已经被回收,则对其进行清理
if (!cleanSomeSlots(i, sz) && sz >= threshold)
rehash();
}
总结ThreadLocalMap.set()方法,其首先会计算当前ThreadLocal对象将要存储的位置,然后使用一个for循环从该位置处往后依次遍历,检查每一个键值对是否已经被回收,被回收了则将当前的键值对存储于该位置,或者是判断当前键是否就是要存储的键,相等则将当前当前键对应的值替换为新的值。这里我们看看replaceStaleEntry()方法的具体实现:
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
int staleSlot) {
Entry[] tab = table;
int len = tab.length;
Entry e;
int slotToExpunge = staleSlot;
// 从被回收的槽位开始往前遍历查找第一个已经被回收的键值对
for (int i = prevIndex(staleSlot, len);
(e = tab[i]) != null;
i = prevIndex(i, len))
if (e.get() == null)
slotToExpunge = i;
// 从被回收的槽位开始向后遍历,查找是否有键为当前要设置的键,有则将其与被回收的槽位进行替换
// 并且对后续第一个被回收的槽位开始的后续元素进行再散列,因为第一个被回收的槽位将重置为空
for (int i = nextIndex(staleSlot, len);
(e = tab[i]) != null;
i = nextIndex(i, len)) {
ThreadLocal<?> k = e.get();
// 在被回收的槽位之后找到与要设置的键相同的键,那么将其值替换为新值,并且与被回收的槽位替换位置
if (k == key) {
// 更新其值为新值
e.value = value;
// 替换槽位
tab[i] = tab[staleSlot];
tab[staleSlot] = e;
// slotToExpunge记录的是被回收的槽位(staleSlot)之后第一个被回收的槽位
if (slotToExpunge == staleSlot)
slotToExpunge = i;
// 对被回收的槽位之后第一个被回收的槽位之后的元素进行再散列,
// 因为该第一个被回收的槽位将被重置为空
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
return;
}
// 记录被回收的槽位之后第一个被回收的槽位
if (k == null && slotToExpunge == staleSlot)
slotToExpunge = i;
}
// 如果在被回收的槽位之后没有找到与要设置的键相同的键,那么直接新建一个entry替换被回收的槽位
tab[staleSlot].value = null;
tab[staleSlot] = new Entry(key, value);
// 如果slotToExpunge != staleSlot则说明被回收的槽位之后有被回收的键值对,那么就从该槽位开始
// 对后续元素进行回收或者再散列
if (slotToExpunge != staleSlot)
cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
总结来说,replaceStaleEntry()方法主要是将要设置的键值对替换为当前已经被回收的键值对,并且其会在当前被回收的键值对之后找到第一个被回收的键值对,找到了则将其重置为空,并且对其后的数据进行再散列。这里再散列的工作是通过expungeStaleEntry()方法实现的,我们可以看看该方法是如何实现的:
private int expungeStaleEntry(int staleSlot) {
Entry[] tab = table;
int len = tab.length;
// 对已经被回收的键值对进行重置
tab[staleSlot].value = null;
tab[staleSlot] = null;
size--;
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;
while (tab[h] != null) // 查找目标槽位之后第一个可以存储数据的槽位
h = nextIndex(h, len);
tab[h] = e;
}
}
}
return i;
}
以上是ThreadLocalMap.set()方法的具体实现,通过上面的说明可以看出,其会经常检查槽位中的数据是否已经被回收,这样做的目的是减少当前map中entry的数量,减少get和set方法需要检查的entry的数量,并且也可以避免rehash操作的数量。这里也说明了线性探测再散列法的一个缺点,即其将数据直接存储在数组上会大大增加冲突发生的数量,因而需要经常对槽位进行清理。至于为什么会增加冲突发生的数量,这也很好理解,比如对于key1,其计算的槽位是3,但是由于3,4和5号槽位都因为冲突存储了数据,那么其只能存储在6号槽位上。此时另一个key2计算的槽位是4,其会发现4号槽位已经存储了数据,因而只能存储在7号槽位上。可以发现,不同hash值的数据因为这种存储方式而扎堆的存储在了一个局部冲突块中。
前面我们对ThreadLocalMap.set()方法实现方式进行了讲解,下面我们来看看ThreadLocalMap.get()方法,其实现思路和set()方法非常类似,即确认要查找的key对应的槽位之后查找是否有key和当前key相同,相同则返回,否则一直查找直到遇到null槽位为止。期间其还会对找到的已经被回收的槽位进行处理。如下是ThreadLocalMap.get()方法的具体代码:
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,则直接返回,否则会从该槽位开始往后查找,如下是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;
}
在从目标槽位开始往后查找的过程中,其会判断当前key是否为要查找的key,如果是则返回,不是则会判断该key是否为空,如果为空则对其进行回收,并且对其后的键值对进行再散列。最后,如果没有找到目标key,则返回空。
最后需要说明的一点是,上述ThreadLocalMap中的键值对为ThreadLocal对象及其存储的值,而在每个Thread对象中都是有一个独立的ThreadLocalMap对象的,这里讲到的Map中的冲突解决等指的都是在同一个线程中创建了多个ThreadLocal对象时发生的,即在同一个线程中,其会把该线程中使用的所有ThreadLocal对象都存储到同一个ThreadLocalMap对象中。