ICode9

精准搜索请尝试: 精确搜索
首页 > 编程语言> 文章详细

【并发编程】(十)线程本地变量的实现——ThreadLocal原理详解

2021-03-02 19:29:35  阅读:211  来源: 互联网

标签:ThreadLocalMap ThreadLocal 详解 线程 key Entry null 节点


文章目录

1.ThreadLocal的实现

接上一篇《线程安全的代码及ThreadLocal的使用》
现在我们知道了ThreadLocal会可以通过线程本地变量来实现线程隔离,那随之而来了两个问题:

  • ThreadLocal被定义为成员变量时是线程共享的,如何做到线程隔离?
  • 线程本地变量到底是保存到哪里的?
  • 各方法间的参数传递是怎么实现的?

两个问题都有同一个答案,在Thread类中定义了一个成员变量threadLocals,使用的是ThreadLocal中的一个内部类ThreadLocalMap,在我们使用到的时候就会初始化这个Map对象用来存放线程本地变量,并且每个线程只能访问到自己的ThreadLocalMap,同时在使用Thread.currentThread获取到当前线程就可以从当前线程的ThreadLocalMap中获取参数,以此来实现线程隔离和参数传递。

下面是Thread源码中定义的成员变量。
在这里插入图片描述

1.1.创建

ThreadLocalMap的初始值为null,是通过ThreadLocal中的get()set()方法来初始化的。那就需要先实例化ThreadLocal对象,下面是创建ThreadLocal对象的三种方式:

public class ThreadLocalCreate {

    // 直接创建,不初始化值。
    ThreadLocal<Object> t1 = new ThreadLocal<>();

    // 匿名内部类创建,重写初始化值方法
    ThreadLocal<String> t2 = new ThreadLocal() {
        @Override
        protected String initialValue() {
            return "张三";
        }
    };

    // 直接使用初始化值方法创建
    ThreadLocal<Integer> t3 = ThreadLocal.withInitial(() -> 0);
    
}
  • t1是创建一个初始值为null的对象。
  • t2是使用了一个匿名内部类,重写了初始化值的方法,用于初始化时回调。
  • t3是通过静态方法,传入一个函数,初始化时会回调这个函数。

1.2.初始化

调用get()set()方法对Thread对象中的threadLocals进行初始化,如果threadLocals为null,则创建一个ThreadLocalMap

下面是set()方法,实现非常的简单:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

上面代码中的this指的是当前的ThreadLocal对象。


get()方法在threadLocals为null,或使用当前ThreadLocal对象作为key查询出的Entry节点为null的时候,会先使用到1.1中的初始化值对ThreadLocalMap进行初始化。

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

上面的setInitialValue()方法和set()方法类似,只是将set()方法中传入的参数值替换为新建初始化值就可以了,在里面会调用initialValue()获取初始值。

protected T initialValue() {
    return null;
}

默认就是返回null,1.1中的t1就是使用这种方式。t2中使用了匿名内部类重写了initialValue(),这里就会返回我写上的初始化值“张三”。

至于t3是在JDK8之后才有的方法,t3是使用的静态内部类SuppliedThreadLocal函数式接口来实现的。

    static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {

        private final Supplier<? extends T> supplier;

        SuppliedThreadLocal(Supplier<? extends T> supplier) {
            this.supplier = Objects.requireNonNull(supplier);
        }

        @Override
        protected T initialValue() {
            return supplier.get();
        }
    }

上面的Supplier是一个函数式接口,我们再1.1中调用withInitial()传入了一个函数作为参数,然后就会调用SuppliedThreadLocal的构造方法创建对象,并将传入的函数储存到变量中。然后重写了initialValue()方法,在get()方法调用的时候,就会发起这个自定义函数的调用。
t3中写的函数是()->0,那这里的初始化值就是0。

1.3.移除

为了避免内存泄露,在使用完ThreadLocal后需要将不再使用的entry节点从ThreadLocalMap中移除掉,remove()方法也非常简单。

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

1.4.存储结构图示

首先,Thread类与ThreadLocal的关系如下入所示:
在这里插入图片描述
每个Thread对象中都维护了一个ThreadLocalMap,这个Map使用ThreadLocal对象作为key。


线程隔离是如何实现的呢?
在这里插入图片描述
可以看到的是,ThreadLocal对象虽然是同一个,但是每个线程中的ThreadLocalMap都是单独的,所以各个线程之间是互不影响的。

1.5.小结

ThreadLocal的实现是非常简单的,就是在通过Thread对象维护一个ThreadLocalMap就实现了,因为Thread对象内的变量只允许当前线程访问,也就天然的实现了线程隔离。同时,在同一个线程中,不管运行到了程序的哪一个位置都可以从自身的变量中获取到已保存好的参数,这样就实现了参数传递。

上面提到的不管是get()还是set()还是remove()都依赖于ThreadLocalMap作为存储参数的数据结构,ThreadLocal的实现那么简单,就是因为ThreadLocalMap为它承担了所有,那下面可以看看是如何实现的。

2.ThreadLocalMap的实现

说到Map这样的哈希表结构,那首先想到的应该就是最熟悉的HashMap了,我们知道HashMap是通过数组+链表来实现的。其中数组作为数据的分片存储和访问的入口,肯定是必须要存在的。

ThreadLocalMap的核心数据结构也一定是数组,它底层维护了一个Entry数组,下面可以看一下Entry的数据结构。

2.1.Entry的结构

static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;

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

EntryThreadLocalMap的一个静态内部类,继承了WeakReference,在使用的时候会将ThreadLocal弱引用对象作为key。

需要注意的是,这个类中并没有定义key变量,作为key的ThreadLocal对象,是通过super()调用顶层的抽象类Reference类的构造函数来保存的,再通过Entry对象来获取key需要使用到Reference类中的get()方法来获取key。

2.1.1.为什么key要使用弱引用

弱引用对象指的是使用WeakReference引用的对象,一个对象如果只存在弱引用,在JVM下一次GC时就会被回收掉。
下面是一个弱引用对象回收的示例:

public class WeekReferenceDemo {

    public static void main(String[] args) {
        // 创建强引用
        WeekReferenceDemo demo = new WeekReferenceDemo();
        // 创建弱引用
        WeakReference<WeekReferenceDemo> reference = new WeakReference<>(demo);
        // 打印对象地址
        System.out.println(reference.get());
        System.gc();
        // GC后再打印地址
        System.out.println(reference.get());
        // 断开强引用
        demo = null;
        System.gc();
        // 再次GC后打印地址
        System.out.println(reference.get());
    }
}

com.ls.practice.jvm.WeekReferenceDemo@677327b6
com.ls.practice.jvm.WeekReferenceDemo@677327b6
null

打印的结果显示,当强引用断开后,即使还存在弱引用,WeekReferenceDemo对象也会被回收掉。


在实际的项目中使用的线程往往是可以复用的,线程的生命周期会很长,这种情况下线程对象就会长期持有ThreadLocalMap对象的强引用,而ThreadLocalMap中定义了Entry数组,它对Entry对象也是强引用
如果Entry对作为key的ThreadLocal对象也是强引用的话,当ThreadLocal对象被使用完,需要被GC掉的时候,也会因为Entry这个强引用导致这个ThreadLocal对象不能被回收,引起内存泄露
如果让EntryThreadLocal对象做弱引用,在没有其它的位置对同一个ThreadLocal有强引用的情况下,下一次GC就可以回收掉了。

但即使对key做了弱引用的设计,也还是会因为Entry对value是强引用,导致出现因为value不能被回收导致的内存泄露问题。

2.1.2.为什么value不使用弱引用

ThreadLocal如果没有其它的强引用了,一定是表示它使用结束了,这种情况下就应该会回收掉,所以使用弱引用没有任何问题。
但是value不一样,value在ThreadLocal的使用周期内,也有可能在其他位置被断开了强引用,如果在Entry中对value使用弱引用的话,在业务流程还在跑的期间,发生了一次GC,这个value对象就被回收掉了,那业务肯定会出现错误。

为了应付这种情况,还是保留了value的强引用,通过已经被回收掉的key作为标识,在使用ThreadLocalget() set()方法时,会去搜索key==null的那些已经失效了的Entry对象,断开Entry对象与value引用的对象之间的强引用,并将他们从ThreadLocalMap中移除掉。

但是我们如果不去调用get() set(),就不会触发对失效Entry对象的清理操作,所以我们在使用ThreadLocal的时候,一定要在代码的结尾位置调用remove()方法,主动发起清理操作。

2.2.Entry初始化

上面提到了,当Thread对象中的threadLocals为null时,或者使用ThreadLocal查询出去Entry为null时,会调用createMap()方法,这里方法中会使用构造方法创建ThreadLocalMap对象。

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
	// INITIAL_CAPACITY = 16 
    table = new Entry[INITIAL_CAPACITY];
    // 计算下标
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    // threshold = INITIAL_CAPACITY * 2 / 3
    setThreshold(INITIAL_CAPACITY);
}

这里的firstValue要么是创建ThreadLocal时通过initialValue()返回的初始值,要么set()方法调用时传入的value值。

  • 首先是国际惯例,初始化一个Entry数组,长度INITIAL_CAPACITY = 16
  • 计算数组下标,这里的threadLocalHashCode涉及到魔数HASH_INCREMENT = 0x61c88647的使用,目的是让Entry在整个数组中分配的更加均匀。
  • 然后创建一个Entry放入到计算出的下标对应的数据节点中,key是一个弱引用的ThreadLocal对象。
  • 最后设置扩容的参照参数threshold = INITIAL_CAPACITY * 2 / 3

2.2.1.模拟魔数的使用

下面使用一个Demo来模拟一下数组下标的计算:

public class ThreadLocalMagicNumber {

    private static final int HASH_INCREMENT = 0x61c88647;
    private static AtomicInteger nextHashCode = new AtomicInteger();

    /**
     * 每次都加上一个魔数
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

    public void test() {
        for (int i = 16; i <= 32; i *= 2) {
            for (int j = 0; j < i; j++) {
                System.out.print(nextHashCode() & (i - 1));
                System.out.print(" ");
            }
            System.out.println();
        }
    }

    public static void main(String[] args) {
        new ThreadLocalMagicNumber().test();
    }
}

分别打印了长度为16,32的分配情况,都是均匀的分配,没有重复的数字。

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0
23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16

2.2.2.ThreadLocal是如何使用魔数的

ThreadLocal中定义两个静态字段和一个实例常量字段,分别是:

// 魔数
private static final int HASH_INCREMENT = 0x61c88647;
// 下一个hash值
private static AtomicInteger nextHashCode = new AtomicInteger();
// 实例化ThreadLocal对象时获取的hash值常量
private final int threadLocalHashCode = nextHashCode();

看看nextHashCode()方法,它里面其实就做了一个事,每实例化一个ThreadLocal对象,就将静态变量nextHashCode的值加上一个魔数。

private static int nextHashCode() {
    return nextHashCode.getAndAdd(HASH_INCREMENT);
}

由于nextHashCode是个静态字段,每个实例化的ThreadLocal对象都会共用这个字段,所以这种每次加上一个魔数的方式,会使每个ThreadLocal实例分配的hash值都不一样,达到在ThreadLocalMap中均匀分配的效果。

2.2.3.数组下标出现重复的情况

在2.2.1模拟魔数使用的测试中看到打印的结果,在某个长度范围内是均匀的打印出了不同的数值,中间没有出现重复的情况,那么数组下标会出现重复吗?答案是肯定会有下标重复的情况。

在2.1.1的例子中nextHashCode的值是均匀增加的,但是在hash的过程中有其他的线程也对这个变量进行递增的操作,分配的结果可能就没有那么均匀了。下面看一下另外一个例子:

public void test1() {
    for (int i = 0; i < 32; i++) {
        System.out.print(nextHashCode() & 31);
        System.out.print(" ");
        // 这里模拟有其它线程插队,对静态变量做了修改
        nextHashCode();
    }
}

0 14 28 10 24 6 20 2 16 30 12 26 8 22 4 18 0 14 28 10 24 6 20 2 16 30 12 26 8 22 4 18

从打印结果中可以明显的看到存在重复的结果,对于ThreadLocalMap来说,这种重复的下标就会造成Hash冲突,如何解决Hash冲突呢?可以看下面的set()方法。

2.3.set()方法

ThreadLocalMapset()方法除了存放值以外,还需要处理可能出现的Hash冲突问题,下面是set()方法的源码。

2.3.1.set()方法源码解析

private void set(ThreadLocal<?> key, Object value) {
	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();
		// key相等,则修改value
	    if (k == key) {
	        e.value = value;
	        return;
	    }
		// entry不为null,key为null则把新的entry替换进去。
	    if (k == null) {
	        replaceStaleEntry(key, value, i);
	        return;
	    }
	}
	// 下标对应的数组节点为null,则set新值。
	tab[i] = new Entry(key, value);
	// 省略部分代码
}

通过计算出的数组下标i从数组中获取Entry对象:

  • 如果Entry对象值为null,则直接将新的Entry对象设置到这个数组节点中。
  • 如果Entry对象值不为null,则判断key是否相等,相等就替换value值。
  • 如果key == null则进入替换Entry节点的方法,这个方法后面会解释里面到底做了什么。
  • 如果key != null并且与传入的ThreadLocal不相等,则表示发生了Hash冲突

2.3.1.1.开放寻址法——解决Hash冲突

如果Entry对象和key都不为null且key != threadLocal,则根据当前计算出的数组下标i,去找i+1的位置,如果i+1大于数组长度则找到数组下标为0的位置,再重复做判断。

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

直到找到了Entry对象为null的节点,这种方法叫做开放寻址法,如下图所示:
在这里插入图片描述


在上面的循环中做寻址操作的时候,也可能会遇到key==null的节点,这时会调用replaceStaleEntry()方法,将新的节点替换进去,并清理掉这个节点。如下图所示:
在这里插入图片描述
但是,由于存在开放寻址,上图中的ThreadLocalB可能在之前已经set到了计算出的下标i的更右边的位置。所以在上图的基础上,还得加上一个寻址的过程,以上图中的k==null的节点下标为基础(下面使用staleSlot代指这个值),向右寻址,会出现下面两种情况。

  • 在寻址过程中,如果一直往右寻址到entry==null的节点,都没有找到,则直接将节点插入到staleSlot位置(就是上图中的操作)。
  • 如果找到了key==ThreadLocalB的节点,下标为i,则将staleSlot这个位置的失效节点移动到下标为i的节点,然后新的节点插入到staleSlot位置。
    在这里插入图片描述

上图中,已经把新增的节点加入到了它最合适的位置,但是失效节点还没有清理掉,这个节点会在调用expungeStaleEntry()方法的时候,与其它失效的entry节点一同被清理掉。
清理的起始点是如何寻找的呢?
相对于set节点的向右寻址,清理的起点位置是通过向左寻址来找到的,从staleSlot值开始,每次数组下标减1。

private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}

直到通过当前的下标i找到的节点entry==null为止。

然后就可以通过这个起始下标往向右迭代寻找key==null的节点,每找到一个就清理一个,如下图所示:
在这里插入图片描述
在这一步清理完成后,可以看到还会存在其他已失效的节点没有被清理掉,为了尽可能的将失效节点清理掉,ThreadLocalMap会调用cleanSomeSlots()再次清理一部分失效的节点。
下面的代码省略了一部分非关键的位置:

private boolean cleanSomeSlots(int i, int n) {
    // 省略部分代码
    do {
    	// 此处的i是expungeStaleEntry结束的位置,在上图中就是6
        i = nextIndex(i, len);
        Entry e = tab[i];
        if (e != null && e.get() == null) {
        	// n传入的是数组长度,如果找到了一个失效节点,则刷新迭代次数
            n = len;
            // 从这个i开始,执行清理操作
            i = expungeStaleEntry(i);
        }
    } 
	// 每迭代一次,将n的值除以2,长度为16最少会迭代4次
	while ( (n >>>= 1) != 0);
    return removed;
}

这么设计也是在做更加全面的清理与减少迭代次数之间做一个平衡,在多数情况下会取得一个比较好的效果。例如上图通过cleanSomeSlots()处理后,所有的失效节点都会被清理掉。
在这里插入图片描述


经过上面的一顿处理之后,整个ThreadLocalMapset逻辑已经趋于完美了,但是还存在一个问题,假如在ThreadLocalB本来应该计算出来的下标是4,但是之前由于被其它节点抢占了,它通过开放寻址,寻址到了下标为5的位置,经过清理后,如果要对ThreadLocalB做修改,就会遇到一个尴尬的处境,如下图所示:
在这里插入图片描述
这种问题ThreadLocalMap中已经解决了,在expungeStaleEntry()方法的寻找失效节点的过程中,每找到一个key != null的节点(假设当前下标为i),则再次计算它的下标(后面用h表示),如果i != h则将这个有效的节点移动的下标为h的位置,如果h的位置还有其他的节点,则向右寻址找到一个entry == null的节点放进去。整个过程叫做Rehash

最终的结构如下图所示:
在这里插入图片描述

2.3.2.扩容

每多插入一个元素(如果没有失效的节点),ThreadLocalMapsize就会+1,然后会做一个清理操作cleanSomeSlots(),这个操作和上面开放寻址提到的是同一个操作。如果清理完后,size还是大于threshold,就会触发rehash()操作:

if (!cleanSomeSlots(i, sz) && sz >= threshold) {
    rehash();
}

触发后会对整个数组做一个完整的清理,把所有失效的节点都删除掉。然后再次判断:

private void rehash() {
	// 从头到尾完整清理
    expungeStaleEntries();

    if (size >= threshold - threshold / 4)
        resize();
}

resize()中会创建一个长度为旧数组两倍大小的新数组,然后遍历旧数组,找到entry != null的节点,再次做判断:

  • 如果key == null则设置value=null来协助GC回收entry对象。
  • 如果key != null则通过key的HashCode与新数组的长度-1做与运算,获得新的下标,然后做一套开发寻址操作,将entry放入到新数组中合适的位置。

2.4.get()方法

有了上面的set()方法的解析经验后,get()方法就非常简单了,这里就不放源码了,总共就是下面几个步骤:

  • 通过ThreadLocal对象计算下标值i
  • 通过i查询到的entry节点对象为null,则返回null。
  • 如果entry != null,判断key == threadLocal,为true则返回这个entry对象。
  • 如果key == null,则做清理操作expungeStaleEntry()
  • 如果key != nullkey != threadLocal,则向右寻址,直到找到了需要查询的对象或找到entry对象为null为止。

2.5.remove()方法

remove()方法并不是单纯的移除当前ThreadLocal对应的节点,而是会做一次失效节点的清理。主要有下面几个步骤:

  • 通过ThreadLocal对象计算下标值i
  • 通过下标和向右寻址找到对应的entry节点。
  • 将这个节点中的key断开弱引用。
  • 从这个节点的下标开始,调用expungeStaleEntry()做清理操作。

3.总结

ThreadLocal:
ThreadLocal的实现就是通过在线程对象Thread中维护一个ThreadLocalMap来实现的,线程对象提供了线程隔离的基础,而ThreadLocalMap作为存放数据的结构,提供了参数传递的基础。

ThreadLocalMap:

  • 使用0x61c88647这样一个魔数来保证数组元素尽可能的分散,以此来减少Hash冲突。
  • 使用开放寻址法作为Hash冲突的解决方案。
  • 由于线程在实际项目中可复用的使用方式,需要使用对key做弱引用这样的方式来避免内存泄露。
  • key == null作为标识的失效Entry对象的清理贯穿了ThreadLocalMap始终。

标签:ThreadLocalMap,ThreadLocal,详解,线程,key,Entry,null,节点
来源: https://blog.csdn.net/qq_38249409/article/details/114059940

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有