深入剖析大厂经典面试题之ThreadLocal原理(涉及斐波拉契散列、线性探测、扩容以及内存泄露问题)

先自我介绍一下,小编浙江大学毕业,去过华为、字节跳动等大厂,目前阿里P7

深知大多数程序员,想要提升技能,往往是自己摸索成长,但自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年最新Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友。
img
img
img
img
img
img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新

如果你需要这些资料,可以添加V获取:vip1024b (备注Java)
img

正文

// 获取当选线程的ThreadLocalMap

return t.threadLocals;

}

get() -> setInitialValue()方法:

/**

  • 这个方法于set方法逻辑一致,只是初始化的value为null

*/

private T setInitialValue() {

// initialValue()返回null

T value = initialValue();

// 后续操作与set()方法是完全相同的

// 这个方法是私有的无法被子类重写 -> 相当于set()方法的一个副本,子类重写了set()方法,还可以使用这个方法来初始化

Thread t = Thread.currentThread();

ThreadLocalMap map = getMap(t);

if (map != null)

map.set(this, value);

else

createMap(t, value);

return value;

}

4、ThreadLocalMap源码分析

ThreadLocalMap是整篇文章的重点,ThreadLocalMap是ThreadLocal的内部类,它提供了真正数据存取的能力;ThreadLocalMap为每个Thread都维护了一个table,这个table中的每一个Entry代表一个ThreadLocal(注意一个线程可以定义多个ThreadLocal,此时它们会存储在table中不同的下标位置)和vlaue的组合。接下来通过源码一层层的分析ThreadLocalMap的原理及实现。

4.1 Entry源码分析

Entry是ThreadLocalMap的静态内部类,它是一个负责元素存储的key-value键值对数据结构,key是ThreadLocal,value是ThreadLocal传入的相关的值。这里有一个重点知识,Entry继承了WeakReference,所以很明显的看出ThreadLocal<?> k将会是一个弱引用,弱引用容易被JVM垃圾收集器回收,因此可能导致内存泄露的问题(后续在详细分析,这里的重点是ThreadLocalMap的实现)。

在这里插入图片描述

static class Entry extends WeakReference<ThreadLocal<?>> {

/** The value associated with this ThreadLocal. */

Object value;

Entry(ThreadLocal<?> k, Object v) {

// key -> 是弱引用

super(k);

// 保存值

value = v;

}

}

4.2 ThreadLocalMap构造函数

在ThreadLocal中3.3 set()方法源码分析中留下来createMap(t, value)的疑问,在获取线程的ThreadLocalMap为空时,通过调用createMap(t, value)方法对ThreadLocalMap进行了初始化。

// ThreadLocal中set()方法调用的createMap方法

void createMap(Thread t, T firstValue) {

t.threadLocals = new ThreadLocalMap(this, firstValue);

}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)源码:

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {

// 实例化一个大小为16的Entry数组,赋值给Entry[] table

table = new Entry[INITIAL_CAPACITY];

// 根据当前的ThreadLocal计算其在table中的数组下标,这里不懂看前面3.2

int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

// 通过传入的ThreadLocal和value值,构造一个Entry赋值给table的指定位置的值

table[i] = new Entry(firstKey, firstValue);

// 记录table中Entry的个数,也拥有触发扩容,初始化时为1

size = 1;

// 设置扩容阈值len * 2 / 3

setThreshold(INITIAL_CAPACITY);

}

4.3 set()方法源码分析

在ThreadLocal的set()方法中,当ThreadLocalMap不为空时,也就是说在上面4.2初始化之后,当前线程再次调用ThreadLocal的set()方法将会执行的是下面的逻辑。

set()方法中有三个重点知识:

  1. 当计算的Entry下标位置不存在数据时,直接插入

  2. 当存在数据时,通过线性探测来解决hash冲突

  3. 当table中的Entry个数达到扩容阈值时,进行扩容处理

private void set(ThreadLocal<?> key, Object value) {

Entry[] tab = table;

int len = tab.length;

// 计算数组下标

int i = key.threadLocalHashCode & (len-1);

// 线性探测

// for循环中的内容就是从当前产生hash冲突的位置往后找

// 找到不为null的Entry 有两种情况 1、key相等则更新 2、key=null则需要做replaceStaleEntry处理

// 如果为null,结束for循环

for (Entry e = tab[i];

e != null;

e = tab[i = nextIndex(i, len)]) {

// 获取当前节点的key -> ThreadLocal 对象

ThreadLocal<?> k = e.get();

// 如果key相同则直接替换,结束循环

if (k == key) {

e.value = value;

return;

}

// Entry存在,但是Entry的key为空,表示引用被垃圾回收器回收了

// 此时需要做比较复杂的处理,这个处理请看后面我的详细分析,此处你可以理解为就是找个能放的索引位置放进去,然后结束循环

if (k == null) {

replaceStaleEntry(key, value, i);

return;

}

}

// 在table[i] = null 的位置插入新的entry

tab[i] = new Entry(key, value);

// size + 1

int sz = ++size;

// 如果没有需求清理的key = null的entry,并且size到达扩容阈值

if (!cleanSomeSlots(i, sz) && sz >= threshold)

// 扩容处理

rehash();

}

set() -> nextIndex(i, len)方法:

/**

  • 这里是线性探测的思想,一直往后遍历

  • 当到达数组的最后一个位置仍未找到满足条件的,再从数组的最前面开始遍历

*/

private static int nextIndex(int i, int len) {

// 当数组下标不越界的情况下 返回 i+1 否则返回 0

return ((i + 1 < len) ? i + 1 : 0);

}

set() -> replaceStaleEntry(key, value, i)方法:

这个方法非常重要,它负责对过期的entry(引用被垃圾收集器回收了,因为Entry的key是弱引用,前面Entry源码中有介绍)进行清理,寻找合适的位置插入新的节点、对数组中已有的Entry做rehash寻找新的下标。设计源码的作者思路主要分为如下两个方面:

  1. 向前搜索,寻找其他同样key为null被GC的Entry节点,并记录下最后遍历到的Entry索引,遍历结束条件是Entry为null。这样的好处是为了清理这些Entry的key被GC了的Entry节点。

  2. 向后遍历,ThreadLocal不同于hashmap,它是开放地址法,因此当前索引位置不一定就是这个Entry存放的位置,可能第一次存放的时候发生了hash碰撞,Entry的存储发生了后移,因此要向后遍历,寻找当前与Entry的key相等的槽。

关于replaceStaleEntry(key, value, i)方法,我画了一个简图,图中并未包含所有场景,具体请详细阅读源码(非常精彩的设计思路),假设进入这个方法时staleSlot = 8,并且key的hashcode = 0xx68

在这里插入图片描述

源码分析:

private void replaceStaleEntry(ThreadLocal<?> key, Object value,

int staleSlot) {

Entry[] tab = table;

int len = tab.length;

Entry e;

// 将当前索引的值赋值给slotToExpunge,用于清理

int slotToExpunge = staleSlot;

// 向前搜索,知道tab[i] == null

// 如果tab[i] 不为空,但是tab[i]的key为空,也就是和当前节点一样的情况,key被GC了,那么将当前索引下标的值赋值给slotToExpunge,记录最小的索引值,后续从这里开始清理

for (int i = prevIndex(staleSlot, len);

(e = tab[i]) != null;

i = prevIndex(i, len))

if (e.get() == null)

slotToExpunge = i;

// 向后遍历,直到tab[i]==null

for (int i = nextIndex(staleSlot, len);

(e = tab[i]) != null;

i = nextIndex(i, len)) {

// 获取当前索引位置Entry的key

ThreadLocal<?> k = e.get();

// 如果key相等,证明当前这个节点后移到这里了,需要替换value

// 替换的时候我们可以做一些优化,因为我们第一次命中的索引出存在Entry但是Entry的key被GC了,也就是说无法被访问了,而我们这个节点是因为后移才存储在这里,这个时候我们这个节点是不是可以重新放回去呢?放回去后下次不是一次就命中了么?就不需要往后遍历寻找了么?

if (k == key) {

// 更新value

e.value = value;

// tab[i] 与 tab[staleSlot]交换位置

tab[i] = tab[staleSlot];

tab[staleSlot] = e;

// 如果往前探索的第一个key=null的索引下标和当前替换回去的索引相同

// 由于做了交换,我们又能保证前面不存在key == null的节点了,那么只需将替换后的i的值赋值给slotToExpunge,这样可以减少清理的循环次数

if (slotToExpunge == staleSlot)

slotToExpunge = i;

// 做清理工作和rehash

cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

return;

}

// 初始进来的时候我们有这句代码 slotToExpunge == staleSlot

// 所以如果slotToExpunge == staleSlot仍然成立,并且当前的key == null,那么我们就把当前的下标值赋值给slotToExpunge,很好理解还是为了缩小清理的范围,大师们对提升性能总是那么极致

if (k == null && slotToExpunge == staleSlot)

slotToExpunge = i;

}

// 执行到了这里,说明替换失败了,没找到要么就是它的key也被GC了,要么就是它是第一次set

// 但是当前Entry的key是null,那我们就放这里吧,毕竟这个Entry也用不了

tab[staleSlot].value = null;

tab[staleSlot] = new Entry(key, value);

// slotToExpunge != staleSlo表名需要清理key为null的Entry

if (slotToExpunge != staleSlot)

cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);

}

关于replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot)往前探索并未发现满足条件的Entry时,也就是代码40行slotToExpunge == staleSlot满足时,会做slotToExpunge = i操作,这个如果不清楚我做了图来便于大家理解:

在这里插入图片描述

expungeStaleEntry(int staleSlot)源码分析:

expungeStaleEntry(int staleSlot)主要做了三件事

  1. 从staleSlot索引开始往后遍历到第一个Entry节点不为空的下标这段区间中key=null的Entry节点清空处理

  2. 在遍历中如果key != null 需要做rehash处理,因为前面可能存在节点被清空了,重新根据k.threadLocalHashCode & (len - 1)计算索引,往后遍历寻找第一个为null的Entry移动到这里

  3. 返回i,这个i是从staleSlot往后遍历到的第一个为null的Entry,这个值返回为了cleanSomeSlots(int i, int n),去清理后面的Entry,这里你可能会疑问为啥不直接用expungeStaleEntry(int staleSlot)方法直接全部遍历一遍得了,但是你可以发现源码这分块的清理做了优化,具体实现请看后面的cleanSomeSlots(int i, int n)讲解

private int expungeStaleEntry(int staleSlot) {

Entry[] tab = table;

int len = tab.length;

// 当前staleSlot索引处的Entry清空,注意不仅需要清空Entry还需要清空value,key本身已经为null了不需要再清空了

tab[staleSlot].value = null;

tab[staleSlot] = null;

size–; // 注意要及时的记录table中Entry的个数

Entry e;

int i;

// 1、循环到第一个Entry不为空的位置 清空key == null的Entry和Entry的value

for (i = nextIndex(staleSlot, len);

(e = tab[i]) != null;

i = nextIndex(i, len)) {

ThreadLocal<?> k = e.get();

// key == null 清空Entry和Entry的value

if (k == null) {

e.value = null;

tab[i] = null;

size–; // 注意要及时的记录table中Entry的个数

} else {

// 2、由于做了清空处理,我们要对Entry做rehash。因为他可能可以前移

int h = k.threadLocalHashCode & (len - 1);

// 如果计算的h和当前的索引i不相等,尝试从h开始往后寻找空的Entry

if (h != i) {

// 清空当前Entry

tab[i] = null;

// 循环找到第一个为空的Entry,并记录它的索引

while (tab[h] != null)

h = nextIndex(h, len);

// tab[i]的值移到新的槽(可能是同一个)

tab[h] = e;

}

}

}

// 3、返回i,这个i就是第一个为null的Entry

return i;

}

cleanSomeSlots(int i, int n)源码分析:

cleanSomeSlots(int i, int n)也是对上面expungeStaleEntry(int staleSlot)方法中找到的第一个为null的Entry节点到table.legth的区间范围内,Entry不为空但Entry的key为空的节点进行清理,这个清理不一定会进行到table的最后,因为它做了一个(n >>>= 1) != 0判断,如果在n无符号右移1 == 0 时,并且这右移的期间没有发现满足清理的Entry那么就会结束往后寻找。

n >>>=1 相当于 n= n>>>1,位运算右移一位相当于除以2

举个例子,如果i=5,n=16,此时如果在往后遍历四次,也就是到i=9,仍然没有满足e != null && e.get() == null的Entry,那么后续10-16就不再遍历了,这些都是对算法的优化。

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];

// 找到满足条件的做两个操作

// 1、重置n

// 2、调用expungeStaleEntry(i)清理

if (e != null && e.get() == null) {

n = len;

removed = true;

i = expungeStaleEntry(i);

}

} while ( (n >>>= 1) != 0); // n = n >>> 1 相当于 除以2

return removed;

}

4.4 rehash()源码分析

rehash()包含两个部分的逻辑

  1. 从table数组的第一个节点到最后一个节点中e != null && e.get() == null的Entry执行上面的expungeStaleEntry(int staleSlot)方法

  2. 当达到扩容阈值,进行扩容处理

4.4.1 rehash源码:

private void rehash() {

// 处理table中Entry的key被GC了的元素,后面将

expungeStaleEntries();

// 这里使用的双倍阈值,也就是threshold在计算了一次threshold

if (size >= threshold - threshold / 4)

resize();

}

4.4.2 expungeStaleEntries()源码分析

expungeStaleEntries()源码非常简单,从table数组的第一个节点到最后一个节点中e != null && e.get() == null的Entry执行上面的expungeStaleEntry(int staleSlot)方法。

private void expungeStaleEntries() {

Entry[] tab = table;

int len = tab.length;

for (int j = 0; j < len; j++) {

Entry e = tab[j];

// 如果e != null && e.get() == null 即 Entry的key被GC了

// 执行expungeStaleEntry(int staleSlot)方法 -> 上面详细分析了

if (e != null && e.get() == null)

expungeStaleEntry(j);

}

}

4.4.3 resize()源码分析

resize()的源码也比较简单,主要做了三个操作:

  1. 实例化一个原先大小两倍的数组newTab

  2. 遍历原先的旧数组中的每一个节点,将不为空的Entry节点计算其在新数组中的下标,放入新的数组中,放入的方式与set一致,使用线性探测解决hash冲突,注意如果节点不为空,key为空,需要将节点和节点的value置为空,帮助GC

  3. 设置新的扩容阈值,记录新的size,替换table的引用

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;

}

4.4 getEntry(ThreadLocal<?> key)源码分析

private Entry getEntry(ThreadLocal<?> key) {

// 计算数组下标

int i = key.threadLocalHashCode & (table.length - 1);

// 取出Entry

Entry e = table[i];

// 如果Entry不为空,且key相等直接返回

if (e != null && e.get() == key)

return e; // 返回

else

return getEntryAfterMiss(key, i, e); // 当前节点未命中

}

getEntryAfterMiss(key, i, e)源码分析:

进入这个方法存在多种情况:

  1. 节点发生了hash冲突,节点插入后移了(这种情况也有可能会被GC)

  2. 节点为发送hash冲突,但是key被GC了

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {

Entry[] tab = table;

int len = tab.length;

while (e != null) {

ThreadLocal<?> k = e.get();

// key相等则直接返回

if (k == key)

return e;

// key为空,要做清除和rehash

if (k == null)

expungeStaleEntry(i);

// 往下遍历直至末尾在从前开始 ((i + 1 < len) ? i + 1 : 0)

else

i = nextIndex(i, len);

e = tab[i];

}

// 可能未匹配上

return null;

}

5、ThreadLocal内存泄漏

ThreadLocal内存泄漏是我们谈及ThreadLocal存在的问题中所提及的最频繁的一个,那么我们接下来就从为什么会内存泄漏和如何解决内存泄漏这两个点来分析这个问题:

5.1 ThreadLocal为什么会内存泄漏

当Thread中存在一个ThreadLocal的内存分布和引用情况的简图如下:

在这里插入图片描述

我们知道Entry extends WeakReference<ThreadLocal<?>>,也就是说ThreadLocal作为一个弱引用key,如果没有被强引用所引用,那么它将活不过下次GC,这个也是上面产生那么多Entry的key为null的原因。当弱引用被指向的对象被GC那么将会导致我们程序员无法访问到这个Entry中的value对象,再加上table中的Entry它不发生hash冲突或者扩容(这些方法中都会去处理这些key为null的Entry,java大佬们一直在优化这些问题),如果线程长期存活,那么这些key为null的Entry的value将永远得不到GC,从而内存泄露。

5.2 防止内存泄露

防止内存泄露的处理方式很简单,ThreadLocal提供了remove()方法,供程序员主动清除Entry

ThreadLocal的remove()方法:

public void remove() {

// 获取当前线程的ThreadLocalMap

ThreadLocalMap m = getMap(Thread.currentThread());

// 不为空则调用ThreadLocalMap的remove(ThreadLocal<?> key)方法进行清理操作

if (m != null)

m.remove(this);

}

ThreadLocalMap的remove(ThreadLocal<?> key)方法:

private void remove(ThreadLocal<?> key) {

Entry[] tab = table;

int len = tab.length;

// 获取索引

int i = key.threadLocalHashCode & (len-1);

// 遍历寻找当前节点

for (Entry e = tab[i];

e != null;

e = tab[i = nextIndex(i, len)]) {

if (e.get() == key) {

// 引用置空

e.clear();

// 对其他key为null的Entry做清理和不为null的节点做rehash

expungeStaleEntry(i);

return;

最后

总而言之,面试官问来问去,问的那些Redis知识点也就这么多吧,复习的不够到位,知识点掌握不够熟练,所以面试才会卡壳。将这些Redis面试知识解析以及我整理的一些学习笔记分享出来给大家参考学习

还有更多学习笔记面试资料也分享如下:

都是“Redis惹的祸”,害我差点挂在美团三面,真是“虚惊一场”

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Java)
img

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
etMap(Thread.currentThread());

// 不为空则调用ThreadLocalMap的remove(ThreadLocal<?> key)方法进行清理操作

if (m != null)

m.remove(this);

}

ThreadLocalMap的remove(ThreadLocal<?> key)方法:

private void remove(ThreadLocal<?> key) {

Entry[] tab = table;

int len = tab.length;

// 获取索引

int i = key.threadLocalHashCode & (len-1);

// 遍历寻找当前节点

for (Entry e = tab[i];

e != null;

e = tab[i = nextIndex(i, len)]) {

if (e.get() == key) {

// 引用置空

e.clear();

// 对其他key为null的Entry做清理和不为null的节点做rehash

expungeStaleEntry(i);

return;

最后

总而言之,面试官问来问去,问的那些Redis知识点也就这么多吧,复习的不够到位,知识点掌握不够熟练,所以面试才会卡壳。将这些Redis面试知识解析以及我整理的一些学习笔记分享出来给大家参考学习

还有更多学习笔记面试资料也分享如下:

[外链图片转存中…(img-ek5JhOBD-1713140767281)]

网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。

需要这份系统化的资料的朋友,可以添加V获取:vip1024b (备注Java)
[外链图片转存中…(img-Ot01chyK-1713140767281)]

一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!

  • 11
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值