ThreadLocal是在 JDK1.2 之后提供的一个类,为解决多线程程序的并发问题提供了一种新的思路。可以这么理解,ThreadLocal是与线程绑定的局部变量,即每个线程都对应一个ThreadLocal实例,各个线程互不干扰。我们可以利用ThreadLocal的特性编写出更加简洁优美的多线程程序。接下来让我们通过ThreadLocal的源码来解析ThreadLocal。
在解读源码的时候最好是从一个测试程序debug进入,这样可以针对性的读源码,不至于不知道从哪里看起,而且这样也能让我们更清楚代码的执行流程,更贴近于日常开发。
先看一下我们用于测试的程序:
public class ThreadLocalTest {
private static ThreadLocal threadLocal = new ThreadLocal();
public static void main(String[] args) throws InterruptedException {
threadLocal.set("abc");
new Thread(() -> {
System.out.println("threadA:begin:" + threadLocal.get());
threadLocal.set(255);
System.out.println("threadA:afterSet:" + threadLocal.get());
threadLocal.remove();
System.out.println("threadA:afterRemove:" + threadLocal.get());
}).start();
// 等线程A执行完
Thread.sleep(1000);
System.out.println("main:" + threadLocal.get());
}
}
单看上面程序,你能说出运行结果吗?对于使用过ThreadLocal的开发者,说出运行结果应该不是问题,但是你知道为什么是这样的结果吗?底层实现呢?这里先把运行结果贴出来。
首先,我们找到ThreadLocal类,该类只有一个无参构造方法,而且是一个空构造:
接下来通过断点调试一下。
我们先看一下set方法的源码:
首先是获取当前线程,接着通过当前线程获取一个ThreadLocalMap,这个ThreadLocalMap又是什么东西呢?我们点进ThreadLocalMap源码:
我们可以简单看一下注释,ThreadLocalMap是ThreadLocal的一个静态内部类,是一个自定义的hash map,用于存储线程的本地变量,关于这个类,我们后面还会介绍到。
我们回头看我们的测试代码,通过断点调试进入set(T value)
方法:
我们往下运行并进入getMap(Thread t)
方法:
这个方法返回的是Thread类中threadLocals
变量:
threadLocals
是Thread类中维护的一个ThreadLocalMap
变量,即每一个线程中的ThreadLocalMap都是不同的。
获取到当前线程的map之后,我们可以先大致猜测一下他的运行流程,我们是第一次调用set方法,当前线程的threadLocals
变量应该为null,所以在if语句中会进入createMap()这个方法。按照这个思路,我们先进入createMap(Thread t, T firstValue)
这个方法看一下:
该方法创建了一个新的ThreadLocalMap并绑定到当前线程上,我们进入这个ThreadLocalMap这个构造方法:
接下解读一下这个构造函数。ThreadLocalMap中的table是一个Entry数组:
那么什么是Entry?
Entry是ThreadLocalMap中的一个静态内部类,继承WeakReference这个类。WeakReference这个类是Java中的弱引用,在这里不做扩展,读者只需要记住,Java中的弱引用的对象在jvm垃圾回收时无论内存够不够,都会被回收。Entry使用key-value的结果存储数据,key是一个ThreadLocal的弱引用,value即我们存储的变量值。从这里也可以看出ThreadLocalMap构造函数ThreadLocalMap(ThreadLocal> firstKey, Object firstValue)
中的两个参数是用于构建Entry。
回到构造函数中:
INITIAL_CAPACITY
是数组的初始容量大小,为16。
接下来这两行代码我们可以大致猜测一下,因为之前已经创建了Entry数组,这里应该是计算所要存储的Entry对应在Entry数组中的索引。
先看threadLocalHashCode
:
threadLocalHashCode
是ThreadLocal中的常量,通过AotomicInteger这个原子类计算的hashCode。计算hashCode的时候使用了HASH_INCREMENT
这个常量,从注释中我们可以得知,HASH_INCREMENT
是连续生成的两个hashCode之间的间隔,是为了让生成的hashCode均匀分布在2^n数组中。通过这个常量我们可以发现,生成的hashCode的最大值会是Integer.MAX_VALUE
,这个值是十分庞大的,用这个数来作为数组下标是不可取的,会造成大量内存的浪费。那么如何解决这个问题呢?看下面这一步操作:
这个操作是通过hashCode与INITIAL_CAPACITY - 1
的与操作求出数组下标,上面已经说过了INITIAL_CAPACITY
的是Entry数组的初始值为16(2^4),16- 1 = 15(1111),与操作相当与取hashCode的低4位,即结果i
的最大值为15,同时也是Entry数组下标的最大值。这样在使hashCode均匀分布的基础上又解决了内存浪费的问题。这里额外提一下,Entry数组的容量大小必须是2^n,这样才能保证这些这一步代码后生成的数组i
值不会超过数组容量大小。
接下来这两行代码就是往数组中存值和记录数组中Entry数:
然后就是第一个构造函数的最后一行代码:
关于setThreshold(int len)
方法的定义:
简单理解,这是为Entry数组设置的一个阈值,如果数组中Entry数超过这个阈值,就需要进行数组的扩容,这个在后面会讲到。
回到set()方法中,第一次运行时map为空,会调用createMap(Thread t, T firstValue)
,初始化该map。初始化map后,下次调用就会进入set(ThreadLocal> key, Object value)
方法,我们点进去看下这个方法:
这个方法先是获取ThreadLocalMap中的Entry数组,即table,并计算数组长度。然后是这一步:
这一步相信读者已经不陌生了,我们前面已经介绍过了。这里在简单说一下,这个操作是通过hashCode与len(数组长度) - 1
的与操作求出数组下标。当数组长度为2^n时,与操作相当与取hashCode的低n位,这n位的最大值是数组下标的最大值。
接着往下看:
从for循环的条件中我们可以看出,这个应该是解决地址冲突问题。先获取根据之前求出的索引i
获取table中相应位置的值,如果i
位置有值,即 e != null
,就需要对 e
的值进行判断。我们知道,e是一个key-value结构的Entry,如果该Entry中的key与我们要插入的key相等,则直接替换value即可。如果key为null,则替换掉该Entry。如果都不符合,则表明当前所有位置的Entry有值,并且该值有效。此时需要重新计算数组下标,在ThreadLocal中采用的是开发地址法解决地址冲突,具体实现方法就是nextIndex(int i, int len)
:
ThreadLocal会寻找冲突地址的下一个地址(环形查找),直至地址不冲突。
如果for循环条件不成立(即table[i] == null
),则执行下面这语句:
这一段代码是直接将Entry添加到数组中,然后判断数组是否需要扩容(rehash操作)。这里有一个条件!cleanSomeSlots(i,sz)
,这一步是用于清除数组中部分key为null的Entry(因为Entry中key为弱引用,在垃圾回收时会被回收),如果有满足条件的value被清除,则该方法返回true,同时数据不需要进行扩容。如果没有数据被清除,则返回false并继续判断数组中Entry数是否大于等于阈值。这里需要注意一点的是,cleanSomeSlots
不会对整个数组进行扫描判断所有Entry的key是否为null,因为这样太浪费时间了,cleanSomeSlots
会挑选一部分Entry进行扫描判断。
接下来就是rehash操作了:
在数组扩容前,会有一步expungeStaleEntries()
,这一步跟前面说到的cleanSomeSlots()
效果差不多,都是清除数组中的key为null的Entry,但是``cleanSomeSlots是部分扫描,而
expungeStaleEntries`是对整个数组进行扫描,找到所有key为null的Entry并清除。再清除过后使用一个更低的阈值(这里是原阈值的3/4)判断是否需要进行扩容。
接下来是数组扩容resize()
:
这些代码还是比较清晰的,就是创建一个大小为原数组大小两倍的新数组,将原数组的Entry复制到新数组中。
这里解释一下Entry的复制过程:
遍历原数组,取出每一个Entry。对Entry的key进行判断,如果key为null(脏Entry),则说明key(弱引用)在已经被回收了,此时将value也设置为null,防止内存泄漏。如果key不为null,则计算Entry在新数组的对应存储的位置,如果地址冲突,则找到下一个不冲突的地址,并将Entry复制到新数组中。最后是修改ThreadLocal中相应的变量(threshold、size、table)与新数组相对应。
到这里,有关set方法中的流程和涉及到的方法就讲完了,接下来我们继续运行程序,进入get方法:
在有了前面讲解的基础上,我们应该能很快理解上面这段代码的意思。先获取当前线程的ThreadLocalMap,如果ThreadLocalMap不为null,根据当前ThreadLocal对象获取相应的Entry。让我们看下getEntry(ThreadLocal> key)
的实现:
这一步是根据当前的ThreadLocal对象的threadLocalHashCode
计算出相应的数组下标,根据数组下标取出Entry,如果Entry中的key相同且不为null,则直接返回该Entry。如果为null或者取出的Entry的key不是当前ThreadLocal对象,就会进入getEntryAfterMiss(ThreadLocal> key, int i, Entry e)
方法,这里解释一下为什么计算出来数组下标的时候Entry中的key还会不同,还记得直接我们说我ThreadLocal中是如何解决地址冲突的吗?使用的是开发地址法,会寻找下一个为null的数组下标,也就是说根据threadLocalHashCode
计算出来的下标不一定是相应Entry存储的下标。
接下来进入getEntryAfterMiss(ThreadLocal> key, int i, Entry e)
方法:
这个方法是从当前索引开始往后查找,对之后每一个索引的存储Entry进行比对,如果key与当前ThreadLocal对象相同,则返回该Entry,如果key为null,则清除该Entry。如果遍历到第一个为null的Entry前没有找到相应的Entry,则直接返回null,即没有该Entry。
回到get()
如果Entry不为null,则直接返回对应的值。否则(map == null || (map != null && e == null)
),进入setInitialValue()
:
看下initialValue()
:
这个方法返回的是一个null值。也就是说setInitialValue()
方法是创建一个以当前ThreadLocal对象为key,值为null的Entry对象,并将该Entry存储到当前线程的ThreadLocalMap(如果当前线程的map不存在,则创建)中,并返回该value值(null)。
现在我们可以总结一下get方法的流程。先获取当前线程的ThreadLocalMap,如果map为空,则初始化该map并赋予一个默认的Entry(key为当前ThreadLocal对象,value为null),并返回null值。如果map不为空,则查找map是否存在以当前ThreadLocal对象为key的Entry对象,如果存在,返回相应的value。不存在,则返回null。
讲完get和set所涉及的方法,根据我们的测试程序,接下来就是remove方法了:
根据当前线程获取ThreadLocalMap,如果map不为空,进行remove操作:
首先是要找到对应的Entry,找的方法跟上面的方法getEntryAfterMiss(ThreadLocal> key, int i, Entry e)
是一样的,在遇到Entry为null之前,取出每一个Entry的key进行比对,如果相等,则清除该引用。
关于replaceStaleEntry(ThreadLocal> key, Object value,int staleSlot)
、expungeStaleEntry(int staleSlot)
、cleanSomeSlots(int i, int n)
这几个方法我没写,这几个方法比较复杂,这里有一篇文章,一篇文章,从源码深入详解ThreadLocal内存泄漏问题,详细介绍了这几个方法。强烈建议读者阅读这篇文章,我也是看了这篇文章后对这几个方法有了更加深入的认识。
关于ThreadLocal的内存泄漏问题,我们借助一张图来理解(图片来自:https://www.jianshu.com/p/dde92ec37bd1):
上图中,实线表示强引用,虚线表示弱引用。
我们知道,ThreadLocal是借助ThreadLocalMap中的Entry来存储数据,Entry的值是当前ThreadLocal对象的弱引用。前面已经说过,弱引用在JVM进行gc的时候就会被回收。也就是说,当ThreadLocal对象的外部强引用为null时,该ThreadLocal对象只剩下一个Entry的弱引用,在gc是就会被回收,导致相应的Entry为null,但是value却不为null,而我们却不能通过一个为null的key去访问value,这就出现了我们前面所提到的脏Entry。由于存在一个强引用链,所有脏Entry在垃圾回收时不会被回收,所有当脏Entry数过多时就存在了内存泄漏问题。
其实在上面的源码解读过程中,我们会发现在许多方法中都会使用到replaceStaleEntry(ThreadLocal> key, Object value,int staleSlot)
、expungeStaleEntry(int staleSlot)
、cleanSomeSlots(int i, int n)
这几个方法,这几个方法都是用于清除脏Entry,确保不会发生内存泄漏。
虽然ThreadLocal中许多方法以及做了一些清除操作以确保不会发生内存泄漏,但还是建议开发者在ThreadLocal存储的数据不再使用的时候进行手动清除。因为这样可以避免一些不必要的操作,同时也能养成一个良好的习惯。
相信在看完这篇文章后,读者对于ThreadLocal不再是一知半解。ThreadLocal存储数据,其实底层使用的是ThreadLocalMap,可以认为,ThreadLocal是用于维护ThreadLocalMap的一个工具。实际的ThreadLocalMap存在于每一个线程中,这也就是为什么ThreadLocal存储的数据是线程隔离的原因。
至于ThreadLocalMap,ThreadLocalMap中维护的是一个Entry数组,Entry是以当前ThreadLocal对象的弱引用为key,存储的数据(Object)为value的key-value结构。由于key是弱引用,所有当存储的数据不再使用的时候最好手动移除。
最后回到测试程序,测试程序主要是为了验证ThreadLocal中存储的数据在不同线程中是相互隔离的,同时也是为了根据程序运行流程走一遍代码。测试程序写得可能有点粗超,望读者见谅。
这里记录一个调试过程中的小坑,在main方法中第一次调用set()方法,当前线程的threadLocals
变量应该为null,所以在if语句中会进入createMap()这个方法。但我实际debug的过程却不是这样的。
我先通过断点运行到第一次运行到set()方法结尾:
此处我获取到的map是不为空的。
再看下此时各变量的值:
可以看到,这里的获取当前主线程的map的值是不为空的。而且加上这次设置进去的值,table中共有4个Entry,为什么这里会有4个值呢?
这个问题也困扰了我很久,这里说下我个人的理解。我通过在ThreadLocal中对get()、set()方法打断点,直接运行主程序进行调试,发现在进入main方法前,程序会有一段频繁调用ThreadLocal中get()和set()方法的过程。
以其中一个为例,在一次进入get()方法的调用过程如下:
这应该是涉及到一些类加载的过程。也就是说,程序在main方法前的一些对于核心包的加载和初始化的过程中会使用到ThreadLocal,而且会初始化当前main线程中的ThreadLocalMap
变量(这里我通过打断点发现main线程的ThreadLocalMap变量的初始化并不在ThreadLocal中),所以当我们在main方法中使用ThreadLocal获取当前main线程的ThreadLocalMap变量的时候就不为空了。
这里提一点小细节,笔者以前在调试过程中经常是直接在源码和测试程序中打断点后再运行测试程序调试,而正确的调试过程应该是先在测试程序中打断点,运行测试程序,在运行断点处后再进入指定方法的源码打断点,这样有时候会省去很多麻烦事。
并发容器之ThreadLocal
一篇文章,从源码深入详解ThreadLocal内存泄漏问题
深入理解 ThreadLocal (这些细节不应忽略)