多线程学习之ThreadLocal详细笔记

ThreadLocal详细笔记

  • 一、ThreadLocal的基本概念
  • 二、ThreadLocal的独特性
    • [2.1 数据访问方式](#2.1 数据访问方式)
    • [2.2 线程安全实现](#2.2 线程安全实现)
    • [2.3 适用场景](#2.3 适用场景)
  • [三、ThreadLocal 的简单使用](#三、ThreadLocal 的简单使用)
  • [四、ThreadLocal 的工作原理](#四、ThreadLocal 的工作原理)
  • 五、ThreadLocal和内存泄漏的关系
    • [5.1 ThreadLocalMap的Entry的Key设计成弱引用](#5.1 ThreadLocalMap的Entry的Key设计成弱引用)
    • [5.2 弱引用会导致内存泄漏](#5.2 弱引用会导致内存泄漏)
  • [六、ThreadLocal 的注意事项](#六、ThreadLocal 的注意事项)
    • [6.1 线程复用问题](#6.1 线程复用问题)
    • [6.2 初始化值的正确设置](#6.2 初始化值的正确设置)
    • [6.3 理解线程局部性的范围](#6.3 理解线程局部性的范围)
    • [6.4 适当的命名和文档说明](#6.4 适当的命名和文档说明)

一、ThreadLocal的基本概念

ThreadLocal 属于Java中能在多线程环境下存储线程局部变量的机制。作用在于给每个线程都准备单独的变量副本,通过这种方式防止线程之间出现竞争条件。

ThreadLocal 的工作方式是在每个线程内部创建一个独立的变量副本,而且每个线程仅仅可以访问属于自己的那个副本。

主要可以处理的多线程并发问题:

  • 避免多线程对共享变量的并发访问冲突:比如在一个 Web 应用中,存储每个线程的用户会话信息等。每个线程都能独立地操作自己的 ThreadLocal 变量副本,而不需要担心其他线程对其的干扰。
  • 简化代码中的线程安全问题处理:当某些对象或数据只在特定线程内使用,并且不希望受到其他线程的影响时,使用 ThreadLocal 可以更方便地管理这些数据,而不需要复杂的同步机制。

二、ThreadLocal的独特性

传统的共享变量方式与 ThreadLocal 有很大的不同,ThreadLocal 的独特性主要体现在:

2.1 数据访问方式

  • 传统共享变量:
xml 复制代码
多个线程可直接访问和修改同一个共享变量。需要使用同步机制(如synchronized关键字或锁)来确保数据的一致性和完整性,以避免数据竞争和不一致的情况。
例如,多个线程同时对一个共享计数器进行递增操作,如果不进行适当的同步,可能会导致计数器的值不准确。

所有线程都能看到共享变量的最新状态,任何一个线程对其的修改都会立即对其他线程可见。这在某些情况下是必要的,但增加复杂性,因为线程需要时刻考虑其他线程可能对共享变量的影响。
  • ThreadLocal:
xml 复制代码
每个线程都有自己独立的变量副本,线程只能访问自己的副本,不会直接影响其他线程的副本。可消除对复杂同步机制的需求,极大地简化多线程编程。
例如,在使用 ThreadLocal 存储每个线程的用户会话信息时,不同线程可以独立地修改自己的会话信息,而不用担心与其他线程的冲突。

每个线程的变量副本对于该线程是私有的,修改自己的副本不会影响其他线程的副本状态,各个线程之间的数据相互隔离。

2.2 线程安全实现

  • 传统共享变量:
xml 复制代码
要保证线程安全,需要开发者谨慎地使用同步代码块或方法,确保在并发访问时正确地协调对共享变量的读写操作。这不仅需要深入理解多线程同步的原理,还容易出现死锁、活锁等并发问题。
例如,使用synchronized关键字不当可能导致线程长时间等待锁,从而影响程序性能和响应性。

对于复杂的数据结构作为共享变量,还需要考虑更高级的并发控制策略,如使用读写锁等,以提高并发性能。
  • ThreadLocal:
xml 复制代码
由于每个线程都操作自己独立的变量副本,从本质上就保证线程安全。不需要额外的同步代码来保护变量的读写操作,减少因同步带来的性能开销和潜在的并发问题。
开发者可以更专注于单个线程内的业务逻辑,而不必过多担心多线程并发访问的复杂性。

2.3 适用场景

  • 传统共享变量:
xml 复制代码
适用于需要多个线程共享数据并进行协作的场景。
比如在一个生产者-消费者模型中,生产者和消费者线程需要共享一个队列来传递数据。这种情况下,共享变量作为数据传递和协作的媒介是必要的。

在一些需要全局状态管理的系统中,共享变量可以用来存储系统的配置信息等,供所有线程读取和使用(在修改时需要注意同步)。
  • ThreadLocal:
xml 复制代码
常用于需要保存每个线程自身状态的情况。
例如在 Web 应用中,每个线程处理不同的用户请求,使用 ThreadLocal 可以方便地保存每个用户请求相关的上下文信息,如用户身份认证信息、事务状态等。
这样不同的请求线程可以独立地维护自己的状态,互不干扰。

在一些日志记录框架中,ThreadLocal 可以用来保存每个线程的日志级别等配置信息,使得不同线程可以根据自己的需求进行独立的日志记录,而不需要在每次记录日志时都传递相关配置信息。

总而言之,ThreadLocal 以其独特的数据隔离和线程安全特性,为多线程编程中处理线程局部数据提供一种简洁而有效的方式,与传统共享变量方式对比,在特定的应用场景中具有很大的优势。

三、ThreadLocal 的简单使用

以日期转换工具类为例,使用ThreadLocal。

在多线程中进行日期的格式转换:

java 复制代码
public class DateUtil {

    private static final SimpleDateFormat simpleDateFormat =
            new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static Date parse(String dateString) {
        Date date = null;
        try {
            date = simpleDateFormat.parse(dateString);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 50; i++) {
            executorService.execute(()->{
                System.out.println(DateUtil.parse("2024-08-08 20:20:20"));
            });
        }
        executorService.shutdown();
    }
}

报错如下:

java 复制代码
Exception in thread "pool-1-thread-6" Exception in thread "pool-1-thread-23" Exception in thread "pool-1-thread-21" Exception in thread "pool-1-thread-20" java.lang.NumberFormatException: multiple points
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
at java.lang.Double.parseDouble(Double.java:538)
at java.text.DigitList.getDouble(DigitList.java:169)
at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at org.example.threadLocal.DateUtil.parse(DateUtil.java:19)
at org.example.threadLocal.DateUtil.lambda$main$0(DateUtil.java:31)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)

因为SimpleDateFormat不是线性安全的,并发多线程场景下即会报错,不同的线程都能基于初始值进行独立计算。

使用ThreadLocal对SimpleDateFormat进行处理:

java 复制代码
public class SafeDateUtil {

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

    public static Date parse(String dateString) {
        Date date = null;
        try {
            date = simpleDateFormat.get().parse(dateString);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(5);

        for (int i = 0; i < 50; i++) {
            executorService.execute(()->{
                System.out.println(SafeDateUtil.parse("2024-08-08 20:20:20"));
            });
        }
        executorService.shutdown();
    }
}

结果如下:

java 复制代码
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024
Thu Aug 08 20:20:20 CST 2024

四、ThreadLocal 的工作原理

ThreadLocal的内存结构图:

Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。

ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型对象值。

源码中:

Thread线程类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,即每个线程都有一个属于自己的ThreadLocalMap。

ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本身,value是ThreadLocal的泛型值。

并发场景下,每个线程Thread,在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而可以实现了线程隔离。

ThreadLocal类中的关键set()方法:

java 复制代码
public void set(T value) {
    Thread t = Thread.currentThread(); //获取当前线程t
    ThreadLocalMap map = getMap(t);  //根据当前线程获取到ThreadLocalMap
    if (map != null)  //如果获取的ThreadLocalMap对象不为空
        map.set(this, value); //K,V设置到ThreadLocalMap中
    else
        createMap(t, value); //创建一个新的ThreadLocalMap
}

ThreadLocalMap getMap(Thread t) {
   return t.threadLocals; //返回Thread对象的ThreadLocalMap属性
}

void createMap(Thread t, T firstValue) { //调用ThreadLocalMap的构造函数
    t.threadLocals = new ThreadLocalMap(this, firstValue); // this表示当前类ThreadLocal
}

ThreadLocal类中的关键get()方法:

java 复制代码
public T get() {
    Thread t = Thread.currentThread();//获取当前线程t
    ThreadLocalMap map = getMap(t);//根据当前线程获取到ThreadLocalMap
    if (map != null) { //如果获取的ThreadLocalMap对象不为空
        //由this(即ThreadLoca对象)得到对应的Value,即ThreadLocal的泛型值
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value; 
            return result;
        }
    }
    return setInitialValue(); //初始化threadLocals成员变量的值
}

private T setInitialValue() {
    T value = initialValue(); //初始化value的值
    Thread t = Thread.currentThread(); 
    ThreadLocalMap map = getMap(t); //以当前线程为key,获取threadLocals成员变量,它是一个ThreadLocalMap
    if (map != null)
        map.set(this, value);  //K,V设置到ThreadLocalMap中
    else
        createMap(t, value); //实例化threadLocals成员变量
    return value;
}

五、ThreadLocal和内存泄漏的关系

5.1 ThreadLocalMap的Entry的Key设计成弱引用

在 Java 中,弱引用对象只要垃圾回收器运行,就会被回收,不管内存是否充足。比如ThreadLocal中ThreadLocalMap的Key用弱引用,就是为在Key没有其他强引用的时候,能尽快被回收,防止因为一直保留不需要的Key而导致内存泄漏。

  • 防止内存泄漏:
    当ThreadLocal对象作为ThreadLocalMap中Entry的Key时,如果Key是强引用,那么即使在外部没有对ThreadLocal对象的强引用(例如,在方法调用结束后,局部的ThreadLocal变量不再被使用),但由于ThreadLocalMap中Entry的Key对它有强引用,ThreadLocal对象及其关联的值将无法被垃圾回收。
    这可能会导致内存泄漏,尤其是在长期运行的应用程序中,如果有大量的线程创建和销毁,而这些ThreadLocal对象没有被正确清理,就会占用不必要的内存。
    通过将Key设计成弱引用,当没有其他强引用指向ThreadLocal对象时,垃圾回收器可以回收该ThreadLocal对象,从而避免这种内存泄漏问题。
  • 符合线程局部变量的生命周期特点:
    线程局部变量ThreadLocal的生命周期通常与创建它的线程相关。
    当线程结束时,理论上它的所有线程局部变量都应该可以被回收。
    然而,如果Key是强引用,即使线程结束了,但ThreadLocalMap可能仍然持有对ThreadLocal对象的引用,导致相关资源无法及时释放。
    设计成弱引用可以使得在合适的时候(当没有其他强引用时)自动清理不再使用的ThreadLocal对象及其关联的值,更符合线程局部变量的实际生命周期管理需求。
  • 避免无用数据的累积:
    在多线程环境下,如果不将Key设计成弱引用,随着线程的创建和销毁,可能会有很多不再使用的ThreadLocal对象残留在ThreadLocalMap中,导致ThreadLocalMap占用的内存不断增加。
    使用弱引用Key可以在一定程度上自动清理这些无用的数据,保持内存的合理使用。

例如,如果Key不是弱引用,可能导致的问题:

java 复制代码
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadLocalMemoryLeakExample {
    static final int THREAD_COUNT = 5;

    public static void main(String[] args) {
        // 创建固定线程数的线程池
        ExecutorService executorService = Executors.newFixedThreadPool(THREAD_COUNT);

        for (int i = 0; i < THREAD_COUNT; i++) {
            executorService.submit(new Runnable() {
                @Override
                public void run() {
                    // 创建一个 ThreadLocal 对象
                    ThreadLocal<String> threadLocal = new ThreadLocal<>();
                    threadLocal.set("Some value for this thread");
                    // 这里假设执行一些其他操作后,threadLocal 变量在方法内不再被使用,但由于 Key 是强引用,它可能无法被回收
                    System.out.println(Thread.currentThread().getName() + " set value in ThreadLocal");
                }
            });
        }

        // 关闭线程池
        executorService.shutdown();
    }
}

可见,如果ThreadLocal的Key不是弱引用,那么每次循环创建的ThreadLocal对象即使在任务执行完毕后可能也无法被回收,因为ThreadLocalMap中的Entry一直持有对它的强引用。而将Key设计成弱引用可以在合适的时候让这些不再使用的ThreadLocal对象被垃圾回收。

因此,将ThreadLocalMap中Entry的Key设计成弱引用是为了更好地管理内存,防止内存泄漏,并符合线程局部变量的生命周期特点。

5.2 弱引用会导致内存泄漏

TreadLocal的引用示意图:

hreadLocalMap使用ThreadLocal的弱引用作为key,当ThreadLocal变量被手动设置为null,即一个ThreadLocal没有外部强引用来引用它,当系统GC时,ThreadLocal一定会被回收。这样的话,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话(比如线程池的核心线程),这些key为null的Entry的value就会一直存在一条强引用链:

java 复制代码
Thread变量 -> Thread对象 -> ThreaLocalMap -> Entry -> value -> Object 

对象永远无法回收,造成内存泄漏。

实际上,ThreadLocalMap的设计中已经考虑到这种情况。所以有一些防护措施:即在ThreadLocal的get,set,remove方法,都会清除线程ThreadLocalMap里所有key为null的value。

set方法:

get方法:

六、ThreadLocal 的注意事项

综上,在使用ThreadLocal时需要注意以下几点:

6.1 线程复用问题

在一些应用服务器或者线程池的环境中,线程可能会被复用。如果在一个线程中使用了ThreadLocal,并且没有正确清理,当下次这个线程被复用执行其他任务时,可能会得到上一次遗留下来的数据,从而导致错误的结果。所以在这种情况下,更要确保在合适的时机(比如任务执行结束时)清理ThreadLocal的数据。

6.2 初始化值的正确设置

如果使用ThreadLocal的withInitial方法来设置初始值,要确保初始值的计算是线程安全的。因为这个初始值的供应函数只会在每个线程第一次获取ThreadLocal的值时被调用,如果这个供应函数内部有不安全的操作,可能会导致问题。例如:

java 复制代码
ThreadLocal<Integer> counter = ThreadLocal.withInitial(() -> {
   // 这里如果有不安全的操作,可能会有问题
   return someSharedVariable++; 
});

这里someSharedVariable的自增操作如果没有正确同步,在多线程环境下会导致错误的初始值。

6.3 理解线程局部性的范围

ThreadLocal变量只在当前线程内是局部的。不要错误地认为它在整个应用程序中都是隔离的。不同线程之间的ThreadLocal变量是相互独立的,但是在同一个线程内,对ThreadLocal变量的修改会影响到该线程后续对这个变量的使用。比如,在一个线程中修改ThreadLocal的值,那在该线程的后续代码中获取到的就是修改后的值。

6.4 适当的命名和文档说明

使用ThreadLocal时,应该起一个有意义的名称,并且在代码中做好说明,以便其他开发者能够清楚地理解这个ThreadLocal变量的用途和生命周期管理的重要性。

相关推荐
飞滕人生TYF6 分钟前
java Queue 详解
java·队列
VertexGeek10 分钟前
Rust学习(八):异常处理和宏编程:
学习·算法·rust
武子康27 分钟前
大数据-230 离线数仓 - ODS层的构建 Hive处理 UDF 与 SerDe 处理 与 当前总结
java·大数据·数据仓库·hive·hadoop·sql·hdfs
武子康29 分钟前
大数据-231 离线数仓 - DWS 层、ADS 层的创建 Hive 执行脚本
java·大数据·数据仓库·hive·hadoop·mysql
苏-言36 分钟前
Spring IOC实战指南:从零到一的构建过程
java·数据库·spring
界面开发小八哥43 分钟前
更高效的Java 23开发,IntelliJ IDEA助力全面升级
java·开发语言·ide·intellij-idea·开发工具
二进制_博客1 小时前
Flink学习连载文章4-flink中的各种转换操作
大数据·学习·flink
草莓base1 小时前
【手写一个spring】spring源码的简单实现--容器启动
java·后端·spring
codebolt1 小时前
ADS学习记录
学习
Allen Bright1 小时前
maven概述
java·maven