【面试】面试官让我谈谈ThreadLocal的使用

首先是ThreadLocal能干啥的问题,

我将其总结为三点

  1. 传递数据:实现在公共组件中传递信息的需求
  2. 线程隔离:实现每个线程都是不相互影响的
  3. 线程并发:在多线程场景下完成需求

ThreadLocal和synchronized的区别与联系

我们知道解决多线程的并发问题,最常见的思想就是加锁,我们很轻易的就能想到Synchronized关键字,但是使用synchronized关键字加锁会导致程序的性能降低。

前提:当需要保持数据线程封闭性时即每个线程需要独立访问和修改数据时,使用 ThreadLocal。例如,数据库连接管理、事务管理、线程上下文信息等。

主要的是两者的思想不同

synchronized主要使用时间换空间,侧重于多线程程序的同步性

threadlocal主要是用空间换时间,侧重于线程之间的隔离

选择使用 ThreadLocal 还是锁取决于具体的应用场景和需求。如果数据不需要在多个线程间共享,使用 ThreadLocal 可以简化代码并提高性能;如果数据需要在多个线程间共享,那么使用锁是保证线程安全的必要手段。

为什么要使用ThreadLocal

并发场景下,会存在多个线程同时修改一个共享变量的场景。这就可能会出现线性安全问题

为了解决线性安全问题,可以用加锁的方式,比如使用synchronized 或者Lock。但是加锁的方式,可能会导致系统变慢。加锁示意图如下:

还有另外一种方案,就是使用空间换时间的方式,即使用ThreadLocal。使用ThreadLocal类访问共享变量时,会在每个线程的本地,都保存一份共享变量的拷贝副本。多线程对共享变量修改时,实际上操作的是这个变量副本,从而保证线性安全。

常用于以下场景

线程上下文信息传递:例如在web应用中,服务器接收到请求后,需要在不同的过滤器、处理器链路中传递用户会话信息,
此时可以将这些信息存放在ThreadLocal中,因为在Servlet容器中,
每个HTTP请求都会被分配到一个单独的线程中处理。

避免同步开销:对于那些只需要在单个线程内保持状态,不需要线程间共享的数据,
使用 ThreadLocal可以避免使用锁带来的性能损耗。

数据库连接、事务管理:在多线程环境下,每个线程有自己的数据库连接,
可以使用 ThreadLocal 存储当前线程的数据库连接对象,以确保线程安全。

性能监控和日志记录:在进行性能监控和日志记录时,通常需要为每个请求或线程生成一个唯一的标识符(如请求ID或线程ID)。这些标识符可以存储在ThreadLocal中,以便在日志或监控信息中标识出当前请求或线程的相关信息。
实际开发中的用法

使用 ThreadLocal 主要涉及以下三个方法:

初始化/设置值:ThreadLocal.set(T value) 方法用来设置当前线程的变量副本值。

获取值:T get() 方法用来获取当前线程所对应的变量副本的值,如果此线程从未设置过值,
那么返回 null 或者初始值(如果有的话)。

移除值:remove() 方法用于删除当前线程保存的变量副本,如果不主动清理,可能会造成内存泄露。
使用时需要注意的问题
内存泄露:当线程结束生命周期后,如果没有显式调用 remove() 方法,
存储在线程本地变量表中的 ThreadLocal 变量副本不会自动删除,这可能导致它们无法被垃圾回收,
尤其是在线程池场景中,如果线程会被复用,这个问题更为突出。

线程安全的误解:虽然 ThreadLocal 保证了每个线程只能访问自己的变量副本,
但是它并不能保证变量副本本身的线程安全性,即如果存放在 ThreadLocal 中的对象不是线程安全的,
多个线程通过各自的 ThreadLocal 访问相同的非线程安全对象时,还需要采取额外的同步措施。

过度使用:不恰当的使用 ThreadLocal 可能导致代码逻辑变得复杂,增加维护难度,
尤其是当线程间本来就需要共享数据时,不应该滥用 ThreadLocal 避免数据交换。
使用中可能出现的事故
线程未正确清理:如果在使用完 ThreadLocal 之后忘记调用 remove() 方法,
特别是在长期运行的后台线程或者线程池中,容易积累大量的废弃对象,造成内存泄漏。

跨线程错误访问:在多线程环境中,如果不明确线程间的数据隔离,
误以为 ThreadLocal 存储的数据在整个应用范围内可见,就会导致逻辑错误,
因为每个线程看到的 ThreadLocal 值实际上是不同的。

异常处理不当:在 try-finally 结构中,如果在 finally 块中没有正确清理 ThreadLocal,
在抛出异常的情况下,可能会遗留未清理的资源。

内存泄露原因

ThreadLocal 变量未被明确移除:当使用 ThreadLocal 变量后,如果没有手动调用 remove() 方法清除变量,那么即使线程结束,这部分内存也无法被回收,从而导致内存泄露。
ThreadLocalMap 中持续存在的引用:每个线程都有一个 ThreadLocalMap,用于存储 ThreadLocal 变量及其对应的值。如果 ThreadLocal 变量没有被移除,那么它所引用的对象也会一直存在于 ThreadLocalMap 中,占用内存空间。


解决办法

使用 remove() 方法清理:在使用完 ThreadLocal 变量后,应手动调用 remove() 方法来清理对应的线程变量,确保内存能够及时释放。
使用弱引用:ThreadLocalMap 中的键(即 ThreadLocal 变量)可以是弱引用。这样,当 ThreadLocal 实例在当前线程中不再被引用时,它能够被垃圾回收器(GC)回收,从而避免内存泄露。但请注意,即使使用弱引用,如果值(即 ThreadLocal 变量所引用的对象)仍然被线程持有,那么这部分内存仍然无法被回收。因此,正确清理 ThreadLocal 变量仍然非常重要。
合理设置线程池:在使用线程池时,应合理设置线程池的大小和最大空闲时间,避免创建过多的线程或线程长时间运行而无法及时回收资源。
避免死锁和大量临时对象:合理设计程序逻辑,避免死锁现象的发生;同时,尽量减少程序中大量临时对象的创建,以降低内存泄露的风险。

用法示例

案例一:web应用存储用户信息

在Web应用程序中,尤其是在基于线程模型的Servlet容器(如Tomcat)中,每个HTTP请求通常由一个单独的线程负责处理。当用户登录时,为了能够在处理请求的过程中便捷地访问当前已登录用户的相关信息,而不必在各个服务层和控制器之间传递这些信息,可以利用ThreadLocal来存储用户的登录信息。

// CurrentUser类,封装了用户登录后的相关信息
public class CurrentUser {
    private String username;
    private String userId;
    // 其他用户属性...
 
    // 构造函数,getter和setter略...
}
// 创建一个ThreadLocal来存放CurrentUserInfo
public class UserContext {
    private static final ThreadLocal<CurrentUser> currentUserThreadLocal = new ThreadLocal<>();
 
    public static void setCurrentUser(CurrentUser user) {
        currentUserThreadLocal.set(user);
    }
 
    public static CurrentUser getCurrentUser() {
        return currentUserThreadLocal.get();
    }
 
    public static void clearCurrentUser() {
        currentUserThreadLocal.remove();
    }
}
// 在登录验证成功后,我们会在登录拦截器或者登录成功的处理逻辑中设置当前用户的上下文:
// 登录成功后设置ThreadLocal
public class LoginInterceptor implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
 
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        // 假设 authenticate(...) 方法完成了验证并返回了登录用户对象
        CurrentUser loggedInUser = authenticate(httpServletRequest);
 
        if (loggedInUser != null) {
            UserContext.setCurrentUser(loggedInUser);
        }
 
        try {
            chain.doFilter(request, response);
        } finally {
            // 请求处理完毕后清理ThreadLocal,防止内存泄漏
            UserContext.clearCurrentUser();
        }
    }
    
    // ...authenticate方法实现细节...
}
// 在需要用户上下文的地方,可以直接从ThreadLocal中获取:
public class UserService {
    public void processUserData() {
        CurrentUser currentUser = UserContext.getCurrentUser();
        if (currentUser != null) {
            // 处理当前登录用户的数据...
        }
    }
}

注意:

通过这种方式,每个请求线程都会有自己的用户上下文,而且这个上下文仅对该线程可见,不会与其他线程混淆。同时,务必在请求处理完毕后清理 ThreadLocal 中的数据,以防止内存泄漏。在实际应用中,一般会配合Spring Security或者其他安全框架来处理这类用户上下文的管理。

案例二ThreadLocal经常在日期转换工具类中出现

我们先来看个反例


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

我们在多线程环境跑DateUtil这个工具类:

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("2022-07-24 16:34:30"));
            });
        }
        executorService.shutdown();
    }

运行后,发现报错了:

如果在DateUtil工具类,加上ThreadLocal,运行则不会有这个问题:

/**
 * 日期工具类
 */
public class DateUtil {

    private static 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;
    }

    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("2022-07-24 16:34:30"));
            });
        }
        executorService.shutdown();
    }
}

运行结果:

Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022
Sun Jul 24 16:34:30 GMT+08:00 2022

刚刚反例中,为什么会报错呢?这是因为SimpleDateFormat不是线性安全的,它以共享变量出现时,并发多线程场景下即会报错。

案例三:线程池使用ThreadLocal

使用 ThreadLocal 时,你需要注意内存泄漏和意外的数据保留问题。因为 ThreadLocal 绑定的是线程,如果线程是重用的(如在线程池中),那么除非显式清除,否则 ThreadLocal 变量中存储的数据可能会在下一次使用线程时仍然存在。因此,在不再需要这些数据时,应该调用 ThreadLocal 的 remove() 方法来清理数据。

案例背景:

有用户反馈查询订单列表接口有点慢,我就去打印每一步的耗时信息。发现查询订单之前,
需要先根据用户ID查询用户信息,而查询用户信息接口需要调用用户团队提供的服务,
有时候网络较慢的时候,耗时达到200毫秒。

而查询订单接口层层调用的时候,调用了好几次查询用户信息接口。当然可以改成再最上层查询一次,
然后层层往下传递,这样一来改的地方比较多,也很麻烦。

我琢磨着能不能加个本地缓存,把用户信息缓存起来,这样就不用每次去调用用户服务查询了。
刚好就想到了使用ThreadLocal,听说高级程序员都用ThreadLocal,我也想用一下试试。

ThreadLocal是线程私有的,调用结束后,线程销毁了,ThreadLocal里面数据也跟着没了。

听着ThreadLocal是线程安全的,应该没什么问题。

我先写一个ThreadLocal的工具类,用来存储和获取用户信息:

/**
 * @author 一灯
 * @apiNote 本地缓存用户信息
 **/
public class ThreadLocalUtil {
​
    // 使用ThreadLocal存储用户信息
    private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();
​
    /**
     * 获取用户信息
     */
    public static User getUser() {
        // 如果ThreadLocal中没有用户信息,就从request请求解析出来放进去
        if (threadLocal.get() == null) {
            threadLocal.set(UserUtil.parseUserFromRequest());
        }
        return threadLocal.get();
    }
​
}

然后在查询订单接口里面,调用这个工具类的方法获取用户信息,最后根据用户信息查询订单信息,完美。

/**
 * 获取订单列表方法
 */
public List<Order> getOrderList() {
    // 1. 从ThreadLocal缓存中获取用户信息
    User user = ThreadLocalUtil.getUser();
    // 2. 根据用户信息,调用用户服务获取订单列表
    return orderService.getOrderList(user);
}

自测、提测、验收、上线,接口访问速度“嗖”一下就上去了,一切看上去都是那么完美。

事与愿违:

上线一个小时后,值班群炸了。

陆续开始有用户反馈自己刚下的订单不见了,其他用户也有反馈自己的订单列表莫名其妙多了一些订单。

我一脸懵逼,没碰到过这种情况,逐渐反馈的用户越来越多,我已经不知所措了。

领导当机立断,小灯,你小子搞什么飞机,赶紧回滚服务。

经过无数次打日志、debug,终于定位到问题了。

ThreadLocal确实是线程私有的,并且会在线程销毁后,ThreadLocal里面的数据也会被清理掉。

但是问题就出在,无论我们服务端用的是Tomcat、Jetty、SpringBoot、Dubbo等,都不会来一个请求就创建一个线程,而是创建一个线程池,所有请求共享这这个线程池里的线程。

一个线程处理完一个请求,并不会被销毁。可能导致多个用户请求共用一个线程,最后出现数据越权,看到了别的用户的订单。

解决方案

解决办法就是,在使用完ThreadLocal后,再调用remove方法清除ThreadLocal数据。

/**
 * @author 一灯
 * @apiNote 本地缓存用户信息
 **/
public class ThreadLocalUtil {
​
    // 使用ThreadLocal存储用户信息
    private static final ThreadLocal<User> threadLocal = new ThreadLocal<>();
​
    /**
     * 获取用户信息
     */
    public static User getUser() {
        // 如果ThreadLocal中没有用户信息,就从request请求解析出来放进去
        if (threadLocal.get() == null) {
            threadLocal.set(UserUtil.parseUserFromRequest());
        }
        return threadLocal.get();
    }
​
    /**
     * 删除用户信息
     */
    public static void removeUser() {
        threadLocal.remove();
    }
​
}

 使用try/catch包裹业务代码,然后在finally中清除ThreadLocal数据。

/**
 * 获取订单列表
 */
public List<Order> getOrderList() {
    // 1. 从ThreadLocal缓存中获取用户信息
    User user = ThreadLocalUtil.getUser();
    // 2. 根据用户信息,调用用户服务获取订单列表
    try {
        return orderService.getOrderList(user);
    } catch (Exception e) {
        throw new RuntimeException(e.getMessage());
    } finally {
        // 3. 使用完ThreadLocal后,删除用户信息
        ThreadLocalUtil.removeUser();
    }
    return null;
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

常生果

喜欢我,请支持我

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值