写点什么

ThreadLocal 不过如此

作者:java易二三
  • 2023-08-18
    湖南
  • 本文字数:11074 字

    阅读完需:约 36 分钟

 

前言

在并发情况下为了保证线程安全往往会选择加锁,但是无论是哪种锁总对性能有所影响,而使用 ThreadLocal 可以为线程创建一个独享变量,从而避免线程间竞争的情况,达到线程安全的作用。

ThreadLocal 也是面试过程当中经常会问到的,所以对于准备面试的同学也是很有必要学习 ThreadLocal 的。

先给出几个面试题,本文后后面会给出答案:

  1. ThreadLocal 是什么?

  2. ThreadLocal 的结构是怎么样的?

  3. 使用 ThreadLocal 需要注意哪些问题?

  4. ThreadLocalMap 为什么 key 要设置成弱引用呢?

  5. 那为什么 value 不设置成弱引用呢?

  6. 为什么会出现内存泄漏?你是怎么发现内存泄漏的?

  7. 怎么避免出现脏数据问题?

ThreadLocal 的基本使用

创建

  1. 通过 new 关键字 一个 ThreadLocal 变量

ThreadLocal<String> threadLocal = new ThreadLocal<>();

  1. ThreadLocal.withInitial 静态方法创建一个带有初始值的 ThreadLocal

    参数是一个 Supplier 的函数式接

ThreadLocal<String> withInitialValueThreadLocal = ThreadLocal.withInitial(()->"hello,ThreadLocal");

两种方式差不多,只是第二种方式会自带一个初始值

赋值

赋值只需要调用 ThreadLocal 的 set 方法,就可以将值保存到 ThreadLocal 中

ThreadLocal 变量也是一个变量,使用上完全可以把他当作一个普通的变量来使用,只是他天生是线程安全的,因为这个变量的值不会受其他任何线程所影响

threadLocal.set("aaa");

取值

赋值只需要调用 ThreadLocal 的 get()方法,不需要任何参数

threadLocal.get(); // aaa

删除

ThreadLocal 和普通变量不同的地方在于不用时建议手动删除,避免内存泄露(虽然不手动删除也不一定内存泄露,但是还是建议手动删除)

threadLocal.remove()

ThreadLocal 变量为什么线程独享的呢?

原理图



这是我从一文详解ThreadLocal截取过来的原理图,先大致讲一下。

每个线程 Thread 内部都有一个 ThreadLocalMap,为 ThreadLocal 赋值其实就是到线程内部的 Map 里插入一个以 ThreadLocal 变量作为 key,变量值为 Value 的键值对,取值也是去线程内部的 ThreadLocalMap 中以当前的 ThreadLocal 变量作为 key 调用 Map 的 get 方法返回结果,remove 则是在 ThreadLocalMap 中删除以 ThreadLocal 变量作为了 Map 的 key 的键值对。

现在看不懂也没关系,下面就来从源码来看一下 ThreadLocal 的工作流程

ThreadLocal-set 方法

流程解析

  1. 取得当前的线程

  2. 取得当前线程内部的 ThreadLocalMap

  3. 调用 Map 的 set 方法 加入一个以当前 ThreadLocal 变量作为 key,变量值为 Value 的键值对

  4. 如果 Map 还为创建则为线程创建一个 ThreadLocalMap,并

源码分析

public void set(T value) {  Thread t = Thread.currentThread(); //取得当前的线程  ThreadLocalMap map = getMap(t);  if (map != null) {    map.set(this, value);  // this 这是ThreadLocal的方法,指代的就是当前的ThreadLocal } else {    createMap(t, value); } } ThreadLocalMap getMap(Thread t) {   return t.threadLocals; // 取得线程内部的ThreadLocalMap } // void createMap(Thread t, T firstValue) {  t.threadLocals = new ThreadLocalMap(this, firstValue); // 创建Map 并加入键值对 }

get 方法

流程解析

  1. 取得当前的线程

  2. 取得当前线程内部的 ThreadLocalMap

  3. 调用 ThreadLocalMap 的 get 方法返回结果

  4. 如果线程内 ThreadLocalMap 还未创建或者 ThreadLocalMap 内还未保存当前 ThreadLocal 的键值对,则调用初始化方法 setInitialValue(),如果有初始化方法则初始化并返回初始值,没有返回 null

源码分析

public T get() {  Thread t = Thread.currentThread(); //取得当前的线程  ThreadLocalMap map = getMap(t); //取得当前线程内部的ThreadLocalMap  if (map != null) { //程内ThreadLocalMap还未创建    ThreadLocalMap.Entry e = map.getEntry(this);    if (e != null) { // ThreadLocalMap内还未保存当前ThreadLocal的键值对      @SuppressWarnings("unchecked")      T result = (T)e.value;      return result;   } }  return setInitialValue(); // 有初始化方法则初始化并返回初始值,没有返回null }

remove 方法

流程解析

  1. 取得当前的线程并取得当前线程内部的 ThreadLocalMap

  2. 如果 ThreadLocalMap 存在,则调用 ThreadLocalMap 的 remove 方法删除以当前 ThreadLocal 变量作为 key 的键值对

源码分析

 public void remove() {         ThreadLocalMap m = getMap(Thread.currentThread()); //取得当前的线程并取得当前线程内部的ThreadLocalMap         if (m != null) {             m.remove(this); //删除以当前ThreadLocal变量作为key的键值对         }     }

从上面的部分其实已经基本了解了 ThreadLocal 的工作原理了,但是你会发现他所有 set、get、remove 其实都是调用了线程内部那个 ThreadLocalMap 的方法,所以下面我们就更深度的解析一下 ThreadLocalMap 的源码,面试经常问到的内存泄露问题在了解了源码之后也很好理解了。

ThreadLocalMap 源码解析

ThreadLocalMap 的源码有两个原因导致其比较复杂

  1. ThreadLocalMap 内部却是使用的开放地址法中的线性探测法来处理的 Hash 冲突

  2. ThreadLocalMap 为了尽可能避免内存泄露,所以 Entry 的 Key 值(ThreadLocal)使用的是弱引用,也就是说随时都有可能存在 Map 里某个 Entry 的 Key 被 GC 回收了变成了 null 值,他在每次 get、set 和 remove 操作时都需要考虑某些特殊情况下 GC 可能引起的错误,以及每次都会清除一部分被 GC 的清理掉 Key 值的 Entry

    static class Entry extends WeakReference<ThreadLocal<?>>

基于上面两个点导致 ThreadLocalMap 的源码变得相对比较复杂,但是其实理解了也不算特别复杂

Hash 冲突的 4 种处理方法

Map 类型是基于 Hash 表结构的,但是目前不存在任何一种的 Hash 算法能保证不会出现 hash 冲突问题,所以解决 Hash 冲突问题是所有 Map 内部需要考虑的事情

处理的方法有 4 种:

  1. 开放定址法(ThreadLocalMap) :发生哈希冲突时,寻找一个新的空闲的哈希地址存放 1.1 线性探测法:一直加 1 并对 m 取模直到存在一个空余的地址 1.2 平方探测法:前后寻找(i 的平方)而不是单独方向的寻找

  2. 再哈希法:构造多个不同的哈希函数,等发生哈希冲突时就使用第二个、第三个...哈希函数计算地址,直到不发生冲突为止

  3. 链地址法(HashMap) :将所有哈希地址相同的记录都链接在同一链表中

  4. 建立公共溢出区:将哈希表分为基本表和溢出表,将发生冲突的都存放在溢出表中

我们使用最多的 HashMap 就是使用的链地址法处理的 hash 冲突,但是 ThreadLocalMap 内部却是使用的开放地址法中的线性探测法来处理的 Hash 冲突,这可能会使得了解过 HashMap 源码的同学刚接触时感觉到有些困惑,所以我先在这个地方提醒大家注意一下

set 方法

我们先从 ThreadLocalMap 的 set 方法看起,他和 HashMap 相同的是内部都是由 Entry 节点的数组组成。

set 方法的流程解析

  1. 先取得当前 key(也就是 ThreadLocal)的 hash 值,然后拿到其在 Entry 数组中的下标 i

  2. 从 i 节点开始往后找空位置来存放当前的 Entry 节点 (因为 ThreadLocalMap 使用的是开放定址法解决 hash 冲突,所以如果 i 节点处已经有其他节点了,那么就要不断下标+1 去找一个没有其他节点的位置)

  3. 如果找到 k 值等于当前要插入节点的 key 值,则直接覆盖(和 HashMap 相同,key 值不能重复)

  4. 如果找到 k 值为 null 的节点(注意:节点不为 null,只是节点的 key 为 null)说明这个节点的 key 值被垃圾回收掉了,就使用当前要插入的节点替换这个被垃圾回收的节点调用 replaceStaleEntry(key, value, i)

set 方法源码解析

private void set(ThreadLocal<?> key, Object value) {  // We don't 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); // 取得key的Hash值在Entry数组中的下标 ​  for (Entry e = tab[i];       e != null;     // 直到找到空位置(e==null)       e = tab[i = nextIndex(i, len)]) { // e 等于下一个节点    ThreadLocal<?> k = e.get();  // e.get()是取当前Entry节点的key值 ​    if (k == key) {  // k值等于当前要插入节点的key值,则直接覆盖      e.value = value;      return;   } ​    if (k == null) { // k值为null, 当前要插入的节点替换这个被垃圾回收的节点      replaceStaleEntry(key, value, i);      return;   } }  // 执行到这里的都是要新插入一个节点情况,无论上面的覆盖还是替换都走不到这里  tab[i] = new Entry(key, value);  int sz = ++size;  // 当前Entry节点的总数  if (!cleanSomeSlots(i, sz) && sz >= threshold)    rehash(); }

局部清理 cleanSomeSlots

当我们新插入一个节点后,会调用 cleanSomeSlots 对 Map 做一个局部清理,这是 ThreadLocalMaps 为了避免内存泄露所做的努力

局部清理逻辑分析:
  1. 从当前插入新节点下标的下一个节点的位置开始,每次右移一位,直到 0,判断这些节点是否被 GC 回收,

  2. 如果被回收了则会调用 expungeStaleEntry(i) ,将这些被 GC 的节点移除,

  3. 最后会返回此次局部清理是否移除了至少一个僵尸节点(被 GC 回收掉 key 的节点称为僵尸节点)

cleanSomeSlots 源码解析

private boolean cleanSomeSlots(int i, int n) {   boolean removed = false;  // 标记是否成功移除至少一个僵尸节点   Entry[] tab = table;   int len = tab.length;   do {     i = nextIndex(i, len);     Entry e = tab[i];     if (e != null && e.get() == null) {  // 如果entry非空,但是e.get()也就是k为空则是僵尸节点       n = len;       removed = true;       i = expungeStaleEntry(i);  // 清除僵尸节点     }   } while ( (n >>>= 1) != 0);   return removed;  // 此次局部清理是否移除了至少一个僵尸节点 }

清除僵尸节点 expungeStaleEntry

上面局部清理方法中检测到僵尸节点后调用的 expungeStaleEntry 方法去执行清除僵尸节点的操作

但是因为 ThreadLocalMap 是采用的开放地址法来处理 hash 冲突,所以清除僵尸节点不能只是把当前节点给删除就结束了,因为后续的节点可能就是 hash 值在当前节点的位置,但是冲突不断往后找空位才放到了后面的位置,如果只是把当前节点给删除了,那后续节点查找的通过 hash 值找到了一个空节点就会误认为不存在了。

你也不能简单把后续节点往前移动 1 位,因为可能存在某个节点是正确存在于其 hash 值对应位置的,往前移动了之后查找时只会从 hash 定位的位置向后找,也会找不到节点,所以采用的方式就是将后续连续的节点(连续的一段非空的节点)重新 hash 定位存放到 Map 中

expungeStaleEntry 逻辑分析:
  1. 将当前节点删除

  2. 从下一个节点开始往后遍历,将后续连续的节点重新 hash 定位存放

  3. 如果如果节点的 k 被回收的节点则删除

所以他回收的是从当前节点开始连续一段节点上的所有僵尸节点

expungeStaleEntry 源码分析

 private int expungeStaleEntry(int staleSlot) {    Entry[] tab = table;    int len = tab.length; ​    // 删除当前节点    tab[staleSlot].value = null;    tab[staleSlot] = null;    size--; ​    // 对后续连续节点重新hash定位    Entry e;    int i;    for (i = nextIndex(staleSlot, len);         (e = tab[i]) != null;         i = nextIndex(i, len)) {      ThreadLocal<?> k = e.get();      if (k == null) {  // 如果节点k为null则回收,避免内存泄露        e.value = null;        tab[i] = null;        size--;     } else { // 对正常节点重新定位存放        int h = k.threadLocalHashCode & (len - 1);        if (h != i) {          tab[i] = null;          // 如果hash值对应的下标处已经有节点了就向后找空节点 (开放地址法-线性探测法)          while (tab[h] != null)            h = nextIndex(h, len);          tab[h] = e;       }     }   }    return i;  // 返回本次rehash定位的连续节点中最后一个节点的之前的下标 }

全局清理 rehash

在 set 方法中新加入了一个节点后,如果局部清理 cleanSomeSlots 没有能成功清理至少一个节点,也就代表说节点数增加了一个,这个时候需要判断节点个数是否达到了阈值 sz >= threshold,如果达到了则会调用 rehash 方法进行全局清理

全局清理逻辑分析
  1. 首先会调用 expungeStaleEntries() 进行全局清理,清理的思路也很简单,就是把整个 table 都遍历一遍然后将其中 k 被 gc 回收的节点调用上述的 expungeStaleEntry 方法清楚僵尸节点

  2. 全局清理完毕后如果剩余节点还是>=阈值的 0.75,就会调用扩容方法 resize()。

    可能会疑惑,明明已经减少到阈值之下了,为什么还要扩容,这个地方我猜测是因为全局清理是非常耗时的,如果全局清理出来的节点个数并不太多,那么再次触发全局清理的可能性很大,可能出现反复全局清理的情况,出于性能考虑扩容更划算

rehash 源码分析

private void rehash() {  expungeStaleEntries();  // 全局清理 ​  // 如果剩余节点还是>=阈值的0.75则扩容  if (size >= threshold - threshold / 4)    resize(); } ​ // 全局清理 private void expungeStaleEntries() {  Entry[] tab = table;  int len = tab.length;  for (int j = 0; j < len; j++) {  // 遍历所有的Entry节点    Entry e = tab[j];    if (e != null && e.get() == null) // 如果节点不为空,但是k为空则说明该节点已经是僵尸节点      expungeStaleEntry(j);  // 回收僵尸节点 } }

扩容 resize

扩容方法比较简单,和 HashMap 相同

扩容逻辑分析:
  1. 创建一个原来两倍大小的临时 Table

  2. 遍历原来的节点,将其 hash 值在新的 table 中定位,然后存放到新的 table 中

  3. 最后将新的 table 替换原来的旧 table 使用

注意:由于考虑到节点的 key 值可能被 gc,所以遍历节点时遇到被 gc 的节点就直接将节点的 value 值回收,节点不加入到新节点中

resize 源码解析

private void resize() {  Entry[] oldTab = table;  int oldLen = oldTab.length;  int newLen = oldLen * 2;  Entry[] newTab = new Entry[newLen]; // 创建一个大小为原来两倍的table  int count = 0; // 遍历原来的节点  for (int j = 0; j < oldLen; ++j) {    Entry e = oldTab[j];    if (e != null) {      ThreadLocal<?> k = e.get();      if (k == null) { // 如果节点的key值被gc则将将节点的value值回收        e.value = null; // Help the GC     } else {        // 计算节点在新的table中的位置        int h = k.threadLocalHashCode & (newLen - 1);        // 开放地址法存放到新table中        while (newTab[h] != null)          h = nextIndex(h, newLen);        newTab[h] = e;        count++;     }   } }  // 利用新table替换旧table  setThreshold(newLen);  size = count;  table = newTab; }

节点替换 replaceStaleEntry

在 set 方法中,当在寻找空位存放节点的过程中如果遇到僵尸节点,则会 replaceStaleEntry 直接替换节点。

前面 expungeStaleEntry 中提到删除节点的时候不能直接删除,需要考虑通过开放地址法解决 hash 冲突和 gc 回收节点 key 的影响,节点替换的时候不能直接替换,也需要考虑这个因素的影响

  1. 考虑到 gc 回收节点 key 的影响,他会遍历当前节点所在的连续节点段(包括当前节点之前的连续节点和当前节点之后的连续节点)如果存在被 gc 的节点则会标记该段节点中最早的一个僵尸节点,然后调用 expungeStaleEntry 删除这个节点段上所有的僵尸节点

  2. 因为是遇到僵尸就直接替换,所以可能存在上一次节点 A 存放在 3 号位置,后果后续 2 号节点被 gc 了,再 set 节点 A 的时候会在 2 号节点处就进行节点替换了,这种情况是不正确的,所以在替换节点是需要往后遍历确保该节点不会存放两份

节点替换逻辑分析:
  1. 向前遍历连续的节点段检查是否有僵尸节点,如果有则标记位置

  2. 向后遍历 1.查找是否存在 key 与当前新加入节点的 key 相同 2.查找僵尸节点

  3. 如果存在 key 相同的节点 B 则将节点 B 的 value 值替换为新的 value,将节点 B 交换到新指定的位置

  4. 如果存在僵尸节点则调用 expungeStaleEntry 从最早的一个僵尸节点开始回收该节点段上的所有僵尸节点

replaceStaleEntry 源码解析

  private void replaceStaleEntry(ThreadLocal<?> key, Object value,                                       int staleSlot) {     Entry[] tab = table;     int len = tab.length;     Entry e; ​    // slotToExpunge记录最靠前的僵尸节点的位置,如果slotToExpunge = staleSlot则说明不存在僵尸节点     int slotToExpunge = staleSlot;      // 向前遍历连续的节点段检查是否有僵尸节点     for (int i = prevIndex(staleSlot, len);         (e = tab[i]) != null;          i = prevIndex(i, len))       if (e.get() == null)         slotToExpunge = i; // 向后遍历 1.查找是否存在key与当前新加入节点的key相同 2.查找僵尸节点     for (int i = nextIndex(staleSlot, len);         (e = tab[i]) != null;          i = nextIndex(i, len)) {       ThreadLocal<?> k = e.get(); ​             if (k == key) {         e.value = value; // 如果存在key相同的节点则将该节点的value值替换 ​         tab[i] = tab[staleSlot]; // 然后将原来存在的节点交换到新指定的位置         tab[staleSlot] = e; ​         if (slotToExpunge == staleSlot) // 如果替换节点之前没有僵尸节点,则从当前位置开始回收僵尸节点           slotToExpunge = i;         cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);         return;       } ​       if (k == null && slotToExpunge == staleSlot)         slotToExpunge = i;     } ​     // 如果能走到这里说明后面没有与新节点key相同的节点     tab[staleSlot].value = null;     tab[staleSlot] = new Entry(key, value); ​     if (slotToExpunge != staleSlot)  // 如果 slotToExpunge != staleSlot 则说明至少存在一个僵尸节点       cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);   }

好了,恭喜你到这里 set 方法就结束了,这个 set 方法中涉及到了 ThreadLocalMap 中的绝大部分操作,前面都理解了后续 get 和 remove 方法其实就很简单了

获取节点 getEntry 方法

get 方法逻辑分析

  1. 根据 key 的 hash 值定位节点在 table 中的下标

  2. 如果该下标节点存在且 key 相等则直接返回 Entry

  3. 如果该下标节点不存在目标节点有三种情况

    该下标处节点为空,那么可以直接说明节点不存在

    节点不为空,key 值也不为空,但是 key 值不相等,因为采用的是开放地址法解决 hash 冲突,所以会往后继续找

    节点不为空,但是 key 值为空,说该节点 key 被 gc 了,成为了僵尸节点,但是也不能确定是我们要找的节点

    因此会从当前节点向后遍历继续查找目标 key 的节点,直到遇到空节点则说明寻找的节点不存在返回 null,过程中也会回收僵尸节点,

getEntry 源码解析

private Entry getEntry(ThreadLocal<?> key) {   int i = key.threadLocalHashCode & (table.length - 1); // 根据key的hash值定位节点在table中的下标   Entry e = table[i];   if (e != null && e.get() == key) // 如果该下标节点存在且key相等则直接返回Entry     return e;   else     /*     如果找不到有3种情况     1. 该下标处节点为空,那么可以直接说明节点不存在     2. 节点不为空,key值也不为空,但是key值不相等,因为采用的是开放地址法解决hash冲突,所以会往后继续找     3. 节点不为空,但是key值为空,说该节点key被gc了,成为了僵尸节点,但是也不能确定是我们要找的节点     */     return getEntryAfterMiss(key, i, e); } // 寻找不存在的节点  private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {    Entry[] tab = table;    int len = tab.length; // 会从当前节点向后遍历继续查找目标key的节点,直到遇到空节点,过程中也会回收僵尸节点    while (e != null) {      ThreadLocal<?> k = e.get();      if (k == key)        return e;      if (k == null)        expungeStaleEntry(i);      else        i = nextIndex(i, len);      e = tab[i];   }    return null; }

删除节点 remove

删除节点逻辑分析:
  1. 根据 key 的 hash 值定位节点在 table 中的下标

  2. 从该下标开始从前往后找待删除的目标节点

  3. 找到后删除该节点,并从删除位置开始 调用 expungeStaleEntry 清除该位置之后的连续代码段上的所有僵尸节点

remove 源码解析

private void remove(ThreadLocal<?> key) {  Entry[] tab = table;  int len = tab.length;  int i = key.threadLocalHashCode & (len-1); //根据key的hash值定位节点在table中的下标  // 从该下标开始从前往后找待删除的目标节点  for (Entry e = tab[i];       e != null;       e = tab[i = nextIndex(i, len)]) {    if (e.get() == key) {      e.clear();// 删除该节点      expungeStaleEntry(i); // 清除该位置之后的连续代码段上的所有僵尸节点      return;   } } }

恭喜你到这里 ThreadLocalMap 你已经拿下了!

内存泄露问题分析

内存泄露:内存泄漏是指在程序运行过程中,分配的内存空间没有被正确释放或回收的现象。

举个例子:你为一个变量申请了内存,你是用完这个变量后却没有去释放其内存,但是你又永远都不会去使用他了,但是这块内存始终被那个变量占用着,导致你变量所占用的那块内存你永远也不能使用了

幸运的是 Java 语言里面具备垃圾回收机制,使得程序员在开发过程中无需去关注内存释放的问题,但是 ThreadLocal 为什么会存在内存泄露的风险呢?那我们就需要先大致了解一下垃圾回收如何判断哪些对象需要回收的

四种引用类型

当内存充足的时候,就可以保留多的对象在内存中,但是当内存紧张的时候,就需要尽可能的回收更多的对象来释放出内存,但是也不能无差别的去回收,有些正在使用的对象如果被回收则会导致程序运行失败,所以将对象采用了 4 种不同级别的引用,以便在内存使用的不同情况下回收不同级别的对象。

  1. 强引用:通过 new 关键字创建对象得到的引用就是强引用。

    只要存在强引用的对象则 GC 不会回收

  2. 软引用:通过 softReference 创建的引用。

    当内存不够时才会回收存在软引用的对象

  3. 弱引用:通过 WeakReference 创建的引用

    垃圾回收器会直接回收弱引用的对象。

  4. 虚引用:通过 PhantomReference 对象创建的对象

    虚引用不会对对象的生存时间造成影响,也无法通过虚引用得到一个对象,只是在这个对象被回收时收到一个系统通知。

关于垃圾回收的部分我后续会专门总结一下。

ThreadLocal 内存泄露

因为 ThreadLocal 变量是保存在线程内部的 ThreadLocalMap 中,同时线程的创建与销毁是比较耗时的,所以线程一般采用线程池的方式达到复用的效果,因此当一次请求进来被线程处理完成后,ThreadLocalMap 内部为本次请求所保存的 Entry 节点就不会再被使用了,如果不清理则会造成内存泄露,这也就是内存泄露的原因。

因此 ThreadLocalMap 的设计者才会把 Entry 节点的 key(ThreadLocal)设置为弱引用,以避免内存泄露问题。

具体一次请求处理的过程如下:

  1. 当请求进来会在方法内创建一个 ThreadLocal 的强引用,这个引用被存放在栈内存的栈帧中,这个时候在 ThreadLocalMap 内保存的 Entry 节点虽然 key(threadLocal)是弱引用,但是因为栈帧内存在对 threadLocal 的强引用,所以垃圾回收时不会回收 ThreadLocal。

  2. 如果本次请求结束,栈帧弹栈后之前强引用就消失了,再次 gc 的时候就会把 ThreadLocalMap 内的 key 给回收掉,同时如我们上面源码看到的 ThreadLocalMap 每一次读写数据都会去清理一部分僵尸节点,从而避免了内存泄露

但是可能会问为什么不把 Value 也设置为弱引用,这样在源码里面不就不需要一次次的处理僵尸节点了吗?

因为需要保证 Value 的有效性,我们通常会在某个方法内为 ThreadLocal 赋值然后需要保证在整个请求作用时间内在其他方法内可以使用(如果只在同一个方法内赋值并使用那只需要使用局部变量就行了),因此如果 value 使用弱引用,那么在赋值的方法结束后栈帧弹出则 value 可能就不存在其他强引用了,那么 value 在下次 gc 的时候就会被回收,就不能保证 value 的有效性了。

但是 ThreadLocalMap 是不是能一定保证不会出现内存泄露呢?那肯定也不是的

比如你创建了一个具有初始值的 ThreadLocal 静态变量,由于静态变量的生命周期是整个应用程序,所以 ThreadLocal 会一直存在强引用,也就是说永不会被 GC 回收,所以你需要手动回收。如果不回收的话你第二次请求进来直接调用 get 方法本想得到初始值,但却可能就会得到上一次请求遗留下来的脏数据

面试题解答

相信如果看懂了前面的部分,一开始的面试题也都能有自己的理解了。

ThreadLocal 是什么?

ThreadLocal 是在多线程环境下为每个线程提供本地变量的类,该变量的值不会受到其他线程的影响,从而避免多线程之间的竞争问题。

ThreadLocal 的结构是怎么样的?

线程 Thread 内部都有一个 ThreadLocalMap 类。

ThreadLocalMap 内部结构是一个哈希表将 ThreadLocal 作为键,将 value 作为值同时是使用线性探测法解决哈希冲突。

ThreadLocal 内部的 get,set,remove 方法都是获取当前的线程对象,然后通过线程对象获取线程内部的 ThreadLocalMap,再以自己为键调用 Map 对应的方法处理。

使用 ThreadLocal 需要注意哪些问题?

  1. 内存泄漏问题:建议使用完成后都手动调用 ThreadLocal 的 remove 方法。因为虽然 ThreadLocalMap 会清理内部的僵尸节点,但是可能会清理不及时长时间遗留在线程内部,另外如果 ThreadLocal 是静态变量则无法被 ThreadLocalMap 自动清理,造成内存泄露甚至可能因为脏数据造成系统故障

  2. 不适用于共享数据:ThreadLocal 是为了做线程间的数据隔离,不应该把共享数据存放到 ThreadLocal 中。

ThreadLocalMap 为什么 key 要设置成弱引用呢?

因为线程一般不会都是服用的,如果不将 key 设置为弱引用,那么会存在很多再也不需要使用的节点存放在 ThreadLocalMap 中得不到清理,也就是操作内存泄露。

通过将 key 设置为弱引用,在使用 ThreadLocal 变量的方法栈帧弹出后 ThreadLocal 将会在下一次 gc 的时候被回收,配合 ThreadLocalMap 内部清理僵尸节点的机制可能尽可能的避免内存泄露。

那为什么 value 不设置成弱引用呢?

因为需要保证 Value 的有效性,我们通常会在某个方法内为 ThreadLocal 赋值然后需要保证在整个请求作用时间内在其他方法内可以使用(如果只在同一个方法内赋值并使用那只需要使用局部变量就行了),因此如果 value 使用弱引用,那么在赋值的方法结束后栈帧弹出则 value 可能就不存在其他强引用了,那么 value 在下次 gc 的时候就会被回收,就不能保证 value 的有效性了。

为什么会出现内存泄漏?

  1. 线程的重复利用:如果不进行线程复用则 ThreadLocal 不会存在内存泄露问题

  2. 没有合理的清除 ThreadLocal:如果每次使用完毕都手动调用 remove 方法也不会造成内存泄露问题

怎么避免出现脏数据问题?

  1. 及时清理:在使用完成 ThreadLocal 后手动清理

  2. 异常处理:在程序出现异常的情况下也需要手动清理 ThreadLocal

  3. 正确的使用范围:避免将其应用于全局变量或长时间运行的线程,比如尽量避免使用 ThreadLocal 静态变量,如果使用了则一定要手动清理

用户头像

java易二三

关注

还未添加个人签名 2021-11-23 加入

还未添加个人简介

评论

发布
暂无评论
ThreadLocal不过如此_Java_java易二三_InfoQ写作社区