为什么要使用ThreadLocal?
并发编程是一项非常重要的技术,它让我们的程序变得更加高效。但在并发的场景中,如果有多个线程同时修改公共变量,可能会出现线程安全问题,即该变量最终结果可能出现异常。
为了解决线程安全问题,JDK
出现了很多技术手段,比如:使用synchronized
或Lock
,给访问临界资源的代码加锁,保证了代码的原子性
。
但在高并发的场景中,如果多个线程同时竞争一把锁,这时会存在大量的锁等待,可能会浪费很多时间,让系统的响应时间一下子变慢。
因此,JDK
还提供了另外一种用空间换时间的新思路:ThreadLocal
。(为对象分配内存时的TLAB分配过程也是这个思想)。
它的核心思想是:共享变量在每个线程
都有一个副本
,每个线程操作的都是自己的副本,对其他线程没有影响。
@Service
public class ThreadLocalService {
private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
public void add() {
threadLocal.set(1);
doSamething();
Integer integer = threadLocal.get();
}
}
ThreadLocal的原理是什么?
为了搞清楚ThreadLocal的底层实现原理,我们不得不扒一下源码。
ThreadLocal的内部有一个静态的内部类叫:ThreadLocalMap
。
public class ThreadLocal<T> {
// 省略很多代码......
public T get() {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
T result = (T)e.value;
return result;
}
}
return setInitialValue();
}
private T setInitialValue() {
T value = initialValue();
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
if (this instanceof TerminatingThreadLocal) {
TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
}
return value;
}
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
createMap(t, value);
}
}
// 省略很多代码......
}
ThreadLocal的get方法、set方法和setInitialValue方法,其实最终操作的都是ThreadLocalMap类中的数据。其中ThreadLocalMap类的内部如下:
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
...
private Entry[] table;
...
}
ThreadLocalMap 里面包含一个静态的内部类 Entry,该类继承于 WeakReference 类。其中Entry的key,就是ThreadLocal,被定义成了弱引用,value就是要存储的值。ThreadLocalMap 内部还包含了一个Entry数组,而ThreadLocalMap被定义成了Thread类的成员变量。
public class Thread implements Runnable {
...
ThreadLocal.ThreadLocalMap threadLocals = null;
}
用一张图总结一下引用关系:
如果ThreadLocal被定义成了静态变量的话,还会被定义ThreadLocal所在类的Class对象所引用。
ThreadLocalMap为什么要用ThreadLocal做key,而不是用Thread做key?
如果在你的应用中,一个线程中只使用了一个 ThreadLocal 对象,那么使用 Thread 做key也未尝不可。但实际情况中,你的应用,一个线程中很有可能不只使用了一个 ThreadLocal 对象。这时使用 Thread 做key不就出有问题?
@Service
public class ThreadLocalService {
private static final ThreadLocal<Integer> threadLocal1 = new ThreadLocal<>();
private static final ThreadLocal<Integer> threadLocal2 = new ThreadLocal<>();
private static final ThreadLocal<Integer> threadLocal3 = new ThreadLocal<>();
}
假如使用Thread做key时,你的代码中定义了3个ThreadLocal对象,那么通过Thread对象,它怎么知道要获取哪个ThreadLocal对象呢?
因此,不能使用 Thread 做key,而应该改成用 ThreadLocal 对象做key,这样才能通过具体ThreadLocal对象的get
方法,轻松获取到你想要的ThreadLocal对象。
Entry的key为什么设计成弱引用?
Entry的key是ThreadLocal对象,使用了 WeakReference 对象,即被设计成了弱引用 (弱引用的对象,在GC做垃圾清理的时候,就会被自动回收)。
那么,为什么要这样设计?
假如 key 对 ThreadLocal 对象的弱引用,改为强引用。
ThreadLocal变量对ThreadLocal对象是有强引用存在的,即使ThreadLocal变量生命周期完了,设置成null了,但由于key对ThreadLocal还是强引用。此时,如果执行该代码的线程
使用了线程池
,一直长期存在,不会被销毁。
就会存在这样的强引用链
:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> key -> ThreadLocal对象。那么,ThreadLocal对象和ThreadLocalMap都将不会被 gc 回收,于是产生了内存泄露
问题。
为了解决这个问题,JDK的开发者们把Entry的key设计成了弱引用。如果key是弱引用,当ThreadLocal变量指向null之后,在GC做垃圾清理的时候,key会被自动回收,其值也被设置成null。
由于当前的ThreadLocal变量已经被指向null了,但如果直接调用它的get、set或remove方法,很显然会出现空指针异常。因为它的生命已经结束了,再调用它的方法也没啥意义。
此时,如果系统中还定义了另外一个ThreadLocal变量b,调用了它的get、set或remove,三个方法中的任何一个方法,都会自动触发清理机制,将key为null的value值清空。
如果key和value都是null,那么Entry对象会被GC回收。如果所有的Entry对象都被回收了,ThreadLocalMap也会被回收了。
这样就能最大程度的解决内存泄露问题。
此外,你可能还会问这样一个问题:Entry的value为什么不设计成弱引用?
答:Entry的value假如只是被Entry引用,有可能没被业务系统中的其他地方引用。如果将value改成了弱引用,被GC贸然回收了(数据突然没了),可能会导致业务系统出现异常。
而相比之下,Entry的key,管理的地方就非常明确了。
这就是Entry的key被设计成弱引用,而value没被设计成弱引用的原因。
ThreadLocal真的会导致内存泄露?
通过上面的Entry对象中的key设置成弱引用,并且使用get、set或remove方法清理key为null的value值,就能彻底解决内存泄露问题?
答案是否定的。
假如ThreadLocalMap中存在很多key为null的Entry,但后面的程序,一直都没有调用过ThreadLocal的get、set或remove方法。那么,Entry的value值一直都没被清空。
所以会存在这样一条强引用链:Thread变量 -> Thread对象 -> ThreadLocalMap -> Entry -> value -> Object。其结果就是:Entry和ThreadLocalMap将会长期存在下去,会导致内存泄露。
如何解决内存泄露问题?
前面说过的ThreadLocal还是会导致内存泄露的问题,我们有没有解决办法呢?
答:有办法,调用ThreadLocal对象的remove方法。不是在一开始就调用remove方法,而是在使用完ThreadLocal对象之后。
列如:先创建一个CurrentUser类,其中包含了ThreadLocal的逻辑。
public class CurrentUser {
private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
public static void set(UserInfo userInfo) {
THREA_LOCAL.set(userInfo);
}
public static UserInfo get() {
THREA_LOCAL.get();
}
public static void remove() {
THREA_LOCAL.remove();
}
}
然后在业务代码中调用相关方法:
public void doSamething(UserDto userDto) {
UserInfo userInfo = convert(userDto);
try{
CurrentUser.set(userInfo);
...
//业务代码
UserInfo userInfo = CurrentUser.get();
...
} finally {
CurrentUser.remove();
}
}
需要我们特别注意的地方是:一定要在finally代码块中,调用remove方法清理没用的数据。如果业务代码出现异常,也能及时清理没用的数据。remove方法中会把Entry中的key和value都设置成null,这样就能被GC及时回收,无需触发额外的清理机制,所以它能解决内存泄露问题。
ThreadLocal是如何get数据的?
get方法的源码
public T get() {
// 当前线程
Thread t = Thread.currentThread();
// 拿到线程中定义的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// map已初始化
if (map != null) {
// 已当前ThreadLocal为key,从map获取entry
ThreadLocalMap.Entry e = map.getEntry(this);
// 若存在null,则返回value
if (e != null) {
T result = (T)e.value;
return result;
}
}
// map未初始化 或者get到的value为null
return setInitialValue();
}
private Entry getEntry(ThreadLocal<?> key) {
// 计算数组下标
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
// entry不为null,且key也和entry里的key相同
if (e != null && e.refersTo(key))
// 说明找到数据了,直接返回
return e;
else
// 说明出现hash冲突了,继续往后找
// 并且会清除掉key为null的entry
return getEntryAfterMiss(key, i, e);
}
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
Entry[] tab = table;
int len = tab.length
// entry不为null,则一直往后找
while (e != null) {
// 当前Entry的key正好是我们所需要寻找的key
if (e.refersTo(key))
return e;
// entry的key为null,说明不存在要找的数据,则清理脏数据
if (e.refersTo(null))
// 先将entry的value置为null,再将entry置为null
// 然后继续往后判断entry
// 若entry为null,则方法退出
// 若entry不为null,但key为null,则清除该entry
// 若entry不为null,key也不为null,则判断需不需要重新定位该entry的存放位置
expungeStaleEntry(i);
// 则继续往后找
else
i = nextIndex(i, len);
e = tab[i];
}
return null;
}
ThreadLocal从数组中找数据的大致过程:
- 通过key的hashCode取余计算出一个下标;
- 通过下标,在数组中定位具体Entry,如果key正好是我们所需要的key,说明找到了,则直接返回数据;
- 如果该entry不是我们想要的数据,则从数组的下标位置,继续往后面找,找到则返回数据;
- 如果找到最后一个位置,还是没有找到数据,则再从头,即下标为0的位置,继续从前往后找数据;
- 直到找到第一个entry为null或者entry的key为null为止。
ThreadLocal是如何set数据和扩容的?
在set方法中会调用rehash方法:
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)]) {
// 数据已存在,则覆盖数据
if (e.refersTo(key)) {
e.value = value;
return;
}
// entry的key为null
if (e.refersTo(null)) {
// 将e的value置为null,以便于gc回收
// 将key,value放置在该位置
replaceStaleEntry(key, value, i);
return;
}
tab[i] = new Entry(key, value);
int sz = ++size;
// 清理一些过期的数据,如果有清理掉一些过期数据,则返回
// 如果没有过期数据可以清理,则判断元素个数是否达到扩容阈值,若达到则尝试扩容
// threshold = 数组长度的2/3
if (!cleanSomeSlots(i, sz) && sz >= threshold)
// 尝试扩容
rehash();
}
// 在真正扩容之前,先尝试回收所有key为null的entry,腾出一些空间。
// 如果回收之后的size大于等于threshold的3/4时,才需要真正的扩容。
private void rehash() {
// 清除数组中所有过期的数据(即key为null的entry)
expungeStaleEntries()
// threshold = 数组长度的2/3
// 清理过后再次判断数组元素是否达到阈值的3/4
// 若数组长度为16,则threshold = 10
// threshold - threshold / 4 = 8
// 即添加数据后,数组中元素个数达到数组长度的一半时,则需要扩容
if (size >= threshold - threshold / 4)
// 真正的扩容
resize();
}
// 数组扩容
private void resize() {
Entry[] oldTab = table; // 老数组
int oldLen = oldTab.length; // 老数组长度
int newLen = oldLen * 2; // 新的数组长度,为老数组的2倍
Entry[] newTab = new Entry[newLen]; // 新数组
int count = 0
// 遍历老数组,将数据数据赋值到新数组
for (Entry e : oldTab) {
if (e != null) {
ThreadLocal<?> k = e.get();
if (k == null) {
e.value = null;
} 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;
}
set数据和扩容的主要过程:
-
添加数据后将size加1;
-
从数组当前位置往后检查部分槽位上是否存在key为null的entry(检查个数跟size大小有关),若存在,则清理脏数据,不需要考虑扩容;
-
若没有检测到脏数据,则判断szie是否达到扩容阈值(扩容阈值为数组长度的2/3)。若达到,则需要考虑扩容;
-
扩容前先尝试清除数组中所有的脏数据,腾出一些空间;
-
回收之后,若size大于等于数组长度的一半时,才需要真正的扩容;
-
扩容的新数组长度为原数组长度的2倍。
父子线程如何共享数据?
使用 InheritableThreadLocal,它是JDK自带的类,继承了ThreadLocal类。
public class ThreadLocalTest {
public static void main(String[] args) {
InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();
threadLocal.set(6);
System.out.println("父线程获取数据:" + threadLocal.get());
new Thread(() -> {
System.out.println("子线程获取数据:" + threadLocal.get());
}).start();
}
}
父线程获取数据:6
子线程获取数据:6
其实,在Thread类中除了成员变量threadLocals之外,还有另一个成员变量:inheritableThreadLocals。
ThreadLocal.ThreadLocalMap threadLocals = null;
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
InheritableThreadLocal 使用的是Thread的inheritableThreadLocals 这个ThreadLocalMap ,在它的 init方法 中会将父线程中往ThreadLocal设置的值,拷贝一份到子线程中。
线程池中如何共享数据?
在真实的业务场景中,一般很少用单独的线程,绝大多数,都是用的线程池。那么,在线程池中如何共享ThreadLocal对象生成的数据呢?因为涉及到不同的线程,如果直接使用ThreadLocal,显然是不合适的。
我们应该使用InheritableThreadLocal,具体代码如下:
private static void fun1() {
InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>();
threadLocal.set(6);
System.out.println("父线程获取数据:" + threadLocal.get());
ExecutorService executorService = Executors.newSingleThreadExecutor();
threadLocal.set(6);
executorService.submit(() -> {
System.out.println("第一次从线程池中获取数据:" + threadLocal.get());
});
threadLocal.set(7);
executorService.submit(() -> {
System.out.println("第二次从线程池中获取数据:" + threadLocal.get());
});
}
父线程获取数据:6
第一次从线程池中获取数据:6
第二次从线程池中获取数据:6
由于这个例子中使用了单例线程池,固定线程数是1。
第一次submit任务的时候,该线程池会自动创建一个线程。因为使用了InheritableThreadLocal,所以创建线程时,会调用它的init方法,将父线程中的inheritableThreadLocals数据复制到子线程中。所以我们看到,在主线程中将数据设置成6,第一次从线程池中获取了正确的数据6。
之后,在主线程中又将数据改成7,但在第二次从线程池中获取数据却依然是6。
因为第二次submit任务的时候,线程池中已经有一个线程了,就直接拿过来复用,不会再重新创建线程了。所以不会再调用线程的init方法,所以第二次其实没有获取到最新的数据7,还是获取的老数据6。
那么,这该怎么办呢?
答:使用 TransmittableThreadLocal,它并非JDK自带的类,而是阿里巴巴开源jar包中的类。
可以通过如下pom文件引入该jar包:
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>transmittable-thread-local</artifactId>
<version>2.11.0</version>
<scope>compile</scope>
</dependency>
private static void fun2() {
TransmittableThreadLocal<Integer> threadLocal = new TransmittableThreadLocal<>();
threadLocal.set(6);
System.out.println("父线程获取数据:" + threadLocal.get());
ExecutorService ttlExecutorService = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1));
threadLocal.set(6);
ttlExecutorService.submit(() -> System.out.println("第一次从线程池中获取数据:" + threadLocal.get()));
threadLocal.set(7);
ttlExecutorService.submit(() -> System.out.println("第二次从线程池中获取数据:" + threadLocal.get()));
threadLocal.set(8);
ttlExecutorService.submit(() -> System.out.println("第二次从线程池中获取数据:" + threadLocal.get()));
}
父线程获取数据:6
第一次从线程池中获取数据:6
第二次从线程池中获取数据:7
第二次从线程池中获取数据:8
你可能会发现,代码中除了使用TransmittableThreadLocal类之外,还使用了TtlExecutors.getTtlExecutorService方法,去创建ExecutorService对象。
这是非常重要的地方,如果没有这一步,TransmittableThreadLocal在线程池中共享数据将不会起作用。
创建ExecutorService对象,底层的submit方法会将任务封装成TtlRunnable或TtlCallable对象。
以TtlRunnable类为例,它实现了Runnable接口,同时还实现了它的run方法:
public void run() {
Map<TransmittableThreadLocal<?>, Object> copied = (Map)this.copiedRef.get();
if (copied != null && (!this.releaseTtlValueReferenceAfterRun || this.copiedRef.compareAndSet(copied, (Object)null))) {
Map backup = TransmittableThreadLocal.backupAndSetToCopied(copied);
try {
this.runnable.run();
} finally {
TransmittableThreadLocal.restoreBackup(backup);
}
} else {
throw new IllegalStateException("TTL value reference is released after run!");
}
}
这段代码的主要逻辑如下:
- 把当时的ThreadLocal做个备份,然后将父类的ThreadLocal拷贝过来。
- 执行真正的run方法,可以获取到父类最新的ThreadLocal数据。
- 从备份的数据中,恢复当时的ThreadLocal数据。
ThreadLocal有哪些使用场景?
- 在spring事务中,保证一个线程下,一个事务的多个操作拿到的是一个Connection。
- 在JDK8之前,为了解决SimpleDateFormat的线程安全问题。
- 获取当前登录用户上下文。
- 临时保存权限数据。
ThreadLocal变量为什么建议要定义成static的?
ThreadLocal 的原理是在Thread内部有一个ThreadLocalMap的集合对象,他的key是ThreadLocal,value就是你要存储的变量副本,不同线程的 ThreadLocalMap 是隔离开的,如果ThreadLocal 变量是非 static 的话,就会使得每次生成实例都要生成不同的 ThreadLocal 对象,虽然这样程序不会有什么异常,但是会造成内存资源的浪费。
但是ThreadLocal 变量定义成staic也有一定的缺点,就是在线程池的情况下更容易造成内存泄露,在使用完ThreadLocal后要调用其remove方法,手动清理失效的Entry对象,避免内存泄漏。