ThreadLocal一探到底

前言

Thread-local Storage,缩写TLS,中文翻译为线程本地存储。是多线程编程中一种常用的设计模式,在许多编程语言中都有对应实现,具体可以看这个wiki。本篇文章笔者聚焦Java中的实现——ThreadLocal,争取一篇文章将源码、坑点和最佳实践讲明白。

基本介绍

image-20210630173918181

API

ThreadLocal暴露的API非常精简,只有两个初始化方法和三个操作方法,分别是:

  • get 获取ThreadLocal的值
  • set 赋值/修改ThreadLocal的值
  • remove 清空ThreadLocal的值

数据结构

通过ThreadLocal对象,可以对线程私有对象(Thread Specific Object,简称TSO)进行操作。这个TSO本身是存储在当前线程中的,Thread类中持有这样一个Map字段:

image-20210630175043241

也就是说,每个线程都会有一个自己的ThreadLocalMap字段,用来存储TSO,那这个ThreadLocalMap中的键值对是谁呢?

image-20210630175549664

从这可以看出来,ThreadLocalMap的键是ThreadLocal对象,值是TSO。这里有点绕,实际上ThreadLocal只是一个代理,让程序员可以方便的操作线程私有变量。如果不使用这种代理的话,我们获取TSO的伪代码是这样:

Thread.currentThread()//获取当前线程
  .getThreadLocalMap()//每个线程内都持有一个线程私有的ThreadLocalMap,对应源码java.lang.ThreadLocal.ThreadLocalMap
  .getEntry(threadLocal)//Entry的key是threadLocal对象
  .getValue();//返回TSO

而通过代理的话:

threadLocal.get();

可以看出来,代码简化了很多,对程序员的使用是非常友好的。

数据结构图:

ThreadLocal引用关系图

分析ThreadLocal的设计

假设ThreadLocalMap的Key是强引用

这里有一张ThreadLocal的内存占用图:15473735704801.jpg
我们看到ThreadLocalMap的key指向ThreadLocal对象,是一条虚线,虚线标识这个引用是一个弱引用Weak Reference,弱引用的大意是:当JVM发生GC时,不管内存空间是否足够,都会将只有弱引用的对象回收掉。那为什么ThreadLocal要采用弱引用的设计呢,我们先假设使用强引用的话。

从上文我们知道,ThreadLocal对象是Java暴露给开发者的获取TSO的代理入口,如果ThreadLocal对象在程序中被GC回收掉的话,那么ThreadLocalMap、TSO对象都失去了存在的意义,因为获取这些对象的钥匙🔑——ThreadLocal已经不存在了。那么什么情况下ThreadLocal会被回收呢?看下面这段代码:

image-20210630214755103

t1()方法中声明了一个线程局部变量local,这个变量本身是个在方法中声明的局部变量。我们知道局部变量对堆中对象的引用在方法弹栈的时候就会断掉,从而让堆中的对象失去GC ROOTs而可以被垃圾回收。所以这个local变量在t1()方法执行完,弹栈之后,它引用的ThreadLocal对象就会被垃圾回收。如果ThreadLocalMap持有的是强引用的ThreadLocal,那么即使t1()方法执行完,也仍然存在强引用链:Thread -> ThreadLocalMap -> ThreadLocal

我们知道ThreadLocalMap是与Thread一一绑定的,声明周期与Thread相同,在我们这个Demo中,Thread对象随着test12()方法执行完,主线程结束,Thread对象也被回收了,那么相安无事,ThreadLocal也会被回收;

但是如果Thread对象生命周期很长呢?

现在很多池化的技术都会将线程存起来减少创建和销毁的开销,比如数据库连接池等等。这种技术中,线程的生命周期会非常长,那么对应的ThreadLocalMap和强引用的ThreadLocal就会一直存在。但是ThreadLocal又没有人会用到它,这就导致了ThreadLocal对象的内存泄露

JDK的源码

image-20210630212321302

查看java.lang.ThreadLocal.ThreadLocalMap的JavaDoc,可以见到这样一句话:为了帮助处理非常大和长期存在的用法,哈希表Entry使用 WeakReferences 作为键。 但是,由于不使用引用队列,因此只有在哈希表开始耗尽空间时才能保证删除陈旧条目。

注释中提到,为了解决大对象和长期存在的线程的问题,ThreadLocalMap的key使用了弱引用。

使用了弱引用之后,在只有Thread本身持有TheadLocal对象引用的情况下,ThreadLocal对象被成功回收掉了:

image-20210630214950183

TSO怎么办?

“长期存在的线程”我们已经上文已经理解了,那“大对象”又是什么意思呢?

在上文中,我们通过弱引用,将ThreadLocalMap的key指向的ThreadLocal对象回收掉了,这样ThreadLocalMap中就会存在一些key=null,value=TSO的Entry键值对。

在执行ThreadLocal.set(T)方法时,会出发扫描机制:将ThreadLocalMap中key为null的Entry删除,释放TSO对象的内存空间。

假设key没有使用弱引用的话,这些不再被使用的TSO大对象也无从处理。不得不说JDK的源码还是颇为精妙的。

声明ThreadLocal变量

声明为局部变量?

ThreadLocal的使用场景:在单个线程的生命周期中,依附于线程,纵向传播TSO信息。

那么如果声明一个ThreadLocal变量为某个方法的局部变量的话,就没有使用价值了,因为其他方法取不到这个ThreadLocal局部变量,也就获取不了TSO。

声明为对象的域field?

由于一个具有唯一用途的ThreadLocal对象在线程生命周期内具有唯一性,如果声明为对象的域field,那么每次创建这个包含threadLocal属性的对象,都会创建一个新的ThreadLocal对象出来,这样违背了具有唯一用途的ThreadLocal与Thread对象的一一对应的规则。

第一容易导致错误,第二会创建多个无用的对象占用内存。

声明为类的静态变量?

声明为静态变量可以保证在JVM中只有唯一的一份ThreadLocal对象。推荐这样使用。

JavaDoc推荐:

image-20210701142802327

【参考】ThreadLocal 对象使用 static 修饰,ThreadLocal 无法解决共享对象的更新问题。

说明:这个变量是针对一个线程内所有操作共享的,所以设置为静态变量,所有此类实例共享此静态变 量,也就是说在类第一次被使用时装载,只分配一块存储空间,所有此类的对象(只要是这个线程内定义 的)都可以操控这个变量。

——《阿里巴巴Java开发手册》

但是注意一点:

类的静态变量与类Class的生命周期一致,我们知道由JVM声明的三个类加载器加载出来的类,在JVM运行期间,都是无法卸载的。所以会导致ThreadLocal也一直无法卸载。那么JDK中ThreadLocalMap精心设计的弱引用键就没用了,这意味着ThreadLocal将一直无法回收,key无法回收,那么value对应的TSO也就永远无法回收!

ThreadLocal的内存泄露问题

什么是内存泄露

ThreadLocal在使用上非常简单,但是又非常容易出现内存泄露的问题,对于这种工具,我们需要理解他的原理,才能掌握好使用边界,不至于出现大问题。

先来看内存泄露的定义,wiki:由于疏忽或错误造成程序未能释放已经不再使用的内存。也就是说,泄露的内存在程序中再也无法访问,从而造成内存浪费。

Tomcat自定义类加载器导致的内存泄露

可以看这个回答:将ThreadLocal变量设置为private static的好处是啥? - Viscent大千的回答 - 知乎

开发中如何避免内存泄露

由于日常开发中,ThreadLocal将使用static修饰,所以ThreadLocalMap自带的弱引用机制基本无效了,所以要靠开发人员手动调用ThreadLocal.remove()方法来讲TSO的内存释放,否则如果TSO对象本身是大对象,比如List或Map等容器;再使用池化等技术延长线程的生命周期。久而久之,必然会导致TSO引起的内存溢出。

posted @ 2021-07-01 14:39  孔令翰  阅读(123)  评论(0编辑  收藏  举报