ThreadLocal大家都不陌生,字面意思是线程本地副本,可在多线程环境下,为每个线程创建独立的副本保证线程安全,在需要线程隔离的场合应用很广泛,但是关于ThreadLocal,总是有两个疑惑:
- 听说ThreadLocal中有有使用弱引用,为什么要用弱引用?用弱引用,发生一次gc后,set进去的值再get就是null了吗?
- 听说ThreadLocal可能引起内存泄露?啥场景会内存泄露?为何使用了弱引用依然可能发生内存泄露?怎么避免?
首先先来一段代码,看下最基本的使用:我们声明两个线程,将线程的名字通过ThreadLocal保存,然后再通过ThreadLocal取出,看一下每个线程获取到的线程名字
public class TestThreadLocal {
final static ThreadLocal<String> LOCAL = new ThreadLocal();
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(3);
// 线程1
executorService.execute(() -> {
// 存值
LOCAL.set(Thread.currentThread().getName());
// 获取值
System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
});
// 线程2
executorService.execute(() -> {
LOCAL.set(Thread.currentThread().getName());
System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
});
executorService.shutdown();
}
}
运行结果
pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2
结果没有什么悬念,每一个线程都获取到了与自己相对于的名字。
现在我们就点源码,看下它内部是怎么存储和获取数据的(源码基于jdk1.8,不同版本的jdk实现方式可能稍有不同)
首先看下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);
}
源码短短几行,首先获取当前线程,然后调用getMap(),返回一个ThreadLocalMap,暂且不管这个ThreadLocalMap是什么,通过名字我们简单猜测,就是一个map,我们继续往下看,如果map不为空直接保存数据,map为空则创建然后再保存数据,而保存数据的方法,key传入的this,也就是当前的ThreadLocal对象,value是我们要保存的值
(所以注意了,我们不能说ThreadLocal能保存线程独享的变量,而是保存数据的钥匙,通过它操作ThreadLocalMap)。
我们一直在说ThreadLocalMap,现在回过头来,看看ThreadLocalMap是什么,怎么来的吧。首先看看它的由来:ThreadLocalMap map = getMap(t)
,点进去,很简单,获取了当前线程的成员变量:ThreadLocal.ThreadLocalMap threadLocals
,我们可以理解为,每个线程在实例化的时候,都会创建一个ThreadLocalMap实例,保存线程独享的数据。
然后我们在看看ThreadLocalMap吧,该类的源码在ThreadLocal类中,是一个静态的class,简单看一下ThreadLocalMap的实现
static class ThreadLocalMap {
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
...
}
大致看下实现,不要恋战,我们不难看出2点:
- 虽然它的名字叫Map,但并没有实现java.util.Map接口,而是自己单独实现的。
- 同大多数的Map的实现类似,其内部也是维护了一个Entry存储数据,Entry里有key和value,其中的value在Entry里声明,但是key却并没有直接在Entry里声明,而是继承WeakReference,是一个弱引用,在WeakReference的父类Reference里,声明了
T referent
,即为该map的key
好的,还记得刚刚我们看的ThreadLocal的set()吗,先获取ThreadLocalMap实例,然后调用ThreadLocalMap的set(),我们来看一下ThreadLocal的set()吧,我们依旧不要恋战,没必要一行一行的读,我们大致看一下就好了
/**
* Set the value associated with key.
*
* @param key the thread local object
* @param value the value to be set
*/
private void set(ThreadLocal<?> key, Object value) {
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();
}
这就是个简易版的Map的put存放数据的方法,相信大家都知道HashMap的实现,对此应该很清楚,大体上就是根据当前哈希桶容量和key的哈希值,计算一个存放角标,将存值的时候,没有当前key,直接新增一个Entry(上文说过,Entry的key是弱引用哦),有当前key,替换掉其value。
但说明一点,与HashMap这种哈希链表存储不同的是,在寻址冲突时,ThreadLocalMap并没有使用链表或红黑树等方式链地址来解决,而是当前地址不可用,就在当前map的数据数组中继续查找下一个可用的地址,有兴趣的可以仔细看下。
兜了一圈,一句话总结这个ThreadLocal的set(T value),就是在当前线程的ThreadLocalMap里存放了数据,key是使用弱引用的ThreadLocal,value就是我们set进去的value
ThreadLocal的获取值等其他方法就不做过多分析了,下面重点分析下开始时抛出的问题一:关于弱引用的问题。
弱引用,在经历一次gc后,不管当前内存是否足够,都会被清除,我们把开始的代码修改一下,在通过ThreadLocal保存数据后,停顿一秒,然后在main线程中触发一次gc,然后在在线程中通过ThreadLocal获取数据,看会不会被清除。为了确认到底有没有发生gc,在启动时我们加入参数
-XX:+PrintGCDetails
public class TestThreadLocal {
static ThreadLocal<String> LOCAL = new ThreadLocal();
public static void main(String[] args) throws InterruptedException {
ExecutorService executorService = Executors.newFixedThreadPool(3);
executorService.execute(() -> {
// 存值
LOCAL.set(Thread.currentThread().getName());
try {
// 停顿一秒,以便先在gc,再get
Thread.sleep(1000l);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 获取值
System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
});
// 线程二
executorService.execute(() -> {
LOCAL.set(Thread.currentThread().getName());
try {
Thread.sleep(1000l);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "-->" +LOCAL.get());
});
// 主线程中触发gc
System.gc();
executorService.shutdown();
}
}
结果如下,如旧成功获取了数据
[GC (System.gc()) [PSYoungGen: 5243K->784K(76288K)] 5243K->792K(251392K), 0.0028957 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]
[Full GC (System.gc()) [PSYoungGen: 784K->0K(76288K)] [ParOldGen: 8K->597K(175104K)] 792K->597K(251392K), [Metaspace: 3724K->3724K(1056768K)], 0.0119867 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
pool-1-thread-1-->pool-1-thread-1
pool-1-thread-2-->pool-1-thread-2
可见ThreadLocal的使用没有受到gc的影响,原因何在?
我们先分析一下里面的引用链,其中实线为强引用,虚线为弱引用
可见,现在的ThreadLocal,是有两条引用链的,一条是当前线程中的,由线程指向ThreadLocalMap,通过Map指向Entry,而Entry指向key;另一条引用链则是当前执行的测试类的成员变量:TestThreadLocal#LOCAL,且为强引用,所以目前来说并不会受到gc影响。
我们再来看下问题二,内存泄露的问题,还是来段代码跑跑再说,这段代码,主要做的就是,分别通过new Thread()和线程池的方式开100个线程,每个线程都向ThreadLocal存入1M大小的对象,为了尽快实验出效果,我们把最大堆内存调小点
-Xmx50m -XX:+PrintGCDetails
public class TestThreadLocalLeak {
final static ThreadLocal<byte[]> LOCAL = new ThreadLocal();
final static int _1M = 1024 * 1024;
public static void main(String[] args) {
//testUseThread();
testUseThreadPool();
}
/**
* 使用线程
*/
private static void testUseThread() {
for (int i = 0; i < 100; i++) {
new Thread(() ->
LOCAL.set(new byte[_1M])
).start();
}
}
/**
* 使用线程池
*/
private static void testUseThreadPool() {
ExecutorService executorService = Executors.newFixedThreadPool(100);
for (int i = 0; i < 100; i++) {
executorService.execute(() ->
LOCAL.set(new byte[_1M])
);
}
executorService.shutdown();
}
}
使用线程打印结果(部分日志)
[GC (Allocation Failure) [PSYoungGen: 13819K->1712K(13824K)] 24099K->11992K(48128K), 0.0007287 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 12586K->1280K(14336K)] 22866K->12181K(48640K), 0.0008377 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 12257K->1120K(14336K)] 23158K->12021K(48640K), 0.0006637 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 12191K->1216K(14336K)] 23093K->12117K(48640K), 0.0010607 secs] [Times: user=0.01 sys=0.00, real=0.00 secs]
使用线程池打印结果(部分日志)
[Full GC (Ergonomics) java.lang.OutOfMemoryError: Java heap space
[PSYoungGen: 12800K->2080K(14848K)] [ParOldGen: 33327K->33322K(34304K)] 46127K->35402K(49152K), [Metaspace: 3770K->3770K(1056768K)], 0.0129146 secs] [Times: user=0.06 sys=0.00, real=0.01 secs]
当调用testUseThread()时,系统在运行时执行了大量YGC,但始终稳定回收,最后正常执行,但是执行testUseThreadPool()时,经历的频繁的Full GC,内存却没有降下去,最终发生了OOM。
我们分析一下,在使用new Thread()的时候,当线程执行完毕时,随着线程的终止,那个这个Thread对象的生命周期也就结束了,此时该线程下的成员变量,ThreadLocalMap是GC Root不可达的,同理,下面的Entry、里面的key、value都会在下一次gc时被回收;而使用线程池后,由于线程执行完一个任务后,不会被回收,而是被放回线程池以便执行后续任务,自然其成员变量ThreadLocalMap不会被回收,最终引起内存泄露直至OOM。至于怎么避免出现内存泄露,就是在使用线程完成任务后,如果保存在ThreadLocalMap中的数据不必留给之后的任务重复使用,就要及时调用ThreadLocal的remove(),这个方法会把ThreadLocalMap中的相关key和value分别置为null,就能在下次GC时回收了。
最后,我们回过头来,再看下问题一中的一个疑问:ThreadLocalMap的Entry的key,为什么使用弱引用?还记得我们说,ThreadLocal是有两条引用链吗?那么我们断掉强引用,看看弱引用的表现吧。
这次来段代码,我们自己debug一下
public class TestThreadLocalLeak {
static ThreadLocal LOCAL = new ThreadLocal();
public static void main(String[] args) {
LOCAL.set("测试ThreadLocalMap弱引用自动回收");
Thread thread = Thread.currentThread();
LOCAL = null;
System.gc();
System.out.println("");
}
}
在gc前和gc后打断点,之前我们分析了,之所以ThreadLocal的数据不会被回收,是因为有两个引用链指向ThreadLocal,一个是当前线程的ThreadLocalMap,另一条就是当前类中的成员变量LOCAL,所以我们手动把LOCAL置为null,再次调用System.gc(),看一下弱引用是不是被回收了
System.gc()前
System.gc()后
可见,执行完gc后,确实回收了弱引用key,但是value并没有被回收,原因当然是他是强引用。
上面例子都是基于自己的理解自己写的demo,如果理解的不到位或错误之处,欢迎大家不吝赐教,谢谢!
2020-12-22更新
关于ThreadLocal使用的讨论
看到有些编码规范上,对使用ThreadLocal有如下要求和建议:
(强制)在代码逻辑中使用完ThreadLocal,都要调用remove方法,及时清理。
(推荐)尽量不要使用全局的ThreadLocal。
关于强制的要求的解读为:目前我们的项目中使用的线程,通常是对线程池化管理的(不管是我们自定义的线程池或是tomcat的线程池等),核心线程数之内的线程都是长期驻留池内的。如果不能及时调用remove,一方面可能造成数据泄露,另一方面有可能让使用了上次未清除的值,导致严重的业务逻辑问题。所以推荐在ThreadLocal使用前后都调用remove清理,同时针对异常情况也要在finally中清理。
关于推荐不使用全局ThreadLocal,假设我们全局使用了ThreadLocal,那么这个引用可能保留给了多个业务使用,当有某业务线程修改了该ThreadLocal引用的实例后,会造成其他业务线程获不到解决等不符合预期的问题。