吃透源码的每一个细节和设计原理 - ThreadLocal - 今日头条

本文由 简悦 SimpRead 转码, 原文地址 www.toutiao.com

专注于 Java 领域优质技术,欢迎关注作者:面试君引言 ThreadLocal 是面试过程中非常高频的一个类,这类的复杂程度绝对是可以带出一系列连环

专注于 Java 领域优质技术,欢迎关注

作者:面试君

ThreadLocal 是面试过程中非常高频的一个类,这类的复杂程度绝对是可以带出一系列连环炮的面试轰炸。biu biu biu ~~~~.

一直觉得自己对这个类很了解了,但是直到去看源码,接二连三的技术浮出水面(弱引用,避免内存溢出的操作,开放地址法解决 hash 冲突,各种内部类的复杂的关系),看到你怀疑人生,直到根据代码一步一步的画图才最终理解(所以本篇文章会有大量的图)。 这里也给大家一个启示,面对复杂的事情的时候,实在被问题绕晕了,就画图吧,借助图可以让问题可视化,便于理解。

ThreadLocal 是一个线程的本地变量,也就意味着这个变量是线程独有的,是不能与其他线程共享的,这样就可以避免资源竞争带来的多线程的问题,这种解决多线程的安全问题和 lock(这里的 lock 指通过 synchronized 或者 Lock 等实现的锁) 是有本质的区别的:

  1. lock 的资源是多个线程共享的,所以访问的时候需要加锁。
  2. ThreadLocal 是每个线程都有一个副本,是不需要加锁的。
  3. lock 是通过时间换空间的做法。
  4. ThreadLocal 是典型的通过空间换时间的做法。

当然他们的使用场景也是不同的,关键看你的资源是需要多线程之间共享的还是单线程内部共享的

ThreadLocal 的使用是非常简单的,看下面的代码

https://p26.toutiaoimg.com/origin/pgc-image/04384e52b4d54e3eaf864f5ba170ae3c?from=pc

看到这里是不是觉得特别简单?别高兴太早,点进去代码看看,你绝对会怀疑人生

在分析源码之前先画一下 ThreadLocal ,ThreadLocalMap 和 Thread 的关系,如果你对他们的关系还不了解的话,请看我的另一篇文章 BAT 面试必考:ThreadLocal ,ThreadLocalMap 和 Thread 的关系

https://p26.toutiaoimg.com/origin/pgc-image/160aa241af3648f79b60aa20b167ffb6.png?from=pc

set 方法

https://p26.toutiaoimg.com/origin/pgc-image/a28a11a9015047dbb9c87655912845f7?from=pc

createMap 方法只是在第一次设置值的时候创建一个 ThreadLocalMap 赋值给 Thread 对象的 threadLocals 属性进行绑定,以后就可以直接通过这个属性获取到值了。从这里可以看出,为什么说 ThreadLocal 是线程本地变量来的了

https://p26.toutiaoimg.com/origin/pgc-image/9944aed16331489b808b7349fcf6b9e8?from=pc

值真正是放在 ThreadLocalMap 中存取的,ThreadLocalMap 内部类有一个 Entry 类,key 是 ThreadLocal 对象,value 就是你要存放的值,上面的代码 value 存放的就是 hello word。ThreadLocalMap 和 HashMap 的功能类似,但是实现上却有很大的不同:

  1. HashMap 的数据结构是数组 + 链表
  2. ThreadLocalMap 的数据结构仅仅是数组
  3. HashMap 是通过链地址法解决 hash 冲突的问题
  4. ThreadLocalMap 是通过开放地址法来解决 hash 冲突的问题
  5. HashMap 里面的 Entry 内部类的引用都是强引用
  6. ThreadLocalMap 里面的 Entry 内部类中的 key 是弱引用,value 是强引用

为什么 ThreadLocalMap 采用开放地址法来解决哈希冲突?

jdk 中大多数的类都是采用了链地址法来解决 hash 冲突,为什么 ThreadLocalMap 采用开放地址法来解决哈希冲突呢?首先我们来看看这两种不同的方式

链地址法

这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。列如对于关键字集合 {12,67,56,16,25,37, 22,29,15,47,48,34},我们用前面同样的 12 为除数,进行除留余数法:

https://p26.toutiaoimg.com/origin/pgc-image/a5e145ba9bd440d9a65b2ab42264bdc4.png?from=pc

开放地址法

这种方法的基本思想是一旦发生了冲突,就去寻找下一个空的散列地址 (这非常重要,源码都是根据这个特性,必须理解这里才能往下走),只要散列表足够大,空的散列地址总能找到,并将记录存入。

比如说,我们的关键字集合为 {12,33,4,5,15,25}, 表长为 10。 我们用散列函数 f(key) = key mod l0。 当计算前 S 个数{12,33,4,5} 时,都是没有冲突的散列地址,直接存入(蓝色代表为空的,可以存放数据):

https://p26.toutiaoimg.com/origin/pgc-image/5b91bc14cb3947baaca6dd7d6d692936.png?from=pc

计算 key = 15 时,发现 f(15) = 5,此时就与 5 所在的位置冲突。于是我们应用上面的公式 f(15) = (f(15)+1) mod 10 =6。于是将 15 存入下标为 6 的位置。这其实就是房子被人买了于是买下一间的作法:

https://p26.toutiaoimg.com/origin/pgc-image/14f090abc75949efa6663d9bc116fd7b.png?from=pc

链地址法和开放地址法的优缺点

开放地址法:

  1. 容易产生堆积问题,不适于大规模的数据存储。
  2. 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
  3. 删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。

链地址法:

  1. 处理冲突简单,且无堆积现象,平均查找长度短。
  2. 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
  3. 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
  4. 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。

ThreadLocalMap 采用开放地址法原因

  1. ThreadLocal 中看到一个属性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一个神奇的数字,让哈希码能均匀的分布在 2 的 N 次方的数组里, 即 Entry[] table,关于这个神奇的数字 google 有很多解析,这里就不重复说了
  2. ThreadLocal 往往存放的数据量不会特别大(而且 key 是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也低

如果对弱引用不了解的同学,先看下我之前的写的一篇文章别再找了,一文彻底解析 Java 中的弱引用(参考官网)系

接下来我们看看 ThreadLocalMap 中的存放数据的内部类 Entry 的实现源码

https://p26.toutiaoimg.com/origin/pgc-image/b890114fe966408a8a8d25fe0803af57?from=pc

我们可以知道 Entry 的 key 是一个弱引用,也就意味这可能会被垃圾回收器回收掉

1
threadLocal.get()==null

也就意味着被回收掉了

ThreadLocalMap set 方法

https://p26.toutiaoimg.com/origin/pgc-image/a6b21a79d75f49799f866cfce4b00a96?from=pc

https://p26.toutiaoimg.com/origin/pgc-image/77a680edb53b44488ac2b77e6a27f66d?from=pc

还是拿上面解释开放地址法解释的例子来说明下。 比如说,我们的关键字集合为 {12,33,4,5,15,25}, 表长为 10。 我们用散列函数 f(key) = key mod l0。 当计算前 S 个数{12,33,4,5,15,25} 时,并且此时 key=33,k=5 已经过期了(蓝色代表为空的,可以存放数据,红色代表 key 过期,过期的 key 为 null):

https://p26.toutiaoimg.com/origin/pgc-image/995b79811ddc4a339bae352500e62d25.png?from=pc

https://p26.toutiaoimg.com/origin/pgc-image/29445864876848fb8cc9e1803fbfc14e?from=pc

replaceStaleEntry 这个方法

https://p26.toutiaoimg.com/origin/pgc-image/6e122f4f6c134c7fae9ec01a10bb57d8?from=pc

https://p26.toutiaoimg.com/origin/pgc-image/37f3d493238b441d9aa5c6175a8c7560?from=pc

https://p26.toutiaoimg.com/origin/pgc-image/3d0fa23c5bea43c390303378396ba999?from=pc

第一个 for 循环是向前遍历数据的,直到遍历到空的 entry 就停止(这个是根据开放地址的线性探测法), 这里的例子就是遍历到 index=1 就停止了。向前遍历的过程同时会找出过期的 key, 这个时候找到的是下标 index=3 的为过期,进入到

1
if (e.get() == null) slotToExpunge = i;

注意此时 slotToExpunge=3,staleSlot=5

第二个 for 循环是从 index=staleSlot 开始,向后编列的,找出是否有和当前匹配的 key, 有的话进行清理过期的对象和重新设置当前的值。这个例子遍历到 index=6 的时候,匹配到 key=15 的值,进入如下代码

https://p26.toutiaoimg.com/origin/pgc-image/f9689f3fc5e2461fad573d112d4f160a?from=pc

先进行数据交换,注意此时 slotToExpunge=3,staleSlot=5,i=6。这里就是把 5 和 6 的位置的元素进行交换,并且设置新的 value=new, 交换后的图是这样的

https://p26.toutiaoimg.com/origin/pgc-image/5fc99cfdb19346459a35f9d79b2e973e.png?from=pc

为什么要交换

这里解释下为什么交换,我们先来看看如果不交换的话,经过设置值和清理过期对象,会是以下这张图

https://p26.toutiaoimg.com/origin/pgc-image/864ff32ba20f4f2d82670c292a73255d.png?from=pc

这个时候如果我们再一次设置一个 key=15,value=new2 的值,通过 f(15)=5, 这个时候由于上次 index=5 是过期对象,被清空了,所以可以存在数据,那么就直接存放在这里了

https://p26.toutiaoimg.com/origin/pgc-image/069b7bf7441149ec852face24b3d4caf.png?from=pc

你看,这样整个数组就存在两个 key=15 的数据了,这样是不允许的,所以一定要交换数据 expungeStaleEntry

https://p26.toutiaoimg.com/origin/pgc-image/9a0842d4c42748848818207f71c7b930?from=pc

https://p26.toutiaoimg.com/origin/pgc-image/39b54515663a433a8e152443c5d30fd4?from=pc

接下来我们详细模拟下整个过程 根据我们的例子,key=5,15,25 都是冲突的,并且 k=5 的值已经过期,经过 replaceStaleEntry 方法,在进入 expungeStaleEntry 方法之前,数据结构是这样的

https://p26.toutiaoimg.com/origin/pgc-image/000877ff15cb46cb8374b773654b316f.png?from=pc

此时传进来的参数 staleSlot=6,

https://p26.toutiaoimg.com/origin/pgc-image/5dea3574b0b3480ea0ca0d1bf1a6c26e?from=pc

这个时候会把 index=6 设置为 null, 数据结构变成下面的情况

https://p26.toutiaoimg.com/origin/pgc-image/e012b7fca431414a8023d8b9edda2c82.png?from=pc

接下来我们会遍历到 i=7,经过 int h = k.threadLocalHashCode & (len - 1) (实际上对应我们的举例的函数 int h= f(25)); 得到的 h=5, 而 25 实际存放在 index=7 的位置上,这个时候我们需要从 h=5 的位置上重新开始编列,直到遇到空的 entry 为止

https://p26.toutiaoimg.com/origin/pgc-image/4e3c65ab40204eb6a4edb864f06dd4e8?from=pc

这个时候 h=6,并把 k=25 的值移到 index=6 的位置上,同时设置 index=7 为空,如下图

https://p26.toutiaoimg.com/origin/pgc-image/21f19a4b611a40759de3379a5b288773.png?from=pc

其实目的跟 replaceStaleEntry 交换位置的原理是一样的,为了防止由于回收掉中间那个冲突的值,导致后面冲突的值没办法找到(因为 e==null 就跳出循环了)

通过上面的分析,我们知道 expungeStaleEntry() 方法是帮助垃圾回收的,根据源码,我们可以发现 get 和 set 方法都可能触发清理方法 expungeStaleEntry(),所以正常情况下是不会有内存溢出的 但是如果我们没有调用 get 和 set 的时候就会可能面临着内存溢出,养成好习惯不再使用的时候调用 remove(), 加快垃圾回收,避免内存溢出

退一步说,就算我们没有调用 get 和 set 和 remove 方法, 线程结束的时候,也就没有强引用再指向 ThreadLocal 中的 ThreadLocalMap 了,这样 ThreadLocalMap 和里面的元素也会被回收掉,但是有一种危险是,如果线程是线程池的, 在线程执行完代码的时候并没有结束,只是归还给线程池,这个时候 ThreadLocalMap 和里面的元素是不会回收掉的

来源:掘金 链接: https://juejin.im/post/5d8b2bde51882509372faa7c