并发——深入分析ThreadLocal的实现原理

1|0一、前言


  这篇博客来分析一下ThreadLocal的实现原理以及常见问题,由于现在时间比较晚了,我就不废话了,直接进入正题。


2|0二、正文


2|12.1 ThreadLocal是什么


  在讲实现原理之前,我先来简单的说一说ThreadLocal是什么。ThreadLocal被称作线程局部变量,当我们定义了一个ThreadLocal变量,所有的线程共同使用这个变量,但是对于每一个线程来说,实际操作的值是互相独立的。简单来说就是,ThreadLocal能让线程拥有自己内部独享的变量。举一个简单的例子:

// 定义一个线程共享的ThreadLocal变量 static ThreadLocal<Integer> tl = new ThreadLocal<>(); public static void main(String[] args) { // 创建第一个线程 Thread t1 = new Thread(() -> { // 设置ThreadLocal变量的初始值,为1 tl.set(1); // 循环打印ThreadLocal变量的值 for (int i = 0; i < 10; i++) { System.out.println(Thread.currentThread().getName() + "----" + tl.get()); // 每次打印完让值 + 1 tl.set(tl.get() + 1); } }, "thread1"); // 创建第二个线程 Thread t2 = new Thread(() -> { // 设置ThreadLocal变量的初始值,为100,与上一个线程区别开 tl.set(100); // 循环打印ThreadLocal变量的值 for (int i = 0; i < 10; i++) { System.out.println(Thread.currentThread().getName() + "----" + tl.get()); // 每次打印完让值 - 1 tl.set(tl.get() - 1); } }, "thread2"); // 开启两个线程 t1.start(); t2.start(); tl.remove(); }

  上面的代码,运行结果如下(注:每次运行的结果可能不同):

thread1----1 thread2----100 thread1----2 thread2----99 thread1----3 thread2----98 thread1----4 thread2----97 thread1----5 thread2----96 thread1----6 thread2----95 thread1----7 thread2----94 thread1----8 thread2----93 thread1----9 thread2----92 thread1----10 thread2----91

  通过上面的输出结果我们可以发现,线程1线程2虽然使用的是同一个ThreadLocal变量存储值,但是输出结果中,两个线程的值却互不影响,线程11输出到10,而线程2100输出到91。这就是ThreadLocal的功能,即让每一个线程拥有自己独立的变量,多个线程之间互不影响。


2|22.2 ThreadLocal的实现原理


  下面我就就来说一说ThreadLocal是如何做到线程之间相互独立的,也就是它的实现原理。这里我直接放出结论,后面再根据源码分析:每一个线程都有一个对应的Thread对象,而Thread类有一个成员变量,它是一个Map集合,这个Map集合的key就是ThreadLocal的引用,而value就是当前线程在key所对应的ThreadLocal中存储的值。当某个线程需要获取存储在ThreadLocal变量中的值时,ThreadLocal底层会获取当前线程的Thread对象中的Map集合,然后以ThreadLocal作为key,从Map集合中查找value值。这就是ThreadLocal实现线程独立的原理。也就是说,ThreadLocal能够做到线程独立,是因为值并不存在ThreadLocal中,而是存储在线程对象中。下面我们根据ThreadLocal中两个最重要的方法来确认这一点。


2|32.3 ThreadLocal中的get方法


  get方法的作用非常简单,就是线程向ThreadLocal中取值,下面我们来看看它的源码:

public T get() { // 获取当前线程的Thread对象 Thread t = Thread.currentThread(); // getMap方法传入Thread对象,此方法将返回Thread对象中存储的一个Map集合 // 这个Map集合的类型为ThreadLocalMap,这是ThreadLoacl的一个内部类 // 当前线程存放在ThreadLocal中的值,实际上存放在这个Map集合中 ThreadLocalMap map = getMap(t); // 如果当前Map集合已经初始化,则直接从Map集合中查找 if (map != null) { // ThreadLocalMap的key其实就是ThreadLoacl对象的引用 // 所以要找到线程在当前ThreadLoacl中存放的值,就需要以当前ThreadLoacl作为key // getEntry方法就是通过key获取map中的一个key-value,而这里使用的key就是this ThreadLocalMap.Entry e = map.getEntry(this); // 如果返回值不为空,表示查找成功 if (e != null) { @SuppressWarnings("unchecked") // 于是获取对应的value并返回 T result = (T)e.value; return result; } } // 若当前线程的ThreadLocalMap还未初始化,或者查找失败,则调用以下方法 return setInitialValue(); } private T setInitialValue() { // 此方法默认返回null,但是可以由子类进行重新,根据需求返回需要的值 T value = initialValue(); // 获取当前线程的Thread对象 Thread t = Thread.currentThread(); // 获取对应的ThreadLocalMap ThreadLocalMap map = getMap(t); // 如果Map已经初始化了,就直接往map中加入一个key-value // key就是当前ThreadLocal对象的引用,而value就是上面获取到的value,默认为null if (map != null) map.set(this, value); // 若还没有初始化,则调用createMap创建ThreadLocalMap对象 else createMap(t, value); // 返回initialValue方法返回的值,默认为null return value; } void createMap(Thread t, T firstValue) { // 创建ThreadLocalMap对象,构造方法传入的是第一对放入其中的key-value // 这个key也就是当前线程第一次调用get方法的ThreadLocal对象,也就是当前ThreadLocal对象 // 而firstValue则是initialValue方法的返回值,默认为null t.threadLocals = new ThreadLocalMap(this, firstValue); }

  上面的代码非常直观的验证了我之前说过的ThreadLocal的实现原理。通过上面的代码,我们可以非常直观的看到,线程向ThreadLocal中存放的值,最后都放入了线程自己的ThreadLocalMap中,而这个mapkey就是当前ThreadLocal的引用。而ThreadLocal中,获取线程的ThreadLocalMap的方法getMap的代码如下:

ThreadLocalMap getMap(Thread t) { // 直接返回Thread对象的threadLocals成员变量 return t.threadLocals; }

  我们再看看Thread类中的threadLocals变量:

/** 可以看到,ThreadLocalMap是ThreadLocal的内部类 */ ThreadLocal.ThreadLocalMap threadLocals = null;

2|42.4 ThreadLocal中的set方法


  下面再来看一看ThreadLocalset方法的实现,set方法用来使线程向ThreadLocal中存放值(实际上是存放在线程自己的Map中):

public void set(T value) { // 获取当前线程的Thread对象 Thread t = Thread.currentThread(); // 获取当前线程的ThreadLocalMap ThreadLocalMap map = getMap(t); // 若map已经初始化,则之际将value放入Map中,对应的key就是当前ThreadLocal的引用 if (map != null) map.set(this, value); // 若没有初始化,则调用createMap方法,为当前线程t创建ThreadLocalMap, // 然后将key-value放入(此方法已经在上面讲解get方法是看过) else createMap(t, value); }

  这就是set方法的实现,比较简单。看完上面两个关键方法的实现,相信大家对ThreadLocal的实现已经有了一个比较清晰的认识,下面我们来更加深入的分析ThreadLocal,看看ThreadLocalMap的一些实现细节。


2|52.5 ThreadLocalMap的中的弱引用


  ThreadLocalMap的实现其实就是一个比较普通的Map集合,它的实现和HashMap类似,所以具体的实现细节我们就不一一讲解了,这里我们只关注它最特别的一个地方,即它内部的节点Entry。我们先来看看Entry的代码:

// Entry是ThreadLocalMap的内部类,表示Map的节点 // 这里继承了WeakReference,这是java实现的弱引用类,泛型为ThreadLocal // 表示在这个Map中,作为key的ThreadLocal是弱引用 // (这里value是强引用,因为没用WeakReference) static class Entry extends WeakReference<ThreadLocal<?>> { /** 存储value */ Object value; Entry(ThreadLocal<?> k, Object v) { // 将key的值传入父类WeakReference的构造方法,用弱引用来引用key super(k); // value则直接使用上面的强引用 value = v; } }

  可以看到,上面的Entry比较特殊,它继承自WeakReference类型,这是Java实现的弱引用。在具体讲解前,我们先来介绍一下不同类型的引用:

强引用:这是Java中最常见的引用,在没有使用特殊引用的情况下,都是强引用,比如Object o = new Object()就是典型的强引用。能让程序员通过强引用访问到的对象,不会被JVM垃圾回收,即使内存空间不够,JVM也不会回收这些对象,而是抛出内存溢出异常;

软引用:软引用描述的是一些还有用,但不是必须的对象。被软引用所引用的对象,也不会被垃圾回收,直到JVM将要发生内存溢出异常时,才会将这些对象列为回收对象,进行回收。在JDK1.2之后,提供了SoftReference类实现软引用;

弱引用:弱引用描述的是非必须的对象,被弱引用所引用的对象,只能生存到下一次垃圾回收前,下一次垃圾回收来临,此对象就会被回收。在JDK1.2之后,提供了WeakReference类实现弱引用(也就是上面Entry继承的类);

虚引用:这是最弱的一种引用关系,一个对象是否有虚引用,完全不会对其生存时间产生影响,我们也不能通过一个虚引用访问对象,使用虚引用的唯一目的就是,能在这个对象被回收时,受到一个系统的通知。JDK1.2之后,提供了PhantomReference实现虚引用;

  介绍完各类引用的概念,我们就可以来分析一下Entry为什么需要继承WeakReference类了。从代码中,我们可以看到,Entrykey值,也就是ThreadLocal的引用传入到了WeakReference的构造方法中,也就是说在ThreadLocalMap中,key的引用是弱引用。这表明,当没有其他强引用指向key时,这个key将会在下一次垃圾回收时被JVM回收。

  为什么需要这么做呢?这么做的目的自然是为了有利于垃圾回收了。如果了解过JVM的垃圾回收算法的应该知道,JVM判断一个对象是否需要被回收,判断的依据是这个对象还能否被我们所使用,举个简单的例子:

public static void main(String[] args) { Object o = new Object(); o = null; }

  上面的代码中,我们创建了一个对象,并使用强引用o指向它,然后我们将o置为空,这个时候刚刚创建的对象就丢失了,因为我们无法通过任何引用找到这个对象,从而使用它,于是这个对象就需要被回收,这种判断依据被称为可达性分析。关于JVM的垃圾回收算法,可以参考这篇博客:Java中的垃圾回收算法详解

  好,回归正题,我们开始分析为什么ThreadLocalMap需要让key使用弱引用。假设我们创建了一个ThreadLocal,使用完之后没有用了,我们希望能够让它被JVM回收,于是有了下面这个过程:

// 创建ThreadLocal对象 ThreadLocal tl = new ThreadLocal(); // .....省略使用的过程... // 使用完成,希望被JVM回收,于是执行以下操作,解除强引用 tl = null;

  我们在使用完ThreadLocal之后,解除对它的强引用,希望它被JVM回收。但是JVM无法回收它,因为我们虽然在此处释放了对它的强引用,但是它还有其它强引用,那就是Thread对象的ThreadLocalMapkey。我们之前反复说过,ThreadLocalMapkey就是ThreadLocal对象的引用,若这个引用是一个强引用,那么在当前线程执行完毕,被回收前,ThreadLocalMap不会被回收,而ThreadLocalMap不会被回收,它的key引用的ThreadLocal也就不会回收,这就是问题的所在。而使用弱引用就可以保证,在其他对ThreadLocal的强引用解除后,ThreadLocalMap对它的引用不会影响JVM对它进行垃圾回收。这就是使用弱引用的原因。


2|62.6 ThreadLocal造成的内存溢出问题


  上面描述了对ThreadLocalMapkey使用弱引用,来避免JVM无法回收ThreadLocal的问题,但是这里却还有另外一个问题。我们看上面Entry的代码发现,key值虽然使用的弱引用,但是value使用的却是强引用。这会造成一个什么问题?这会造成key被JVM回收,但是value却无法被收,key对应的ThreadLocal被回收后,key变为了null,但是value却还是原来的value,因为被ThreadLocalMap所引用,将无法被JVM回收。若value所占内存较大,线程较多的情况下,将持续占用大量内存,甚至造成内存溢出。我们通过一段代码演示这个问题:

public class Main { public static void main(String[] args) { // 循环创建多个TestClass for (int i = 0; i < 100; i++) { // 创建TestClass对象 TestClass t = new TestClass(i); // 调用反复 t.printId(); // *************注意此处,非常关键:为了帮助回收,将t置为null t = null; } } static class TestClass { int id; // 每个TestClass对象对应一个很大的数组 int[] arr = new int[100000000]; // 每个TestClass对象对应一个ThreadLocal对象 ThreadLocal<int[]> threadLocal = new ThreadLocal<>(); TestClass(int id) { this.id = id; // threadLocal存放的就是这个很大的数组 threadLocal.set(arr); } public void printId() { System.out.println(id); } } }

  上面的代码多次创建所占内存非常大的对象,并在创建后,立即解除对象的强引用,让对象可以被JVM回收。按道理来说,上面的代码运行应该不会发生内存溢出,因为我们虽然创建了多个大对象,占用了大量空间,但是这些对象立即就用不到了,可以被垃圾回收,而这个对象被垃圾回收后,对象的id,数组,和threadLocal成员都会被回收,所以所占内存不会持续升高,但是实际运行结果如下:

0 1 2 Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at Main$TestClass.<init>(Main.java:23) at Main.main(Main.java:10)

  可以看到,很快就发生了内存溢出异常。为什么呢?需要注意到,在TestClass的构造方法中,我们将数组arr放入了ThreadLocal对象中,也就是被放进了当前线程的ThreadLocalMap中,作为value存在。我们前面说过,ThreadLocalMapvalue是强引用,这也就意味着虽然ThreadLocal可以被正常回收,但是作为value的大数组无法被回收,因为它仍然被ThreadLocalMap的强引用所指向。于是TestClass对象的超大数组就一种在内存中,占据大量空间,我们连续创建了多个TestClass,内存很快就被占满了,于是发生了内存溢出。而JDK的开发人员自然发现了这个问题,于是有了下面这个解决方案:

public class Main { public static void main(String[] args) { for (int i = 0; i < 100; i++) { TestClass t = new TestClass(i); t.printId(); // **********注意,与上面的代码只有此处不同************ // 此处调用了ThreadLocal对象的remove方法 t.threadLocal.remove(); t = null; } } static class TestClass { int id; int[] arr; ThreadLocal<int[]> threadLocal; TestClass(int id) { this.id = id; arr = new int[100000000]; threadLocal = new ThreadLocal<>(); threadLocal.set(arr); } public void printId() { System.out.println(id); } } }

  上面的代码中,我们在将t置为空时,先调用了ThreadLocal对象的remove方法,这样做了之后,再看看运行结果:

0 1 2 // ....神略中间部分 98 99

  做了上面的修改后,没有再发生内存溢出异常,程序正常执行完毕。这是为什么呢?ThreadLocalremove方法究竟有什么作用。其实remove方法的作用非常简单,执行remove方法时,会从当前线程的ThreadLocalMap中删除key为当前ThreadLocal的那一个记录,keyvalue都会被置为null,这样一来,就解除了ThreadLocalMapvalue的强引用,使得value可以正常地被JVM回收了。所以,今后如果我们确认不再使用的ThreadLocal对象,一定要记得调用它的remove方法。

  我们之前说过,如果我们没有调用remove方法,那就会导致ThreadLocal在使用完毕后,被正常回收,但是ThreadLocalMap中存放的value无法被回收,此时将会在ThreadLocalMap中出现keynull,而value不为null的元素。为了减少已经无用的对象依旧占用内存的现象,ThreadLocal底层实现中,在操作ThreadLocalMap的过程中,线程若检测到keynull的元素,会将此元素的value置为null,然后将这个元素从ThreadLocalMap中删除,占用的内存就可以让JVM将其回收。比如说在getEntry方法中,或者是Map扩容的方法中等。


3|0三、总结


  ThreadLocal实现线程独立的方式是直接将值存放在Thread对象的ThreadLocalMap中,Mapkey就是ThreadLocal的引用,且为了有助于JVM进行垃圾回收,key使用的是弱引用。在使用ThreadLocal后,一定要记得调用remove方法,有助于JVMvalue的回收。


4|0四、参考



__EOF__

本文作者特务依昂
本文链接https://www.cnblogs.com/tuyang1129/p/12713815.html
关于博主:在互联网洋流中垂死挣扎,但依旧乐观的Java小菜鸟一枚!
版权声明:转载博客请注明出处,并附上原文链接!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角推荐一下。您的鼓励是博主的最大动力!
posted @   特务依昂  阅读(4217)  评论(0编辑  收藏  举报
编辑推荐:
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
阅读排行:
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
点击右上角即可分享
微信分享提示