硬核剖析ThreadLocal源码,面试官看了直呼内行

打印 上一主题 下一主题

主题 873|帖子 873|积分 2629

工作面试中经常遇到ThreadLocal,但是很多同学并不了解ThreadLocal实现原理,到底为什么会发生内存泄漏也是一知半解?今天一灯带你深入剖析ThreadLocal源码,总结ThreadLocal使用规范,解析ThreadLocal高频面试题。
1. ThreadLocal是什么

ThreadLocal是线程本地变量,就是线程的私有变量,不同线程之间相互隔离,无法共享,相当于每个线程拷贝了一份变量的副本。
目的就是在多线程环境中,无需加锁,也能保证数据的安全性。
2. ThreadLocal的使用
  1. /**
  2. * @author 一灯架构
  3. * @apiNote ThreadLocal示例
  4. **/
  5. public class ThreadLocalDemo {
  6.     // 1. 创建ThreadLocal
  7.     static ThreadLocal<String> threadLocal = new ThreadLocal<>();
  8.     public static void main(String[] args) {
  9.         // 2. 给ThreadLocal赋值
  10.         threadLocal.set("关注公众号:一灯架构");
  11.         // 3. 从ThreadLocal中取值
  12.         String result = threadLocal.get();
  13.         System.out.println(result); // 输出 关注公众号:一灯架构
  14.         
  15.         // 4. 删除ThreadLocal中的数据
  16.         threadLocal.remove();
  17.         System.out.println(threadLocal.get()); // 输出null
  18.     }
  19. }
复制代码
ThreadLocal的用法非常简单,创建ThreadLocal的时候指定泛型类型,然后就是赋值、取值、删除值的操作。
不同线程之间,ThreadLocal数据是隔离的,测试一下:
  1. /**
  2. * @author 一灯架构
  3. * @apiNote ThreadLocal示例
  4. **/
  5. public class ThreadLocalDemo {
  6.     // 1. 创建ThreadLocal
  7.     static ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
  8.     public static void main(String[] args) {
  9.         IntStream.range(0, 5).forEach(i -> {
  10.                   // 创建5个线程,分别给threadLocal赋值、取值
  11.             new Thread(() -> {
  12.                 // 2. 给ThreadLocal赋值
  13.                 threadLocal.set(i);
  14.                 // 3. 从ThreadLocal中取值
  15.                 System.out.println(Thread.currentThread().getName()
  16.                         + "," + threadLocal.get());
  17.             }).start();
  18.         });
  19.     }
  20. }
复制代码
输出结果:
  1. Thread-2,2
  2. Thread-4,4
  3. Thread-1,1
  4. Thread-0,0
  5. Thread-3,3
复制代码
可以看出不同线程之间的ThreadLocal数据相互隔离,互不影响,这样的实现效果有哪些应用场景呢?
3. ThreadLocal应用场景

ThreadLocal的应用场景主要分为两类:

  • 避免对象在方法之间层层传递,打破层次间约束。
    比如用户信息,在很多地方都需要用到,层层往下传递,比较麻烦。这时候就可以把用户信息放到ThreadLocal中,需要的地方可以直接使用。
  • 拷贝对象副本,减少初始化操作,并保证数据安全。
    比如数据库连接、Spring事务管理、SimpleDataFormat格式化日期,都是使用的ThreadLocal,即避免每个线程都初始化一个对象,又保证了多线程下的数据安全。
使用ThreadLocal保证SimpleDataFormat格式化日期的线程安全,代码类似下面这样:
  1. /**
  2. * @author 一灯架构
  3. * @apiNote ThreadLocal示例
  4. **/
  5. public class ThreadLocalDemo {
  6.     // 1. 创建ThreadLocal
  7.     static ThreadLocal<SimpleDateFormat> threadLocal =
  8.             ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
  9.     public static void main(String[] args) {
  10.         IntStream.range(0, 5).forEach(i -> {
  11.             // 创建5个线程,分别从threadLocal取出SimpleDateFormat,然后格式化日期
  12.             new Thread(() -> {
  13.                 try {
  14.                     System.out.println(threadLocal.get().parse("2022-11-11 00:00:00"));
  15.                 } catch (ParseException e) {
  16.                     throw new RuntimeException(e);
  17.                 }
  18.             }).start();
  19.         });
  20.     }
  21. }
复制代码
4. ThreadLocal实现原理

ThreadLocal底层使用ThreadLocalMap存储数据,而ThreadLocalMap内部是一个数组,数组里面存储的是Entry对象,Entry对象里面使用key-value存储数据,key是ThreadLocal实例对象本身,value是ThreadLocal的泛型对象值。

4.1 ThreadLocalMap源码
  1. static class ThreadLocalMap {
  2.     // Entry对象,WeakReference是弱引用,当没有引用指向时,会被GC回收
  3.     static class Entry extends WeakReference<ThreadLocal<?>> {
  4.         // ThreadLocal泛型对象值
  5.         Object value;
  6.         // 构造方法,传参是key-value
  7.         // key是ThreadLocal对象实例,value是ThreadLocal泛型对象值
  8.         Entry(ThreadLocal<?> k, Object v) {
  9.             super(k);
  10.             value = v;
  11.         }
  12.     }
  13.   
  14.     // Entry数组,用来存储ThreadLocal数据
  15.     private Entry[] table;
  16.     // 数组的默认容量大小
  17.     private static final int INITIAL_CAPACITY = 16;
  18.     // 扩容的阈值,默认是数组大小的三分之二
  19.     private int threshold;
  20.     private void setThreshold(int len) {
  21.         threshold = len * 2 / 3;
  22.     }
  23. }
复制代码
set方法具体流程如下:

从源码和流程图中得知,ThreadLocal是通过线性探测法解决哈希冲突的,线性探测法具体赋值流程如下:

  • 通过key的hashcode找到数组下标
  • 如果数组下标位置是空或者等于当前ThreadLocal对象,直接覆盖值结束
  • 如果不是空,就继续向下遍历,遍历到数组结尾后,再从头开始遍历,直到找到数组为空的位置,在此位置赋值结束
线性探测法这种特殊的赋值流程,导致取值的时候,也要走一遍类似的流程。
4.3 get方法源码
  1. // 给ThreadLocal设值
  2. public void set(T value) {
  3.     // 获取当前线程对象
  4.     Thread t = Thread.currentThread();
  5.     // 获取此线程对象中的ThreadLocalMap对象
  6.     ThreadLocalMap map = getMap(t);
  7.     // 如果ThreadLocal已经设过值,直接设值,否则初始化
  8.     if (map != null)
  9.         // 设值的key就是当前ThreadLocal对象实例,value是ThreadLocal泛型对象值
  10.         map.set(this, value);
  11.     else
  12.         // 初始化ThreadLocalMap
  13.         createMap(t, value);
  14. }
复制代码
再看一下具体的遍历Entry数组的逻辑:
  1. // key就是当前ThreadLocal对象实例,value是ThreadLocal泛型对象值
  2. private void set(ThreadLocal<?> key, Object value) {
  3.     // 获取ThreadLocalMap中的Entry数组
  4.     Entry[] tab = table;
  5.     int len = tab.length;
  6.     // 计算key在数组中的下标,也就是ThreadLocal的hashCode和数组大小-1取余
  7.     int i = key.threadLocalHashCode & (len - 1);
  8.     // 查找流程:从下标i开始,判断下标位置是否有值,
  9.     // 如果有值判断是否等于当前ThreadLocal对象实例,等于就覆盖,否则继续向后遍历数组,直到找到空位置
  10.     for (Entry e = tab[i];
  11.          e != null;
  12.         // nextIndex 就是让在不超过数组长度的基础上,把数组的索引位置 + 1
  13.          e = tab[i = nextIndex(i, len)]) {
  14.         ThreadLocal<?> k = e.get();
  15.         // 如果等于当前ThreadLocal对象实例,直接覆盖
  16.         if (k == key) {
  17.             e.value = value;
  18.             return;
  19.         }
  20.         // 当前key是null,说明ThreadLocal对象实例已经被GC回收了,直接覆盖
  21.         if (k == null) {
  22.             replaceStaleEntry(key, value, i);
  23.             return;
  24.         }
  25.     }
  26.     // 找到空位置,创建Entry对象
  27.     tab[i] = new Entry(key, value);
  28.     int sz = ++size;
  29.     // 当数组大小大于等于扩容阈值(数组大小的三分之二)时,进行扩容
  30.     if (!cleanSomeSlots(i, sz) && sz >= threshold)
  31.         rehash();
  32. }
复制代码
再看一下线性探测法特殊的取值方法:
  1. // 从ThreadLocal从取值
  2. public T get() {
  3.     // 获取当前线程对象
  4.     Thread t = Thread.currentThread();
  5.     // 获取此线程对象中的ThreadLocalMap对象
  6.     ThreadLocalMap map = getMap(t);
  7.     if (map != null) {
  8.         // 通过ThreadLocal实例对象作为key,在Entry数组中查找数据
  9.         ThreadLocalMap.Entry e = map.getEntry(this);
  10.         // 如果不为空,表示找到了,直接返回
  11.         if (e != null) {
  12.             T result = (T)e.value;
  13.             return result;
  14.         }
  15.     }
  16.     // 如果ThreadLocalMap是null,就执行初始化ThreadLocalMap操作
  17.     return setInitialValue();
  18. }
复制代码
ThreadLocal的get方法流程如下:

4.4 remove方法源码

remove方法流程跟set、get方法类似,都是遍历数组,找到ThreadLocal实例对象后,删除key、value,再删除Entry对象结束。
  1. // 具体的遍历Entry数组的方法
  2. private Entry getEntry(ThreadLocal<?> key) {
  3.     // 通过hashcode计算数组下标位置
  4.     int i = key.threadLocalHashCode & (table.length - 1);
  5.     Entry e = table[i];
  6.     // 如果下标位置对象不为空,并且等于当前ThreadLocal实例对象,直接返回
  7.     if (e != null && e.get() == key)
  8.         return e;
  9.     else
  10.         // 如果不是,需要继续向下遍历Entry数组
  11.         return getEntryAfterMiss(key, i, e);
  12. }
复制代码
5. ThreadLocal使用注意事项

使用ThreadLocal结束,一定要调用remove方法,清理掉threadLocal数据。具体流程类似下面这样:
  1. // 如果不是,需要继续向下遍历Entry数组
  2. private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
  3.     Entry[] tab = table;
  4.     int len = tab.length;
  5.     // 循环遍历数组,直到找到ThreadLocal对象,或者遍历到数组为空的位置
  6.     while (e != null) {
  7.         ThreadLocal<?> k = e.get();
  8.         // 如果等于当前ThreadLocal实例对象,表示找到了,直接返回
  9.         if (k == key)
  10.             return e;
  11.         // key是null,表示ThreadLocal实例对象已经被GC回收,就帮忙清除value
  12.         if (k == null)
  13.             expungeStaleEntry(i);
  14.         else
  15.                   // 索引位置+1,表示继续向下遍历
  16.             i = nextIndex(i, len);
  17.         e = tab[i];
  18.     }
  19.     return null;
  20. }
  21. // 索引位置+1,表示继续向下遍历,遍历到数组结尾,再从头开始遍历
  22. private static int nextIndex(int i, int len) {
  23.     return ((i + 1 < len) ? i + 1 : 0);
  24. }
复制代码
如果忘了调用remove方法,可能会导致两个严重的问题:

  • 导致内存溢出
    如果线程的生命周期很长,一直往ThreadLocal中放数据,却没有删除,最终产生OOM
  • 导致数据错乱
    如果使用了线程池,一个线程执行完任务后并不会被销毁,会继续执行下一个任务,导致下个任务访问到了上个任务的数据。
6. 常见面试题剖析

看完了ThreadLocal源码,再回答几道面试题,检验一下学习成果怎么样。
6.1 ThreadLocal是怎么保证数据安全性的?

ThreadLocal底层使用的ThreadLocalMap存储数据,而ThreadLocalMap是线程Thread的私有变量,不同线程之间数据隔离,所以即使ThreadLocal的set、get、remove方法没有加锁,也能保证线程安全。

6.2 ThreadLocal底层为什么使用数组?而不是一个对象?

因为在一个线程中可以创建多个ThreadLocal实例对象,所以要用数组存储,而不是用一个对象。
6.3 ThreadLocal是怎么解决哈希冲突的?

ThreadLocal使用的线性探测法法解决哈希冲突,线性探测法法具体赋值流程如下:

  • 通过key的hashcode找到数组下标
  • 如果数组下标位置是空或者等于当前ThreadLocal对象,直接覆盖值结束
  • 如果不是空,就继续向下遍历,遍历到数组结尾后,再从头开始遍历,直到找到数组为空的位置,在此位置赋值结束
6.4 ThreadLocal为什么要用线性探测法解决哈希冲突?

我们都知道HashMap采用的是链地址法(也叫拉链法)解决哈希冲突,为什么ThreadLocal要用线性探测法解决哈希冲突?而不用链地址法呢?
我的猜想是可能是创作者偷懒、嫌麻烦,或者是ThreadLocal使用量较少,出现哈希冲突概率较低,不想那么麻烦。
使用链地址法需要引入链表和红黑树两种数据结构,实现更复杂。而线性探测法没有引入任何额外的数据结构,直接不断遍历数组。
结果就是,如果一个线程中使用很多个ThreadLocal,发生哈希冲突后,ThreadLocal的get、set性能急剧下降。
线性探测法相比链地址法优缺点都很明显:
优点: 实现简单,无需引入额外的数据结构。
缺点: 发生哈希冲突后,ThreadLocal的get、set性能急剧下降。
6.5 ThreadLocalMap的key为什么要设计成弱引用?

先说一下弱引用的特点:
弱引用的对象拥有更短暂的生命周期,在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。 不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
ThreadLocalMap的key设计成弱引用后,会不会我们正在使用,就被GC回收了?
这个是不会的,因为我们一直在强引用着ThreadLocal实例对象。
  1. public void remove() {
  2.     // 获取当前线程的ThreadLocalMap对象
  3.     ThreadLocalMap m = getMap(Thread.currentThread());
  4.     if (m != null)
  5.         m.remove(this);
  6. }
  7. // 具体的删除方法
  8. private void remove(ThreadLocal<?> key) {
  9.     ThreadLocal.ThreadLocalMap.Entry[] tab = table;
  10.     int len = tab.length;
  11.     // 计算数组下标
  12.     int i = key.threadLocalHashCode & (len - 1);
  13.     // 遍历数组,直到找到空位置,
  14.     // 或者值等于当前ThreadLocal对象,才结束
  15.     for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
  16.          e != null;
  17.          e = tab[i = nextIndex(i, len)]) {
  18.         // 找到后,删除key、value,再删除Entry对象
  19.         if (e.get() == key) {
  20.             e.clear();
  21.             expungeStaleEntry(i);
  22.             return;
  23.         }
  24.     }
  25. }
复制代码
由上面代码中得知,如果我们一直在使用threadLocal,触发GC后,并不会threadLocal实例对象。
ThreadLocalMap的key设计成弱引用的目的就是:
防止我们在使用完ThreadLocal后,忘了调用remove方法删除数据,导致数组中ThreadLocal数据一直不被回收。
  1. /**
  2. * @author 一灯架构
  3. * @apiNote ThreadLocal示例
  4. **/
  5. public class ThreadLocalDemo {
  6.     // 1. 创建ThreadLocal
  7.     static ThreadLocal<User> threadLocal = new ThreadLocal<>();
  8.     public void method() {
  9.         try {
  10.             User user = getUser();
  11.             // 2. 给threadLocal赋值
  12.             threadLocal.set(user);
  13.             // 3. 执行其他业务逻辑
  14.             doSomething();
  15.         } finally {
  16.             // 4. 清理threadLocal数据
  17.             threadLocal.remove();
  18.         }
  19.     }
  20. }
复制代码
6.6 ThreadLocal为什么会出现内存泄漏?

ThreadLocal出现内存泄漏的原因,就是我们使用完ThreadLocal没有执行remove方法删除数据。
具体是哪些数据过多导致的内存泄漏呢?
一个是数组的Entry对象,Entry对象中key、value分别是ThreadLocal实例对象和泛型对象值。
因为我们在使用ThreadLocal的时候,总爱把ThreadLocal设置成类的静态变量,直到线程生命周期结束,ThreadLocal对象数据才会被回收。
另一个是数组中Entry对象的value值,也就是泛型对象值。虽然ThreadLocalMap的key被设置成弱引用,会被GC回收,但是value并没有被回收。需要等到下次执行get、set方法遍历数组,遍历到这个位置,才会删除这个无效的value。这也是造成内存泄漏的原因之一。
6.7 怎么实现父子线程共享ThreadLocal数据?

只需要InheritableThreadLocal即可,当初始化子线程的时候,会从父线程拷贝ThreadLocal数据。
  1. /**
  2. * @author 一灯架构
  3. * @apiNote ThreadLocal示例
  4. **/
  5. public class ThreadLocalDemo {
  6.     // 1. 创建ThreadLocal
  7.     static ThreadLocal<String> threadLocal = new ThreadLocal<>();
  8.     public static void main(String[] args) {
  9.         // 2. 给ThreadLocal赋值
  10.         threadLocal.set("关注公众号:一灯架构");
  11.         // 3. 从ThreadLocal中取值
  12.         String result = threadLocal.get();
  13.         // 手动触发GC
  14.         System.gc();
  15.         System.out.println(result); // 输出 关注公众号:一灯架构
  16.     }
  17. }
复制代码
我是「一灯架构」,如果本文对你有帮助,欢迎各位小伙伴点赞、评论和关注,感谢各位老铁,我们下期见


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

本帖子中包含更多资源

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

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

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

本版积分规则

泉缘泉

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表