ThreadLocal 基本原理与set源码解析

看文章前先了解java弱引用和多线程

简介

当我们想要多个线程使用同一个变量时,会出现并发安全问题,这时候我们必须采取措施,对变量的访问进行同步。
为了达到这个目的,我们一般是采用加锁的方式解决,但是加锁的方式太重了,会使系统的并发量大大降低,有什么办法能够解决这个问题呢?
当然有,不过是另辟蹊径: 不对访问进行同步,而是对每个线程,复制一份该变量,每个线程操作自己的那部分变量,来消除并发安全。Threadlocal就是用来干这个的

Threadlocal的使用
public class MyThreadLocal {

    final static ExecutorService executor = Executors.newCachedThreadPool();
    static ThreadLocal<Integer> num = new ThreadLocal<>();
    public static void main(String[] args) {

        try {
            executor.execute(()->{
                // 赋初始值
                num.set(1);
              print("thread-1");
                num.remove();
            });

            executor.execute(()->{
                // 赋初始值
                num.set(2);
                print("thread-2");
                num.remove();
            });
        }finally {
            executor.shutdown();
        }
    }

    static void print(String str){
        System.out.println(str + ":"+num.get());
    }
}

在这里插入图片描述

可以看到两个线程正常运行,互不影响

ThreadLocal类结构解析

在这里插入图片描述
实际上,ThreadLocal并非是存储属性的实际变量,它只不过是个工具类,或者说门面,访问的入口
在这里插入图片描述

在这里插入图片描述

可以看到ThreadLocal下有个静态内部类ThreadLocalMap,ThreadLocalMap下又有一个静态内部类Entry,Entry继承了一个弱引用对象,这个Entry就是实际存放变量的地方,而ThreadLocalMap则是存放Entry的map对象,其实际实现是一个Entry数组,set时,变量通过hash算法,插入到特定的位置

源码解析

我们先从set方法入手

// ThreadLocal -> set(T value)
public void set(T value) {
        // 获取当前线程
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        // 如果map不为null,则插入
        if (map != null) {
            map.set(this, value);
        } else {
            // 在当前线程创建ThreadLocalMap
            createMap(t, value);
        }
    }
// ThreadLocal -> createMap(Thread t, T firstValue)
void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
// ThreadLocal -> ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue)
protected ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
        // 新建Entry数组,INITIAL_CAPACITY默认为16
        table = new Entry[INITIAL_CAPACITY];
        // hash出下标
        int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
        // 第一次set,坑定不会出现hash冲突
        table[i] = new Entry(firstKey, firstValue);
        size = 1;
        // 设置threshold,值为(INITIAL_CAPACITY * 2 / 3);
        setThreshold(INITIAL_CAPACITY);
    }
// ThreadLocal->ThreadLocalMap->Entry。注意它继承了一个弱引用对象
static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

从上面我们可以看出,key就是ThreadLocal,value是set的值,最终这对kv会封装到Entry里,放入ThreadLocalMap中,ThreadLocalMap在第一次使用set时,初始化

现在我们再看看看非初始化的set方法

// ThreadLocal->ThreadLocalMap#set(ThreadLocal<?> key, Object value)
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循环快速找到插入位置
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();
                // 如果k已经有了,则直接覆盖
                if (k == key) {
                    e.value = value;
                    return;
                }
                // 如果k为null,则说明被gc回收,需要先清除旧value再设置新value
                // 除此之外,还得重新hash该下标左右范围内的Entry,否则该位置为修改后,可能会导致曾与该位置发生hash冲突的Entry找不到(因为置null之后,get()时碰到null就直接返回回了)
                if (k == null) {
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }

            tab[i] = new Entry(key, value);
            int sz = ++size;
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }
// ThreadLocal->ThreadLocalMap#nextIndex(int i, int len)
private static int nextIndex(int i, int len) {
        return ((i + 1 < len) ? i + 1 : 0);
}
代码大致流程
  1. 使用开放地址法访问位置
    • 位置上的key等于当前的ThreadLocal,则直接覆盖,并返回
    • 若key为null,则说明被gc回收,我们可以把该位置清空并set,但在set之前我们还得做“清理”工作(稍后细琐,很关键)
  2. new一个Entry,赋给当前位置
  3. size加1
  4. 清理其余过期对象,若sz大于threshold,则需要扩容,重新hash
清理过期对象方法 void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot)

 我们知道,map中k和v是1对1的关系,而ThreadLocalMap底层只是个Entry[],我们如何维护这种1对1关系呢?replaceStaleEntry()的意义就在于此。(replaceStaleEntry()的位置看上面代码)

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;
            Entry e;

        
            // 首先初始化slotToExpunge,它代表要被清除的table[i]
            int slotToExpunge = staleSlot;
            // 从table[stateSlot]开始,往回查询,直到遇见table[i] == null。此时slotToExpunge为从staleSlot左边起,到stateSlot为止,第一个弱引用被回收的table[i]。
            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
            // 从stateSlot+1开始,到第一个table[i]==null位置,进行遍历
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                ThreadLocal<?> k = e.get();

                
                // 如果发现key有重复,则将本来要插入的位置的Entry和key的位置的Entry进行交换
                // 
                if (k == key) {
                    e.value = value;

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

                
                    // 如果slotToExpunge == staleSlot,则说明往前扫描并未发现过期的Entry,过期的只有tab[staleSlot]
                    // 将i赋给slotToExpunge,并清除。为什么会选择清除tab[i],而不是tab[staleSlot]?不是说过期的是tab[staleSlot]吗?因为就在前3行,我们把tab[staleSlot]和tab[i]的位置交换了....
                    if (slotToExpunge == staleSlot)
                        slotToExpunge = i;
                    // 清除
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

                // 如果找到过期的Entry且slotToExpunge==staleSlot(slotToExpunge==staleSlot说明这次找到的就是从数组中一个过期的Entry,之前往前找没有找到)
                if (k == null && slotToExpunge == staleSlot)
                    slotToExpunge = i;
            }

            // If key not found, put new entry in stale slot
            // 清除tab[staleSlot]上的引用,并赋值新的Entry
            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);
    }

从之前的set方法我们知道,在找到应该插入的位置,且该位置(staleSlot)的Entry已经被gc清除后(强调一下,这里是指key变null了,整个Entry还是存在的)后,它不急着立刻插入进去,而是在[null->staleSlot->null]之间,先把[null,staleSlot]之间所有的过期Entry给清除,然后再在[statleSlot,null]之间寻找是否有重复key的Entry(设tab[i]),顺便把[statleSlot,null]中第一个过期的Entry下标赋值给slotToExpunge,并交换tab[staleSlot]和tab[i]的位置,清除tab[i](实际上清除的就是原来的tab[staleSlot])。遇到null,则结束循环

结束循环后,终于可以在tab[staleSlot]上放入新的Entry,最后在结束前执行cleanSomeSlots(expungeStaleEntry(slotToExpunge), len),目的是来重新hash右边第一个null之后的所有Entry,目的是为了防止因前面set后出现的新的null,导致其他get set获取不到本应该读取到的值的情况发生

可能有人要问了,为什么set执行后,若有新的位置上出现null,可能会导致get set失效呢?因为无论是哪种hash算法,一定会产生hash冲突,为了解决hash冲突,ThreadLocalMap采用的是开放地址法,冲突了就往后找。因此如果被清除的位置出现在冲突处,会导致产生冲突的Entry找不到,因为冲突处变null了,set在遇到null时会直接插入,map里就可能会出现两个key相同的Entry,而get时只能找到其中一个

举个例子

以下图为例,
在这里插入图片描述

先假设f(7)=4,f(4)=4

如果我们要新set的值是7,那么staleSlot就是4,而tab[4]已经有了Entry,但是key已经被回收了。

  • 这时候7不会直接set到4的位置,而是先往左找,在碰到null之前,找到左边离它最远的key为null的Entry,并把它的下标赋值给为slotToExpunge

  • 再往右找,寻找是否有key和当前相同Entry,若有则交换两者的位置
    在这里插入图片描述

  • 如果没有key重复,则遇到第一个null时,退出循环,并插入值

  • 接着,也就是最重要的一步: 把slotToExpunge开始直到遇见第一个null之间的全部过期值都清除掉,并且重新hash,填满因清除旧数据而产生的null。

为什么ThreadLocal采用开放地址法
开放地址法
  1. 容易产生堆积问题,不适于大规模的数据存储。
  2. 散列函数的设计对冲突会有很大的影响,插入时可能会出现多次冲突的现象。
  3. 删除的元素是多个冲突元素中的一一个,需要对后面的元素作处理,实现较复杂。
  4. 一般由数组实现,访问快
链地址法
  1. 处理冲突简单,且无堆积现象,平均查找长度短。
  2. 链表中的结点是动态申请的,适合构造表不能确定长度的情况。
  3. 删除结点的操作易于实现。只要简单地删去链表上相应的结点即可。
  4. 指针需要额外的空间,故当结点规模较小时,开放定址法较为节省空间。
  5. 链表结构,访问慢

ThreadLocal一般不会存放大量的数据,而开发地址法结构相对简单,访问速度快,适合ThreadLocal。

参考
被大厂面试官连环炮轰炸的ThreadLocal (吃透源码的每一个细节和设计原理)

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值