并发——深入分析ThreadLocal的实现原理
一、前言
这篇博客来分析一下ThreadLocal
的实现原理以及常见问题,由于现在时间比较晚了,我就不废话了,直接进入正题。
二、正文
2.1 ThreadLocal是什么
在讲实现原理之前,我先来简单的说一说ThreadLocal
是什么。ThreadLocal
被称作线程局部变量,当我们定义了一个ThreadLocal
变量,所有的线程共同使用这个变量,但是对于每一个线程来说,实际操作的值是互相独立的。简单来说就是,ThreadLocal能让线程拥有自己内部独享的变量。举一个简单的例子:
// 定义一个线程共享的ThreadLocal变量
static ThreadLocal<Integer> tl = new ThreadLocal<>();
public static void main(String[] args) {
// 创建第一个线程
Thread t1 = new Thread(() -> {
// 设置ThreadLocal变量的初始值,为1
tl.set(1);
// 循环打印ThreadLocal变量的值
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "----" + tl.get());
// 每次打印完让值 + 1
tl.set(tl.get() + 1);
}
}, "thread1");
// 创建第二个线程
Thread t2 = new Thread(() -> {
// 设置ThreadLocal变量的初始值,为100,与上一个线程区别开
tl.set(100);
// 循环打印ThreadLocal变量的值
for (int i = 0; i < 10; i++) {
System.out.println(Thread.currentThread().getName() + "----" + tl.get());
// 每次打印完让值 - 1
tl.set(tl.get() - 1);
}
}, "thread2");
// 开启两个线程
t1.start();
t2.start();
tl.remove();
}
上面的代码,运行结果如下(注:每次运行的结果可能不同):
thread1----1
thread2----100
thread1----2
thread2----99
thread1----3
thread2----98
thread1----4
thread2----97
thread1----5
thread2----96
thread1----6
thread2----95
thread1----7
thread2----94
thread1----8
thread2----93
thread1----9
thread2----92
thread1----10
thread2----91
通过上面的输出结果我们可以发现,线程1
和线程2
虽然使用的是同一个ThreadLocal
变量存储值,但是输出结果中,两个线程的值却互不影响,线程1
从1
输出到10
,而线程2
从100
输出到91
。这就是ThreadLocal
的功能,即让每一个线程拥有自己独立的变量,多个线程之间互不影响。
2.2 ThreadLocal的实现原理
下面我就就来说一说ThreadLocal
是如何做到线程之间相互独立的,也就是它的实现原理。这里我直接放出结论,后面再根据源码分析:每一个线程都有一个对应的Thread对象,而Thread类有一个成员变量,它是一个Map集合,这个Map集合的key就是ThreadLocal的引用,而value就是当前线程在key所对应的ThreadLocal中存储的值。当某个线程需要获取存储在ThreadLocal变量中的值时,ThreadLocal底层会获取当前线程的Thread对象中的Map集合,然后以ThreadLocal作为key,从Map集合中查找value值。这就是ThreadLocal
实现线程独立的原理。也就是说,ThreadLocal
能够做到线程独立,是因为值并不存在ThreadLocal
中,而是存储在线程对象中。下面我们根据ThreadLocal
中两个最重要的方法来确认这一点。
2.3 ThreadLocal中的get方法
get
方法的作用非常简单,就是线程向ThreadLocal
中取值,下面我们来看看它的源码:
public T get() {
// 获取当前线程的Thread对象
Thread t = Thread.currentThread();
// getMap方法传入Thread对象,此方法将返回Thread对象中存储的一个Map集合
// 这个Map集合的类型为ThreadLocalMap,这是ThreadLoacl的一个内部类
// 当前线程存放在ThreadLocal中的值,实际上存放在这个Map集合中
ThreadLocalMap map = getMap(t);
// 如果当前Map集合已经初始化,则直接从Map集合中查找
if (map != null) {
// ThreadLocalMap的key其实就是ThreadLoacl对象的引用
// 所以要找到线程在当前ThreadLoacl中存放的值,就需要以当前ThreadLoacl作为key
// getEntry方法就是通过key获取map中的一个key-value,而这里使用的key就是this
ThreadLocalMap.Entry e = map.getEntry(this);
// 如果返回值不为空,表示查找成功
if (e != null) {
@SuppressWarnings("unchecked")
// 于是获取对应的value并返回
T result = (T)e.value;
return result;
}
}
// 若当前线程的ThreadLocalMap还未初始化,或者查找失败,则调用以下方法
return setInitialValue();
}
private T setInitialValue() {
// 此方法默认返回null,但是可以由子类进行重新,根据需求返回需要的值
T value = initialValue();
// 获取当前线程的Thread对象
Thread t = Thread.currentThread();
// 获取对应的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 如果Map已经初始化了,就直接往map中加入一个key-value
// key就是当前ThreadLocal对象的引用,而value就是上面获取到的value,默认为null
if (map != null)
map.set(this, value);
// 若还没有初始化,则调用createMap创建ThreadLocalMap对象
else
createMap(t, value);
// 返回initialValue方法返回的值,默认为null
return value;
}
void createMap(Thread t, T firstValue) {
// 创建ThreadLocalMap对象,构造方法传入的是第一对放入其中的key-value
// 这个key也就是当前线程第一次调用get方法的ThreadLocal对象,也就是当前ThreadLocal对象
// 而firstValue则是initialValue方法的返回值,默认为null
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
上面的代码非常直观的验证了我之前说过的ThreadLocal
的实现原理。通过上面的代码,我们可以非常直观的看到,线程向ThreadLocal
中存放的值,最后都放入了线程自己的ThreadLocalMap
中,而这个map
的key
就是当前ThreadLocal
的引用。而ThreadLocal
中,获取线程的ThreadLocalMap
的方法getMap
的代码如下:
ThreadLocalMap getMap(Thread t) {
// 直接返回Thread对象的threadLocals成员变量
return t.threadLocals;
}
我们再看看Thread
类中的threadLocals
变量:
/** 可以看到,ThreadLocalMap是ThreadLocal的内部类 */
ThreadLocal.ThreadLocalMap threadLocals = null;
2.4 ThreadLocal中的set方法
下面再来看一看ThreadLocal
的set
方法的实现,set
方法用来使线程向ThreadLocal
中存放值(实际上是存放在线程自己的Map
中):
public void set(T value) {
// 获取当前线程的Thread对象
Thread t = Thread.currentThread();
// 获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
// 若map已经初始化,则之际将value放入Map中,对应的key就是当前ThreadLocal的引用
if (map != null)
map.set(this, value);
// 若没有初始化,则调用createMap方法,为当前线程t创建ThreadLocalMap,
// 然后将key-value放入(此方法已经在上面讲解get方法是看过)
else
createMap(t, value);
}
这就是set
方法的实现,比较简单。看完上面两个关键方法的实现,相信大家对ThreadLocal
的实现已经有了一个比较清晰的认识,下面我们来更加深入的分析ThreadLocal
,看看ThreadLocalMap
的一些实现细节。
2.5 ThreadLocalMap的中的弱引用
ThreadLocalMap
的实现其实就是一个比较普通的Map
集合,它的实现和HashMap
类似,所以具体的实现细节我们就不一一讲解了,这里我们只关注它最特别的一个地方,即它内部的节点Entry
。我们先来看看Entry
的代码:
// Entry是ThreadLocalMap的内部类,表示Map的节点
// 这里继承了WeakReference,这是java实现的弱引用类,泛型为ThreadLocal
// 表示在这个Map中,作为key的ThreadLocal是弱引用
// (这里value是强引用,因为没用WeakReference)
static class Entry extends WeakReference<ThreadLocal<?>> {
/** 存储value */
Object value;
Entry(ThreadLocal<?> k, Object v) {
// 将key的值传入父类WeakReference的构造方法,用弱引用来引用key
super(k);
// value则直接使用上面的强引用
value = v;
}
}
可以看到,上面的Entry
比较特殊,它继承自WeakReference
类型,这是Java
实现的弱引用。在具体讲解前,我们先来介绍一下不同类型的引用:
强引用:这是Java中最常见的引用,在没有使用特殊引用的情况下,都是强引用,比如Object o = new Object()就是典型的强引用。能让程序员通过强引用访问到的对象,不会被JVM垃圾回收,即使内存空间不够,JVM也不会回收这些对象,而是抛出内存溢出异常;
软引用:软引用描述的是一些还有用,但不是必须的对象。被软引用所引用的对象,也不会被垃圾回收,直到JVM将要发生内存溢出异常时,才会将这些对象列为回收对象,进行回收。在JDK1.2之后,提供了SoftReference类实现软引用;
弱引用:弱引用描述的是非必须的对象,被弱引用所引用的对象,只能生存到下一次垃圾回收前,下一次垃圾回收来临,此对象就会被回收。在JDK1.2之后,提供了WeakReference类实现弱引用(也就是上面Entry继承的类);
虚引用:这是最弱的一种引用关系,一个对象是否有虚引用,完全不会对其生存时间产生影响,我们也不能通过一个虚引用访问对象,使用虚引用的唯一目的就是,能在这个对象被回收时,受到一个系统的通知。JDK1.2之后,提供了PhantomReference实现虚引用;
介绍完各类引用的概念,我们就可以来分析一下Entry
为什么需要继承WeakReference
类了。从代码中,我们可以看到,Entry
将key
值,也就是ThreadLocal
的引用传入到了WeakReference
的构造方法中,也就是说在ThreadLocalMap
中,key
的引用是弱引用。这表明,当没有其他强引用指向key
时,这个key
将会在下一次垃圾回收时被JVM
回收。
为什么需要这么做呢?这么做的目的自然是为了有利于垃圾回收了。如果了解过JVM
的垃圾回收算法的应该知道,JVM
判断一个对象是否需要被回收,判断的依据是这个对象还能否被我们所使用,举个简单的例子:
public static void main(String[] args) {
Object o = new Object();
o = null;
}
上面的代码中,我们创建了一个对象,并使用强引用o
指向它,然后我们将o
置为空,这个时候刚刚创建的对象就丢失了,因为我们无法通过任何引用找到这个对象,从而使用它,于是这个对象就需要被回收,这种判断依据被称为可达性分析。关于JVM
的垃圾回收算法,可以参考这篇博客:Java中的垃圾回收算法详解。
好,回归正题,我们开始分析为什么ThreadLocalMap
需要让key
使用弱引用。假设我们创建了一个ThreadLocal
,使用完之后没有用了,我们希望能够让它被JVM
回收,于是有了下面这个过程:
// 创建ThreadLocal对象
ThreadLocal tl = new ThreadLocal();
// .....省略使用的过程...
// 使用完成,希望被JVM回收,于是执行以下操作,解除强引用
tl = null;
我们在使用完ThreadLocal
之后,解除对它的强引用,希望它被JVM
回收。但是JVM
无法回收它,因为我们虽然在此处释放了对它的强引用,但是它还有其它强引用,那就是Thread
对象的ThreadLocalMap
的key
。我们之前反复说过,ThreadLocalMap
的key
就是ThreadLocal
对象的引用,若这个引用是一个强引用,那么在当前线程执行完毕,被回收前,ThreadLocalMap
不会被回收,而ThreadLocalMap
不会被回收,它的key
引用的ThreadLocal
也就不会回收,这就是问题的所在。而使用弱引用就可以保证,在其他对ThreadLocal的强引用解除后,ThreadLocalMap对它的引用不会影响JVM对它进行垃圾回收。这就是使用弱引用的原因。
2.6 ThreadLocal造成的内存溢出问题
上面描述了对ThreadLocalMap
对key
使用弱引用,来避免JVM
无法回收ThreadLocal
的问题,但是这里却还有另外一个问题。我们看上面Entry
的代码发现,key
值虽然使用的弱引用,但是value使用的却是强引用。这会造成一个什么问题?这会造成key被JVM回收,但是value却无法被收,key对应的ThreadLocal被回收后,key变为了null,但是value却还是原来的value,因为被ThreadLocalMap所引用,将无法被JVM回收。若value
所占内存较大,线程较多的情况下,将持续占用大量内存,甚至造成内存溢出。我们通过一段代码演示这个问题:
public class Main {
public static void main(String[] args) {
// 循环创建多个TestClass
for (int i = 0; i < 100; i++) {
// 创建TestClass对象
TestClass t = new TestClass(i);
// 调用反复
t.printId();
// *************注意此处,非常关键:为了帮助回收,将t置为null
t = null;
}
}
static class TestClass {
int id;
// 每个TestClass对象对应一个很大的数组
int[] arr = new int[100000000];
// 每个TestClass对象对应一个ThreadLocal对象
ThreadLocal<int[]> threadLocal = new ThreadLocal<>();
TestClass(int id) {
this.id = id;
// threadLocal存放的就是这个很大的数组
threadLocal.set(arr);
}
public void printId() {
System.out.println(id);
}
}
}
上面的代码多次创建所占内存非常大的对象,并在创建后,立即解除对象的强引用,让对象可以被JVM
回收。按道理来说,上面的代码运行应该不会发生内存溢出,因为我们虽然创建了多个大对象,占用了大量空间,但是这些对象立即就用不到了,可以被垃圾回收,而这个对象被垃圾回收后,对象的id
,数组,和threadLocal
成员都会被回收,所以所占内存不会持续升高,但是实际运行结果如下:
0
1
2
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at Main$TestClass.<init>(Main.java:23)
at Main.main(Main.java:10)
可以看到,很快就发生了内存溢出异常。为什么呢?需要注意到,在TestClass
的构造方法中,我们将数组arr
放入了ThreadLocal
对象中,也就是被放进了当前线程的ThreadLocalMap
中,作为value
存在。我们前面说过,ThreadLocalMap
的value
是强引用,这也就意味着虽然ThreadLocal
可以被正常回收,但是作为value
的大数组无法被回收,因为它仍然被ThreadLocalMap
的强引用所指向。于是TestClass
对象的超大数组就一种在内存中,占据大量空间,我们连续创建了多个TestClass
,内存很快就被占满了,于是发生了内存溢出。而JDK
的开发人员自然发现了这个问题,于是有了下面这个解决方案:
public class Main {
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
TestClass t = new TestClass(i);
t.printId();
// **********注意,与上面的代码只有此处不同************
// 此处调用了ThreadLocal对象的remove方法
t.threadLocal.remove();
t = null;
}
}
static class TestClass {
int id;
int[] arr;
ThreadLocal<int[]> threadLocal;
TestClass(int id) {
this.id = id;
arr = new int[100000000];
threadLocal = new ThreadLocal<>();
threadLocal.set(arr);
}
public void printId() {
System.out.println(id);
}
}
}
上面的代码中,我们在将t
置为空时,先调用了ThreadLocal
对象的remove
方法,这样做了之后,再看看运行结果:
0
1
2
// ....神略中间部分
98
99
做了上面的修改后,没有再发生内存溢出异常,程序正常执行完毕。这是为什么呢?ThreadLocal
的remove
方法究竟有什么作用。其实remove
方法的作用非常简单,执行remove
方法时,会从当前线程的ThreadLocalMap
中删除key
为当前ThreadLocal
的那一个记录,key
和value
都会被置为null,这样一来,就解除了ThreadLocalMap
对value
的强引用,使得value
可以正常地被JVM
回收了。所以,今后如果我们确认不再使用的ThreadLocal
对象,一定要记得调用它的remove
方法。
我们之前说过,如果我们没有调用remove
方法,那就会导致ThreadLocal
在使用完毕后,被正常回收,但是ThreadLocalMap
中存放的value
无法被回收,此时将会在ThreadLocalMap
中出现key
为null
,而value
不为null
的元素。为了减少已经无用的对象依旧占用内存的现象,ThreadLocal
底层实现中,在操作ThreadLocalMap
的过程中,线程若检测到key
为null
的元素,会将此元素的value
置为null
,然后将这个元素从ThreadLocalMap
中删除,占用的内存就可以让JVM
将其回收。比如说在getEntry
方法中,或者是Map
扩容的方法中等。
三、总结
ThreadLocal
实现线程独立的方式是直接将值存放在Thread
对象的ThreadLocalMap
中,Map
的key
就是ThreadLocal
的引用,且为了有助于JVM
进行垃圾回收,key
使用的是弱引用。在使用ThreadLocal
后,一定要记得调用remove
方法,有助于JVM
对value
的回收。
四、参考
- 《深入理解Java虚拟机(第二版)》
- https://mp.weixin.qq.com/s/Y24LQwukYwXueTS6NG2kKA