一、引言

在Java多线程编程中,ThreadLocal是一个非常有用的工具,它提供了一种将对象与线程关联起来的机制,使得每个线程都可以拥有自己独立的对象副本,从而避免了线程安全问题。然而,使用不当会导致内存泄漏问题。

二、ThreadLocal介绍

ThreadLocal是一个线程本地变量(与其说是线程本地变量,不如说是线程局部变量),它为每个线程提供了一个独立的副本,每个线程都可以独立地改变自己的副本,而不会影响其他线程的副本。ThreadLocal通常用于解决线程安全问题,例如在多线程环境下共享对象时,可以使用ThreadLocal来保存每个线程独立的对象副本,从而避免了同步操作。下面笔者提供一个代码案例来说明它的用法。

package com.execute.batch.executebatch;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * 日期工具类
 * @author hulei
 */
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;
    }
}

上面是一个日期工具类,内部定义了一个日期格式转换方法parse(),还有一个日期格式转换器SimpleDateFormat类。

多线程测试代码如下

package com.execute.batch.executebatch;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * @author hulei
 * @date  2024/5/23 15:44
 */

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

        for (int i = 0; i < 10; i++) {
            executorService.execute(()->{
                System.out.println(DateUtil.parse("2024-05-23 16:34:30"));
            });
        }
        executorService.shutdown();
    }
}

测试结果报错
ThreadLocal原理及使用-LMLPHP

把工具类的

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

替换成如下写法,用ThreadLocal包起来

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

工具类变成如下

package com.execute.batch.executebatch;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
 * 日期工具类
 * @author hulei
 */
public class DateUtil {

    private static final ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
 
    public static Date parse(String dateString) {
        Date date = null;
        try {
            date = dateFormatThreadLocal.get().parse(dateString);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

测试发现不报错了

package com.execute.batch.executebatch;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
 * 日期工具类
 * @author hulei
 */
public class DateUtil {

    private static final ThreadLocal<SimpleDateFormat> dateFormatThreadLocal =
            ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
 
    public static Date parse(String dateString) {
        Date date = null;
        try {
            date = dateFormatThreadLocal.get().parse(dateString);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return date;
    }
}

刚才第一次测试报错,是因为SimpleDateFormat不是线程安全的类,SimpleDateFormat 不是线程安全的主要原因在于以下几个方面:

  • 内部状态共享:SimpleDateFormat 内部维护了一些状态,如日期字段的解析和格式化信息。这些状态在解析或格式化日期时可能会被修改。当多个线程同时访问一个实例时,如果没有适当的同步控制,这些状态的修改可能会发生冲突,导致不一致的结果。

  • 可变性:SimpleDateFormat 实例是可以修改的。比如,可以通过调用 applyPattern() 方法来改变其格式模式,这会影响实例的状态。如果多个线程同时修改同一个实例,可能会出现竞态条件。

  • 缓存行为:SimpleDateFormat 在解析日期时,可能会缓存一些日期字段的解析结果,这些缓存是基于实例的。如果多个线程同时访问,可能会导致缓存的数据不准确或丢失。

  • 线程本地副本:在某些情况下,SimpleDateFormat 实例可能需要使用线程本地副本来提高性能,但Java的标准实现并未内置这样的机制,所以开发者需要手动处理线程安全问题。

为了避免这些问题,有几种常见的解决方案:

  • 线程局部实例:为每个线程创建单独的 SimpleDateFormat 实例,避免共享。

  • 同步访问:如果必须共享实例,可以在访问时使用 synchronized 关键字或 java.util.concurrent.locks.Lock 进行同步。

  • 使用不可变的 DateTimeFormatter:Java 8及更高版本提供了 java.time.format.DateTimeFormatter 类,它是线程安全的,可以替代 SimpleDateFormat。

在多线程环境中,使用 ThreadLocal 是一个好的选择,因为它可以确保每个线程拥有自己SimpleDateFormat 实例,从而消除线程安全问题。

三、内存泄露问题

虽然ThreadLocal提供了一种便捷的线程封闭机制,但是如果使用不当会导致内存泄漏问题。ThreadLocal的内存泄漏问题主要表现在以下两个方面:

  1. 线程结束后没有手动清理
    当一个线程结束后,它所持有的ThreadLocal变量并不会立即释放,如果没有手动调用remove()方法清理ThreadLocal变量,那么这些变量会一直保留在内存中,直到线程池被销毁或者应用程序退出。

  2. ThreadLocal变量被弱引用持有
    ThreadLocal内部通过一个ThreadLocalMap来存储线程独立的变量副本,而ThreadLocalMap中的Entry是由ThreadLocal的弱引用持有的。如果一个ThreadLocal没有被外部强引用持有,那么在垃圾回收时,ThreadLocal对象会被回收,但是对应的Entry并不会被自动清理,这样就会导致内存泄漏问题。

四、避免内存泄漏

为了避免ThreadLocal的内存泄漏问题,我们可以采取以下几种解决方案:

及时清理ThreadLocal变量

在使用完ThreadLocal变量后,应该及时调用remove()方法清理ThreadLocal变量,以便释放资源。

ThreadLocal<String> threadLocal = new ThreadLocal<>();
threadLocal.set("value");
// 使用完毕后清理ThreadLocal变量
threadLocal.remove();

日期转换工具类代码可以加入以下语句清理ThreadLocal变量
ThreadLocal原理及使用-LMLPHP

使用ThreadLocal的弱引用

为了避免ThreadLocal对象被强引用持有导致的内存泄漏问题,可以将ThreadLocal声明为静态内部类,以使得ThreadLocal对象的生命周期比较长,从而避免了被短生命周期的线程持有。意思是生命为静态内部变量,大致如下:

public class MyThreadLocal {
    private static final ThreadLocal<Object> threadLocal = new ThreadLocal<>();
    // 省略其他代码
}

使用InheritableThreadLocal

InheritableThreadLocal是ThreadLocal的一个子类,它可以让子线程从父线程中继承ThreadLocal变量,但是使用InheritableThreadLocal也会增加内存泄漏的风险,因此需要谨慎使用。

public class MyThreadLocal {
    private static final ThreadLocal<Object> threadLocal = new InheritableThreadLocal<>();
    // 省略其他代码
}

注意:实际java8以后的版本,ThreadLocal的实现包含了一个弱引用机制,当线程结束时,即使未手动调用remove(),与线程相关的ThreadLocalMap.Entry也会有机会被垃圾回收器回收,从而减少了内存泄漏的风险。但这种机制并不能完全排除内存泄漏,特别是在长期运行的线程或线程池中,如果ThreadLocal的引用没有被及时清理,仍然可能导致大量无用对象占据内存空间。所以仍然建议手动释放掉ThreadLocal变量。

05-24 10:51