关于后端:深入详解ThreadLocal

45次阅读

共计 10478 个字符,预计需要花费 27 分钟才能阅读完成。

本文已收录至 GitHub,举荐浏览 👉 Java 随想录

微信公众号:Java 随想录

原创不易,重视版权。转载请注明原作者和原文链接

在咱们日常的并发编程中,有一种神奇的机制在静悄悄地为咱们解决着各种看似辣手的问题,它就是「ThreadLocal」。

这个奢侈却弱小的工具,许多 Java 开发者可能并没有真正理解过其外部运作原理和利用场景。

本篇文章,我将和大家一起摸索 JDK 中这个独特而又弱小的类——ThreadLocal。

透过本文,咱们将揭开它神秘的面纱,并深刻了解它是如何优雅解决线程级别的数据隔离,以及在理论开发中如何无效地利用它。

话不多说,咱们进入正题。

什么是 ThreadLocal

ThreadLocal 是 Java 中的一个类,它提供了一种线程绑定机制,能够将状态与线程(Thread)关联起来。每个线程都会有本人独立的一个 ThreadLocal 变量,因而对该变量的读写操作只会影响到以后执行线程的这个变量,而不会影响到其余线程的同名变量

咱们先来看一个简略的 ThreadLocal 应用示例:

public class ThreadLocalTest {private static ThreadLocal<String> threadLocal = new ThreadLocal<>();
 
    public static void main(String[] args) {Thread thread1 = new Thread(() -> {threadLocal.set("本地变量 1");
            print("thread1");
            System.out.println("线程 1 的本地变量的值为:"+threadLocal.get());
        });
 
        Thread thread2 = new Thread(() -> {threadLocal.set("本地变量 2");
            print("thread2");
            System.out.println("线程 2 的本地变量的值为:"+threadLocal.get());
        });
 
        thread1.start();
        thread2.start();}
 
    public static void print(String s){System.out.println(s+":"+threadLocal.get());
     
    }

执行后果如下:

thread2: 本地变量 2
thread1: 本地变量 1
线程 2 的本地变量的值为: 本地变量 2
线程 1 的本地变量的值为: 本地变量 1 

通过下面的例子,咱们能够很轻易的看出,ThreadLocal 打消了不同线程间共享变量的需要,能够用来实现「线程局部变量」,从而防止了多线程同步(synchronization)的问题。

OK,上面开始解说 ThreadLocal,讲 ThreadLocal 之前,咱们得先从 Thread 类讲起。

Thread 类中有保护两个 ThreadLocal.ThreadLocalMap 对象,别离是:threadLocalsinheritableThreadLocals

源码如下:

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
 * InheritableThreadLocal values pertaining to this thread. This map is
 * maintained by the InheritableThreadLocal class.
 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

初始它们都为 null,只有在调用 ThreadLocal 类的 set 或 get 时才创立它们。ThreadLocalMap 能够了解为线程公有的 HashMap。

ThreadLoalMap 是 ThreadLocal 中的一个动态外部类,是一个相似 HashMap 的数据结构,但并没有实现 Map 接口。

ThreadLoalMap 中初始化了一个「大小 16 的 Entry 数组」,Entry 对象用来保留每一个 key-value 键值对。key 是 ThreadLocal 对象。

有图有假相,源码中的定义如下:

仔细的你必定发现了,Entry 继承了「弱援用(WeakReference)」。在 Entry 外部应用 ThreadLocal 作为 key,应用咱们设置的 value 作为 value。

ThreadLocal 原理

ThreadLocal 中咱们最罕用的必定是 set()get()办法了,所以先从这两个办法动手。

set 办法

当咱们调用 ThreadLocal 的 set() 办法时理论是调用了以后线程的 ThreadLocalMap 的 set() 办法。

ThreadLocal 的 set() 办法中,会进一步调用Thread.currentThread() 取得以后线程对象,而后获取到以后线程对象的 ThreadLocalMap,判断是不是为空。

为空就先调用 creadMap() 创立 ThreadLocalMap 对象,在结构参数里 set 进变量。

不为空就间接set(value)

这种保障线程平安的形式有个专业术语,称为「线程关闭」,线程只能看到本人的 ThreadLocal 变量。线程之间是相互隔离的。

get 办法

get()办法用来获取与以后线程关联的 ThreadLocal 的值。

如果以后线程没有该 ThreadLocal 的值,则调用「initialValue 函数」获取初始值返回,所以个别咱们应用时须要继承该函数,给出初始值(不重写的话默认返回 Null)。

/**
 * Returns the value in the current thread's copy of this
 * thread-local variable.  If the variable has no value for the
 * current thread, it is first initialized to the value returned
 * by an invocation of the {@link #initialValue} method.
 *
 * @return the current thread's value of this thread-local
 */
public T get() {Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {@SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();}

get 办法的流程次要是以下几步:

  1. 获取以后的 Thread 对象,通过 getMap 获取 Thread 内的 ThreadLocalMap。
  2. 如果 map 曾经存在,以以后的 ThreadLocal 为键,获取 Entry 对象,并从从 Entry 中取出值。
  3. 否则,调用 setInitialValue 进行初始化。

咱们能够重写initialValue(),设置初始值,具体写法如下:

private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
     @Override
     protected Integer initialValue() {return Integer.valueOf(0);
     }
}

举荐设置初始值,如果不设置为 null,在某些状况下会引发空指针的问题。

remove 办法

最初一个须要探索的就是 remove() 办法,它用于在 map 中移除一个不必的 Entry。

先计算出 hash 值,若是第一次没有命中,就循环直到 null,在此过程中也会调用「expungeStaleEntry」革除空 key 节点。代码如下:

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


/**
 * Remove the entry for 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();
                    expungeStaleEntry(i);
                    return;
                }
            }
}

下面咱们看了 ThreadLocal 的源码,咱们晓得 ThreadLocalMap 中应用的 key 为 ThreadLocal 的弱援用,弱援用的特点是,如果这个对象只存在弱援用,那么在下一次垃圾回收的时候必然会被清理掉

所以如果 ThreadLocal 没有被内部强援用的状况下,在垃圾回收的时候会被清理掉的,这样一来 ThreadLocalMap 中应用这个 ThreadLocal 的 key 也会被清理掉。然而,value 是强援用,不会被清理,这样一来就会呈现 key 为 null 的 value。

呈现「内存透露」的问题。

其实在执行 ThreadLocal 的 set、remove、rehash 等办法时,它都会扫描 key 为 null 的 Entry,如果发现某个 Entry 的 key 为 null,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就能够被失常回收了。

然而假如 ThreadLocal 曾经不被应用了,那么实际上 set、remove、rehash 办法也不会被调用,与此同时,如果这个线程又始终存活、不终止的话,那么方才的那个调用链就始终存在,也就导致了内存透露。

ThreadLocal 的 Hash 算法

ThreadLocalMap相似 HashMap,它有本人的 Hash 算法。

private final int threadLocalHashCode = nextHashCode();

private static final int HASH_INCREMENT = 0x61c88647;
  
private static int nextHashCode() {return nextHashCode.getAndAdd(HASH_INCREMENT);
}
    
public final int getAndAdd(int delta) {return unsafe.getAndAddInt(this, valueOffset, delta);
}

HASH_INCREMENT这个数字被称为「斐波那契数 」也叫「 黄金分割数」,其中的数学原理咱们不去纠结,

咱们只需晓得用斐波那契数去散列,带来的益处就是 hash 散布十分平均。

每当创立一个 ThreadLocal 对象,这个ThreadLocal.nextHashCode 这个值就会增长 0x61c88647

讲到 Hash 就会波及到 Hash 抵触,跟 HashMap 通过「链地址法 」不同的是,ThreadLocal 是通过「 线性探测法 / 凋谢地址法」来解决 hash 抵触。

ThreadLocal 1.7 和 1.8 的区别

ThreadLocal 1.7 版本的时候,entry 对象的 key 是 Thread。到了 1.8 版本 entry 的 key 是 ThreadLocal。

1.8 版本的益处是当 Thread 销毁的时候,ThreadLocalMap 也会随之销毁,缩小内存的应用。因为 ThreadLocalMap 是 Thread 的外部类,所以只有 Thread 隐没了,那 ThreadLocalMap 就不复存在了。

ThreadLocal 的问题

ThreadLocal 内存泄露问题

在 ThreadLocalMap 中的 Entry 的 key 是对 ThreadLocal 的 WeakReference 弱援用,而 value 是强援用。

留神构造函数里的第一行代码 super(k),这意味着 ThreadLocal 对象是一个弱援用

/**
 * The entries in this hash map extend WeakReference, using
 * its main ref field as the key (which is always a
 * ThreadLocal object).  Note that null keys (i.e. entry.get()
 * == null) mean that the key is no longer referenced, so the
 * entry can be expunged from table.  Such entries are referred to
 * as "stale entries" in the code that follows.
 */
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

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

其实要解决也简略,只须要应用完 ThreadLocal 后手动调用 remove() 办法。

但其实在 ThreadLocalMap 的实现中以及思考到这种状况,因而在调用 set()get()remove() 办法时,也会清理 key 为 null 的记录。

为什么应用弱援用而不是强援用?

为什么采纳了弱援用的实现而不是强援用呢?

这个问题在源码的正文上有阐明,咱们来瞅瞅。

原谅我英语不好,这段话应用的某道翻译,翻译如下:

为了帮助解决数据比拟大并且生命周期比拟长的场景,hash table 的 entry 应用了 WeakReference 作为 key。

所以,貌似看起来弱援用反而是为了解决内存存储问题而专门应用的?

认真思考一下,实际上,采纳弱援用反而多了一层保障。

如果 ThreadLocal 作为 key 应用强援用,那么只有 ThreadLocal 实例自身在内存中,它的 entry(包含 value)就会始终存在于 ThreadLocalMap 中,即便线程曾经不再须要它的 ThreadLocal 变量。它们也不会被回收,这导致了一种模式的内存透露。

而如果咱们应用 WeakReference 作为 key。这意味着当对 ThreadLocal 实例的所有强援用都被垃圾收集器革除后,它的 entry(包含 value)也能够从 ThreadLocalMap 中革除,避免了潜在的内存透露。

这样设计让 ThreadLocal 生命周期的控制权交给了用户,用户能够抉择什么时候完结 ThreadLocal 实例的生命周期。

ThreadLocal 被清理后 key 为 null,对应的 value 在下一次 ThreadLocalMap 调用 set、get 时革除,这样就算遗记调用 remove 办法,弱援用比强援用能够多一层保障。

所以,内存泄露的根本原因在于是否手动革除操作,而不是弱援用。

ThreadLocal 父子线程继承

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

/*
 * InheritableThreadLocal values pertaining to this thread. This map is
 * maintained by the InheritableThreadLocal class.
 */
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

Thread 类里一共有两个 ThreadLocal 变量,一个是 threadLocals,也就是咱们常说的 threadlocal,还有一个是 inheritableThreadLocals,这个是干啥用的呢?

inheritableThreadLocals 是用于父子线程之间继承的,这个变量用来存储那些须要被子线程继承的变量。

并且子线程能够拜访和批改这个变量的值,而不会影响到父线程对应变量的值。

异步场景下无奈给子线程共享父线程的线程正本数据,能够通过「InheritableThreadLocal」类解决这个问题。

它的原理就是当新建一个线程对象时,子线程是通过在父线程中调用 new Thread() 创立的,在 Thread 的构造方法中调用了 Thread 的 init() 办法。

init() 办法中父线程的「inheritableThreadLocals」数据会复制到子线程:

ThreadLocal.createInheritedMap(parent.inheritableThreadLocals) 

上面是一个简略的示例代码:

public class Main {public static void main(String[] args) {
        // 在主线程中设置 InheritableThreadLocal
        InheritableThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>();
        inheritableThreadLocal.set("Hello from the main thread");

        // 创立新的线程
        Thread thread = new Thread(() -> {
            // 子线程尝试获取 InheritableThreadLocal 的值
            String value = inheritableThreadLocal.get();
            System.out.println("In child thread, value from InheritableThreadLocal:" + value);
        });

        thread.start();  // 启动子线程
        
        try {thread.join();  // 期待子线程完结
        } catch (InterruptedException e) {e.printStackTrace();
        }

        System.out.println("In main thread, value from InheritableThreadLocal:" + inheritableThreadLocal.get());
    }
}

以上代码首先在主线程中创立了一个 InheritableThreadLocal 对象,并设置了其值。而后,该代码创立并启动了一个新的线程,在新线程中尝试读取 InheritableThreadLocal 的值。因为 InheritableThreadLocal 的值是从父线程继承的,所以新线程可能读取到在主线程中设置的值。

输入如下:

In child thread, value from InheritableThreadLocal: Hello from the main thread
In main thread, value from InheritableThreadLocal: Hello from the main thread

请留神,这种继承是一次性的,只在创立新线程的那一刻产生,之后父子线程对 InheritableThreadLocal 的批改就互不影响了。

同时,因为应用的是浅拷贝,所以如果 InheritableThreadLocal 的值是可变对象,那么仍然可能存在多个线程共享数据的状况。

然而咱们做异步解决个别应用线程池,线程池会复用线程,所以InheritableThreadLocal 在线程池场景中会生效。

不过网上有开源框架,咱们能够应用阿里巴巴的 TTL 解决这个问题:https://github.com/alibaba/transmittable-thread-local。

探测式清理 & 启发式清理

能把下面那些常识消化完,足够应酬 90% 的面试和工作场景了。

然而也只能让面试官感觉你有点货色,但不是很多。如果想让面试官直呼牛 B,那咱就得来聊聊「探测式清理 」和「 启发式清理」了。

ThreadLocal 应用了两种清理有效条目(即键为 null 的条目)的形式:探测式清理和启发式清理。

  • 探测式清理(源码中:expungeStaleEntry() 办法)
  • 启发式清理(源码中:cleanSomeSlots() 办法)

探测式清理

这种清理办法基于一个事实:在查找特定键时,如果遇到有效条目(即键为 null 的条目),能够平安地删除它,因为它必定不是正在寻找的键。

以下是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;
        }

简略叙述下源码说了什么:

遍历散列数组,从开始地位(hash 失去的地位)向后探测清理过期数据,如果遇到过期数据,则置为 null。

如果碰到的是未过期的数据,则将此数据rehash,而后从新在 table 数组中定位。

如果定位的地位曾经存在数据,则往后顺延,直到遇到没有数据的地位。

说白了就是:从以后节点开始遍历数组,将 key 等于 null 的 entry 置为 null,key 不等于 null 则 rehash 重新分配地位,若重新分配上的地位有元素则往后顺延。

启发式清理

启发式清理须要接管两个参数:

  1. 探测式清理后返回的数字下标。
  2. 数组总长度。

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

依据源码能够看出,启动式清理会从传入的下标 i 处,向后遍历。如果发现过期的 Entry 则再次触发探测式清理,并重置 n

这个 n 是用来管制 do while 循环的跳出条件。如果遍历过程中,间断 m 次没有发现过期的 Entry,就能够认为数组中曾经没有过期 Entry 了。

这个 m 的计算是 n >>>= 1,能够了解为是数组长度的 2 的几次幂。

例如:数组长度是 16,那么 2^4=16,也就是间断 4 次没有过期 Entry。

说白了就是: 从以后节点开始,进行 do-while 循环查看清理过期 key,完结条件是间断 n 次未发现过期 key 就跳出循环,n 是通过位运算计算得出的,能够简略了解为数组长度的 2 的多少次幂次。

触发机会

这两种清理形式会在源码中多个地位被触发。

上面的触发场景中,我都从源码中找到了对应的地位,间接对号入座即可,有趣味的能够去深刻浏览这部分的源码。

  • set() 办法中,遇到 key=null 的状况会触发一轮探测式清理流程。
  • set() 办法最初会执行一次启发式清理流程。
  • rehash() 办法中会调用一次探测式清理流程。
  • get() 办法中遇到 key 过期的时候会触发一次探测式清理流程。
  • 启发式清理流程中遇到 key=null 的状况也会触发一次探测式清理流程。

最初,给本篇文章做个总结。

总结

ThreadLocal 是 Java 提供的一种十分有用的工具,它能够帮忙咱们在每个线程中存储并治理各自独立的数据正本。这种个性使得 ThreadLocal 在解决多线程编程中的某些问题时极为高效且易于应用,例如实现线程平安、保护线程间的数据隔离等。

然而,ThreadLocal 也要审慎应用,因为不正确的应用可能会导致内存透露。特地是在应用完 ThreadLocal 后,咱们须要记住及时调用其 remove()办法清理掉线程局部变量,避免对曾经不存在的对象的长时间援用,引发内存透露。

总的来说,ThreadLocal 具备弱小的性能,但必须理解其工作原理和可能的危险,能力充分利用它而不会产生意料之外的问题。因而, 深刻了解并正当应用 ThreadLocal 是每个 Java 开发者的必备技能。

本篇文章到这完结了~,那就下次再见吧👋🏻,感觉有播种点个赞哦。


感激浏览,如果本篇文章有任何谬误和倡议,欢送给我留言斧正。

老铁们,关注我的微信公众号「Java 随想录」,专一分享 Java 技术干货,文章继续更新,能够关注公众号第一工夫浏览。

一起交流学习,期待与你共同进步!

正文完
 0