ThreadLocal作用很大,就是一个线程一个独立副本,看这篇文章之前,你需要确保你已经了解过或体会到ThreadLocal,本篇主要围绕ThreadLocal原理和为何会内存泄漏展开讲解。
我们知道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工具类中,但是肯定会使用到拦截器和过滤器得到当前请求的用户信息!
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;
}
这才是ThreadLocal实际使用到的结构,我们观察到ThreadLocalMap中有一个Entry数组。Map是什么?不就是Key-Value的键值对的形式存放数据的吗?为什么这里却是一个数组的结构?
实际上就是键值对的结构,ThreadLocalMap通过计算Key的哈希值得到下标,这个就作为数组的下标直接存取数据。下面有提及到存取原理。
这个就是线程类不必多说。我们只看其一个字段,就是ThreadLocal.ThreadLocalMap。这个不就是声明的就是TheadLocal的内部类ThreadLocalMap么?为什么要这么声明呢?
试想一下?ThreadLocal作为一个线程隔离的工具类,既然想要实现线程之间的数据隔离,而线程就是Thread的实例罢了。一个Thread实例等价于一个线程。这么一来,你是不是悟到了什么呢?原来实现线程某个数据的隔离,就是把他丢在他的成员变量就好了!
但是,具体实现原理可没这么简单,要考虑两个问题:1.有多个想作为线程隔离的属性怎么办(核心,涉及到如何存取,结构如何设计
)?2.线程作为珍贵的资源,更多是使用线程池来使用线程,线程不会轻易销毁,这么这个ThreadLocal.ThreadLocalMap threadLocals我们怎么清空,以至不影响其他任务?
准备,下文提到的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);//未初始化
}
[重点]:关于这个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图解
一个线程一个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()等等
详细说说
这一个框框就只是一个栈的一个栈帧罢了,就是一个方法调用中的局部变量。
假设我们有多个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覆盖了。
这面试加分项,也是难点之一
看代码或图知道,有个弱引用
,这个弱引用就是问题得出发点了。
前提知识:在JVM中,gc对于弱引用,只要gc执行,弱引用的就会被回收。但是强引用一定不会(图中实线)
Entry[]怎么清空
我们知道,我们可以通过local.remove()方法,把当前的Entry从Entry[]清掉,这样就可以释放内存。
但是,如果我们用完这个local了,请求处理完成了,最后并没用remove()怎么办?
此时因为方法全部调用完成,Stack为空,Heap空间中的ThreadLocal没有了来着Stack的强引用,只有来自Entry的弱引用。
当弱应用被gc回收后,Entry中的ThreadLocal不就为null了吗?说明这个Entry过时了,理应被回收
上面说了,Entry的定位是通过计算local的哈希值,那local是存放在stack的,既然请求都结束了,那么local自然就无了,我们得不到local指向Heap的地址,那么还怎么哈希计算?自然无法通过下标定位到具体的Entry了。
而Entry[]数组被ThreadLocalMap强引用,gc不会回收,你又不能local.remove()掉其value,那么内存就一直被无效占用而无法回收,这就是内存泄漏
Tomcat请求处理的补充
如果Thread自己被回收,Entry[]自然会被回收掉的。所以有些同学得出结论,在Web开发中,一个Thread不就是一个请求处理完成之后就销毁了吗?那Entry[]自然不就被回收了,哪来的内存泄漏?
实际上,一个请求一个线程处理没问题,但是有没考虑过,线程创建和销毁的开销很大?所以请求处理完,线程并没有被回收,只是被Tomcat丢到了连接线程池了而已,提供给下一个请求直接取出来用,那这回Entry[]不就不会被回收了么?还不是依旧有内存泄漏问题
!
有些人说,把弱引用改为强引用不就好了,这样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官文)。
如果修改为强引用,当stack被清空,处于堆中的ThreadLocal依旧还有来自Entry的强引用。
那么ThreadLocal的内存不会被释放,但是,Entry的指向ThreadLocal的引用就不可能会为null,那就有大问题了。
后果是:检测机制的那三个方法,怎么检测Entry[]中的某个Entry失效了???换句话说,怎么区分Entry[]中哪些Entry是过时的?哪些是在用的?
检测不出来!!!反而导致检测机制无法起作用,内存泄漏的风险更大
因此,弱引用的出现,是为了提高代码的健壮性,他解决了:如何找出失效Entry的问题。
弱引用根本不是导致内存泄漏的(直接/间接)原因!!!
博客到此结束,这是只是简略的说说ThreadLocal的原理,其还有初始化,父子线程值传递等功能~ 有了原理的基础,之后的功能理解起来会很容易。
90%人的误区,某些up主都讲错了!
弱引用导致的内存泄漏这是伪命题!
看到很多博客都说这个弱引用被gc,最后导致Entry的ThreadLocal的指向为null,导致Value定位不到,真是误人子弟
。
Entry的定位依赖的是ThreadLocal的哈希计算得到Entry[]下标。
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.