彻底搞懂ThreadLocal

ThreadLocal原理和内存泄漏原因简单讲解,包懂

1.简介

​ ThreadLocal作用很大,就是一个线程一个独立副本,看这篇文章之前,你需要确保你已经了解过或体会到ThreadLocal,本篇主要围绕ThreadLocal原理和为何会内存泄漏展开讲解。

1.2 实战例子

​ 我们知道Tomcat接收到一个请求之后,会从线程池中取出一个线程对这个请求进行处理,这个请求包含着token(登录令牌)。

​ 在很多业务层的方法中,我们都希望可以得到这个请求的用户信息,用于记录日志、权限校验和记录行为等。但是有个尴尬的问题,如果我们每当需要用户的信息时,都要对token解析、查缓存、查数据库来得到用户信息将变得十分繁琐和难以维护!

有些朋友可能想到了一个办法,如下:

public class IndexController{
    
    
    private UserInfo userInfo;
    @GetMapping
    public void test(Request req){
    
    
        //解析token、查数据库最后得到了用户信息
        this.userInfo = xxMethod(req);
        //TODO Other..
    }    
}

​ 不一定放到Controller,也可能放到其他业务层当中,然后只要想获取当前登录用户的时候就直接获取。大错特错!

​ 因为Spring默认是单例模式,所以只有一个Controller,当多个请求也就是相当于多个线程过来的时候,对于同一个Controller对象的成员变量修改与获取,必须存在线程安全问题。

正确做法

​ 我们可以在全局过滤器,或者拦截器当中拦截请求,然后对请求进行验证,最后把用户信息放到ThreadLocal中就能解决线程安全问题。

public class UserInfoUntil implements HandlerInterceptor{
    
    
    
    private static ThreadLocal<UserInfo> threadLocal = new ThreadLocal<UserInfo>();
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    
    
        //解析并放置当前请求的用户信息
        threadLocal.set(xxMethod(request));
        return HandlerInterceptor.super.preHandle(request, response, handler);
    }
    //随时可以获取当前用户信息啦
    public static UserInfo  getUserInfo(){
    
    
        return threadLocal.get();
    }
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    
    
        //防止内存泄漏
        threadLocal.remove();
    }
}

​ ps:实际开发中并不会这么操作直接把用户信息放在拦截器类中,正确的而是放在User工具类中,但是肯定会使用到拦截器和过滤器得到当前请求的用户信息!

2. Thread和ThreadLocal涉及的简单结构

ThreadLocal数据结构

//ThreadLocal结构
public class ThreadLocal<T> {
    
    
    //实际用到的数据结构
    static class ThreadLocalMap {
    
    
        //节点数组,就是Node意思
        private Entry[] table;
        //长度
        private int size = 0;
        //节点结构
        static class Entry extends WeakReference<ThreadLocal<?>>{
    
    
            Object value;
            Entry(ThreadLocal<?> k, Object v) {
    
    
                super(k);//放到父类的referent字段中。所以Entry包含{referent 还有 Value两个成员变量}
                value = v;
            }
        }
    }
}

Thread涉及结构

public class Thread {
    
    
    ThreadLocal.ThreadLocalMap threadLocals = null;
    //还有一个,这是父子线程共享变量传递,本篇不讲。
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

2.1 结构说明

2.1.1 ThreadLocalMap

​ 这才是ThreadLocal实际使用到的结构,我们观察到ThreadLocalMap中有一个Entry数组。Map是什么?不就是Key-Value的键值对的形式存放数据的吗?为什么这里却是一个数组的结构?

​ 实际上就是键值对的结构,ThreadLocalMap通过计算Key的哈希值得到下标,这个就作为数组的下标直接存取数据。下面有提及到存取原理。

2.1.2 Thread

​ 这个就是线程类不必多说。我们只看其一个字段,就是ThreadLocal.ThreadLocalMap。这个不就是声明的就是TheadLocal的内部类ThreadLocalMap么?为什么要这么声明呢?

​ 试想一下?ThreadLocal作为一个线程隔离的工具类,既然想要实现线程之间的数据隔离,而线程就是Thread的实例罢了。一个Thread实例等价于一个线程。这么一来,你是不是悟到了什么呢?原来实现线程某个数据的隔离,就是把他丢在他的成员变量就好了!

​ 但是,具体实现原理可没这么简单,要考虑两个问题:1.有多个想作为线程隔离的属性怎么办(核心,涉及到如何存取,结构如何设计)?2.线程作为珍贵的资源,更多是使用线程池来使用线程,线程不会轻易销毁,这么这个ThreadLocal.ThreadLocalMap threadLocals我们怎么清空,以至不影响其他任务?

3. set原理

准备,下文提到的local就是这里声明的local。

ThreadLocal<Integer> local = new ThreadLocal<>();
local.set(99)

我们来看看ThreadLocal.set()的源码

public void set(T value) {
    
    
    Thread t = Thread.currentThread();//获取当前线程
    ThreadLocalMap map = getMap(t);//获取当前线程的 ThreadLocal.ThreadLocalMap threadLocals
    if (map != null)
        map.set(this, value);//map已经初始化。【这个this重点关注下!】
    else
        createMap(t, value);//未初始化
}

image-20220815142544493

[重点]:关于这个this,指向谁呢?看到“准备”的代码,

local.set(99)

显然,这个this指向的是ThreadLocal的实例->local,为什么要传递这个this?

这个this作用是什么呢?这个非常关键,this的作用非常大,你需要记住这个this被传递了,关系到后续原理的理解,我们后面看看这个this在干嘛。

这个方法很简单,我们再看看ThreadLcoalMap set()方法

private void set(ThreadLocal<?> key, Object value) {
    
    
    Entry[] tab = table;//ThreadLocalMap 的Entry节点,这个table是属于Thread实例的ThreadLocal.ThreadLocalMap的中的table
    int len = tab.length;
    int i = key.threadLocalHashCode & (len-1); //计算key的哈希值,得到key理应在Entry[]什么位置
	//key对应的下标不为空
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
    
    //当前key已存在,则下一个。(i + 1 < len) ? i + 1 : 0。不会覆盖0滴,这是是循环来滴
       //key->this->local
       //k与key都是TheadLocal的实例,
        ThreadLocal<?> k = e.get();// k->从Entry[]取出的Entry中的referet
        if (k == key) {
    
    
            e.value = value; //key相等则更新值
            return;
        }
        if (k == null) {
    
    
            replaceStaleEntry(key, value, i);//k被gc回收,清掉这个Entry 这个涉及到内存泄漏 后面讲
            return;
        }
    }
    //key下标为空,则直接new一个节点
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)//检测Entry为null的,以清掉 也是内存泄漏 后面讲
        rehash();//重计算hash,重排Entry[]
}

set图解

未命名文件

3.1 图解ThreadLocal

image-20220815150727554

​ 一个线程一个Stack。

​ Heap是堆内存,Entry是数组,图中展示的是一个Entry表示含有ThreadLocal的引用和对应的Value。

Thread有个ThreadLocalMap

class Thread {
    
    
    ThreadLocal.ThreadLocalMap threadLocals;
}

ThreadLocalMap中有一个Entry

class ThreadLocalMap{
    
    
    Entry[] table;
    class Entry{
    
    
    }
}

​ Entry中的Value,比如就是你自己通过local.set(99),的值,这个值就是99.但是为什么是Entry[]数组呢?因为业务可能需要的是local1.set()。local2.set()等等

详细说说

image-20220815152305035

这一个框框就只是一个栈的一个栈帧罢了,就是一个方法调用中的局部变量。

  1. ThreadLocal local可以是成员变量或者局部变量,这个不重要。我们结合代码可以知道,this就是local,通过哈希算法计算this的哈希值得到下标,这个下标就是Entry[]中的一个槽位(假设该槽位没有元素)。
  2. 然后直接new一个Entry对象丢进去,Entry中的Value就是local.set(99)中的99这个值。
  3. 所以这个Entry在Entry[]的定位是通过计算key(就是local->this)的哈希值找到槽位,然后赋值进去。

假设我们有多个ThreadLocal local

ThreadLocal userId = new ThreadLocal();
ThreadLocal request = new ThreadLocal();
ThreadLocal response = new ThreadLocal();
userId.set("123");
request.set(..);
response.set(...);

因为ThreadLocal中的内部类ThreadLocalMap,是Thread的一个成员变量。

ThreadLocalMap中有个Entry。

假设我们ThreadLocalMap不是Entry[],而只有单单一个Entry。那么是不是就不需要计算this的哈希值得到数组下标,找到某个Entry在Entry[]的槽位了?

但是!这样的话,想实现上面的代码,存放多个欲线程隔离的数据将变得不可能! 原因:被后续的Entry覆盖了。

4.内存泄漏

这面试加分项,也是难点之一

image-20220815150727554

看代码或图知道,有个弱引用,这个弱引用就是问题得出发点了。

前提知识:在JVM中,gc对于弱引用,只要gc执行,弱引用的就会被回收。但是强引用一定不会(图中实线)

Entry[]怎么清空

  1. 我们知道,我们可以通过local.remove()方法,把当前的Entry从Entry[]清掉,这样就可以释放内存。

  2. 但是,如果我们用完这个local了,请求处理完成了,最后并没用remove()怎么办?

  3. 此时因为方法全部调用完成,Stack为空,Heap空间中的ThreadLocal没有了来着Stack的强引用,只有来自Entry的弱引用。

  4. 当弱应用被gc回收后,Entry中的ThreadLocal不就为null了吗?说明这个Entry过时了,理应被回收

  5. 上面说了,Entry的定位是通过计算local的哈希值,那local是存放在stack的,既然请求都结束了,那么local自然就无了,我们得不到local指向Heap的地址,那么还怎么哈希计算?自然无法通过下标定位到具体的Entry了。

  6. 而Entry[]数组被ThreadLocalMap强引用,gc不会回收,你又不能local.remove()掉其value,那么内存就一直被无效占用而无法回收,这就是内存泄漏

Tomcat请求处理的补充

如果Thread自己被回收,Entry[]自然会被回收掉的。所以有些同学得出结论,在Web开发中,一个Thread不就是一个请求处理完成之后就销毁了吗?那Entry[]自然不就被回收了,哪来的内存泄漏?

实际上,一个请求一个线程处理没问题,但是有没考虑过,线程创建和销毁的开销很大?所以请求处理完,线程并没有被回收,只是被Tomcat丢到了连接线程池了而已,提供给下一个请求直接取出来用,那这回Entry[]不就不会被回收了么?还不是依旧有内存泄漏问题

4.1 大聪明

有些人说,把弱引用改为强引用不就好了,这样Entry中的ThreadLocal就不会被回收了嘛。你都能想到这样能解决问题,那写ThreadLocal代码的作者也没必要费尽心思要弄个弱引用来了。

讨论这个问题之前,我们需要说说ThreadLocal作者怎么提高了代码健壮性。

作者肯定也是考虑到有内存泄漏的隐患,所以代码中其实有检查机制,其方法为:

private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot);
private int expungeStaleEntry(int staleSlot);
private boolean cleanSomeSlots(int i, int n);

可能不知这三个,但他们都有一个共性,他们都会去判断Entry中的ThreadLocal的指向是不是null,如果为null,这么这个Entry一定会被干掉

而这三个方法的触发时机,都是local.set(),local.get(),local.remove()的时候调用的,remove()是删掉local的Entry,但是依旧会触发null检查,因为Entry是数组,而不是只有一个

回到来

知道了作者的费尽心思,如果大家在使用ThreadLocal最后一定会remove,这么这些检查的操作都是多余的,但是如果世界这么简单就美好了(引用Mybatis官文)。

image-20220815150727554

如果修改为强引用,当stack被清空,处于堆中的ThreadLocal依旧还有来自Entry的强引用。

那么ThreadLocal的内存不会被释放,但是,Entry的指向ThreadLocal的引用就不可能会为null,那就有大问题了。

后果是:检测机制的那三个方法,怎么检测Entry[]中的某个Entry失效了???换句话说,怎么区分Entry[]中哪些Entry是过时的?哪些是在用的?

检测不出来!!!反而导致检测机制无法起作用,内存泄漏的风险更大

因此,弱引用的出现,是为了提高代码的健壮性,他解决了:如何找出失效Entry的问题。

弱引用根本不是导致内存泄漏的(直接/间接)原因!!!

5. 总结

博客到此结束,这是只是简略的说说ThreadLocal的原理,其还有初始化,父子线程值传递等功能~ 有了原理的基础,之后的功能理解起来会很容易。

90%人的误区,某些up主都讲错了!

  1. 弱引用导致的内存泄漏这是伪命题!

  2. 看到很多博客都说这个弱引用被gc,最后导致Entry的ThreadLocal的指向为null,导致Value定位不到,真是误人子弟

  3. Entry的定位依赖的是ThreadLocal的哈希计算得到Entry[]下标。

  4. Entry中的ThreadLocal指向,纯粹就是用来标识这个Entry有没有过时(null时)的而已!自己的字段不是用来定位Entry的,而是外部的ThreadLocal来定位的!

例子:

小A(ThreadLocal)

地图(小A把自己哈希计算得到的小B下标)

小B(Entry)

​ 小A拿着地图找小B的位置,找到后,小A把地图复印一份给小B。 小B的手弱小,地图弄丢了(gc回收)。 如果小A还在,是不是依旧能拿着地图(计算哈希)能找到小B,但是小B有没有地图不影响他能否被小A找到。

​ 但是有天小A不在了(stack调用结束),那么就再也找不到小B了。而小B尽管有地图(ThreadLocal的强引用)自己知道自己在哪里,但他没有途径告诉其他人自己在哪里了。

所以,并不是Entry中ThreadLocal指向为null导致找不到Entry的Value。而是我们无法从得到local,从而哈希计算不到Entry的位置,最后得不到Entry的value。导致value永存内存当中。

over.

猜你喜欢

转载自blog.csdn.net/qq15347747/article/details/126464525