ThreadLocal源码走读分析

整体结构

WeakReference示意图

key到ThreadLocal的点线就代表弱引用,当ThreadLocal被赋值为null,只剩WeakReference时,GC就要回收key了

createMap方法分析

ini 复制代码
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

首先要明确,ThreadLocalMap是定义在Thread类的变量,这就保证了为啥是线程隔离的,因为都是每个线程内部自己的东西,那自然就没有什么并发问题了。createMap即新建一个threadlocalmap并与线程中的threadLocals绑定起来了。

新建了一个长度为16的Entry数组,Entry元素包含一个指向threadlocal的弱引用和对应的value,并设置resize阈值

expungeStaleEntry方法分析

ini 复制代码
// expungeStaleEntry中文名为"删除过时的实体"
// 该方法的核心作用,就是先清理当前位置的过期元素,然后向后遍历,顺便清理在e为null之前所有的过期元素。
// 同时,对e不为null和key也不为null的元素重新判定hash位置。
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;

    // expunge entry at staleSlot
    // 先清理当前位置的元素,注意它的清理方式,e的value也要处理!!!
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    size--;

    // Rehash until we encounter null
    Entry e;
    int i;
    // 注意循环结束条件,当遇到e=null,即空位时停止循环
    // 但要注意,nextIndex其实是一个环形的索引,可以理解为对列表全局遍历,而不要片面理解为只往后走
    // 也有可能会问,你这个环形的for循环,有可能死循环吗,即都不为null?那其实在里面就有答案了,里面
    // 会对key==null的元素做置null操作,所以死循环问题不用担心。
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 当时有个疑问,这里还要做一个index推断干啥?什么意思呢,就是说i当前位置的元素e,需要重新
            // 来判断一下它的索引位置。目的是啥呢?
            // 很简单,因为ThreadLocalMap解决哈希冲突的方式是开放链表法,打个比方,对于元素e,它的index
            // 本应该是4,但是index=4的位置已经有一个元素了,那没办法,e只能往后挪,假定e挪到了index=6的
            // 位置,那下面这个方法将会替元素e找回原本的家,如果原来的位置是空,那就把它放回去,否则就继续
            // 往后找空位。
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;
                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    return i;
}

上面else判断中的元素索引再推断的原因,可以如下的白话思考:

因为ThreadLocalMap解决哈希冲突的方式是开放链表法,打个比方,对于元素e,它的index本应该是4,但是index=4的位置已经有一个元素了,那没办法,e只能往后挪,假定e挪到了index=6的位置,那下面这个方法将会替元素e找回原本的家,如果原来的位置是空,那就把它放回去,否则就继续往后找空位。

为啥这么做呢?这样做有一个好处,就是尽量使得元素与它应该在的位置相对应,这样的话,后续查找就可以直接取值,而尽量避免走循环去查找。

总而言之,expungeStaleEntry的方法,就是在遇到e为null之前,清理当前slot及这期间所有的过期元素(均置为null),并且顺带将没过期的元素重新计算索引位置,尽量让它回到它本应该在的位置上去。

set方法分析

ini 复制代码
// 核心方法,以当前ThreadLocal对象为key,设置value
// 核心思想是遍历,在计算出对应的index后,由于可能存在哈希冲突,所以采用的是遍历检查,判断条件依旧是
// 遇到第一个e为null为止(为null就直接赋值了,后面for循环外就是这个逻辑)
private void set(ThreadLocal<?> key, Object value) {

    // We don't use a fast path as with get() because it is at
    // least as common to use set() to create new entries as
    // it is to replace existing ones, in which case, a fast
    // path would fail more often than not.

    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)]) {
        ThreadLocal<?> k = e.get();
        
        // 1. 如果key相等,即threadlocal一致,那就直接替换e的value,结束
        if (k == key) {
            e.value = value;
            return;
        }
        // 2. 如果发现过期的元素,那就要准备清理工作了
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    // 发现了空的位置,直接赋值
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 判断是否需要扩容
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

set方法主要做了几件事

  • 计算出key在table中的索引index
  • 从index位置开始,往后遍历,直到遇到一个为null的位置。
  • 在遍历过程中,判断与每一个元素的key的关系,如果相等,说明直接替换value就完事。但如果key是null,说明存在过期元素,就走replaceStaleEntry方法处理
  • 如果遇到了空的位置,那太好了,直接把key和value包裹在entry中,赋值到该位置
  • 同时,还需要判断是否需要扩容,虽然表面看上去是个rehash方法,但调用的是resize方法。

replaceStaleEntry方法分析

scss 复制代码
// 这个方法是有点复杂的,一步一步的分析
// 注意,当前staleSlot位置的点是key==null的点,需要处理
private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // Back up to check for prior stale entry in current run.
    // We clean out whole runs at a time to avoid continual
    // incremental rehashing due to garbage collector freeing
    // up refs in bunches (i.e., whenever the collector runs).
    
    // 这个slotToExpunge变量的作用,是找到table数组中,在遇到e==null之前,存在失效
    // 元素的最前的index,意思就是,staleSlot站在这里和slotToExpunge说,你往前看看,看看
    // 还有没有和我一样的,找到最前面那个停下来。
    int slotToExpunge = staleSlot;
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    // Find either the key or trailing null slot of run, whichever
    // occurs first
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();

        // If we find key, then we need to swap it
        // with the stale entry to maintain hash table order.
        // The newly stale slot, or any other stale slot
        // encountered above it, can then be sent to expungeStaleEntry
        // to remove or rehash all of the other entries in run.
        if (k == key) {
            e.value = value;

            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;

            // Start expunge at preceding stale entry if it exists
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // If we didn't find stale entry on backward scan, the
        // first stale entry seen while scanning for key is the
        // first still present in the run.
        if (k == null && slotToExpunge == staleSlot)
            slotToExpunge = i;
    }

    // If key not found, put new entry in stale slot
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // If there are any other stale entries in run, expunge them
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

图示如下:

再来分析一下for循环里面的问题

ini 复制代码
if (slotToExpunge == staleSlot)
      slotToExpunge = i;
      
if (k == null && slotToExpunge == staleSlot)
    slotToExpunge = i;

为什么这么判断?从图示里可以看出,有如下可能

  • slotToExpunge朝前嗅探的时候,如果前面有过期元素,那这个相等条件必然不成立,因为slotToExpunge的作用就是要朝前遇到元素为null的节点前,找出最靠前的一个过期元素,它是要在后面对这些元素作处理的。
  • 如果slotToExpunge还没迈出第一步就停止了,正如图示的那样,那说明前面不用检查,没有过期元素,只需要关心后面就可以
  • 那好,因为staleSlot本身就是因为遇到了过期元素才进来replaceStaleEntry方法的,但它并没有直接赋值,而是朝后看,看看有没有和它key一样的,如果有,则交换一下,把过期元素挪到后面去,正常的移到前面来。这一步设计感觉很巧妙,这我咋想的到呢
  • ok,把过期的挪到后面去了,那slotToExpunge就得一起走,因为它代表着嗅探到的过期元素的起点位置,所以如果它和StaleSlot相等,那就说明变量i指向的是第一个过期元素,直接赋值
  • 然后会开启清理的操作,这个后面继续分析,清理完方法就返回了。
  • 如果key不相等,但又存在过期元素,那为啥直接就把i赋值给了slotToExpunge了呢?staleSlot自身就是一个过期元素啊,不管了是吧?
  • 倒也不是,循环结束后,staleSlot这个节点直接把值给set进去了,由过期元素变成了正常元素了,而slotToExpunge则找到了自己的新起点。
  • 所以就有了最后一个判断,如果slotToExpunge != staleSlot,那么就开始清理操作,这也算是一个兜底操作吧。

大白话总结一下

replaceStaleEntry突出一个replace替换,当set发现key==null时触发,staleSlot陷入了异常情况,为了安全起见,召唤哨兵slotToExpunge,往前去看看有没有一样的情况,自己则在原地等待。等哨兵slotToExpunge检查完,staleSlot让仆人i去朝前检查,如果有发现自己的兄弟是正常的,则施展法术把自己的异常情况转移到兄弟身上,自己恢复正常,然后命令哨兵slotToExpunge开始处理异常情况,自己直接开溜。(return)

如果没有发现自己的兄弟,那staleSlot只能自己靠自己,更新自己,把自己从异常变为正常,然后再让哨兵处理异常情况,自己开溜。

cleanSomeSlots方法分析

ini 复制代码
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];
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

这个方法要结合之前的expungeStaleEntry方法一起来看,为什么会需要这个方法。

expungeStaleEntry方法上面说过,是清理过期的节点的方法,它的返回条件是当e ==null,这意思就是,cleanSomeSlots方法的起点也是从这个null节点开始的。

从前面的方法分析可以看得出来,很多判断条件都是当e==null时结束,那有个疑问了,e==null后面的节点就不管了吗?这不,解决这个问题的方法就来了。cleanSomeSlots方法就是这个作用,它更像是一种兜底策略。

每次迭代都会使 n 右移一位(n >>>= 1,比如10000,右移一位就成了01000,由16变成了8,每次迭代相当于除以2。

注意,每次迭代,如果发现了过期节点,则进行n = len赋值,相当于循环又要继续进行。

这个方法和expungeStaleEntry方法结合起来,可以使得过期节点大大减少,而且元素不为null的节点会尽量聚集在一块。

remove方法解析

ini 复制代码
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();
            expungeStaleEntry(i);
            return;
        }
    }
}

该方法做了这么几件事

  1. 找出key在table中的索引index
  2. for循环遍历,记住这里的nextIndex是一个环形的,可以从尾巴跳回开头
  3. 如果key相等,注意了,e.clear()是调用了Reference类中的clear方法,作用是啥呢,作用是移除key的弱引用!即key=null,那么意味着这个就是一个过期元素,那就需要调用expungeStaleEntry来处理了。做到了即刻remove即刻处理,避免内存泄露。

resize方法分析

scss 复制代码
if (!cleanSomeSlots(i, sz) && sz >= threshold)
    rehash();

如果set完元素后,也没有发现过期元素并且size超过了阈值,那就要扩容了。逻辑如下:

scss 复制代码
private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

它先是对table中所有的stale元素进行了一次清除,for循环调用expungeStaleEntry方法。然后再判断条件是否满足,满足的话进行resize()。为啥会把这个方法称为rehash?因为expungeStaleEntry方法里面对不为null的元素有rehash操作。

ini 复制代码
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;
}

resize的方法逻辑很清晰,直接扩容为原table大小的两倍。

  • 生成两倍大小的新的table数组
  • 遍历老的table数组,如果发现k==null的,即过期元素,就把对应元素的value也置为null,交由gc处理,这种元素并不会放入新的table数组中
  • 如果不为null且k也不是null,就会将hashcode与新的数组长度-1进行位运算,得到在新的数组中的index
  • 得到index还不够,还得看看这个index下面是否已经有元素占位了,如果已经被占位了,那就顺延往下走,看哪一个是空位置,是的话就放入。
  • 设置新的阈值返回

resize的方法逻辑很简单,扩容两倍,然后将正常的元素,通过线性探测法,插到新数组里即可。

相关推荐
憨子周1 小时前
2M的带宽怎么怎么设置tcp滑动窗口以及连接池
java·网络·网络协议·tcp/ip
霖雨3 小时前
使用Visual Studio Code 快速新建Net项目
java·ide·windows·vscode·编辑器
SRY122404193 小时前
javaSE面试题
java·开发语言·面试
Fiercezm3 小时前
JUC学习
java
无尽的大道3 小时前
Java 泛型详解:参数化类型的强大之处
java·开发语言
ZIM学编程3 小时前
Java基础Day-Sixteen
java·开发语言·windows
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
P.H. Infinity4 小时前
【RabbitMQ】07-业务幂等处理
java·rabbitmq·java-rabbitmq
爱吃土豆的程序员4 小时前
java XMLStreamConstants.CDATA 无法识别 <![CDATA[]]>
xml·java·cdata
2401_857610034 小时前
多维视角下的知识管理:Spring Boot应用
java·spring boot·后端