探究-ThreadLocal内存泄露问题
探究这个问题之前,先来说说ThreadLocal是什么东西?
ThreadLocal 翻译过来就是线程本地。它存储的内容只在当前线程可见,其他线程则访问不到。
下面看一段代码方便理解:
代码解读: 1. 定义成员变量ThreadLocal 2. 定义成员变量User对象 3. 定义线程1将User对象set进ThreadLocal中 线程1 通过get获取对象并打印 4. 定义线程2从ThreadLocal中get获取对象
看了上面的控制台输出:线程1可以get到,线程2无法get到。结果会不会和你想的不一样呢?
既然ThreadLocal可以放进对象,是否能把它比作一个容器呢?
但拿我们平时常用的List来看的话,线程1对List存值,线程2自然可以get到,因为List是个容器嘛。可为什么ThreadLocal不行呢?
带着这些疑问,脑海里不禁有一个初步判断:无论哪一个线程对ThreadLocal存值,那么这个值仅限于当前线程使用~
可同一个ThreadLocal这个对象怎么做到不同线程存值,对数据进行隔离呢?让我们点开set方法看看究竟!
public void set(T value) { // 获取正在被执行的线程信息 Thread t = Thread.currentThread(); // 通过获取的线程信息进行getMap操作,返回值是ThreadLocalMap ThreadLocalMap map = getMap(t); if (map != null) // 如果获取的ThreadLocalMap 不为null,则直接进行存值 map.set(this, value); else // 反之则调用了创建Map的方法 createMap(t, value); }
看了上面的源码,是不是能理解一点~ 原来ThreadLocal的set方法 只是往一个Map里存入key-value
让我们点进getMap方法看看做了什么:
没想到啊...getMap方法只是返回Thread的一个成员变量threadLocals 继续跟进,看看这threadLocals究竟是什么东东
点进来以后,原来threadLocals 只是Thread类的一个成员变量,类型是ThreadLcalMap 。
看到这里是不是就解答了上面的疑问:
为什么同一个ThreadLocal对象 不同线程存入的值,只在当前线程可见?
原因很显而易见了,每次ThreadLocal的set方法 先获取当前执行的线程信息,通过这个线程信息获取到当前执行的线程自己的成员变量ThreadLcalMap ,然后把值存进去。那么别的线程肯定就不能访问另外一个线程自己的Map了。 如图所示~
ThreadLocal有什么用呢?
了解Spring的小伙伴应该知道Spring有一个事务注解 @Transactional,当我们在方法上加上这个注解以后,无论进行几次增删改,都是带事务的对数据库进行操作。那么它是怎么做到的呢?
大家都应该知道,当我们的程序每一次对数据库操作时都是一个新的数据库连接,如果数据库连接都不同的话,那事务就不复存在了。说白了,只要保证增删改 都是使用的一个数据库连接,那就可以保证事务。说到这里大家是不是好像理解了什么~
没错!Spring的 @Transactional 内部就是使用了ThreadLocal 保存的数据库连接,而ThreadLocal保存的东西只有在当前线程可见,所以单个服务进行无论进行多少次增删改,实际上都是去ThreadLocal使用get方法获取同一个数据库连接进行操作,那么事务也就实现了~
简单介绍了ThreadLocal是什么以后,现在来探究ThreadLocal的内存泄漏问题
ps: ThreadLocal的内存泄漏问题,需要有JAVA引用类型的基础,如果不太清楚的小伙伴可以先看下我的这篇帖子~
现在进入正题
JAVA的引用类型有一个弱引用,而ThreadLocal内部就是使用的弱引用。
接下来跟进一下源码看看ThreadLocal的set方法:
可以看到调用set 往ThreadLocal中存值时,也就是map.set(this, value); 这一段。value是需要存入的值,而key却是this,这个this指代当前类,也就是当前的ThreadLocal。
所以就说明,如果要往一个线程的ThreadLocalMap 要存入多个键值对,一个键值对 = 一个ThreadLocal ,看如下代码:
public class ThreadLocal_0 { // 第一个ThreadLocal对象 static ThreadLocal<User> tl = new ThreadLocal<>(); // 第二个ThreadLocal对象 static ThreadLocal<String[]> tl2 = new ThreadLocal<>(); // 提前定义好的一个User对象 static User u = new User(); public static void main(String[] args) { Thread t1 = new Thread(() -> { tl.set(u); tl2.set(new String[]{"hello,world!"}); }); } }
开头我说ThreadLocal内部就是使用的弱引用,实际上就是ThreadLocal的set方法存值时的key 使用弱引用指向当前的ThreadLocal。继续看map.set()的源码:
private void set(ThreadLocal<?> key, Object value) { // We dont 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(); }
既然是属于Map,那么Entry是必不可少的,看源码也是有Entry的对象,Entry则是键值对的形式。那么让我们继续跟进Entry查看:
些许有点拨开迷雾的感觉... ,ThreadLocalMap的键值对Entry对象继承了 WeakReference 弱引用。而第二个红框圈出的调用super方法,把k传了进去,实则就是使用弱引用指向堆内存的一个ThreadLocal对象当作map的键。
看下图方便理解:
为什么要这么设计呢? 不妨这样思考,假设key也用强引用指向当前ThreadLocal的话,那么如果我这时候写 t1 = null ,按理说下次GC时,应该要把堆内存的new ThreadLocal() 这个对象进行回收才对,但此时我的key如果设计成强引用,显然GC无法对它进行回收,因为key还强引用指向它。这就会造成内存泄漏,所以ThreadLocal存值时,key采用弱引用。 key使用弱引用的特点就很明显了(只要是GC回收,不管内存够不够,都会回收弱引用指向的对象),当我写 t1 = null , 下次GC回收时,就可以将new ThreadLocal() 这个对象会被回收掉。 内存泄漏不单单只是上面的问题,上面所述的内存泄漏问题不需要关心,因为源码已经设计好并解决了。 现在继续思考:既然key是虚引用指向,当写 t1 = null ,下次进行GC回收时,这个堆中的ThreadLocal对象会被回收掉,那么key的引用就没有了,key会变成null,那么值的内容还存在着,就会产生内存泄漏!,如下图:
这时候你是不是在想,这怎么会造成内存泄漏呢,哪怕value指向的内容还存在 并且无法被回收,但只要线程结束,那么所有跟此线程相关的所有东西都会被回收掉了啊,为什么还会存在内存泄漏呢? 是的,这样想是没问题,线程结束以后,跟它相关的一切确实就不存在了。 但不妨思考一个问题,很多时候为了节省资源,我们都会用线程池来管理线程,大家知道线程池的特点,当一个线程使用结束后,会回到线程池等待下次使用。它并不会被回收掉,所以这时候线程使用的多了,每次使用都会造成value的内存泄漏,久而久之会引发OOM!这是个很严重的问题~ 解决方案是什么呢,其实很简单!当我们使用完ThreadLocal以后 一定要调用它的remove()方法,它会删除与当前ThreadLocal对应的键值对,从而解决了内存泄漏的可能。