第二十六章 ThreadLocal原理及生产中遇到的坑

ThreadLocal原理及生产中遇到的坑

  • 什么是ThreadLocal
  • ThreadLocal实现原理
    • ThreadLocal使用场景
    • ThreadLocal常用示例
    • ThreadLocal 实现原理
      • 静态内部类的实例化
      • ThreadLocal结构
      • ThreadLocal主要方法剖析
        • 几个概念
          • 主要方法
        • initialValue方法
        • set方法
          • 清除脏的entry
          • 替换脏的entry
        • rehash方法
        • get方法
        • remove方法
        • thread.exit()
    • ThreadLocal内存泄漏问题
    • ThreadLocal存在的问题
    • 总结
    • 2.4 为什么使用弱引用?

什么是ThreadLocal

在多线程环境中,我们会遇到一个问题,很多线程都同时在改这一个变量,很可能我们获取到的就是其他线程设置的值了,threadlocal就是要解决这种问题的,有了threadlocal每个线程中的变量都是他自己的,其他线程无法更改你的变量。

C++中Thread结构体中的TEB(thread environment block)子结构体,线程环境块是给应用程序用的。

ThreadLocal提供了一个“线程级”变量的作用域。也就是说,ThreadLocal中提供的变量只在当前线程的生命周期内起作用,可以理解为在Java层提供了线程的私有空间。

ThreadLocal实现原理

ThreadLocal提供了一个"线程级"变量的作用域。它是一种线程封闭(每个线程独享变量)技术,它提供的变量只在该线程的生命周期内起作用,可以理解为在Java层提供了线程的私有空间。

Thread结构体中的TEB(thread environment block),线程环境块是给应用程序用的。

ThreadLocal可以解决两类问题。

  1. 并发问题。使用ThreadLocal代替synchronized来保证线程安全。synchronized同步机制采用空间换时间,仅提供一份变量,各个线程轮流访问,而ThreadLocal每个线程都持有一份变量,访问时互不影响。但ThreadLocal并不是解决多线程下共享资源的技术。
  2. B/S架构下,复杂逻辑下的对象传递。当没有使用ThreadLocal时,如果一个对象需要在线程穿越的各个模块各个层级使用,我们通常使用参数传递,但是有了ThreadLocal之后,我们就可以不用参数传递了,ThreadLocal将变量绑定在线程上,在本线程及子线程内,你可以随意使用该变量。简而言之就是每个线程拥有自己的实例,当需要某个变量在该线程中多个方法中共享但是不希望被多线程共享,就可以使用ThreadLocal。
  3. 进行事务操作,用于存储线程事务信息。如JDBC中的事务就是通过该方式实现。

ThreadLocal使用场景

  1. 用ThreadLocal代替synchronized来保证线程安全。
    (1)时间类SimpleDateFormat和DateFormat是有状态类,是线程不安全的。可以采用ThreadLocal进行隔离。因为Calendar累的概念复杂,牵扯到时区与本地化等等,Jdk的实现中使用了成员变量来传递参数,这就造成在多线程的时候会出现错误。calendar.setTime(date)这条语句改变了calendar。当其他线程获取时就会导致时间不对。
  2. B/S架构下,用于对象的传递。
    京东全球购系统中很多地方用到了ThreadLocal。
    (1)用ThreadLocal来解决request线程内用户的基础数据存储。比如,登录令牌,语言,客户端IP,客户端版本等。这些数据可以放入到ThreadLocal对象中,用于不同模块中的方法使用。
    (2)用来记录不同功能的日志级别,如测试环境,商品模块的日志级别为debug,订单模块的日志级别为info。
    在一些公共组件中也经常用到
    (1)spring声明式事务的重要实现基础就是ThreadLocal,有兴趣的读者可以参考TransactionSynchronizationManager类。
    (2)读写分离插件shardingsphere就是通过threadlocal来实现某一次读请求路由到主库。通过threadlocal设置标识即可,有兴趣的读者可以下载源码查看MasterVisitedManager类。
    (3)用于日志追踪,如slf4j的MDC组件的使用,可以在日志中每次请求过程加key,方便定位一次请求流程问题。
    (4)ThreadLocal还有一个派生的子类:InheritableThreadLocal ,可以允许线程及该线程创建的子线程均可以访问同一个变量。该特性经常用于全链路组件。

ThreadLocal常用示例

WEB系统中,我们经常会获取前端传过来的通用数据,比如用户令牌,用户名。这些参数几乎在每个接口中都需要传入,但是如果在Spring的拦截适配器中把这些通用参数处理保存到ThreadLocal中,该线程执行经过的所有代码,就不需要把该变量一直传下去,直接从ThreadLocal中取即可。部分代码如下。

  1. 构建请求公共参数对象。
public class RequestContextUtils {
    //使用静态变量,引用的指针不变,但是引用的内容可以变化
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    /**
     * clear ThreadLocal value
     */
    public static void clear() {
        context.remove();//记得用完清除
    }

    public static RequestContext getRequestContext() {
        return context.get();
    }

    public static void setRequestContext(RequestContext requestContext) {
        context.set(requestContext);
    }
}
//需要保存的公共参数
public class RequestContext {
    /**
     * 登录令牌
     * 若需要登录校验
     */
    private String userToken;
    /**
     * 用户名
     */
    private String userName;
}
  1. 采用Spring拦截器获取用户TOKEN等信息并设置,通过RequestContextUtils.setRequestContext(requestContext)方法设置到ThreadLocalMap中。
/**
 * 更加详细代码请联系微信号15102802575
 * 请求公共值拦截器
 */
@Component
public class RequestFilter extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        RequestContext requestContext = new RequestContext();
        requestContext.setUserId(request.getHeader("userId"));
        requestContext.setUserToken(request.getHeader("userToken"));
        RequestContextUtils.setRequestContext(requestContext);
        return super.preHandle(request, response, handler);
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
        RequestContextUtils.clear();
        super.postHandle(request, response, handler, modelAndView);
    }
}


public class RequestContextUtils {


    public static final ThreadLocal<RequestContext> requestContext = new ThreadLocal<>();


    public static void setRequestContext(RequestContext context) {
        requestContext.set(context);
    }

    public static RequestContext getReqeustContext(){
       return requestContext.get();
    }

    public static void clear() {
        requestContext.remove();
    }

}
  1. 在不同对象中通过以下方法就可以获取之前保存的公共数据了。使用完后,必须记住remove,否则可能会引发内存泄露。
RequestContextUtils.getRequestContext()

示例中不同线程中是使用同一个ThreadLocal对象对当前线程中的ThreadLocalMap进行修改。不同线程访问同一个ThreadLocal的get方法时,ThreadLocal内部会从各自的线程中取出一个table数组,然后再从数组中根据当前ThreadLocal的索引去查找出对应的value值。所以,不同线程中的是不同的数组对象,这就是为什么通过ThreadLocal可以在不同的线程中维护一套数据的副本并且彼此互不干扰。下面从上面示例结合源码进行深入分析。

ThreadLocal 实现原理

静态内部类的实例化

ThreadLocal中包括静态类ThreadLocalMap,而该类中又包含静态类Entry。应该有很多读者对静态类并未理解。有必要先讲解下。

静态内部类与静态变量、静态方法是不一样的。静态变量也叫类变量,在内存中只有一个,它是JVM在类加载过程中分配在方法区的,被所有类的实例共享,所以任何一个对象对静态数据成员的修改,都会影响其它对象。而静态方法与静态变量类似。

但静态内部类除了枚举类(枚举类本身就是public static)外,都是可以实例化多个的,如下。静态类中的"static"可简单理解为静态内部类的"静态类型"不需要外部类的实例化。所以静态内部类叫"内部类"是不准确的,可以简单叫"静态类"。

1.Java集合类HashMap内部就有一个静态内部类Entry。Entry是HashMap存放元素的抽象,HashMap 内部维护Entry数组用了存放元素,但是Entry 对使用者是透明的。像这种和外部类关系密切的,且不依赖外部类实例的,都可以使用静态内部类。
2.定义在方法中的类,就是局部类。如果一个类只在某个方法中使用,则可以考虑使用局部类。

public class MultipleNested {
    static class Nested {
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Nested();//可以新建多个实例
        }
    }
}

ThreadLocal结构

ThreadLocal内部维持的静态内部类ThreadLocalMap,ThreadLocalMap可理解为HashMap的结构。而ThreadLocalMap中维持了静态内部类Entry类的数组结构。Entry类中Key为当前ThreadLocal实例,value就是我们要保存的对象。这个Entry继承了WeakReference,也就是说它是弱引用的,之所以被设计成WeakReference是为了能够在JVM发生垃圾回收事件时,能够自动回收防止OOM的情况。

第二十六章 ThreadLocal原理及生产中遇到的坑_第1张图片

每个Thread里都有一个ThreadLocalMap成员变量,ThreadLocal只是操作每个线程的ThreadLocalMap而已。ThreadLocalMap的get方法, 就是在数组中寻址,其结构如下所示,如何在数组中定位索引i有两个要求。

  1. 索引位置i一定要在数组大小内。
  2. 索引足够分散,减少hash冲突。

第二十六章 ThreadLocal原理及生产中遇到的坑_第2张图片

//firstKey.threadLocalHashCode,就是为了达到要求2,均分分散
            // &(INITIAL_CAPACITY - 1) 为了落在数组范围内,常用进行模运算,这里是巧妙运用位运算,效率更高, %2^n与 &(2^n-1)等价,所以要求数组的容量要为2的幂;
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

// -------------------> firstKey.threadLocalHashCode,
//传入的ThreadLocal对象,做了 0x61c88647的增量后求得hash值,为什么要加0x61c88647呢,与斐波那契数列有关,反正是一个神奇的魔法值,目的就是使的hash值更分散,减少hash冲突。
 private final int threadLocalHashCode = nextHashCode();
 private static final int HASH_INCREMENT = 0x61c88647;
 private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
 }

如果在ThreadLocalMap中,出现了hash冲突,即2个ThreadLocal对象的hash计算出来是相同的下标,这里解决hash冲突使用线性探测法(这里不同于集合Map的拉链法),即这个位置冲突,就寻找下一个位置,如果到数组终点了呢,从0再开始,所以这里数组逻辑上是一个首尾相接的环形数组。

//1,向后遍历,获取索引位置
private static int nextIndex(int i, int len) {
     return ((i + 1 < len) ? i + 1 : 0);
}
//2,向前遍历
private static int prevIndex(int i, int len) {
      return ((i - 1 >= 0) ? i - 1 : len - 1);
}

通过前面的分析,则示例中的内存结构如下所示。同一个ThreadLocal对象可被多个线程引用,如图中右侧的ThreadLocal对象,同时被线程ThreadA,ThreadB引用作为key。可以理解为一个ThreadLocal操作所有线程的方式。当然一个线程可以存储多个ThreadLocal,因线程中存储的只能存储同一个ThreadLocal对象一次,再次存储相同的Threadlocal对象,因为key相同,会覆盖原来的value,value可以是基本数据类型的值,也可以是引用数据类型(如封装的对象)。

第二十六章 ThreadLocal原理及生产中遇到的坑_第3张图片

ThreadLocal主要方法剖析

几个概念

首先说明下几个概念:
1.内存结构。
2.卡槽(如主板上的卡槽)
3.脏的条目e!=null&& e.get()== null

主要方法

boolean cleanSomeSlots(int, i int n):清理一些卡槽,每次以线性探测法,并以log(2n)扫描范围进行扫描。 如果是插入被调用时,n表示entry的数量如果是replaceStaleEntry被调用时,则表示table的长度。通过n来控制扫描。如果有脏的条目被清理,则返回true。
int expungeStaleEntry(int staleSlot):删除当前的条目,并向后查找,直到遇到空的entry, 返回下一个为空的槽位。
replaceStaleEntry(ThreadLocal key, Object value,int staleSlot) :替换掉脏的条目。key,value组成一个新的entry,设置到staleSlot的位置。

initialValue方法

initialValue()方法为ThreadLocal要保存的数据类型指定一个初始化值,在ThreadLocal中默认返回值为null,我们可以通过重写initialValue()方法进行数据的初始化。在get()和set()方法中可以看到何时被调用。

set方法

ThreadLocal的set()方法用于设置当前线程中的局部变量。先获取当前线程,查看当前线程的静态ThreadLocalMap是否存在,如果不存在,则创建且赋值。如果当前指向的Entry是存储过的ThreadLocal,就直接将以前的数据覆盖掉,并结束。

public void set(T value) {
        Thread t = Thread.currentThread();//获取当前线程对象
        ThreadLocalMap map = getMap(t);//获取该线程的静态map对象,每个thread只能有一个ThreadLocalMap对象
        if (map != null)
            map.set(this, value);//如果map存在,则设置值
        else
            createMap(t, value);//否则,创建在该线程内创建一个ThreadLocalMap,并赋值
}

在set方法中针对脏entry做了以下处理。

如果当前table[i]!=null说明hash冲突,如果是key存在,替换用新的value,如果当前这个Entry是一个陈旧Entry(有对象但是key为null的entry)就需要向后环形查找,若在查找过程中遇到脏entry就通过replaceStaleEntry进行处理。

如果当前table[i]==null的话说明新的entry可以直接插入,但是插入后会调用cleanSomeSlots方法检测并清除脏entry用于减少内存泄漏的可能,如果清除不成功,并且大于等于负载阈值 threshold (当前size的2/3)的时候就会rehash。至此数据就成功存储进去了。

//在map的set方法中遍历整个map的Entry,如果发现ThreadLocal相同,则使用新的数据替换即可,set过程结束
private void set(ThreadLocal<?> key, Object value) {
            // 我们没有使用get()一样的快速办法,因为使用set()来创建新条目可能是相同的,因为它
            // 要替换现有的条目,在这种情况下,快速办法会使失败更频繁。
            Entry[] tab = table;
            int len = tab.length;
            // 通过传入的key的hashCode与(len-1)亦或,得到数组下标
            int i = key.threadLocalHashCode & (len-1);
            // 这里用for循环为了找到合适的slot(卡槽),为了解决hash冲突,查找下一个可用的卡槽,这里跟hashMap中的拉链法(在冲突卡槽以链表形式串接)不同。
            for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();

                if (k == key) {//如果Entry是存储过的ThreadLocal,就直接覆盖原来的value
                    e.value = value;
                    return;
                }

                if (k == null) {//如果Entry不为空,有对象但k==null,说明是一个旧的Entry
                    replaceStaleEntry(key, value, i);//将新数据设置进去
                    return;
                }
            }

            tab[i] = new Entry(key, value);//否则,根据key与value新建一个Entry并赋值给刚才计算出来的tab下标
            int sz = ++size;//ThreadLocalMap的size+1
            if (!cleanSomeSlots(i, sz) && sz >= threshold)//最后再根据ThreadLocalMap的当前数据元素的大小和阀值做比较,再次进行key为null的数据的清理,清除不成功且大于阈值threshold,则扩容
                rehash();
}

void createMap(Thread t, T firstValue) {
      //新线程没有ThreadLocalMap对象,实例化一个新的ThreadLocalMap并赋值
      t.threadLocals = new ThreadLocalMap(this, firstValue);
}
清除脏的entry

cleanSomeSlots用于清除脏的entry。其中i表示插入entry的位置,n用于扫描控制。在扫描过程中,如果没有遇到脏entry就整个扫描过程持续log2(n)次,log2(n)的得来是因为n >>>= 1,每次n右移一位相当于n除以2。如果在扫描过程中遇到脏entry,就令n为当前hash桶的长度(n=len),再扫描log2(n)趟,此时n长度增加无非就是增加了循环次数从而通过nextIndex往后搜索的范围扩大。
第二十六章 ThreadLocal原理及生产中遇到的坑_第4张图片

第二十六章 ThreadLocal原理及生产中遇到的坑_第5张图片

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
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

按照n的初始值,搜索范围为黑线,当遇到了脏entry,此时n变成了哈希数组的长度(n取值增大),搜索范围log2(n)增大,红线表示。如果在整个搜索过程没遇到脏entry的话,搜索结束,采用这种方式的主要是用于时间效率上的平衡。

expungeStaleEntry用于清除脏的entry,如果在向后扫描过程中再次遇到脏entry继续将其进行清理,直到遇到哈希桶为null时退出。为什么是遇到null退出呢?原因是存在脏entry的前提条件是当前哈希桶(table[i])不为null,只是该entry的key域为null。如果遇到哈希桶为null,很显然它连成为脏entry的前提条件都不具备。

private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    //清除当前脏entry,即将其value引用置为null,并且将table[staleSlot]也置为null。value置为null后该value域变为不可达,在下一次gc的时候就会被回收掉,同时table[staleSlot]为null后以便于存放新的entry;
    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
    //2.往后环形继续查找,直到遇到table[i]==null时结束
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        //3. 如果在向后搜索过程中再次遇到脏entry,同样将其清理掉
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            //处理rehash的情况
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;

                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

以一个例子更清晰的来说一下,假设当前table数组的情况如下图。

第二十六章 ThreadLocal原理及生产中遇到的坑_第6张图片

如图当前n等于hash表的size即n=10,i=1,在第一趟搜索过程中通过nextIndex,i指向了索引为2的位置,此时table[2]为null,说明第一趟未发现脏entry,则第一趟结束进行第二趟的搜索。

第二趟所搜先通过nextIndex方法,索引由2的位置变成了i=3,当前table[3]!=null但是该entry的key为null,说明找到了一个脏entry,先将n置为哈希表的长度len,然后继续调用expungeStaleEntry方法,该方法会将当前索引为3的脏entry给清除掉(令value为null,并且table[3]也为null),但是该方法可不想偷懒,它会继续往后环形搜索,往后会发现索引为4,5的位置的entry同样为脏entry,索引为6的位置的entry不是脏entry保持不变,直至i=7的时候此处table[7]位null,该方法就以i=7返回。至此,第二趟搜索结束;

由于在第二趟搜索中发现脏entry,n增大为数组的长度len,因此扩大搜索范围(增大循环次数)继续向后环形搜索;

直到在整个搜索范围里都未发现脏entry,cleanSomeSlot方法执行结束退出。

替换脏的entry

replaceStaleEntry用于替换脏的entry。

第二十六章 ThreadLocal原理及生产中遇到的坑_第7张图片
https://www.jianshu.com/p/dde92ec37bd1
正常replaceStaleEntry只是将当前的脏的entry进行替换即可,为啥要对周围的卡槽进行清理呢?

/*
 * @param  key the key
 * @param  value the value to be associated with key
 * @param  staleSlot index of the first stale entry encountered while
 *         searching for key.
 */
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 备份来检查在当前运行状态下以前的陈旧条目
    // 我们一次清理

    //向前找到第一个脏entry
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
1.          slotToExpunge = i;

    // Find either the key or trailing null slot of run, whichever
    // occurs first
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // If we find key, then we need to swap it
        // with the stale entry to maintain hash table order.
        // The newly stale slot, or any other stale slot
        // encountered above it, can then be sent to expungeStaleEntry
        // to remove or rehash all of the other entries in run.
        if (k == key) {
            
            //如果在向后环形查找过程中发现key相同的entry就覆盖并且和脏entry进行交换
2.            e.value = value;
3.            tab[i] = tab[staleSlot];
4.            tab[staleSlot] = e;

            // Start expunge at preceding stale entry if it exists
            //如果在查找过程中还未发现脏entry,那么就以当前位置作为cleanSomeSlots
            //的起点
            if (slotToExpunge == staleSlot)
5.                slotToExpunge = i;
            //搜索脏entry并进行清理
6.            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // If we didn't find stale entry on backward scan, the
        // first stale entry seen while scanning for key is the
        // first still present in the run.
        //如果向前未搜索到脏entry,则在查找过程遇到脏entry的话,后面就以此时这个位置
        //作为起点执行cleanSomeSlots
        if (k == null && slotToExpunge == staleSlot)
7.            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    //如果在查找过程中没有找到可以覆盖的entry,则将新的entry插入在脏entry
8.    tab[staleSlot].value = null;
9.    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
10.    if (slotToExpunge != staleSlot)
        //执行cleanSomeSlots
11.        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

我们先分析其中的第一段代码。这部分代码通过prevIndex方法实现往前环形搜索脏entry的功能,初始时slotToExpunge(需要清理的槽)和staleSlot(脏槽)相同,若在搜索过程中发现了脏entry,则更新slotToExpunge为当前索引i。另外,作者认为在出现脏entry的相邻位置也有很大概率出现脏entry,所以为了一次处理到位,就需要向前环形搜索,找到前面的脏entry。根据向前搜索中有脏的entry和向后循环搜索中是否有可覆盖的entry,一共有4种情况。

    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
           slotToExpunge = i;
  1. 向前有脏entry

    1.1 向前环形搜索到脏entry,向后环形查找到可覆盖的entry的情况。

    如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索遇到脏entry时,在第1行代码中slotToExpunge会更新为当前脏entry的索引i,直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束。在接下来的for循环中进行后向环形查找,若查找到了可覆盖的entry,第2,3,4行代码先覆盖当前位置的entry,然后再与staleSlot位置上的脏entry进行交换。交换之后脏entry就更换到了i处,最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。

第二十六章 ThreadLocal原理及生产中遇到的坑_第8张图片

1.2 前向环形搜索到脏entry,向后环形未搜索可覆盖的entry。

如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索遇到脏entry时,在第1行代码中slotToExpunge会更新为当前脏entry的索引i,直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束。在接下来的for循环中进行后向环形查找,若没有查找到了可覆盖的entry,哈希桶(table[i])为null的时候,后向环形查找过程结束。那么接下来在8,9行代码中,将插入的新entry直接放在staleSlot处即可,最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。

第二十六章 ThreadLocal原理及生产中遇到的坑_第9张图片

  1. 向前没有脏entry

    2.1 前向未搜索到脏entry,后向环形搜索到可覆盖的entry。

    如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束,若在整个过程未遇到脏entry,slotToExpunge初始状态依旧和staleSlot相同。在接下来的for循环中进行后向环形查找,若遇到了脏entry,在第7行代码中更新slotToExpunge为位置i。若查找到了可覆盖的entry,第2,3,4行代码先覆盖当前位置的entry,然后再与staleSlot位置上的脏entry进行交换,交换之后脏entry就更换到了i处。如果在整个查找过程中都还没有遇到脏entry的话,会通过第5行代码,将slotToExpunge更新当前i处,最后使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。

第二十六章 ThreadLocal原理及生产中遇到的坑_第10张图片

2.2 前向环形未搜索到脏entry,后向环形查找未查找到可覆盖的entry

如图,slotToExpunge初始状态和staleSlot相同,当前向环形搜索直到遇到哈希桶(table[i])为null的时候,前向搜索过程结束,若在整个过程未遇到脏entry,slotToExpunge初始状态依旧和staleSlot相同。在接下来的for循环中进行后向环形查找,若遇到了脏entry,在第7行代码中更新slotToExpunge为位置i。若没有查找到了可覆盖的entry,哈希桶(table[i])为null的时候,后向环形查找过程结束。那么接下来在8,9行代码中,将插入的新entry直接放在staleSlot处即可。另外,如果发现slotToExpunge被重置,则第10行代码if判断为true,就使用cleanSomeSlots方法从slotToExpunge为起点开始进行清理脏entry的过程。

第二十六章 ThreadLocal原理及生产中遇到的坑_第11张图片

下面用一个实例来有个直观的感受,示例代码就不给出了,代码debug时table状态如下图所示。

第二十六章 ThreadLocal原理及生产中遇到的坑_第12张图片

如图所示,当前的staleSolt为i=4,首先先进行前向搜索脏entry,当i=3的时候遇到脏entry,slotToExpung更新为3,当i=2的时候tabel[2]为null,因此前向搜索脏entry的过程结束。然后进行后向环形查找,知道i=7的时候遇到table[7]为null,结束后向查找过程,并且在该过程并没有找到可以覆盖的entry。最后只能在staleSlot(4)处插入新entry,然后从slotToExpunge(3)为起点进行cleanSomeSlots进行脏entry的清理。是不是上面的1.2的情况。

通过构造方法新建ThreadLocalMap。这里有借鉴意义。为了使hashcode落入到16个长度的table中,采用了hashCode与该长度对应的最大二进制(F)进行"或"操作来获得。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            table = new Entry[INITIAL_CAPACITY];//创建16的table
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);//计算key的hashCode并确定该hashCode在table中的位置。
            table[i] = new Entry(firstKey, firstValue);//在该位置新建一个Entry。
            size = 1;
            setThreshold(INITIAL_CAPACITY);//确定需要扩容的table中Entry的数量(默认负载因子为2/3)
}

当线程中的ThreadLocalMap对象已经存在时,向该ThreadLocalMap中设置值。代码如下。

这里并不需要考虑ThreadLocalMap的线程安全问题。因为每个线程有且只有一个ThreadLocalMap对象,并且只有该线程自己可以访问它,其它线程不会访问该ThreadLocalMap,也即该对象不会在多个线程中共享,也就不存在线程安全的问题。

rehash方法

/**
* 重新包装或者调整table的大小,首先扫描整个表以删除过时的条目。如果不能充分缩小表的大小,则将表扩大2倍
*/
private void rehash() {
    expungeStaleEntries();

    // 使用较低的阀值而不是使用双倍,而避免滞后
    if (size >= threshold - threshold / 4)
    resize();
}
//删除表中过时的Entry
private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {//遍历table
      Entry e = tab[j];
      if (e != null && e.get() == null)//如果table中的条目Entry不为空,但获取不到值,则认为是过时的Entry
         expungeStaleEntry(j);
    }
}

下面是清除过时的key。

private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // 删除过时的条目
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;//table长度减一

            // 遇到空值之前重新刷新
            Entry e;
            int i;
            for (i = nextIndex(staleSlot, len);//获取过时Entry的下一个
                 (e = tab[i]) != null;//下一个Entry不为空则继续, 否则遍历到Entry为空时, 跳出循环并返回索引位置
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();//获取值
                if (k == null) {//如果key(ThreadLocal对象)为空,则清除
                    e.value = null;
                    tab[i] = null;
                    size--;
                } else {//否则计算key的索引位置
                    int h = k.threadLocalHashCode & (len - 1);
                    if (h != i) {//如果计算的索引位置不为下一个Entry的位置
                        tab[i] = null;// 则将i位置对象清空, 替当前Entry寻找正确的位置(当前对象已经保存在e中了)

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.
                        // 如果h位置不为null,则向h后寻找当前Entry的位置
                        while (tab[h] != null)
                            h = nextIndex(h, len);
                        tab[h] = e;
                    }
                }
            }
            return i;
 }

至此,去除陈旧无用的 expungeStaleEntries() 就执行完了,接下来就是一个判断,因为当前又清除了一遍,table里面使用了的size已经变化,当 size >= threshold - threshold / 4 即 数组table长度 len * 2 / 3 - len * 2 / 3 / 4 = 1/2 * len,意味着当清除后如果还是超过一半的话,就进行扩容。那如何扩容呢?resize()啊。

  private void resize() {
        Entry[] oldTab = table;
        int oldLen = oldTab.length;
        int newLen = oldLen * 2;
        Entry[] newTab = new Entry[newLen];
        int count = 0;

        for (int j = 0; j < oldLen; ++j) {
            Entry e = oldTab[j];
            if (e != null) {
                ThreadLocal k = e.get();
                if (k == null) {
                    e.value = null; // Help the GC
                } else {
                    int h = k.threadLocalHashCode & (newLen - 1);
                    // 检测碰撞,
                    while (newTab[h] != null)
                        h = nextIndex(h, newLen);
                    newTab[h] = e;
                    count++;
               }
           }
        }

        setThreshold(newLen);
        size = count;
        table = newTab;
  }

这一部分也是很简单,最重要的就是 int newLen = oldLen * 2; 说明扩容是以两倍进行扩容。resize() 其实就是先申请两倍长度的table数组,然后将数据拷贝到合适位置,然后将新的table数组的引用赋值给原来的table。

get方法

返回当前线程中保存的ThreadLocal的值,如使用场景中的RequestContext对象。

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);//获取当前线程内部的static类型的ThreadLocalMap对象。表示一个类的多个对象共用该static的ThreadLocalMap对象。存入ThreadLocal中的数据事实上是存储在ThreadLocalMap的Entry中。
        if (map != null) {//如果map已经被创建过
            ThreadLocalMap.Entry e = map.getEntry(this);//以当前ThreadLocal实例对象为key获取ThreadLocalMap中的Entry
            if (e != null) {//如果找到Entry,则返回Entry中的value
                @SuppressWarnings("unchecked")
                T result = (T)e.value;//则获取该ThreadLocalMap中key为该ThreadLocal的value
                return result;
            }
        }
        //如果map不存在,证明此线程还没有维护此ThreadlocalMap对象,调用initialValue初始化值。
        return setInitialValue();
  }

如果是之前没有维护ThreadlocalMap对象,则需要初始化值,如下。

private T setInitialValue() {
        T value = initialValue();  // 返回初始值null
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);//根据当前线程获取对应的ThreadLocalMap
        if (map != null)//此map不为空,set当前ThreadLocal为key,则设置上面的初始值null为value,则调用ThreadLocalMap的set方法(该方法在前面有说到)设置值
            map.set(this, value);
        else//如果map为空,表示首次使用的时候,则创建一个ThreadLocalMap,并且与Thread对象的threadLocals属性相关联(即ThreadLocalMap是一个Lazy初始化的过程)
            createMap(t, value);
        return value;//返回initalValue()方法的结果,当然这个结果在没有被重写的情况下结果为null
}

这里的 value 是恒为null的,在get调用的时候呢,map一定null,就会初始化一个 ThreadLocalMap 给当前Thread,并将为null的value存进去。那这里返回值就为null,意味着当前Thread没有ThreadLocal时,返回null,符合直觉。

如果当前Thread存过值了呢,那ThreadLocalMap map就不会为空,接着调用ThreadLocalMap中的getEntry()得到想要的Entry。

private Entry getEntry(ThreadLocal key) {
      int i = key.threadLocalHashCode & (table.length - 1);
      Entry e = table[i];//获取hash函数与桶大小异或,得到桶的下标中的entry
      if (e != null && e.get() == key)//如果entry存在,且没有“脏”,则返回该entry
          return e;
      else
          return getEntryAfterMiss(key, i, e);
}

如果没有找到,则调用getEntryAfterMiss(key, i, e)从当前节点开始查找。当key==null时,表示遇到脏entry也会调用expungeStaleEntry对脏entry进行清理。

private Entry getEntryAfterMiss(ThreadLocal key, int i, Entry e) {
           Entry[] tab = table;
           int len = tab.length;

           while (e != null) {
               ThreadLocal k = e.get();
               if (k == key)
                   return e;
               if (k == null)//遇到脏entry,向后清理
                   expungeStaleEntry(i);
               else
                   i = nextIndex(i, len);
               e = tab[i];
           }
           return null;
       }

remove方法

当线程执行完后,要调用remove()方法主动清除当前线程的ThreadLocal对应的实体entry对象。避免引起内存泄漏。

在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。

     public void remove() {
         // 获取当前线程Thread对象,进而获取此线程对象中维护的ThreadLocalMap对象
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);//实际上是调用threadLocalMap的remove方法,调用expungeStaleEntry方法清理脏的entry。
     }

thread.exit()

-1,上图实线箭头代表强引用,虚线代表弱引用; JVM存在四种引用:强引用,软引用,弱引用,虚引用,弱引用对象,会在下一次GC(垃圾回收)被回收。 -2,上图可见Entry的 Key指向的ThreadLocal对象的引用是弱引用,一旦tl的强引用断开,没有外部的强引用后,在下一次JVM垃圾回收时,ThreadLocal对象被回收了,此时 key–> null,而此时 Entry对象,是有一条强引用链的,th–> Thread对象–>ThreadLocalMap–> Entry,可达性性分析是可达的,这时ThreadLocalMap集合,即在数组的某一个索引是有Entry引用的,但是该Entry的key为null,value依然有值,但再也用不了了,这时的Entry称为staleEntry(我理解为失效的Entry),造成内存泄漏。 -3,内存泄漏是指分配的内存,gc回收不了,自己也用不了; 内存溢出,是指内存不够,如有剩余2M内存,这时有一个对象创建需要3M,内存不够,导致溢出。内存泄漏可能会导致内存溢出,因为内存泄漏就会有人占着茅坑不拉屎,可用空间越来越少,gc也回收不了,最终导致内存溢出。 -4,那线程对象被回收了,这条引用链断了就没事了,下次Gc就会把ThreadLocalMap集合中对象全部回收了,就不存在内存泄漏问题了;但开发环境,线程一般会在线程池创建来节约资源,每个线程是被重复使用的,生命周期很长,线程对象长时间是存在内存中的,而ThreadLocalMap和Thread生命周期相同,只有线程结束,它内部持有的ThreadLocalMap对象才会销毁,如下Thread#exit:

当线程退出时,会执行exit方法。从源码可以看出当线程结束时,会令threadLocals=null,也就意味着GC的时候就可以将threadLocalMap进行垃圾回收,换句话说threadLocalMap生命周期实际上thread的生命周期相同。

    /**
     * This method is called by the system to give a Thread
     * a chance to clean up before it actually exits.
     */
    private void exit() {
        if (group != null) {
            group.threadTerminated(this);
            group = null;
        }
        /* Aggressively null out all reference fields: see bug 4006245 */
        target = null;
        /* 加速这种资源的释放 */
        threadLocals = null;
        inheritableThreadLocals = null;
        inheritedAccessControlContext = null;
        blocker = null;
        uncaughtExceptionHandler = null;
    }

使用withInitial方法,创建具有初始值的ThreadLocal类型的变量,从结果可以看得出来,我们没有任何的设置,可以获取到值。

ThreadLocal内存泄漏问题

首先要理解内存泄露(memory leak)和内存溢出(out of memory)的区别。内存溢出是因为在内存中创建了大量在引用的对象,导致后续再申请内存时没有足够的内存空间供其使用。内存泄露是指程序申请完内存后,无法释放已申请的内存空间。内存泄漏是导致内存溢出的原因之一,内存泄漏更多的是程序中不再持有某个对象的引用,但是该对象仍然无法被垃圾回收器回收,这是因为该对象到引用根Root的链路是可达的,比如ThreadRef到Entry.Value的引用链路。

下面我们举个内存泄漏的例子。定义一个threadLocal,每次设置100MB大小,最终存储于threadLocal中的数据以最后一次set为主,然后将threadLocal设置为null,最后手动执行一次Full GC,我们用Visual VM工具对JVM的进程进行监控,发现我们无论进行多少次GC,最后还是100MB的内存得不到释放。如下图。

ThreadLocal<byte[]> threadLocal = new ThreadLocal<>();
TimeUnit.SECONDS.sleep(30L);
threadLocal.set(new byte[1024 * 1024 * 100]);
threadLocal.set(new byte[1024 * 1024 * 100]);
threadLocal.set(new byte[1024 * 1024 * 100]);
threadLocal = null;
Thread.currentThread().join();

第二十六章 ThreadLocal原理及生产中遇到的坑_第13张图片

当打开remove方法时,内存将停留在300MB内,当强制执行内存回收时,最终占用的内存会释放到接近0。

第二十六章 ThreadLocal原理及生产中遇到的坑_第14张图片

通过这两个图对比,就是说,当没有调用remove方法时,即使进行了Full GC,也有一部分内存是不能释放的,说明存在内存泄漏问题。下面分析下为什么会存在内存泄漏问题。

根据上面Entry方法的源码,我们知道ThreadLocalMap是使用ThreadLocal的弱引用作为Key的。当Thread和ThreadLocal发生绑定后,关键对象引用如下图所示。将ThreadLocal Ref显示地指定为null时,引用关系就断开了“X"的链接。这个ThreadLocal势必会被回收,这样一来,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:

Current Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value

永远无法回收,造成内存泄露。
只有当前thread结束以后,Current Thread Ref就不会存在栈中,强引用断开Thread,ThreadLocalMap,Entry将全部被GC回收。但如果是线程对象不被回收的情况,比如使用线程池,线程结束是不会销毁的,尤其是包含大量线程的线程池,且threadLocal类型的变量很多,将会占用非常大的空间,就可能出现真正意义上的内存泄露。

如果在执行业务逻辑后调用remove方法,就会断开ThreadLocalMap到Entry的引用,因此可以被回收。那么如果没有显式地进行remove呢?Doug Lea大师做了一些优化,但也只能说如果对应线程之后调用ThreadLocal的get和set方法都有很高的概率会顺便清理掉无效对象,断开value强引用,从而大对象被收集器回收。

通过key的弱引用,以及remove方法等内置逻辑,通过合理的处理,减少了内存泄漏的可能,如果不规范,就仍旧会导致内存泄漏。

第二十六章 ThreadLocal原理及生产中遇到的坑_第15张图片

对于ThreadLocal有2点需要注意。

  1. ThreadLocal实例本身是不存储值,它只是提供了一个在当前线程中设置值和找到副本值(它自己就是ThreadLocalMap的key)。每个Thread中包含一个ThreadLocal对象。get()操作先获取当前Thread对象。然后获取此线程对象中的ThreadLocalMap,先判断ThreadLocalMap是否存在,如果存在,则以当前线程为key,调用ThreadLocalMap的getEntry()方法获取对应的存储实体e,找到对应的value值,然后返回。
  2. public void remove()将当前线程局部变量的值删除。
    该方法是JDK 5.0新增的方法,目的是为了减少内存的占用。需要指出的是,当线程结束后,对应该线程的局部变量将自动被垃圾回收,所以显式调用该方法清除线程的局部变量并不是必须的操作,但它可以加快内存回收的速度,不然会导致JVM的内存出现一段时间升高,然后平缓。当发生GC后就又会走低,这样一直往复。

ThreadLocal存在的问题

public class ThreadLocalTest {

    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static class ParseDate implements Runnable {

        int i = 0;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                 Date t = sdf.parse("2020-09-22 19:29:" + i%60);
                 System.out.println(i + ":" + t);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String [] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        IntStream.rangeClosed(1,1000).forEach(i -> es.execute(new ParseDate(i)));
    }
}

执行后发现时间会不断发生变化,有时还会报错,并且根本停不下来:(。如下图所示。
第二十六章 ThreadLocal原理及生产中遇到的坑_第16张图片
如果为每个线程分配一个实例,就不会存在上面存在的问题。

public class ThreadLocalTest {

    private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    private static ThreadLocal<SimpleDateFormat> t1 = new ThreadLocal<>();

    public static class ParseDate implements Runnable {

        int i = 0;

        public ParseDate(int i) {
            this.i = i;
        }

        @Override
        public void run() {
            try {
                if (t1.get() == null) {
                    t1.set(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));//为每个线程分配一个实例
                }
                Date t = t1.get().parse("2022-09-22 19:29:" + i % 60);
                System.out.println(i + ":" + t);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        ExecutorService es = Executors.newFixedThreadPool(10);
        IntStream.rangeClosed(1, 1000).forEach(i -> es.execute(new ParseDate(i)));
    }
}

总结

ThreadLocal 如何保证Local属性

当需要使用多线程时,有个变量恰巧不需要共享,此时就不必使用synchronized这么麻烦的关键字来锁住,每个线程都相当于在堆内存中开辟一个空间,线程中带有对共享变量的缓冲区,通过缓冲区将堆内存中的共享变量进行读取和操作,ThreadLocal相当于线程内的内存,一个局部变量。每次可以对线程自身的数据读取和操作,并不需要通过缓冲区与 主内存中的变量进行交互。并不会像synchronized那样修改主内存的数据,再将主内存的数据复制到线程内的工作内存。ThreadLocal可以让线程独占资源,存储于线程内部,避免线程堵塞造成CPU吞吐下降。

在每个Thread中包含一个ThreadLocalMap,ThreadLocalMap的key是ThreadLocal的对象,value是独享数据。

  1. 什么循环的终止条件为什么是一旦找到一个空对象就停止返回null(表示没找到)呢?

答: 在进行放的时候,如果哈希碰撞了,就会进行线性探测再散列,现在挨着挨着找,如果当时是存放了数据的话,那么就会放到第一个是空的地方,然后第一个为空的地方不为空了,而现在取的时候都出现null的现象了,说明根本没有存过。

  1. expungeStaleEntry(i) 中的重新放置不会放到当前i之前么?从而导致存了,却取不到数据现象。

答:不会,首先能保证的是从哈希函数算出的下标 H(key) 开始到当前的Entry 都是有效的,因为i开始就判断了 k == key 的,其次 expungeStaleEntry(staleSlot) 是从staleSlot开始,清除key为null的Entry,试想如果当前处理位置的下一位就是 目标Thread 的 ThreadLocalMap ,那么它将会被放在当前位置,因为,当前位置一定为空,从H(key)到当前位置一定都有其他Entry占着位置,这时候在 getEntryAfterMiss(ThreadLocal key, int i, Entry e) 中会再一次取当前位置的值,然后判断。

1.每个Thread中的ThreadLocalMap在初始化为空,且ThreadLocalMap中的Entry使用了当前的threadLocal作为key,但这个Entry继承了弱引用WeakReference,这样设计有什么好处?会带来什么问题?

a.在前面的引用中我们有讨论过弱引用。当执行A a = new A();就会在内存中分配了这个对象,当执行a = null时,GC就会将a的对象加入到软引用队列,过会时间就会回收。

基于上面已经有了 A a = new A();,又出现了一个B b = new B(a);这样一个创建对象b这个操作。此时我们执行a = null;这个操作,GC并不会回收分配给a的空间,因为即使a被设置为null,但是b仍然持有对象a的引用,所以GC不会回收a,这样一来就尴尬了 既不能回收,又不能使用 这种情况就有一个专业的名词叫内存泄露
那么如何处理呢?可以通过b = null;,也可以使用弱引用WeakReference w = new WeakReference(a);。因为使用了弱引用WeakReference,GC是可以回收 a 原先所分配的空间的。
再回到 ThreadLocalMap 的层面来看为啥哈希表的节点要实现WeakReference弱引用。也就是ThreadLocalMap中的key使用Threadlocal实例作为弱引用。如果一个ThreadLocal没有外部引用去引用它,那么在系统GC的时候它势必要被回收的。这样一来ThreadLocalMap中就会出现keynullentry就没有办法访问这些keynullEntryvalue。如果线程一直不能结束的话,就会存在一条强引用链:ThreadLocalRef->Thread->ThreadLocal->ThreadLocalMap->Entry->value永远无法被回收造成内存泄露。其实在ThreadLocalMap的设计中为了防止这种情况,也有一些防护措施,比如新增、移除、获取的时候都会去擦除key==nullvalue。但是这些措施并不能保证一定不会内存泄露,比如:
a. 使用了static修饰的ThreadLocal,延长了ThreadLocal的生命周期,可能会导致内存泄露。
b. 分配使用了ThreadLocal又不再调用get set remove方法也会导致内存泄露。
从表面上看内存泄漏的根源在于使用了弱引用。网上的文章大多着重分析ThreadLocal使用了弱引用会导致内存泄漏,但是另一个问题也同样值得思考:为什么使用弱引用而不是强引用?

官方给的说法是: 为了应对非常大和长时间的用途,哈希表使用弱引用的 key。
我们假设我们自己设计的时候key 使用的强引用和弱引用

  1. key使用强引用:如果引用ThreadLocal的对象ThreadLocalRef被回收了,但是ThreadLocalMap还持有ThreadLocal对象的强引用,如果没有手动删除的话ThreadLocal不会被回收,这样会导致Entry内存泄露
  2. key使用弱引用:引用的ThreadLocal的对象ThreadLocalRef被回收了,由于ThreadLocalMap有ThreadLocal的弱引用,即使没有手动删除,ThreadLocal也会被回收。value在下一次ThreadLocalMap调用get(),set(),remove()的时候会被清除。
    比较上面的2种情况,我们会发现:ThreadLocalMap的生命周期和Thread一样长,如果都没有手动删除key都会导致内存泄露。但是弱引用多了一层保障,就是value在下一次ThreadLocalMap 调用 get(),set(),remove() 的时候会被清除。
    因此可知,ThreadLocal发生内存泄露的根源是由于ThreadLocal的生命周期和Thread一样长,在没有手动删除对应的key的时候就会导致内存泄露,并不是因为弱引用导致的,弱引用只是优化的方式。
    综上分析:为了避免内存的泄露,每次使用完 ThreadLocal 的时候都需要调用 remove() 方法来擦除数据。并且大规模网站一般都会使用到线程池,如果没有及时清理的话不仅是内存泄露,业务逻辑可能也会被影响。所以养成好习惯,记得擦除数据。

第二十六章 ThreadLocal原理及生产中遇到的坑_第17张图片

三者关系

  • 问题2:说一说ThreadLocalsynchronized的区别?

ThreadLocalsynchronized都是用来处理多线程环境下并发访问变量的问题,只是二者处理的角度不同、思路不同。
ThreadLocal 是一个类,通过对当前线程中的局部变量操作来解决不同线程的变量访问的冲突问题。所以ThreadLocal提供了线程安全的共享对象机制,每个线程都拥有其副本。
Java中的synchronized是一个保留字,它依靠JVM的锁机制来实现临界区的函数或者变量的访问中的原子性。在同步机制中,通过对象的锁机制保证同一时间只有一个线程访问变量。此时,被用作“锁机制”的变量时多个线程共享的。
同步机制(synchronized关键字)采用了以“时间换空间”的方式,提供一份变量,让不同的线程排队访问。而ThreadLocal采用了“以空间换时间”的方式,为每一个线程都提供一份变量的副本,从而实现同时访问而互不影响。

  • 问题3:ThreadLocal实现原理是什么?它是怎么样做到局部变量不同的线程之间不会相互干扰的?
    最开始的时候,我还没有去看源码只是知道它的一些功能的时候,依照 ThreadLocal 的表现,我猜的是这样的:每个 ThreadLocal 类都去创建一个Map,然后用线程ID作为key,要存储的局部变量为Value,这样就可以实现线程的值互相隔离的效果。第一次看JDK8的源码的时候才知道我完全理解反了,这是初期的时候的思想。
    现在的底层是每个 Thread 维护了一个ThreadLocalMap 哈希表,这个哈希表key是 ThreadLocal 实例本身,value 是真正要存储的值Object。既然JDK8对这样优化的话一定是有原因的:
    1. 这样设计之后每个Map存储的Entry数量就会变小,因为之前存储数量是由Thread的数量决定的,现在是由 ThreadLocal 的数量决定的
    2. Thread销毁之后,对应的ThreadLocalMap也会随之销毁,减少内存的占用
  • 问题4:原理总结
    1. 每个Thread维护着一个ThreadLocalMap的引用
    2. ThreadLocalMap是ThreadLocal的内部类,用Entry来进行存储
    3. 调用ThreadLocal的set()方法时,实际上就是往ThreadLocalMap设置值,key是ThreadLocal对象,值是传递进来的对象
    4. 调用ThreadLocal的get()方法时,实际上就是往ThreadLocalMap获取值,key是ThreadLocal对象
    5. ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

正是因为这几点,所以能够实现数据隔离,获取当前线程的局部变量值,和其它线程无关。

  • 问题5:线程之间如何传递 ThreadLocal 对象?

在实际开发中,我们经常会使用 ThreadLocal 传递日志的 requestId,以此来获取整条的请求链路记录下来方便排查问题。然而当一个线程中开启了其它的线程,此时的 Threadlocal 里面的数据就会无法获取。比如下面的代码最开始获取到的就是Null。因为不是同一个线程,所以理所当然输出的值为Null,如果要实现父子线程通信,这个问题在 Threadlocal 的子类 InheritableThreadLocal 已经有对应的实现了,通过这个实现,可以实现父子线程之间的数据传递,在子线程中能够使用父线程的 ThreadLocal 本地变量。InheritableThreadLocal 继承了 ThreadLocal 并且重写了三个相关的方法,具体处理大致是 之前的 ThreadLocal 获取 ThreadlocalMap 的时候一般都是用 this ,在这里都是Thread先获取父线程,然后将父线程的 ThreadLocalMap 传递给子线程

/**
 * ThreadLocalTest
 *
 * @author yupao
 * @since 2019/1/22 下午10:52
 */
public class ThreadLocalTest {
    public static void main(String[] args) {
        //ThreadLocal threadLocal = new ThreadLocal<>();
        ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();
        threadLocal.set("main thread");
        Thread thread = new Thread(()->{
            // 直接使用ThreadLocal输出 null,使用ThreadLocal输出 main thread
            System.out.println(threadLocal.get());
        });
        thread.start();
    }
}

真的这么好的吗,看下阿里巴巴编码规范插件说了啥?不要显示创建线程,请使用线程池!所以下面我们用线程池来试试!

第二十六章 ThreadLocal原理及生产中遇到的坑_第18张图片

不规范提示

如下代码所示,当线程池的核心线程数设置为1的时候,2次输出的结果都是 ”我是主线程1“。ThreadPoolManage 是我本地写的一个线程池实现,github上有源码。原因相信都能踩到了,线程池会缓存使用过的线程,第一个任务来的时候创建一个线程,此时线程空闲了,第二次来任务还是会使用这个线程,所以就会出现下面的问题了。如何解决?阿里的transmittable-thread-local 提供了解决方案,思路是,InheritableThreadLocal虽然可以完成父子线程的传递,但是对于使用了线程池的情况线程是让线程池去创建好的,然后拿来复用的,这个时候父子线程传递 ThreadLocalMap 的引用没有意义了,应用需要的是吧任务提交给线程池时候把 ThreadLocalMap 传递到任务去执行。感兴趣在阿里的github上有,已经开源的。

/**
 * ThreadLocalTestExecutor
 *
 * @author yupao
 * @since 2019/1/23 下午11:00
 */
public class ThreadLocalExecutorTest {
    private static ThreadPoolManager threadPoolManager = ThreadPoolManager.INSTANCE;
    public static void main(String[] args) {
        ThreadLocal<String> threadLocal = new InheritableThreadLocal<>();
        threadLocal.set("我是主线程1");
        threadPoolManager.addExecuteTask(()->{
            System.out.println(threadLocal.get());
            return null;
        });
        threadLocal.set("我是主线程2");
        threadPoolManager.addExecuteTask(()->{
            System.out.println(threadLocal.get());
            return null;
        });
        
        //当线程池核心线程数为1的时候2次输出都是 我是主线程1
    }

}
  • 问题6: InheritableThreadLocal 是如何弥补 ThreadLocal 不支持继承的特性,它的实现原理是啥?
    InheritableThreadLocal 在子线程创建的时候把父线程的 ThreadLocalMap 传递给它,它继承 ThreadLocal 并重写了3个方法,并使用 Thread.inheritableThreadLocals 代替了 Thread.threadlocals 字段
public class InheritableThreadLocal<T> extends ThreadLocal<T> {

    protected T childValue(T parentValue) {
        return parentValue;
    }

    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }
}

为什么不直接用线程id来作为ThreadLocalMap的key?

这个问题很容易解释,因为一个线程中可以有多个ThreadLocal对象,所以ThreadLocalMap中可以有多个键值对,存储多个value值,而如果使用线程id作为key,那就只有一个键值对了。 最初的设计是采用ID。

ThreadLocalMap为什么要定义在ThreadLocal中,而不直接定义在Thread中?

ThreadLocalMap不是必需品,定义在Thread中增加了成本,定义在ThreadLocal中按需创建。

ThreadLocalMap本身就是一个Map,其中Map的key是ThreadLocal对象,而value则是ThreadLocal中存储的对象。除此之外,Map的key继承了WeakRefence,也就是说ThreadLocalMap的key是一个弱依赖,如果GC Root不可达的情况下,在下一次Java GC时会被回收。

2.4 为什么使用弱引用?

从文章开头通过threadLocal,threadLocalMap,entry的引用关系看起来threadLocal存在内存泄漏的问题似乎是因为threadLocal是被弱引用修饰的。那为什么要使用弱引用呢?

如果使用强引用

假设threadLocal使用的是强引用,在业务代码中执行threadLocalInstance==null操作,以清理掉threadLocal实例的目的,但是因为threadLocalMap的Entry强引用threadLocal,因此在gc的时候进行可达性分析,threadLocal依然可达,对threadLocal并不会进行垃圾回收,这样就无法真正达到业务逻辑的目的,出现逻辑错误

如果使用弱引用

假设Entry弱引用threadLocal,尽管会出现内存泄漏的问题,但是在threadLocal的生命周期里(set,getEntry,remove)里,都会针对key为null的脏entry进行处理。

从以上的分析可以看出,使用弱引用的话在threadLocal生命周期里会尽可能的保证不出现内存泄漏的问题,达到安全的状态。

https://www.jianshu.com/p/dde92ec37bd1

https://www.jianshu.com/p/e200e96a41a0

https://www.cnblogs.com/flydashpig/p/11922609.html

你可能感兴趣的:(Java,JVM)