ThreadLocal 不过如此

前言

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

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

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

  1. ThreadLocal是什么?
  2. ThreadLocal的结构是怎么样的?
  3. 使用ThreadLocal需要注意哪些问题?
  4. ThreadLocalMap为什么key要设置成弱引用呢?
  5. 那为什么value不设置成弱引用呢?
  6. 为什么会出现内存泄漏?你是怎么发现内存泄漏的?
  7. 怎么避免出现脏数据问题?

ThreadLocal的基本使用

创建

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

java

复制代码

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

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

    参数是一个Supplier的函数式接口

    如果对函数式接口不了解的可以看我的之前的分享 函数式接口一文看懂

 

java

复制代码

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

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

赋值

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

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

 

java

复制代码

threadLocal.set("aaa");

取值

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

 

java

复制代码

threadLocal.get(); // aaa

删除

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

 

java

复制代码

threadLocal.remove()

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

原理图

image-20230815204032996

这是我从一文详解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,并
源码分析
 

java

复制代码

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
源码分析
 

java

复制代码

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的键值对
源码分析
 

java

复制代码

 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

     java 

    复制代码

    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方法源码解析
 

java

复制代码

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回收,
  1. 如果被回收了则会调用expungeStaleEntry(i) ,将这些被GC的节点移除,
  2. 最后会返回此次局部清理是否移除了至少一个僵尸节点(被GC回收掉key的节点称为僵尸节点)
cleanSomeSlots源码解析
 

java

复制代码

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源码分析
 

java

复制代码

 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源码分析
 

java

复制代码

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源码解析
 

java

复制代码

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源码解析
 

java

复制代码

  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. 如果该下标节点不存在目标节点有三种情况

    1. 该下标处节点为空,那么可以直接说明节点不存在
    2. 节点不为空,key值也不为空,但是key值不相等,因为采用的是开放地址法解决hash冲突,所以会往后继续找
    3. 节点不为空,但是key值为空,说该节点key被gc了,成为了僵尸节点,但是也不能确定是我们要找的节点

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

getEntry源码解析
 

java

复制代码

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源码解析
 

java

复制代码

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不会回收

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

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

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

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

  3. 虚引用:通过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静态变量,如果使用了则一定要手动清理

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值