在 Java 并发编程领域,ThreadLocal类具有重要意义。它为每个线程提供独立的变量副本,实现线程间数据隔离。从原理上看,ThreadLocal类似于一种线程局部存储机制,每个线程可将自身相关数据存放在独立空间中,不同线程对这些数据的操作相互独立,如同每个线程拥有各自独立的存储空间,这确保了线程操作数据的安全性与独立性。
同时在并发编程中,数据共享与线程安全是核心问题。多线程同时访问和修改共享数据时,易引发数据不一致和竞态条件等状况。ThreadLocal的出现为解决此类问题提供了新思路与方法。通过使每个线程拥有独立的变量副本,有效避免多线程对共享数据的竞争与冲突,进而显著提升程序的并发性能与稳定性。接下来跟着博主进行探索ThreadLocal吧!
一、ThreadLocal 的作用
线程数据隔离
在多线程环境中,数据的并发访问往往是一个棘手的问题。比如,多个线程同时访问和修改一个共享的数据库连接对象,就可能导致数据不一致、连接池混乱等问题 。而 ThreadLocal 的出现,就像是为每个线程打造了一个专属的 “数据保险箱”,每个线程都可以安全地存放和获取自己的数据,完全不用担心与其他线程的数据产生冲突。
举个例子,在一个电商系统中,订单处理模块可能会被多个线程同时调用。每个线程在处理订单时,都需要记录一些与当前订单相关的信息,如订单编号、客户信息、商品列表等。如果使用普通的共享变量来存储这些信息,那么在多线程并发访问时,就很容易出现数据混乱的情况。但如果使用 ThreadLocal,每个线程都可以拥有自己独立的订单信息副本,各个线程之间的操作互不干扰,从而保证了数据的一致性和线程的安全性。
数据传递简化
在传统的多线程编程中,当我们需要在不同的方法之间传递数据时,通常需要将数据作为参数在方法之间层层传递,这不仅增加了代码的复杂性,还降低了代码的可读性和可维护性 。而 ThreadLocal 则提供了一种更为优雅的解决方案,它可以在同一个线程的不同方法之间轻松地传递数据,避免了繁琐的参数传递过程。
比如,在一个 Web 应用中,用户的登录信息通常需要在多个服务层和持久层方法之间传递。使用 ThreadLocal,我们只需要在用户登录成功后,将登录信息存储到 ThreadLocal 中,然后在后续的方法中,随时都可以通过 ThreadLocal 获取到该登录信息,无需再通过参数传递的方式将登录信息在各个方法之间传递。这样,不仅简化了代码的编写,还提高了代码的灵活性和可维护性。
二、使用 ThreadLocal 的好处
提升并发性能
在多线程编程中,线程竞争往往是影响程序性能的一个重要因素。当多个线程同时访问和修改共享资源时,为了保证数据的一致性和线程安全,我们通常需要使用同步锁来进行控制。然而,同步锁的使用会带来一定的性能开销,因为它会导致线程的阻塞和上下文切换 。
而 ThreadLocal 的出现,为我们提供了一种避免线程竞争的有效方式。由于每个线程都拥有自己独立的变量副本,因此在访问和修改这些变量时,不需要进行同步操作,从而大大减少了线程之间的竞争和冲突,提高了程序的并发性能 。
以一个简单的计数器为例,如果使用普通的共享变量来实现计数器,在多线程环境下,就需要使用同步锁来保证计数器的原子性操作。但是,这样会导致线程的频繁阻塞和唤醒,从而降低程序的性能。而如果使用 ThreadLocal 来实现计数器,每个线程都有自己独立的计数器副本,各个线程之间的计数操作互不干扰,无需使用同步锁,从而提高了程序的并发性能。
优化代码结构
在传统的编程模式中,当我们需要在不同的方法之间传递数据时,通常需要将数据作为参数在方法之间层层传递,这不仅增加了代码的复杂性,还降低了代码的可读性和可维护性 。而 ThreadLocal 的出现,为我们提供了一种更为优雅的解决方案,它可以在同一个线程的不同方法之间轻松地传递数据,避免了繁琐的参数传递过程。
比如,在一个复杂的业务逻辑中,可能需要在多个方法之间传递用户的登录信息、请求上下文等数据。如果使用传统的参数传递方式,每个方法都需要添加相应的参数,这会使代码变得冗长和复杂。而使用 ThreadLocal,我们只需要在合适的位置将数据存储到 ThreadLocal 中,然后在需要的方法中通过 ThreadLocal 获取数据即可,无需在方法参数中显式传递,从而使代码结构更加清晰、简洁 。
三、ThreadLocal 内部结构设计
JDK8 以前结构
在 JDK8 之前,ThreadLocal 的设计相对简单直接 。ThreadLocalMap 作为 ThreadLocal 类的一个静态内部类,承担着存储线程局部变量的重要职责。在这种设计模式下,无论一个进程中存在多少个不同的 ThreadLocal 实例,它们都共享同一个 ThreadLocalMap 结构 。
ThreadLocalMap 本质上是一个哈希表,其中的 Key 代表当前的 Thread,而 Value 则是线程特有的变量值 。当一个线程调用 ThreadLocal 的 set 方法时,实际上是将线程和对应的变量值以键值对的形式存储到了这个共享的 ThreadLocalMap 中。同样,当线程调用 get 方法时,也是从这个共享的 Map 中根据当前线程作为 Key 来获取对应的变量值 。
这种设计虽然简单,但在实际应用中存在一些潜在的问题。由于所有的 ThreadLocal 实例共享同一个 ThreadLocalMap,当线程数量较多时,Map 中的 Entry 数量也会随之增多,这不仅会占用大量的内存空间,还会增加哈希冲突的概率,从而影响数据的存储和读取效率 。
JDK8 以后结构
JDK8 对 ThreadLocal 的结构进行了重大优化,使得 ThreadLocalMap 不仅是 ThreadLocal 类的静态内部类,同时也成为了 Thread 类的一个成员变量 。这一改变意味着每个线程都拥有自己独立的一个 ThreadLocalMap 实例,从而实现了更细粒度的数据隔离和管理 。
在新的设计中,ThreadLocalMap 的 Key 不再是当前线程,而是 ThreadLocal 实例本身,而 Value 依然是线程特有的变量值 。当一个线程调用 ThreadLocal 的 set 方法时,会首先获取当前线程自身的 ThreadLocalMap,然后将当前 ThreadLocal 实例作为 Key,将变量值作为 Value 存储到这个 Map 中 。同样,当线程调用 get 方法时,也是先获取当前线程的 ThreadLocalMap,再根据当前 ThreadLocal 实例作为 Key 来获取对应的变量值 。
结构设计优势
JDK8 之后的这种设计优化带来了诸多显著的好处 :
- 减少内存占用:由于每个线程都有自己独立的 ThreadLocalMap,并且 Map 的 Key 是 ThreadLocal 实例本身,而在实际应用中,ThreadLocal 的数量往往少于线程的数量,因此每个 Map 中存储的 Entry 数量也会相应减少,从而有效地节省了内存空间 。
- 优化清理:当 ThreadLocal 对象不再被使用且没有其他强引用指向它时,由于 ThreadLocalMap 中使用的是弱引用指向 ThreadLocal 对象,所以 ThreadLocal 对象可以被垃圾回收器及时回收 。同时,当线程结束时,其对应的 ThreadLocalMap 也会随之销毁,进一步降低了内存泄漏的风险 。
- 线程局部变量的隔离性:每个线程的 ThreadLocal 操作都是与当前线程的 ThreadLocalMap 进行交互,所有的设置(set)和获取(get)操作都会首先获取当前线程的 ThreadLocalMap,然后再去操作本线程的 map 。这就确保了不同线程之间的数据完全隔离,避免了线程之间的数据干扰和冲突,提高了程序的并发性能和稳定性 。
四、内存泄漏问题剖析
内存泄漏原理
在深入探讨 ThreadLocal 的内存泄漏问题之前,我们先来了解一下 Java 中的引用类型 。在 Java 中,引用类型主要包括强引用、软引用、弱引用和虚引用 。强引用是最常见的引用类型,当一个对象被强引用指向时,它绝对不会被垃圾回收器回收,即使内存空间不足,Java 虚拟机也宁愿抛出 OutOfMemoryError 错误,也不会回收具有强引用的对象 。而弱引用则不同,当一个对象仅被弱引用指向时,在垃圾回收器扫描内存区域时,一旦发现该对象,无论当前内存空间是否足够,都会回收它的内存 。
ThreadLocalMap 中使用的 Key 为 ThreadLocal 的弱引用,而 Value 是强引用 。这就导致了一个潜在的内存泄漏风险 。当 ThreadLocal 没有被外部强引用的情况下,在垃圾回收的时候,Key 会被清理掉,而 Value 不会被清理掉 。这样一来,ThreadLocalMap 中就会出现 Key 为 null 的 Entry 。假如我们不做任何措施的话,Value 永远无法被 GC 回收,这个时候就可能会产生内存泄漏 。
为了更直观地理解这个问题,我们可以通过一个简单的示例代码来演示 :
public class ThreadLocalMemoryLeakExample { private static final ThreadLocal<byte[]> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { Thread thread = new Thread(() -> { // 分配一个10MB的大数组,模拟内存消耗 threadLocal.set(new byte[10 * 1024 * 1024]); // 未调用 threadLocal.remove() 或 threadLocal.set(null) }); thread.start(); try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } // 线程结束后,由于未移除ThreadLocal变量,10MB内存将无法被回收 } }
在上述代码中,我们创建了一个 ThreadLocal 对象,并在一个线程中向其设置了一个大小为 10MB 的字节数组 。由于在代码中没有调用threadLocal.remove()方法来清除这个 ThreadLocal 对象,当线程结束后,虽然 ThreadLocal 对象本身没有被外部强引用,但是在 ThreadLocalMap 中,其对应的 Value(即这个 10MB 的字节数组)仍然被强引用指向,从而无法被垃圾回收器回收,导致了内存泄漏 。
解决内存泄漏
虽然 ThreadLocalMap 的设计本身已经考虑到了内存泄漏的问题,在调用set()、get()、remove()方法的时候,会清理掉 Key 为 null 的记录 。但是,这只是一种保底的手段,并不能完全依赖它来避免内存泄漏 。最优雅和可靠的方式,还是由程序员在使用完 ThreadLocal 后,手动调用remove()方法来释放相关的资源 。
我们可以对上述示例代码进行修改,添加remove()方法的调用 :
public class ThreadLocalMemoryLeakFixedExample { private static final ThreadLocal<byte[]> threadLocal = new ThreadLocal<>(); public static void main(String[] args) { Thread thread = new Thread(() -> { try { // 分配10MB大数组,模拟内存消耗对象 threadLocal.set(new byte[10 * 1024 * 1024]); // 执行其他业务逻辑 } finally { // 确保使用后清理ThreadLocal threadLocal.remove(); } }); thread.start(); try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } }
通过在finally块中调用threadLocal.remove()方法,我们可以确保无论线程执行过程中是否出现异常,都能及时清理掉 ThreadLocal 中存储的变量,从而有效地避免了内存泄漏的发生 。
此外,在使用线程池的情况下,更需要注意 ThreadLocal 的清理 。因为线程池中的线程是可以被复用的,如果在一个线程中使用了 ThreadLocal 却没有及时清理,那么在下一次复用该线程时,可能会导致业务逻辑出现问题,同时也会增加内存泄漏的风险 。因此,在使用线程池时,务必在每个任务执行完毕后,手动调用remove()方法来清理 ThreadLocal 。
五、Hash 冲突问题与扩容
开放寻址法解决冲突
在 ThreadLocalMap 中,当发生哈希冲突时,采用开放寻址法来解决 。开放寻址法的核心思想是:当一个键值对通过哈希函数计算得到的哈希桶已经被占用时,就尝试寻找下一个空闲的哈希桶来存储这个键值对 。具体来说,ThreadLocalMap 使用线性探测法,这是开放寻址法的一种简单实现 。
假设我们有一个 ThreadLocalMap,其内部的 Entry 数组大小为 16 。当我们要存储一个 ThreadLocal 对象及其对应的值时,首先会根据 ThreadLocal 对象的哈希值计算出一个初始的索引位置 。例如,通过key.threadLocalHashCode & (len - 1)计算得到索引值 i 。如果table[i]位置为空,那么就直接将键值对存储在这个位置 。
但如果table[i]位置已经被其他键值对占用(即发生了哈希冲突),那么就会使用nextIndex(i, len)方法来计算下一个索引位置 。nextIndex(i, len)方法的实现很简单,如果i + 1小于数组长度len,则返回i + 1;否则返回 0 。也就是说,它会从当前位置开始,依次向后探测下一个位置,直到找到一个空闲的位置或者遍历完整个数组 。
例如,假设我们已经有一个 ThreadLocal 对象 A 存储在table[3]位置,现在要存储另一个 ThreadLocal 对象 B,其计算得到的初始索引位置也是 3 。由于table[3]已经被占用,就会探测table[4] 。如果table[4]为空,就将对象 B 存储在table[4]位置;如果table[4]也被占用,就继续探测table[5],以此类推 。如果一直探测到数组末尾都没有找到空闲位置,就会从数组开头(即table[0])开始继续探测 。
在探测过程中,如果遇到key为null的 Entry,就会将其视为过期的 Entry,并进行清理操作 。清理操作不仅会将该 Entry 的value设置为null,以便垃圾回收器回收,还会尝试将后续的 Entry 重新整理,以减少哈希冲突和提高查找效率 。
扩容策略详解
ThreadLocalMap 的扩容策略与其他常见的哈希表(如 HashMap)有所不同 。在 ThreadLocalMap 中,当元素数量达到阈值(threshold)时,并不会立即进行扩容,而是先进行一次清理策略 。
ThreadLocalMap 的阈值默认是数组长度的 2/3 。例如,当数组初始大小为 16 时,阈值为 16 * 2/3 = 10(向下取整) 。当元素数量达到或超过这个阈值时,就会触发rehash()方法 。
rehash()方法首先会调用expungeStaleEntries()方法来清理整个数组中所有key为null的过期 Entry 。这个清理过程会遍历整个数组,将过期的 Entry 的value设置为null,并将数组中其他 Entry 的位置进行调整,以填补因清理过期 Entry 而产生的空位 。
清理完过期 Entry 后,会再次判断数组中的元素数量是否仍然大于等于阈值的 3/4 。如果是,才会进行真正的扩容操作 。扩容时,会创建一个新的 Entry 数组,其大小是原数组的两倍 。然后将原数组中的所有有效 Entry 重新计算哈希值,并插入到新数组中 。这个重新插入的过程中,也会再次清理可能存在的过期 Entry 。
通过这种先清理过期 Entry 再根据情况决定是否扩容的策略,ThreadLocalMap 能够在一定程度上减少不必要的扩容操作,提高内存使用效率 。同时,及时清理过期 Entry 也有助于避免内存泄漏问题,保证了 ThreadLocalMap 的性能和稳定性 。
六、get 和 set 方法解析
get 方法执行逻辑
当调用get方法时,ThreadLocal 首先通过Thread.currentThread()获取当前执行线程,接着从当前线程获取私有的ThreadLocalMap。
然后以当前ThreadLocal实例为键,在ThreadLocalMap的Entry数组中根据哈希值计算索引查找对应的Entry。若找到则返回其存储值;若Entry为空或键不匹配(哈希冲突) ,ThreadLocalMap用开放寻址法按探测策略继续查找,遇到过期Entry会清理。若最终没找到,说明当前线程未为该ThreadLocal设值,get方法调用setInitialValue初始化值,setInitialValue先调用initialValue(默认返回null,可重写提供自定义初始值)获取初始值,再将其设置到ThreadLocalMap并返回。
set 方法执行逻辑
当调用set方法时,先获取当前线程,再从当前线程获取对应的ThreadLocalMap。若ThreadLocalMap为空,调用createMap方法创建新的并插入键值对;若不为空则直接插入。插入时,ThreadLocalMap根据ThreadLocal实例哈希值计算在Entry数组中的索引位置,若该位置为空直接插入;
若发生哈希冲突,使用开放寻址法查找下一个可用位置,若找到相同键的Entry则更新值,若遇到key为null的过期Entry,调用replaceStaleEntry方法替换并调整结构。完成插入或更新后,ThreadLocalMap检查是否清理和扩容,若元素数量超阈值且未清理足够过期Entry,调用rehash方法,先清理过期Entry,再决定是否扩容,若扩容则创建更大数组并重新插入有效Entry。
最后、博主总结
- ThreadLocal(它允许每个线程都拥有自己的独立副本,从而实现线程隔离。)
- 作用是什么?
- ThreadLocal使线程某个值与线程关联起来,使线程在任何时候调用ThreadLocal的get方法都是获取到调用set时设置的最新值。
- 在多线程情况下,将共享的全局变量转换为ThreadLocal对象,可以使每个线程都有该变量的副本,独立操作而互不影响。
- 用它的好处?
- 1.传递数据:保存每个线程绑定的数据,在需要的地方可以直接获取,避免参数直接传递带来的代码耦合问题
- 2.线程隔离:各线程之间的数据相互隔离却又具备并发性(也是相较于sync锁的好处),避免同步方式带来的性能损失
- ThreadLocal内部结构设计(怎么设计?好处是什么?)
- 图解
- jdk8以前,ThreadLocalMap是ThreadLocal类的一个静态内部类。也就是说,无论一个进程中存在多少个不同的ThreadLocal实例,它们都共享同一个ThreadLocalMap结构。Key:代表当前的 Thread。Value:线程特有的变量值。
- jdk8以后,ThreadLocalMap不仅是ThreadLocal类的静态内部类,也是·Thread类的一个成员变量。这意味着每个线程都有自己独立的一个ThreadLocalMap实例。进程中map数量变多,但是每个map的entry数量变少。Key:现在是 ThreadLocal 实例本身,而不是与线程关联的状态。Value:线程特有的变量值。
- jdk8后期设计的好处:
- 减少内存占用:每个 Map 中存储的 Entry 数量减少,从而节省内存。
- 优化清理:当 ThreadLocal 被回收时,ThreadLocalMap 也会相应减少,降低内存泄漏的风险。
- 线程局部变量的隔离性:Threadlocal每次操作都是与当前线程的 ThreadLocalMap 交互:所有的设置 (set) 和获取 (get) 操作(下方有方法详解)都会首先获取当前线程的 ThreadLocalMap,然后再去操作本线程的map。这保证了隔离性。
- 图解
- 内存泄漏问题
- 相关概念:
- 产生原因:如果在方法内创建了局部变量ThreadLocal,方法返回后,ThreadLocal和对应的value按道理应该被回收,但如果此时ThreadLocalMap中持有ThreadLocal的引用,所以ThreadLocal无法被回收,这就产生了内存泄露。
- 解决方式:
- 所以ThreadLocalMap 中使用的 key 为 ThreadLocal 被设置为弱引用,这样当外部不存在ThreadLocal的强引用时,就可以被gc回收,但是 value 是被设置强引用的。这样的话如果 ThreadLocal没有被外部强引用的情况下,在垃圾回收的时候,key 是能被清理,而 value 不会被清理掉。这又产生了内存泄露。
- 那value可以设置为弱引用吗?不可以,假设 key 所引用的 ThreadLocal 对象还被其他的引用对象强引用着,那么这个ThreadLocal 对象就不会被 GC 回收,但如果 value 是弱引用且不被其他引用对象引用着,那 GC 的时候就被回收掉了,那线程通过ThreadLocal 来获取 value 的时候就会获得 null。
- 那怎样解决呢,我们知道GC仅是让key的内存释放,相当于key变为null,但ThreadLocalMap还存在着对应的value,所有我们可以根据key是否等于null释放value,ThreadLocalMap实现中已经考虑了这种情况,在调用set()、get()、remove() 方法的时候,会清理掉 key 为 null 的记录,但这是作为保底的手段,优雅的方式应该是由程序员使用完后手动remove释放。
- 相关概念:
- hash冲突问题(扩容)
- 使用开放寻址法,比如一个entry要插入到下标为5的位置,发现已经被占用了,就插入到下标为6的位置,还是被占用了,就插入到下标为7的位置……直到找到空槽位,如果超出范围就从0开始继续探测,在此期间会清除key为null的记录。
- 同时我认为jdk8之后内存结构优化设计,使得每个线程都有自己独立的一个ThreadLocalMap实例,entry实例变少,哈希冲突概率也小了。
- 扩容策略:元素数量达到阈值(2/3容器大小),进行清理策略:清理过程会遍历整个数组,将 key 为 null 的 Entry 清除。,然后再判断清理完后的元素数量是否大于等于3/4阈值,如果是,再进行扩容,扩容为翻倍。哈希表默认大小16,扩容阈值为大小的2/3,第一次也就是达到10。
- 其它(get、set方法)
- 如果key存在,但为null,清除对应的value。如果key不存在,会把key存进去,value为null
- 如果对应的桶下表的key是null,清除对应的value,set设置key、value到对应的位置,同时把连续临近的key为null的value也释放
- 如果key存在,但为null,清除对应的value。如果key不存在,会把key存进去,value为null
- 作用是什么?