用 ThreadLocal 包装的对象,对于每一个线程,都会保留被包装对象的副本,从一定程度上保证共享变量的线程安全性 ThreadLocal 非常适合需要线程安全的全局变量,也常应用于各类上下文 我们以 Sprig Security 的应用场景为例,用户的每次请求都会携带上 Cookie,Sprig Security 会去解析 Cookie,得到一个用户对象,而这个用户对象往往会在这次请求(线程)中被反复获取和使用。为了实现线程在多个方法中都可以获取到同一个用户对象,而不使用方法参数传递, Sprig Security 使用了 ThreadLocal 简单看下 Sprig Security 是怎么做的,首先,我们通过下面的代码来获取用户对象
Authentication user = SecurityContextHolder.getContext().getAuthentication();
进入到获取上下文的方法 getContext() 中,可以发现 contextHolder 就是一个 ThreadLocal,内部封装了 SecurityContext 对象,这样在这个请求的任意方法中都可以通过这个上下文来获取用户对象,而不需要通过方法参数传递,像这样的做法非常常见,在很多框架中都有用到
private static final ThreadLocal<SecurityContext> contextHolder = new ThreadLocal<SecurityContext>(); public SecurityContext getContext() { SecurityContext ctx = contextHolder.get(); if (ctx == null) { ctx = createEmptyContext(); contextHolder.set(ctx); } return ctx; }
来看下 ThreadLocal 是如何实现对象和线程绑定的 我们先猜测下,既然 ThreadLocal 是通过为每个线程保留一份数据,第一时间想到的就是使用 Map ,即 Map 的 key 保存线程 ID,value 保存变量的值,这样,我们通过线程 ID 就可以获取到想要的值。事实上,很久以前确实是这么做的。但 1.8 却并非如此,这里结合下面的示例代码,通过 set、get、remove 三个方法来一窥究竟
private static final ThreadLocal<Integer> context = new ThreadLocal<>(); public static void main(String[] args) { context.set(10); System.out.println(context.get()); context.remove(); }
public void set(T value) { Thread t = Thread.currentThread(); ThreadLocalMap map = getMap(t); if (map != null) map.set(this, value); else createMap(t, value); } ThreadLocalMap getMap(Thread t) { return t.threadLocals; } ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocalMap 是 ThreadLocal 类的内部类,这里截取该类的部分属性做以说明
static class ThreadLocalMap { /** 存放了 ThreadLocal 和 设置进去的值, 作用类似 HashMap 的 Entry */ static class Entry extends WeakReference<ThreadLocal<?>> { /** ThreadLocal 中 set 进去的对象 */ Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } /** table 的初始大小 */ private static final int INITIAL_CAPACITY = 16; /** Entry 数组 */ private Entry[] table; /** 用于扩容 */ private int threshold; // Default to 0
分析到这里,大致可以得到下图的对应关系:
1)每个 Thread 中都有一个 ThreadLocalMap 2)ThreadLocalMap 中有个 Entry[] 数组 3)Entry 中包含了 ThreadLocal 对象和 Value 值 回到示例代码中,getMap 方法返回的并不是 null ,原因是在执行 context.set(10) 方法前,已经有别的 ThreadLocal 对象被放到了 threadLocals 变量中,所以会进到 map.set(this, value) 中
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; 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(); }
上方的 set 方法先是通过 hash 算法计算出例子中 context 对象应该放到 Entry 数组中的下标,注意这个算法在后面还会出现 由于我们是第一次 set ,所以 e == null,跳出循环,然后 new 一个 Entry 对象,将 ThreadLocal 和 Value 放入其中,然后将这个 Entry 放到数组中,下标就是上面计算好的值 接下来就是重新计算 size 和判断是否需要扩容,到这里的话,整个 set 方法就算是结束了 总结一下,就是将 ThreadLocal 和 Value 放到了 Entry 数组中,而且并非是每个 ThreadLocal 存储一堆的线程,而是一个线程存储一大堆的 ThreadLocal,这样设计的好处是当线程结束后,里面的 threadLocals 变量可以被 GC,如果是 ThreadLocal 包含了 Thread 则需要在线程结束时提前将自己从 ThreadLocal 中移除避免造成资源浪费
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(); }
进入 getEntry 第一行代码就是一个 hash 计算,和 set 方法中的算法一模一样,因为每一个 ThreadLocal 的 threadLocalHashCode 是固定的,所以就算出的下标 i 也一样(除非扩容导致 table.length 出现变化,不过在扩容时也会重新 hash ,所以实际上还是一样的),然后去判断 Entry 中的 ThreadLocal 是否就是当前的 ThreadLocal ,是的话就直接返回这个 Entry,进一步就可以拿到 Value
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); }
remove 方法整体逻辑也很简单,就是将 Entry 中的 ThreadLocal 设置为 null,因为是弱引用,所以会被当做垃圾回收 一般情况下,可以不需要去调用 remove 方法,只需要等待线程自己结束销毁然后被回收。但在使用线程池的情况下,线程很可能是不会被销毁的,如果线程后续不再需要 ThreadLocal 时可以使用 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; } } } public void clear() { this.referent = null; }
这里举一个实际开发中的一个案例,谈谈上下文数据混乱问题以及特定情况下 remove 方法的必要性 我们知道,在 web 项目中,每个请求, tomcat 默认都会指派一个线程来处理。为了防止线程过多和减少新建、销毁线程的开支,往往会使用线程池技术,也就是说在不同时间段内的两次请求可能使用的是线程池中的同一个线程,明白了这点后,再来看实际项目中的一段代码
private final ThreadLocal<JSONObject> locationSettingContext = new ThreadLocal<>(); private void setLocationSetting(String locationSetting) { if (StringUtils.isNotBlank(locationSetting)) { JSONObject locationSettingObject = JSON.parseObject(locationSetting); if (locationSettingObject.containsKey(ViewConstant.KEY_LOCATION_ID)) { locationSettingContext.set(locationSettingObject); } } } private void updateLocationSetting(Integer viewId, Integer viewLayerId, Integer devViewLayerId) { if (locationSettingContext.get() != null) { JSONObject locationSettingObject = locationSettingContext.get(); String id = locationSettingObject.getString(ViewConstant.KEY_LOCATION_ID); if (id.equals(String.valueOf(devViewLayerId))) { locationSettingObject.put(ViewConstant.KEY_LOCATION_ID, String.valueOf(viewLayerId)); viewService.updateLocationSetting(JSON.toJSONString(locationSettingObject), viewId); } } }
不执行 remove 操作,两次请求都使用的同一个线程的前提下,如果执行逻辑如下: 1)请求 A 设置了 locationSettingObject,然后再 updateLocationSetting 中使用了 locationSettingObject,请求完成后归还线程 2)请求 B 的 locationSetting 为 NULL,所以不会去设置 locationSettingObject 3)请求 B 在执行 updateLocationSetting 的时候,locationSettingContext.get() != null 理应是 false,但因为请求 A 设置了 locationSettingObject ,所以在实际执行中会返回 true,导致数据混乱,程序逻辑出错 解决:如果请求 A 在使用完之后将 locationSettingObject 清空,就可以避免此类情况