Java 入门指南:Java 并发编程 —— 线程隔离技术 ThreadLocal

2024-09-02 22:20

本文主要是介绍Java 入门指南:Java 并发编程 —— 线程隔离技术 ThreadLocal,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

线程隔离技术

线程隔离是一种多线程编程技术,它可以将数据或资源在不同线程之间进行隔离,保证每个线程使用的数据或资源是独立的,不会互相干扰。线程隔离通常应用于高并发场景下,可以有效提升系统性能并提高并发能力。

实现方式

线程隔离的实现方式通常有以下几种:

  1. 每个线程使用自己的拷贝:每个线程单独维护一份数据或资源的拷贝,不与其他线程共享,从而实现隔离。

  2. 每个线程使用自己的命名空间:每个线程使用自己的命名空间,通过命名空间来隔离数据或资源,从而实现隔离。

  3. 线程局部变量(thread-local variable):线程局部变量是一种特殊的变量,在每个线程中都有自己的副本,不会与其他线程共享,从而实现隔离。Java 中提供了 ThreadLocal 类来实现线程局部变量。

缺陷

线程隔离虽然能够有效保证每个线程使用的数据或资源是独立的,但同时也会带来一些问题:

  1. 内存消耗增大:每个线程需要使用独立的数据或资源拷贝,因此会带来额外的内存消耗。

  2. 数据一致性问题:由于线程之间相互隔离,因此可能会引起数据一致性问题。

  3. 程序复杂度增加:线程隔离需要对数据或资源进行额外的管理和维护,程序复杂度可能会增加。

线程隔离与同步技术

线程隔离同步 是两种不同的技术,它们解决的问题和应用场景也有所不同:

  • 线程隔离主要关注的是隔离数据或资源,确保每个线程操作的是独立的数据或资源,从而提高系统的性能。

  • 同步主要关注的是多线程并发访问共享数据时的线程安全性,避免出现数据竞争和数据不一致的情况。

在某些情况下,线程隔离和同步可以结合使用,以确保在并发环境下既能保证数据隔离又能保证数据的一致性和安全性。例如,可以使用线程局部变量ThreadLocal 实现线程隔离,再结合使用同步机制,如 synchronized 或 ReentrantLock 锁,来保证对共享数据的同步访问。

线程本地存储

线程本地存储(Thread Local Storage,TLS)是一种机制,允许每个线程在使用时维护自己的私有数据副本。它提供了一种有效的方式来在多线程环境下封装线程特定的数据,使得每个线程都可以独立地访问和修改其自己的数据副本,而不会受到其他线程的干扰。

线程安全问题的核心在于多个线程会对同一个临界区的共享资源进行访问,如果每个线程都拥有自己的“共享资源”,独立操作自身的数据,互不影响,避免共享资源的竞争,就可以防止线程安全问题了。

在Java中,线程本地存储是通过 ThreadLocal 类来实现的。

ThreadLocal 是一个线程局部变量,它为每个线程提供了一个独立的变量副本。每个线程可以通过 get() 方法获取变量值,通过 set() 方法设置变量值。每个线程对该变量的操作都只会影响到自己的变量副本,不会影响其他线程的副本。

ThreadLocal

ThreadLocal 是 Java 中的一个线程本地存储类,位于 java.lang 包中。它的主要作用是提供一个线程的本地变量,每个线程拥有该变量独立的一个对象副本,每个线程都可以访问到自己线程的变量副本,保证线程之间的数据隔离性,避免共享资源的竞争。

这是一种“空间换时间”的思想,每个线程拥有自己的“共享资源”,虽然内存占用变大了,但由于不需要同步,也就减少了线程可能存在的阻塞问题,从而提高时间上的效率。

应用场景

ThreadLocal 对象通常用于在多线程环境下保持某些对象的状态,被应用最多的场景是 Session 管理 和 Connection 数据库链接管理,以避免线程安全问题。

  • 用于保存用户登录信息,这样在同一个线程中的任何地方都可以获取到登录信息。

  • 用于保存数据库连接、Session 对象等,这样在同一个线程中的任何地方都可以获取到数据库连接、Session 对象等。

  • 用于保存事务上下文,这样在同一个线程中的任何地方都可以获取到事务上下文。

  • 用于保存线程中的变量,这样在同一个线程中的任何地方都可以获取到线程中的变量。

常用方法

ThreadLocal 类中的常用方法:

  1. get(): 该方法用于获取与当前线程关联的 ThreadLocal 变量的值。如果当前线程还没有在线程本地存储中设置值,则返回 null

    当调用 get() 方法时,如果当前线程还没有在线程本地存储中设置值,则会调用 setinitialValue() 方法以懒初始化的方式来为该线程本地变量创建一个初始值(默认为 null),然后将该值保存在线程本地存储中,仅在实际需要特定于线程的值时才创建这些值。

  2. set(T value): 该方法用于将给定的值设置为当前线程的 ThreadLocal 变量的值。确保了每个线程都有自己的变量副本。

  3. remove(): 该方法用于从当前线程的线程本地存储中删除与线程本地变量相关联的值。等价于将线程本地变量设置为 null

  4. initialValue(): 该方法是一个 protected 方法,用于创建线程本地变量的初始值(默认为 null),ThreadLocal 子类可以重写该方法根据需要选取合适的初始值。

  5. withInitial(Supplier<? extends T> supplier): 该方法是 Java 8 新增的,它允许以更简单的方式创建线程本地变量,并为每个线程提供一个初始值,适用于 [[Java Lambda|Lambda]] 表达式。

当在一个线程中调用 ThreadLocalset 方法设置值时,该值只在当前线程内可见;当另一个线程调用 ThreadLocalset 方法设置同一个 ThreadLocal 的值时,它只会修改自己线程内的值,不会影响其他线程的值。

尽管 ThreadLocal 是线程局部变量,但如果没有清理,它们可能会导致内存泄漏或资源泄漏。当线程执行结束后,需要使用 remove() 方法将 ThreadLocal 变量与线程解绑并清理内存。

使用 ThreadLocal 可以解决一些多线程并发访问共享变量的线程安全问题,并提高程序的并发执行效率。但也应该避免过度使用 ThreadLocal,以免占用过多的内存。

使用步骤

以下是使用 ThreadLocal 的一般步骤:

  1. 创建一个 ThreadLocal 对象:ThreadLocal<MyObject>,其中MyObject 是要存储的数据类型。

  2. 在需要访问变量的线程中,通过 get() 方法获取ThreadLocal变量的值。

  3. 如果需要,可以通过 set() 方法将变量的值设置为当前线程的私有副本。

  4. 在线程结束时,记得通过 remove() 方法清理线程的 ThreadLocal 变量,以防止内存泄漏。

线程本地存储非常有用,特别是在一些需要在每个线程中保持上下文、状态或跟踪信息的情况下。例如,在 Web 应用程序中,可以使用线程本地存储来保存用户会话信息,以避免并发访问的问题。

ThreadLocal 示例

下面是一个示例,展示了如何使用 ThreadLocal 来存储和使用一个对象:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;public class ThreadLocalExample {// 创建一个 ThreadLocal 变量,存储 User 对象private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();// 定义 User 类private static class User {private String name;private int age;public User(String name, int age) {this.name = name;this.age = age;}@Overridepublic String toString() {return "User{" +"name='" + name + '\'' +", age=" + age +'}';}}public static void main(String[] args) {ExecutorService executorService = Executors.newFixedThreadPool(5);// 提交多个任务到线程池for (int i = 0; i < 5; i++) {final int index = i;executorService.submit(() -> {// 设置线程局部变量的值User user = new User("User " + index, 20 + index);threadLocal.set(user);// 在线程中使用该变量System.out.println("Thread " + Thread.currentThread().getId() + ": " + threadLocal.get());});}// 关闭线程池executorService.shutdown();}
}

示例说明:

  • 创建 ThreadLocal 变量threadLocal 是一个 ThreadLocal 对象,用于存储 User 类型的线程局部变量。

  • 设置和获取变量值:在每个线程中,使用 threadLocal.set(user) 设置线程局部变量的值。使用 threadLocal.get() 获取当前线程的局部变量值。

  • User 类:定义了一个简单的 User 类,用于存储用户的姓名和年龄。

  • 输出结果:每个线程都会输出自己的局部变量值,可以看到每个线程都有自己独立的 User 对象副本。

ThreadLocalMap

ThreadLocalMap 是 Java 中的一个类,用于实现 ThreadLocal 类的底层数据结构,是 ThreadLocal 的核心。

每个线程都有一个对应的 ThreadLocalMap 对象,它通过 Thread.threadLocals 字段来引用。因此,每个线程可以独立地操作自己的 ThreadLocalMap 对象。不会与其他线程的 ThreadLocal 变量产生冲突。

ThreadLocalMap 是一个哈希桶数组,通过哈希表实现,每个桶都存储一个 Entry 对象,Entry 对象包含了 ThreadLocal 的引用和对应的值。ThreadLocalMap 实际上就是一个以 ThreadLocal 实例为 key,任意对象为 value 的 Entry 数组

在使用 ThreadLocal 类时,通常会通过 ThreadLocalMap 来实现线程本地变量的存储。为 ThreadLocal 变量赋值时,实际上就是以当前 ThreadLocal 实例为 key,值为 Entry 向其 ThreadLocalMap 中存放。每个线程可以独立地访问和操作自己的线程本地变量,

Entry 对象

![[ThreadLocalMap Entry Source Code.png]]

ThreadLocalMap 中的键值对是弱引用关联的,这意味着当一个 ThreadLocal 对象的强引用被释放后,对应的键值对可能会被垃圾回收。

内存泄漏问题

ThreadThreadLocalThreadLocalMapEntry 的关系,实线表示强引用,虚线表示弱引用

![[ThreadLocal Model.png]]
ThreadLocal 外部强引用被置为 null(ThreadLocalInstance=null )时,根据可达性分析,ThreadLocal 实例此时没有任何一条链路引用它,所以系统 GC 的时候 ThreadLocal 会被回收。

实际开发中,线程为了复用是不会主动结束的,由于线程迟迟不结束,这些 key 为 null 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,无法回收就会造成内存泄漏。因此像数据库连接池这样过大的线程池可能会增加内存泄漏的风险。

为了避免这个问题,在每次使用完 ThreadLocal 之后,最好明确调用 ThreadLocal 的 remove 方法来删除与当前线程关联的值。这样可以确保线程再次使用时不会存储旧的、不再需要的值。

ThreadLocal 的 hashCode

ThreadLocalhashCode 是通过 nextHashCode() 方法获取的,该方法实际上是用 Atmoic 包的 AtomicInteger 加上 0x61c88647 来实现的。

0x61c88647 是一个魔数,用于 ThreadLocal 的哈希码递增。这个值的选择并不是随机的,是一个特定的质数,具有以下特性:

  • 质数:它是一个质数,这意味着它不能被除 1 和它本身之外的任何数字整除。

  • 黄金比例:这个数字大约等于黄金比例的 32 位浮点表示的一半。黄金比例具有一些有趣的数学特性,其中之一是与斐波那契数列的关系。

  • 递增分布:每当创建新的 ThreadLocal 对象时,都会将此值添加到上一个 ThreadLocal 的哈希码中。这个递增的步长有助于在哈希表中均匀地分配 ThreadLocal 对象。

  • 性能优化:通过使用这个特定的值,算法能够确保哈希码的均匀分布,从而减少哈希冲突的可能性。

解决哈希冲突

由于 ThreadLocalMap 基于哈希表实现,那么在存储数据的过程中就可能出现哈希冲突,降低查找的效率。而 ThreadLocalMap 使用开放寻址法(open addressing),通过线性探测法(linear probing)来处理哈希冲突。

当一个桶已经被占用时,ThreadLocalMap 会尝试寻找下一个空闲的桶,一般是往后顺延搜索。这个过程会一直进行,当到哈希表末尾的时候再从 0 开始,循环,直到找到一个空闲的桶来存储新的键值对或者覆盖已有的键值对。

线性探测的开放寻址法也有一些潜在的问题。当哈希桶数组的装载因子(load factor)过高时,即桶中被占用的比例接近或超过阈值时,会导致哈希冲突的频率增加,进而影响性能。为了解决这个问题,ThreadLocalMap 在内部进行了自动扩容,以保证装载因子在一个合理的范围内,提高查找效率。

set 源码

![[ThreadHashMap Set.png]]

  • replaceStaleEntry:向 ThreadLocalMap 添加新数据时,可以检查是否有“脏” Entry(key 为 null 的 Entry),并用新的数据替换它

  • cleanSomeSlots:在某些操作过程中(例如添加、获取等),通过遍历哈希表,删除 key 为 null 的脏 Entry

扩容机制

HashMapThreadLocalMap 初始大小为 16,负载因子(哈希表中已经存放的条目数量与哈希表容量的比例)为 2 3 \frac{2}{3} 32,所以哈希表可用大小为: 16 × 2 3 = 10 16\times\frac{2}{3} = 10 16×32=10,即哈希表可用容量为 10。

当哈希表的 size 大于 threshold(临界值) 的时候,会通过 resize() 方法进行扩容。

新建一个数组,其大小为原来数组长度的两倍,然后遍历旧数组中的 Entry 并将其插入到新的数组中。在扩容的过程中,将脏 Entry 的 value 设为 null,以便被垃圾回收,解决隐藏的内存泄漏问题。移除脏 Entry后,重新确定 Entry 在新数组的位置,然后进行插入。最后,设置新哈希表的 threshholdsize 属性。

remove() 方法

![[ThreadLocal remove().png]]

  1. 通过局部变量 tab 获取 ThreadLocalMap 的哈希表数组,并获取数组的长度。

  2. 通过 key.ThreadLocalHashCode & (len-1) 计算给定ThreadLocal 键的哈希索引。确定索引位置。

  3. 使用开放寻址法遍历哈希表,通过 nextIndex(i, len) 计算下一个索引以处理哈希冲突。

  4. 如果找到与给定键匹配的条目(e.get() == key),执行以下操作:

    • 清除键:通过调用 e.clear() 方法,将条目的键置为 null。由于 Entry 是 WeakReference 的子类,clear 方法将断开对ThreadLocal 对象的引用,允许垃圾收集器在需要时回收它。

    • 清除值:通过调用 expungeStaleEntry(i) 方法,清除该条目的值并对哈希表进行部分清理。该方法的目的是清除哈希表中的无效元素,即那些其键已被垃圾收集的元素。

  5. 结束删除操作:一旦找到并删除了匹配的条目,方法返回。如果遍历整个哈希表都没有找到匹配的键,则该方法不执行任何操作并正常返回。

这篇关于Java 入门指南:Java 并发编程 —— 线程隔离技术 ThreadLocal的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1131232

相关文章

SpringBoot内嵌Tomcat临时目录问题及解决

《SpringBoot内嵌Tomcat临时目录问题及解决》:本文主要介绍SpringBoot内嵌Tomcat临时目录问题及解决,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,... 目录SprinjavascriptgBoot内嵌Tomcat临时目录问题1.背景2.方案3.代码中配置t

SpringBoot使用GZIP压缩反回数据问题

《SpringBoot使用GZIP压缩反回数据问题》:本文主要介绍SpringBoot使用GZIP压缩反回数据问题,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录SpringBoot使用GZIP压缩反回数据1、初识gzip2、gzip是什么,可以干什么?3、Spr

Java程序进程起来了但是不打印日志的原因分析

《Java程序进程起来了但是不打印日志的原因分析》:本文主要介绍Java程序进程起来了但是不打印日志的原因分析,具有很好的参考价值,希望对大家有所帮助,如有错误或未考虑完全的地方,望不吝赐教... 目录Java程序进程起来了但是不打印日志的原因1、日志配置问题2、日志文件权限问题3、日志文件路径问题4、程序

Spring 基于XML配置 bean管理 Bean-IOC的方法

《Spring基于XML配置bean管理Bean-IOC的方法》:本文主要介绍Spring基于XML配置bean管理Bean-IOC的方法,本文给大家介绍的非常详细,对大家的学习或工作具有一... 目录一. spring学习的核心内容二. 基于 XML 配置 bean1. 通过类型来获取 bean2. 通过

Spring Boot 集成 Quartz并使用Cron 表达式实现定时任务

《SpringBoot集成Quartz并使用Cron表达式实现定时任务》本篇文章介绍了如何在SpringBoot中集成Quartz进行定时任务调度,并通过Cron表达式控制任务... 目录前言1. 添加 Quartz 依赖2. 创建 Quartz 任务3. 配置 Quartz 任务调度4. 启动 Sprin

springboot上传zip包并解压至服务器nginx目录方式

《springboot上传zip包并解压至服务器nginx目录方式》:本文主要介绍springboot上传zip包并解压至服务器nginx目录方式,具有很好的参考价值,希望对大家有所帮助,如有错误... 目录springboot上传zip包并解压至服务器nginx目录1.首先需要引入zip相关jar包2.然

Java数组初始化的五种方式

《Java数组初始化的五种方式》数组是Java中最基础且常用的数据结构之一,其初始化方式多样且各具特点,本文详细讲解Java数组初始化的五种方式,分析其适用场景、优劣势对比及注意事项,帮助避免常见陷阱... 目录1. 静态初始化:简洁但固定代码示例核心特点适用场景注意事项2. 动态初始化:灵活但需手动管理代

python中各种常见文件的读写操作与类型转换详细指南

《python中各种常见文件的读写操作与类型转换详细指南》这篇文章主要为大家详细介绍了python中各种常见文件(txt,xls,csv,sql,二进制文件)的读写操作与类型转换,感兴趣的小伙伴可以跟... 目录1.文件txt读写标准用法1.1写入文件1.2读取文件2. 二进制文件读取3. 大文件读取3.1

Java使用SLF4J记录不同级别日志的示例详解

《Java使用SLF4J记录不同级别日志的示例详解》SLF4J是一个简单的日志门面,它允许在运行时选择不同的日志实现,这篇文章主要为大家详细介绍了如何使用SLF4J记录不同级别日志,感兴趣的可以了解下... 目录一、SLF4J简介二、添加依赖三、配置Logback四、记录不同级别的日志五、总结一、SLF4J

将Java项目提交到云服务器的流程步骤

《将Java项目提交到云服务器的流程步骤》所谓将项目提交到云服务器即将你的项目打成一个jar包然后提交到云服务器即可,因此我们需要准备服务器环境为:Linux+JDK+MariDB(MySQL)+Gi... 目录1. 安装 jdk1.1 查看 jdk 版本1.2 下载 jdk2. 安装 mariadb(my