简单理解一下ThreadLocal

339 阅读9分钟

前言

在并发编程中,被多个线程访问的共享变量可能会出现线程不安全的问题,而使用ThreadLocal存储的变量则不会出现线程安全问题,因为它是线程局部变量,只能被线程本身访问到。

ThreadLocal类上的javaDoc注释这样讲到:

This class provides thread-local variables.  These variables differ from
* their normal counterparts in that each thread that accesses one (via its
* {@code get} or {@code set} method) has its own, independently initialized
* copy of the variable.  {@code ThreadLocal} instances are typically private
* static fields in classes that wish to associate state with a thread (e.g.,
* a user ID or Transaction ID).

简单翻译一下:该类提供线程局部变量。这些变量与普通变量不同,因为每个访问一个线程(通过其get/set方法)的线程都有它自己的,独立初始化的变量副本。 ThreadLocal实例通常是作为将状态与线程关联的类中的私有静态字段(例如用户ID或交易ID)。

ThreadLocal 提供了一种方式,让在多线程环境下,每个线程都可以拥有自己独特的数据,并且可以在整个线程执行过程中,实现从上而下的传递。

ThreadLocal的作用是提供线程内的局部变量,在多线程环境下访问时能保证各个线程内的ThreadLocal变量各自独立。也就是说,每个线程的ThreadLocal变量是自己专用的,其他线程是访问不到的

ThreadLocal作为轻量级的存储存在,使得我们使用时不用考虑到它的线程安全问题。

ThreadLocal最常用于以下这个场景:多线程环境下存在对非线程安全对象的并发访问,而且该对象不需要在线程间共享,但是我们不想加锁,这时候可以使用ThreadLocal来使得每个线程都持有一个该对象的副本。

上面只是给出的一些直接的概念介绍,读者看到这里仍然会疑惑,ThreadLocal到底是怎么实现只为当前线程服务的呢?不要着急,在底层原理模块,我们会慢慢展开。

使用场景

在这部分,我们会稍微看一下ThreadLocal的简单应用。

AOP中的一个类

在Spring AOP模块(对于AOP的原理,本文不会作详细说明),我们可以看到这样一个类:

public abstract class AopContext {
    
    /**
	 * ThreadLocal holder for AOP proxy associated with this thread.
	 * Will contain {@code null} unless the "exposeProxy" property on
	 * the controlling proxy configuration has been set to "true".
	 * @see ProxyConfig#setExposeProxy
	 */
	private static final ThreadLocal<Object> currentProxy = new NamedThreadLocal<Object>("Current AOP proxy");
    
    public static Object currentProxy() throws IllegalStateException {
		Object proxy = currentProxy.get();
		if (proxy == null) {
			throw new IllegalStateException(
					"Cannot find current proxy: Set 'exposeProxy' property on Advised to 'true' to make it available.");
		}
		return proxy;
	}
    
    static Object setCurrentProxy(Object proxy) {
		Object old = currentProxy.get();
		if (proxy != null) {
			currentProxy.set(proxy);
		}
		else {
			currentProxy.remove();
		}
		return old;
	}

}

我们可以通过setCurrentProxycurrentProxy方法分别对currentProxy对象进行写操作/读操作。

大概可以猜到,currentProxy实际上存储了一个代理对象,透过命名中的**"current",我们不难得知这个代理对象是 当前的,再一看到ThreadLocal,那就更清楚了,currentProxy指的是当前线程关联的代理对象**。

解决SimpleDateFormat的线程不安全问题

SimpleDateFormat是线程不安全的,我们可以通过ThreadLocal存储SimpleDateFormat对象以此消除线程不安全问题。

private static ThreadLocal<SimpleDateFormat> threadLocal = 
    ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

底层原理

ThreadLocal为何可以存储线程本地变量?

我们创建了一个ThreadLocal对象后,会使用set方法写入值。

 public void set(T value) {
        // 获取当前线程
        Thread t = Thread.currentThread();
        // 获取ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }

我们发现ThreadLocal#set方法里会获取到当前的线程,然后再根据当前线程获取ThreadLocalMap对象。

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

这里就让人惊讶了,ThreadLocalMap居然是Thread类中的一个变量。

public class Thread implements Runnable {
  ...
 ThreadLocal.ThreadLocalMap threadLocals = null;
  ...
}

经过简单的分析后,发现ThreadLocal只是一个外壳,真正存储数据的是与Thread对象关联的ThreadLocaMap对象,因此我们说ThreadLocal可以用来存储线程本地变量,是因为这个ThreadLocalMap对象。

ThreadLocal (外壳)-> Thread.ThreadLocalMap(容器)

数据结构

通过上面小节的讨论,我们发现针对ThreadLocal底层数据结构的探究,其实就是对ThreadLocalMap内部结构的理解。

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

上面是ThreadLocal#set方法,可以发现最终是调用了ThreadLocalMap#set方法

 private void set(ThreadLocal<?> key, Object value) {
            // 数组
            Entry[] tab = table;
            int len = tab.length;
            // 使用hash值计算数组元素下标
            int i = key.threadLocalHashCode & (len-1);

            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                 ...
            }
				...
  }
  • ThreadLocalMap采用数组结构(Entry[])存储数据

  • Entry是一个弱引用实现,只要没有强引用存在,发生GC时就会被回收掉。

     static class Entry extends WeakReference<ThreadLocal<?>> {
                /** The value associated with this ThreadLocal. */
                Object value;
    
                Entry(ThreadLocal<?> k, Object v) {
                    super(k);
                    value = v;
                }
    }
    
  • 数据元素采用哈希散列方式进行存储,这里的散列使用的是 斐波那契(Fibonacci)散列法

  • 由于这里不同于HashMap的数据结构,发生哈希碰撞不会存成链表或红黑树,而是使用拉链法进行存储

    也就是同一个下标位置发生冲突时,则+1向后寻址直到找到空位置或垃圾回收位置进行存储

散列(hash)算法

既然 ThreadLocal 是基于数组结构的拉链法存储,那么就一定会有哈希的计算。

   private final int threadLocalHashCode = nextHashCode();

    /**
     * The next hash code to be given out. Updated atomically. Starts at
     * zero.
     */
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

神秘的0x61c88647

计算hash的方式,主要有除法散列法、平方散列法、斐波那契(Fibonacci)散列法、随机数法等方式。

ThreadLocal 使用的就是 斐波那契(Fibonacci)散列法 +拉链法存储数据到数组结构中。之所以使用斐波那契数列,是为了让数据更加散列,减少哈希碰撞。

0x61c88647魔数的选取与斐波那契散列有关,0x61c88647对应的十进制为1640531527。

通过理论与实践,当我们用0x61c88647作为魔数累加为每个ThreadLocal分配各自的ID也就是threadLocalHashCode再与2的幂取模,得到的结果分布很均匀。

具体来自数学公式的计算求值,公式f(k) = ((k * 2654435769) >> X) << Y

对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28

对于具体的数学公式,我们就不深究了,只要知道ThreadLocal采用了斐波那契数列使得数据更加分散,从而降低了哈希冲突的频率。

源码解读

ThreadLocalMap的构造

        // 初始容量
		private static final int INITIAL_CAPACITY = 16;
		
        private Entry[] table;

        /**
         * table数组元素个数
         */
        private int size = 0;
		
		// 达到扩容条件的阈值
        private int threshold; // Default to 0

       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) {
          	// 16*2/3=10
            threshold = len * 2 / 3;
      }

set

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

            Entry[] tab = table;
            int len = tab.length;
            // 斐波那契散列,计算数组下标
            int i = key.threadLocalHashCode & (len-1);
			
            // 循环 判断 元素是否存在
    		// e表示 通过斐波那契散列算出来的数组下标 获取到的Entry元素
            for (Entry e = tab[i];
                 // 数组元素不为空
                 e != null;
                 e = tab[i = nextIndex(i, len)]) { // 4. key 不相同,拉链法寻址
                
                // 获取Entry元素的ThreadLocal对象(key)
                ThreadLocal<?> k = e.get();
				
                // 2.如果key相等,则替换掉原有的value  
                if (k == key) {
                    e.value = value;
                    return;
                }
				
                // 3. 如果key不相等 并且k为空  (弱引用发生GC时,产生的情况)
                if (k == null) {
                    // 探测式清理过期元素
                    replaceStaleEntry(key, value, i);
                    return;
                }
                
                
            }
			// 1.空位置 直接插入
            tab[i] = new Entry(key, value);
    		
    		// 数组中目前有的元素个数
            int sz = ++size;
    		// cleanSomeSlots:启发式清理
            if (!cleanSomeSlots(i, sz) && sz >= threshold){
                rehash();
            }
}

扩容机制

 if (!cleanSomeSlots(i, sz) && sz >= threshold){
        rehash();
 }
  • cleanSomeSlots(i, sz)启发式清理,把过期元素清理掉
  • 如果没有清理掉任何过期元素,那么就会判断Entry数组元素个数是否超过了threshold,如果超过了就会扩容

扩容的核心方法为rehash

private void rehash() {
    		// 探测式清理过期元素
            expungeStaleEntries();
            // Use lower threshold for doubling to avoid hysteresis
    		// size > = threshold * 3/4
            if (size >= threshold  - threshold / 4)
                resize();
}
ThreadLocalMap#resize
private void resize() {
    		// 旧的Entry数组
            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) {
                    // 获取key
                    ThreadLocal<?> k = e.get();
                    // 如果key是空,则将value设置为空,帮助GC
                    if (k == null) {
                        e.value = null; // Help the GC
                    } else {
                        // 使用斐波那契数列重新计算元素的hash值
                        int h = k.threadLocalHashCode & (newLen - 1);
                        
                        // 如果当前位置已有元素,证明发生了hash冲突,则使用拉链法找下面的位置
                        while (newTab[h] != null)
                            h = nextIndex(h, newLen);
                        // 设置位置
                        newTab[h] = e;
                        // 计算新数组中元素个数
                        count++;
                    }
                }
            }
			// 设置新的扩容阈值
            setThreshold(newLen);
            size = count;
    		// 将新数组关联给table变量
            table = newTab;
}

启发式清理

ThreadLocal#cleanSomeSlots
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;
}

while 循环中不断的右移进行寻找需要被清理的过期元素,最终都会使用 expungeStaleEntry 进行处理,这里还包括元素的移位。

探测式清理

探测式清理,是以当前遇到的元素开始,向后不断的清理。直到遇到 null的Entry元素 为止,才停止 rehash 计算Rehash until we encounter null

ThreadLocalMap#expungeStaleEntry
 private int expungeStaleEntry(int staleSlot) {
            Entry[] tab = table;
            int len = tab.length;

            // expunge entry at staleSlot
            tab[staleSlot].value = null;
            tab[staleSlot] = null;
            size--;

            // Rehash until we encounter null
            Entry e;
            int i;
            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 {
                    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;
        }

总结

本文简单介绍了ThreadLocal的原理,主要包含ThreadLocal如何实现线程间隔离、斐波那契散列降低哈希冲突、扩容、启发式清理和探测式清理等内容,由于本人水平有限,不少地方没有做更深入地探讨,实在惭愧!

本文并没有讲解内存泄漏问题,读者可以参考其他文章理解一下,强推大佬一枝花算不算浪漫的文章面试官:小伙子,听说你看过ThreadLocal源码

在对ThreadLocal的理解中,可以看到技术原理的底层离不开优秀的数据结构和算法,再想想Josh BlochDoug Lea写的代码,如果轻易就被我们看懂了,是不是也不大现实呢,继续加油吧,骚年!

参考内容

  1. ThreadLocal作用及用途

  2. 面经手册 · 第12篇《面试官,ThreadLocal 你要这么问,我就挂了!》