ThreadLocal最终版本

ThreadLocal工作原理

一、官方文档描述

从Java官方文档中的描述:ThreadLocal类用来提供线程内部的局部变量。这种变量在多线程环境下访问(通过get和set方法访问)时能保证各个线程的变量相对独立于其他线程内的变量。

ThreadLocal实例通常来说都是private static类型的,用于关联线程和线程上下文。

我们可以得知 ThreadLocal 的作用是:提供线程内的局部变量,不同的线程之间不会相互干扰,这种变量在线程的生命周期内起作用,减少同一个线程内多个函数或组件之间一些公共变量传递的复杂度。

二、为什么使用ThreadLocal

我们的系统中同时会有很多用户请求,那每个请求都带有用户信息,我们知道通常都是一个线程处理一个用户请求,我们可以把用户信息丢到Threadlocal里面,让每个线程处理自己的用户信息,线程之间互不干扰。

我们程序在处理用户请求的时候,通常后端服务器是有一个线程池,来一个请求就交给一个线程来处理,那为了防止多线程并发处理请求的时候发生串数据,比如AB线程分别处理用户A和用户B的请求,A线程本来处理用户A的请求,结果访问到用户B的数据上了,造成了数据紊乱。

所以在A线程中要达到的效果是只能够操作A线程中的数据,而不应该去操作不属于本线程之内的数据,而ThreadLocal就可以达到这样的一种效果。

效果如下所示:

上述图中场景伪代码说明:

//存放用户信息的ThreadLocal
private static final ThreadLocal<UserInfo> userInfoThreadLocal = new ThreadLocal<>();

public Response handleRequest(UserInfo userInfo) {
    Response response = new Response();
    try {
        // 1.用户信息set到线程局部变量中
        userInfoThreadLocal.set(userInfo);
        doHandle();
    } finally {
        // 3.使用完移除掉
        userInfoThreadLocal.remove();
    }
    return response;
}

//每个线程执行的业务逻辑处理
private void doHandle () {
    // 2.实际用的时候取出来
    UserInfo userInfo = userInfoThreadLocal.get();
    // 查询用户资产
    queryUserAsset(userInfo);
}

2.1、案例

感受一下ThreadLocal 线程隔离的特点

不使用ThreadLocal

对应的结果如下所示:

从结果可以看出多个线程在访问同一个变量的时候出现的异常,线程间的数据没有隔离。


使用ThreadLocal

下面我们来看下采用 ThreadLocal 的方式来解决这个问题的例子。

将结果打印到控制台,查看输出信息,可以看到线程之间实现了隔离。

对应的输出结果:

三、ThreadLocal和syncronized关键字区别

虽然ThreadLocal模式与synchronized关键字都用于处理多线程并发访问变量的问题, 不过两者处理问题的角度和思路不同。

synchronized ThreadLocal
原理 同步机制采用’以时间换空间’的方式, 只提供了一份变量,让不同的线程排队访问 hreadLocal采用’以空间换时间’的方式, 为每一个线程都提供了一份变量的副本,从而实现同时访问而相不干扰
侧重点 多个线程之间访问资源的同步 多线程中让每个线程之间的数据相互隔离

虽然使用ThreadLocal和synchronized都能解决问题,但是使用ThreadLocal更为合适,因为这样可以使程序拥有更高的并发性。

因为synchronized关键字关注的是多线程对共享变量的操作;而ThreadLocal关注的是是共享变量副本的问题,我们希望的是在线程上下文中都可以使用到这个线程中的共享变量的副本,在不使用的时候将其移除掉即可,那么就可以使用ThreadLocal来进行解决。

四、数据结构

五、源码分析

set方法

对应的流程如下所示:

  • 1、首先获取当前线程,并根据当前线程获取一个ThreadLocalMap;
  • 2、如果获取的ThreadLocalMap不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key) ;
  • 3、 如果ThreadLocalMap为空,则给该线程创建 ThreadLocalMap,并设置初始值;

get方法

具体分析下执行流程:

  • 1、首先获取当前线程, 根据当前线程获取得到ThreadLocalMap;
  • 2、如果获取的Map不为空,则在Map中以ThreadLocal的引用作为key来在Map中获取对应的Entry e,否则执行4步骤;
  • 3、如果e不为null,则返回e.value,否则执行4步骤;
  • 4、ThreadLocalMap为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的ThreadLocalMap;

remove方法

因为key是ThreadLocal且是弱引用,那么弱引用对象随时可以被消除掉。

所以就存在着两种情况

  • 1、判断key是否存在;
  • 2、根据key对应的下标定位到数组中的索引下标对应的Entry来进行判断对应的value是否存在;

如果value存在,表示了之前存在ThreadLocal来放入对应的值,而ThreadLocal在进行垃圾回收的时候给回收掉了,而对应的value还没有,所以这里是做一个清理阶段操作。

六、ThreadlLocalMap

在分析ThreadLocal方法的时候,我们了解到ThreadLocal的操作实际上是围绕ThreadLocalMap展开的。

ThreadLocalMap的源码相对比较复杂, 我们从以下三个方面进行讨论:

6.1、ThreadLocalMap结构

ThreadLocalMap是ThreadLocal的内部类,没有实现Map接口,用独立的方式实现了Map的功能,其内部的Entry也是独立实现。

1、成员变量

2、存储结构Entry

在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。不过Entry中的key只能是ThreadLocal对象,这点在构造方法中已经限定死了。

另外,Entry继承WeakReference,也就是key(ThreadLocal)是弱引用,其目的是将ThreadLocal对象的生命周期和线程生命周期解绑。

①为什么key使用弱引用

key是强引用

当前在线程运行期间,Threadlocal引用的生命周期比较短,那么肯定是先消失,而堆中的引用还在。

而因为线程对象还在,意味着ThreadlocalMap还在,那么key会一直引用堆中的Threadlocal对象,那么就会导致了Threadlocal无法被JVM回收,将会导致内存泄漏。而我们经常在web开发中使用到Thread,而web中内嵌了一个线程中,那么将会导致线程无法销毁,那么将会出现内存泄漏问题。

所以如果key是强引用在web开发中,那么内存泄漏是一定的。

key是弱引用

同样假设在业务代码中使用完ThreadLocal ,threadLocal Ref被回收了。

由于ThreadLocalMap只持有ThreadLocal的弱引用,没有任何强引用指向Threadlocal实例, 所以Threadlocal就可以顺利被gc回收,此时Entry中的key=null。

但是在没有手动删除这个Entry以及CurrentThread依然运行的前提下,也存在有强引用链 threadRef->currentThread->threadLocalMap->entry -> value ,value不会被回收, 而这块value永远不会被访问到了,导致value内存泄漏。

也就是说,ThreadLocalMap中的key使用了弱引用, 是存在着value内存泄漏的。

既然都存在着问题,为什么还要将ThreadLocal设计成弱引用?

根据刚才的分析,无论ThreadLocalMap中的key使用哪种类型引用都无法完全避免内存泄漏,所以说跟使用弱引用没有关系。

那么不妨看一下,在key是弱引用的情况下,当key为null的时候,如何来对value进行处理的。

事实上,在ThreadLocalMap中的set/getEntry方法中,会对key为null(也即是ThreadLocal为null)进行判断,如果为null的话,那么是会对value置为null的。

这就意味着使用完ThreadLocal,CurrentThread依然运行的前提下,就算忘记调用remove方法,对应的value在下一次ThreadLocalMap调用set,get,remove中的任一方法的时候会被清除,从而避免内存泄漏。

②出现内存泄漏的真实原因

比较以上两种情况,我们就会发现内存泄漏的发生跟ThreadLocalMap中的key是否使用弱引用是没有关系的。那么内存泄漏的的真正原因是什么呢?

在以上两种内存泄漏的情况中,都有两个前提:

1、没有手动删除这个Entry
2、CurrentThread依然运行

第一种很好理解,只要在使用完ThreadLocal,调用其remove方法删除对应的Entry,就能避免内存泄漏。

第二种稍微复杂一点, 由于ThreadLocalMap是Thread的一个属性,被当前线程所引用,所以它的生命周期跟Thread一样长。那么在使用完ThreadLocal之后,如果当前Thread也随之执行结束,ThreadLocalMap自然也会被gc回收,从根源上避免了内存泄漏。

相对之下,第二种更不好控制。比如说在线程池中,不好控制线程的结束,因为有可能线程池中的线程一直处于运行状态。那么内存泄露就必须要来使用第一种方式来进行解决。

相对来说,第一种更好控制,第二种有点舍近求远。

6.2、哈希冲突的解决

hash冲突的解决是Map中的一个重要内容。我们以hash冲突的解决为线索,来研究一下ThreadLocalMap的核心源码。

(1) 首先从ThreadLocal的set() 方法入手

public void set(T value) {
  Thread t = Thread.currentThread();
  ThreadLocal.ThreadLocalMap map = getMap(t);
  if (map != null)
    //调用了ThreadLocalMap的set方法
    map.set(this, value);
  else
    createMap(t, value);
}

ThreadLocal.ThreadLocalMap getMap(Thread t) {
  return t.threadLocals;
}

void createMap(Thread t, T firstValue) {
  //调用了ThreadLocalMap的构造方法
  t.threadLocals = new ThreadLocal.ThreadLocalMap(this, firstValue);
}

这个方法我们刚才分析过, 其作用是设置当前线程绑定的局部变量 :

A. 首先获取当前线程,并根据当前线程获取一个Map

B. 如果获取的Map不为空,则将参数设置到Map中(当前ThreadLocal的引用作为key) (这里调用了ThreadLocalMap的set方法)

C. 如果Map为空,则给该线程创建 Map,并设置初始值 (这里调用了ThreadLocalMap的构造方法)

这段代码有两个地方分别涉及到ThreadLocalMap的两个方法, 我们接着分析这两个方法。

(2)构造方法ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)

/*
  * firstKey : 本ThreadLocal实例(this)
  * firstValue : 要保存的线程本地变量
  */
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
  //初始化table
  table = new ThreadLocal.ThreadLocalMap.Entry[INITIAL_CAPACITY];
  //计算索引(重点代码)
  int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
  //设置值
  table[i] = new ThreadLocal.ThreadLocalMap.Entry(firstKey, firstValue);
  size = 1;
  //设置阈值(2/3)
  setThreshold(INITIAL_CAPACITY);
}

构造函数首先创建一个长度为16的Entry数组,然后计算出firstKey对应的索引,然后存储到table中,并设置size和threshold。

重点分析: int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1)。

a. 关于firstKey.threadLocalHashCode:

private final int threadLocalHashCode = nextHashCode();

private static int nextHashCode() {
  return nextHashCode.getAndAdd(HASH_INCREMENT);
}
//AtomicInteger是一个提供原子操作的Integer类,通过线程安全的方式操作加减,适合高并发情况下的使用
private static AtomicInteger nextHashCode =  new AtomicInteger();
//特殊的hash值
private static final int HASH_INCREMENT = 0x61c88647;


这里定义了一个AtomicInteger类型,每次获取当前值并加上HASH_INCREMENT,HASH_INCREMENT = 0x61c88647,这个值跟斐波那契数列(黄金分割数)有关,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里, 也就是Entry[] table中尽可能均匀的来进行分布,这样做可以尽量避免hash冲突。

b. 关于& (INITIAL_CAPACITY - 1)

计算hash的时候里面采用了hashCode & (size - 1)的算法,这相当于取模运算hashCode % size的一个更高效的实现。正是因为这种算法,我们要求size必须是2的整次幂,这也能保证在索引不越界的前提下,使得hash发生冲突的次数减小。

(3) ThreadLocalMap中的set方法

private void set(ThreadLocal<?> key, Object value) {
  ThreadLocal.ThreadLocalMap.Entry[] tab = table;
  int len = tab.length;
  //计算索引(重点代码,刚才分析过了)
  int i = key.threadLocalHashCode & (len-1);
  /**
         * 使用线性探测法查找元素(重点代码)
         */
  for (ThreadLocal.ThreadLocalMap.Entry e = tab[i];
       e != null;
       e = tab[i = nextIndex(i, len)]) {
    ThreadLocal<?> k = e.get();
    //ThreadLocal 对应的 key 存在,直接覆盖之前的值
    if (k == key) {
      e.value = value;
      return;
    }
    // key为 null,但是值不为 null,说明之前的 ThreadLocal 对象已经被回收了,
    // 当前数组中的 Entry 是一个陈旧(stale)的元素
    if (k == null) {
      //用新元素替换陈旧的元素,这个方法进行了不少的垃圾清理动作,防止内存泄漏
      replaceStaleEntry(key, value, i);
      return;
    }
  }

  //ThreadLocal对应的key不存在并且没有找到陈旧的元素,则在空元素的位置创建一个新的Entry。
  tab[i] = new Entry(key, value);
  int sz = ++size;
  /**
             * cleanSomeSlots用于清除那些e.get()==null的元素,
             * 这种数据key关联的对象已经被回收,所以这个Entry(table[index])可以被置null。
             * 如果没有清除任何entry,并且当前使用量达到了负载因子所定义(长度的2/3),那么进行				 * rehash(执行一次全表的扫描清理工作)
             */
  if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();
}

/**
     * 获取环形数组的下一个索引。可以将一个数组看成是一个环形链表
     * 可以看到如果是在末尾,也就是说是在index=15的情况下,下一次操作将会回到0索引的位置
     */
private static int nextIndex(int i, int len) {
  return ((i + 1 < len) ? i + 1 : 0);
}


代码执行流程:

A. 首先还是根据key计算出索引 i,然后查找i位置上的Entry,

B. 若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值,

C. 若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry,

D. 不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。

最后调用cleanSomeSlots,清理key为null的Entry,最后返回是否清理了Entry,接下来再判断sz 是否>= thresgold达到了rehash的条件,达到的话就会调用rehash函数执行一次全表的扫描清理。

重点分析 : ThreadLocalMap使用线性探测法来解决哈希冲突的。

该方法一次探测下一个地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。

举个例子,假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。

按照上面的描述,可以把Entry[] table看成一个环形数组。

posted @ 2023-02-09 23:05  写的代码很烂  阅读(51)  评论(0编辑  收藏  举报