多线程系列(七) -ThreadLocal 用法及内存泄露分析

打印 上一主题 下一主题

主题 896|帖子 896|积分 2688

一、简介

在 Java web 项目中,想必很多的同学对ThreadLocal这个类并不陌生,它最常用的应用场景就是用来做对象的跨层传递,避免多次传递,打破层次之间的约束。
比如下面这个HttpServletRequest参数传递的简单例子!
  1. public class RequestLocal {
  2.     /**
  3.      * 线程本地变量
  4.      */
  5.     private static ThreadLocal<HttpServletRequest> local = new ThreadLocal<>();
  6.     /**
  7.      * 存储请求对象
  8.      * @param request
  9.      */
  10.     public static void set(HttpServletRequest request){
  11.         local.set(request);
  12.     }
  13.     /**
  14.      * 获取请求对象
  15.      * @return
  16.      */
  17.     public static HttpServletRequest get(){
  18.         return local.get();
  19.     }
  20.     /**
  21.      * 移除请求对象
  22.      * @return
  23.      */
  24.     public static void remove(){
  25.         local.remove();
  26.     }
  27. }
复制代码
  1. public class MyServlet extends HttpServlet {
  2.     @Override
  3.     protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
  4.         // 存储请求对象变量
  5.         RequestLocal.set(req);
  6.         try {
  7.             // 业务逻辑...
  8.         } finally {
  9.             // 请求完毕之后,移除请求对象变量
  10.             RequestLocal.remove();
  11.         }
  12.     }
  13. }
复制代码
  1. // 在需要的地方,通过 RequestLocal 类获取HttpServletRequest对象
  2. HttpServletRequest request = RequestLocal.get();
复制代码
看完以上示例,相信大家对ThreadLocal的使用已经有了大致的认识。
当然ThreadLocal的作用还不仅限如此,作为 Java 多线程模块的一部分,ThreadLocal也经常被一些面试官作为知识点用来提问,因此只有理解透彻了,回答才能更加游刃有余。
下面我们从ThreadLocal类的源码解析到使用方式做一次总结,如果有不正之处,请多多谅解,并欢迎批评指出。
二、源码剖析

ThreadLocal类,也经常被叫做线程本地变量,也有的叫做本地线程变量,意思其实差不多,通俗的解释:ThreadLocal作用是为变量在每个线程中创建一个副本,这样每个线程就可以访问自己内部的副本变量;同时,该变量对其他线程而言是封闭且隔离的。
字面的意思很容易理解,但是实际上ThreadLocal类的实现原理还有点复杂。
打开ThreadLocal类,它总共有 4 个public方法,内容如下!
方法描述public void set(T value)设置当前线程变量public T get()获取当前线程变量public void remove()移除当前线程设置的变量public static ThreadLocal withInitial(Supplier supplier)自定义初始化当前线程的默认值其中使用最多的就是set()、get()和remove()方法,至于withInitial()方法,一般在ThreadLocal对象初始化的时候,给定一个默认值,例如下面这个例子!
  1. // 给所有线程初始化一个变量 1
  2. private static ThreadLocal<Integer> localInt = ThreadLocal.withInitial(() -> 1);
复制代码
下面我们重点来剖析以上三个方法的源码,最后总结如何正确的使用。
以下源码解析均基于jdk1.8。
2.1、set 方法

打开ThreadLocal类,set()方法的源码如下!
  1. public void set(T value) {
  2.     // 首先获取当前线程对象
  3.     Thread t = Thread.currentThread();
  4.     // 获取当前线程中的变量 ThreadLocal.ThreadLocalMap
  5.     ThreadLocalMap map = getMap(t);
  6.     // 如果不为空,就设置值
  7.     if (map != null)
  8.         map.set(this, value);
  9.     else
  10.         // 如果为空,初始化一个ThreadLocalMap变量,其中key为当前的threadlocal变量
  11.         createMap(t, value);
  12. }
复制代码
我们继续看看createMap()方法的源码,内容如下!
  1. void createMap(Thread t, T firstValue) {
  2.     // 初始化一个 ThreadLocalMap 对象,并赋予给 Thread 对象
  3.     // 可以发现,其实 ThreadLocalMap 是 Thread 类的一个属性变量
  4.     t.threadLocals = new ThreadLocalMap(this, firstValue);
  5. }
复制代码
  1. ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  2.     // INITIAL_CAPACITY 变量的初始值为 16
  3.     table = new Entry[INITIAL_CAPACITY];
  4.     int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  5.     table[i] = new Entry(firstKey, firstValue);
  6.     size = 1;
  7.     setThreshold(INITIAL_CAPACITY);
  8. }
复制代码
  1. static class Entry extends WeakReference<ThreadLocal<?>> {
  2.     /** The value associated with this ThreadLocal. */
  3.     Object value;
  4.     Entry(ThreadLocal<?> k, Object v) {
  5.         super(k);
  6.         value = v;
  7.     }
  8. }
复制代码
从上面的源码上你会发现,通过ThreadLocal类设置的变量,最终保存在每个线程自己的ThreadLocal.ThreadLocalMap对象中,其中key是当前线程的ThreadLocal变量,value就是我们设置的变量。
基于这点,可以得出一个结论:每个线程设置的变量只有自己可见,其它线程无法访问,因为这个变量是线程自己独有的属性
从上面的源码也可以看出,真正负责存储value变量的是Entry静态类,并且这个类继承了一个WeakReference类。稍有不同的是,Entry静态类中的key是一个弱引用类型对象,而value是一个强引用类型对象。这样设计的好处在于,弱引用的对象更容易被 GC 回收,当ThreadLocal对象不再被其他对象使用时,可以被垃圾回收器自动回收,避免可能的内存泄漏。关于这一点,我们在下文再详细的介绍。
最后我们再来看看map.set(this, value)这个方法的源码逻辑,内容如下!
  1. private void set(ThreadLocal<?> key, Object value) {
  2.     Entry[] tab = table;
  3.     int len = tab.length;
  4.     // 根据hash和位运算,计算出数组中的存储位置
  5.     int i = key.threadLocalHashCode & (len-1);
  6.     // 循环遍历检查计算出来的位置上是否被占用
  7.     for (Entry e = tab[i];
  8.          e != null;
  9.          e = tab[i = nextIndex(i, len)]) {
  10.         // 进入循环体内,说明当前位置已经被占用了
  11.         ThreadLocal<?> k = e.get();
  12.         // 如果key相同,直接进行覆盖
  13.         if (k == key) {
  14.             e.value = value;
  15.             return;
  16.         }
  17.         // 如果key为空,说明key被回收了,重新覆盖
  18.         if (k == null) {
  19.             replaceStaleEntry(key, value, i);
  20.             return;
  21.         }
  22.     }
  23.     // 当没有被占用,循环结束之后,取最后计算的空位,进行存储
  24.     tab[i] = new Entry(key, value);
  25.     int sz = ++size;
  26.     if (!cleanSomeSlots(i, sz) && sz >= threshold)
  27.         rehash();
  28. }
复制代码
  1. private static int nextIndex(int i, int len) {
  2.     // 下标依次自增
  3.     return ((i + 1 < len) ? i + 1 : 0);
  4. }
复制代码
从上面的源码分析可以看出,ThreadLocalMap和HashMap,虽然都是键值对的方式存储数据,当在数组中存储数据的下表冲突时,存储数据的方式有很大的不同。jdk1.8种的HashMap采用的是链表法和红黑树来解决下表冲突,当
ThreadLocalMap采用的是开放寻址法来解决hash冲突,简单的说就是当hash出来的存储位置相同但key不一样时,会继续寻找下一个存储位置,直到找到空位来存储数据。

而jdk1.7中的HashMap采用的是链表法来解决hash冲突,当hash出来的存储位置相同但key不一样时,会将变量通过链表的方式挂在数组节点上。

为了实现更高的读写效率,jdk1.8中的HashMap就更为复杂了,当冲突的链表长度超过 8 时,链表会转变成红黑树,在此不做过多的讲解,有兴趣的同学可以翻看关于HashMap的源码分析文章。
一路分析下来,是不是感觉set()方法还是挺复杂的,总结下来set()大致的逻辑有以下几个步骤:

  • 1.首先获取当前线程对象,检查当前线程中的ThreadLocalMap是否存在
  • 2.如果不存在,就给线程创建一个ThreadLocal.ThreadLocalMap对象
  • 3.如果存在,就设置值,存储过程中如果存在 hash 冲突时,采用开放寻址法,重新找一个空位进行存储
2.2、get 方法

了解完set()方法之后,get()方法就更容易了,get()方法的源码如下!
  1. public T get() {
  2.     // 获取当前线程对象
  3.     Thread t = Thread.currentThread();
  4.     // 从当前线程对象中获取 ThreadLocalMap 对象
  5.     ThreadLocalMap map = getMap(t);
  6.     if (map != null) {
  7.         // 如果有值,就返回
  8.         ThreadLocalMap.Entry e = map.getEntry(this);
  9.         if (e != null) {
  10.             @SuppressWarnings("unchecked")
  11.             T result = (T)e.value;
  12.             return result;
  13.         }
  14.     }
  15.     // 如果没有值,重新初始化默认值
  16.     return setInitialValue();
  17. }
复制代码
这里我们要重点看下 map.getEntry(this)这个方法,源码如下!
  1. private Entry getEntry(ThreadLocal<?> key) {
  2.     int i = key.threadLocalHashCode & (table.length - 1);
  3.     Entry e = table[i];
  4.     // 如果找到key,直接返回
  5.     if (e != null && e.get() == key)
  6.         return e;
  7.     else
  8.         // 如果找不到,就尝试清理,如果你总是访问存在的key,那么这个清理永远不会进来
  9.         return getEntryAfterMiss(key, i, e);
  10. }
复制代码
  1. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  2.     Entry[] tab = table;
  3.     int len = tab.length;
  4.     while (e != null) {
  5.         // e指的是entry ,也就是一个弱引用
  6.         ThreadLocal<?> k = e.get();
  7.         // 如果找到了,就返回
  8.         if (k == key)
  9.             return e;
  10.         if (k == null)
  11.             // 如果key为null,说明已经被回收了,同时将value设置为null,以便进行回收
  12.             expungeStaleEntry(i);
  13.         else
  14.             // 如果key不是要找的那个,那说明有hash冲突,继续找下一个entry
  15.             i = nextIndex(i, len);
  16.         e = tab[i];
  17.     }
  18.     return null;
  19. }
复制代码
从上面的源码可以看出,get()方法逻辑,总共有以下几个步骤:

  • 1.首先获取当前线程对象,从当前线程对象中获取 ThreadLocalMap 对象
  • 2.然后判断ThreadLocalMap是否存在,如果存在,就尝试去获取最终的value
  • 3.如果不存在,就重新初始化默认值,以便清理旧的value值
其中expungeStaleEntry()方法是真正用于清理value值的,setInitialValue()方法也具备清理旧的value变量作用。
从上面的代码可以看出,ThreadLocal为了清楚value变量,花了不少的心思,其实本质都是为了防止ThreadLocal出现可能的内存泄漏。
2.3、remove 方法

我们再来看看remove()方法,源码如下!
  1. public void remove() {
  2.     // 获取当前线程里面的 ThreadLocalMap 对象
  3.     ThreadLocalMap m = getMap(Thread.currentThread());
  4.     if (m != null)
  5.         // 如果不为空,就移除
  6.         m.remove(this);
  7. }
复制代码
  1. private void remove(ThreadLocal<?> key) {
  2.     Entry[] tab = table;
  3.     int len = tab.length;
  4.     int i = key.threadLocalHashCode & (len-1);
  5.     // 循环遍历目标key,然后将key和value都设置为null
  6.     for (Entry e = tab[i];
  7.          e != null;
  8.          e = tab[i = nextIndex(i, len)]) {
  9.         if (e.get() == key) {
  10.             e.clear();
  11.             // 清理value值
  12.             expungeStaleEntry(i);
  13.             return;
  14.         }
  15.     }
  16. }
复制代码
remove()方法逻辑比较简单,首先获取当前线程的ThreadLocalMap对象,然后循环遍历key,将目标key以及对应的value都设置为null。
从以上的源码剖析中,可以得出一个结论:不管是set()、get()还是remove(),其实都会主动清理无效的value数据,因此实际开发过程中,没有必要过于担心内存泄漏的问题。
三、为什么要用 WeakReference?

另外细心的同学可能会发现,ThreadLocal中真正负责存储key和value变量的是Entry静态类,并且它继承了一个WeakReference类。
  1. static class Entry extends WeakReference<ThreadLocal<?>> {
  2.     /** The value associated with this ThreadLocal. */
  3.     Object value;
  4.     Entry(ThreadLocal<?> k, Object v) {
  5.         super(k);
  6.         value = v;
  7.     }
  8. }
复制代码
关于WeakReference类,我们在上文只是简单的说了一下,可能有的同学不太清楚,这个再次简要的介绍一下。
了解过WeakHashMap类的同学,可能对WeakReference有印象,它表示当前对象为弱引用类型
在 Java 中,对象有四种引用类型,分别是:强引用、软引用、弱引用和虚引用,级别从高依次到低。
不同引用类型的对象,GC 回收的方式也不一样,对于强引用类型,不会被垃圾收集器回收,即使当内存不足时,另可抛异常也不会主动回收,防止程序出现异常,通常我们自定义的类,初始化的对象都是强引用类型;对于软引用类型的对象,当不存在外部强引用的时候,GC 会在内存不足的时候,进行回收;对于弱引用类型的对象,当不存在外部强引用的时候,GC 扫描到时会进行回收;对于虚引用,GC 会在任何时候都可能进行回收。
下面我们看一个简单的示例,更容易直观的了解它。
  1. public static void main(String[] args) {
  2.         Map weakHashMap = new WeakHashMap();
  3.         //向weakHashMap中添加4个元素
  4.         for (int i = 0; i < 3; i++) {
  5.             weakHashMap.put("key-"+i, "value-"+ i);
  6.         }
  7.         //输出添加的元素
  8.         System.out.println("数组长度:"+weakHashMap.size() + ",输出结果:" + weakHashMap);
  9.         //主动触发一次GC
  10.         System.gc();
  11.         //再输出添加的元素
  12.         System.out.println("数组长度:"+weakHashMap.size() + ",输出结果:" + weakHashMap);
  13.     }
复制代码
输出结果:
  1. 数组长度:3,输出结果:{key-2=value-2, key-1=value-1, key-0=value-0}
  2. 数组长度:3,输出结果:{}
复制代码
以上存储的弱引用对象,与外部对象没有强关联,当主动调用 GC 回收器的时候,再次查询WeakHashMap里面的数据的时候,弱引用对象收回,所以内容为空。其中WeakHashMap类底层使用的数据存储对象,也是继承了WeakReference。
采用WeakReference这种弱引用的方式,当不存在外部强引用的时候,就会被垃圾收集器自动回收掉,减小内存空间压力。
需要注意的是,Entry静态类中仅仅只是key被设计成弱引用类型,value依然是强引用类型。
回归正题,为什么ThreadLocalMap类中的Entry静态类中的key需要被设计成弱引用类型?
我们先看一张Entry对象的依赖图!

如上图所示,Entry持有ThreadLocal对象的引用,如果没有设置引用类型,这个引用链就全是强引用,当线程没有结束时,它持有的强引用,包括递归下去的所有强引用都不会被垃圾回收器回收;只有当线程生命周期结束时,才会被回收。
哪怕显式的设置threadLocal = null,它也无法被垃圾收集器回收,因为Entry和key存在强关联!
如果Entry中的key设置成弱引用,当threadLocal = null时,key就可以被垃圾收集器回收,进一步减少内存使用空间。
但是也仅仅只是回收key,不能回收value,如果这个线程运行时间非常长,又没有调用set()、get()或者remove()方法,随着线程数的增多可能会有内存溢出的风险。
因此在实际的使用中,想要彻底回收value,使用完之后可以显式调用一下remove()方法。
四、应用介绍

通过以上的源码分析,相信大家对ThreadLocal类已经有了一些认识,它主要的作用是在线程内实现变量的传递,每个线程只能看到自己设定的变量。
我们可以看一个简单的示例!
  1. public static void main(String[] args) {
  2.     ThreadLocal threadLocal = new ThreadLocal();
  3.     threadLocal.set("main");
  4.     for (int i = 0; i < 5; i++) {
  5.         final int j = i;
  6.         new Thread(new Runnable() {
  7.             @Override
  8.             public void run() {
  9.                 // 设置变量
  10.                 threadLocal.set(String.valueOf(j));
  11.                 // 获取设置的变量
  12.                 System.out.println("thread name:" + Thread.currentThread().getName() + ", 内容:" + threadLocal.get());
  13.             }
  14.         }).start();
  15.     }
  16.    
  17.     System.out.println("thread name:" + Thread.currentThread().getName() + ", 内容:" + threadLocal.get());
  18. }
复制代码
输出结果:
  1. thread name:Thread-0, 内容:0
  2. thread name:Thread-1, 内容:1
  3. thread name:Thread-2, 内容:2
  4. thread name:Thread-3, 内容:3
  5. thread name:main, 内容:main
  6. thread name:Thread-4, 内容:4
复制代码
从运行结果上可以很清晰的看出,每个线程只能看到自己设置的变量,其它线程不可见。
ThreadLocal可以实现线程之间的数据隔离,在实际的业务开发中,使用非常广泛,例如文章开头介绍的HttpServletRequest参数的上下文传递。
五、小结

最后我们来总结一下,ThreadLocal类经常被叫做线程本地变量,它确保每个线程的ThreadLocal变量都是各自独立的,其它线程无法访问,实现线程之间数据隔离的效果。
ThreadLocal适合在一个线程的处理流程中实现参数上下文的传递,避免同一个参数在所有的方法中传递。
使用ThreadLocal时,如果当前线程中的变量已经使用完毕并且永久不在使用,推荐手动调用移除remove()方法,可以采用try ... finally结构,并在finally中清除变量,防止存在潜在的内存溢出风险。
六、参考

1、https://www.cnblogs.com/xrq730/p/4854813.html
2、https://www.cnblogs.com/xrq730/p/4854820.html
3、https://zhuanlan.zhihu.com/p/102744180

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

盛世宏图

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表