ThreadLocal使用不规范,上线两行泪

7,576 阅读7分钟

思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜


ThreadLocalJava中的一个重要的类,其提供了一种创建线程局部变量机制。从而使得每个线程都有自己独立的副本,互不影响。此外,ThreadLocal也是面试的一个重点,对于此网上已经有很多经典文章来进行分析,但今天我们主要分析笔者在项目中遇到的一个错误使用ThreadLocal的示例,并针对错误原因进行深入剖析,理论结合实践让你更加透彻的理解ThreadLocal的使用。

前言

Java中的ThreadLocal是一种用于在多线程环境中存储线程局部变量的机制,它为每个线程都提供了独立的变量副本,从而避免了线程之间的竞争条件。事实上,ThreadLocal的工作原理是在每个线程中创建一个独立的变量副本,并且每个线程只能访问自己的副本。

进一步,ThreaLocal可以在当前线程中独立的保存信息,这样就方便同一个线程的其他方法获取到该信息。 因此,ThreaLocal的一个最广泛的使用场景就是将信息保存,从而方便后续方法直接从线程中获取。

使用ThreadLocal出现的问题

明白了ThreaLocal的应应用场景后,我们来看一段如下代码:

控制层

@RestController
@Slf4j
@RequestMapping("/user")
public class UserController {

    @Autowire
    private UserService userService;

    @GetMapping("get-userdata-byId")
    public CommonResult<Object> getUserData(Integer uid) {
      
        return userService.getUserInfoById(uid);

}

服务层

@Service
public class UserService {

    ThreadLocal<UserInfo> locals = new ThreadLocal<>();

    public CommonResult<UserInfo> getUserInfoById ( String uid)  {
        UserInfo info = locals.get();

        if (info == null) {
           // 调用uid查询用户
           UserInfo userInfo = UserMapper.queryUserInfoById(uid);
           locals.set(userInfo);
        }
        // ....省略后续会利用UserInfo完成某些操作
        
        return CommonResult.success(info);
    }
}

(注:此处为了方便复现项目代码进行了简化,重点在于理解ThreaLocal的使用)

先来简单介绍一下业务逻辑,前台通过url访问/user/get-userdata-byId后,后端会根据传入的uid信息查询用户信息,以避免进而根据用户信息执行相应的处理逻辑。进一步,在服务层中会缓存当前id对应的用户信息,避免频繁的查询数据库。

直观来看,上述代码似乎没问题。但最近用户反馈会出现这样一个问题,就是用户A登录系统后,查询到的可能是用户B的信息,这个问题就很诡异。遇到问题不要慌,不妨来看看笔者是如何进行思考,来定位,解决问题的。

首先,用户A登录系统后,前端访问/user/get-userdata-byId时携带的uid信息肯定是用户Auid信息;进一步,传到控制层getUserData处的uid信息肯定是用户Auid。所以,发生问题一定发生在UserService中的getUserInfoById方法。

进一步,由于用户传入的uid信息没有问题,那么传入getUserInfoById方法也肯定没有问题,所以问题发生地一定在getUserInfoById中获取用户信息的位置。所以不难得出这样的猜测,即问题大概率在 UserInfo info = locals.get()这行代码。

为了加深理解,我们再来回顾一下问题。"即用户A登录,最终却查询到用户B相关的信息"。 其实,这个问题本质其实在于数据不一致。众所周知,造成数据不一致的原因有很多,但归根到底其实无非就是:“存在多线程访问的资源信息,进一步,多线程的存在导致数据状态的改变原因不唯一”

Spring中的Bean都是单例的,也就是说Bean中成员信息是共享的。换句话说, 如果Bean中会操纵类的成员变量,那么每次服务请求时,都会对该变量状态进行改变,也就会导致该变量成员那状态不断发生改变。

具体到上述例子,UserService中的被方法操纵的成员是什么?当然是locals这个成员变量啦! 至此,问题其实已经被我们定位到了,导致问题发生的原因在于locals变量。

说到此,你可能你会疑惑ThreadLocal不是可以保证线程安全吗?怎么使用了线程安全的工具包还会导致线程安全问题?

问题复现

况且你说是ThreadLocal出问题那就是ThreadLocal出问题吗?你有证据吗?所以,接下来我们将通过几行简单的代码,复现这个问题。


@RestController
@RequestMapping("/th")
public class UserController {

    ThreadLocal<Integer> uids = new ThreadLocal<>();

    @GetMapping("/u")
    public CommonResult getUserInfo(Integer uid) {
        Integer firstId = uids.get();
        String firstMsg = Thread.currentThread().getName() + " id is " + firstId;
        if (firstId == null) {
            uids.set(uid);
        }

        Integer secondId = uids.get();
        String secondMsg = Thread.currentThread().getName() + " id is " + secondId;
        
        List<String> msgs = Arrays.asList(firstMsg,secondMsg);
        return CommonResult.success(msgs);


    }
}
  1. 第一次访问:uid=1

image.png

  1. 第二次访问:uid=2 image.png

可以看到,对于第二次uid=2的访问,这次就出现了 Bug,显然第二次获取到了用户1的信息。其实,从这里就可以看出,我们最开始的猜测没有任何问题。

拆解问题发生原因

既然知道了发生问题的原因在于ThreadLocal的使用,那究竟是什么导致了这个问题呢?事实上,我们在使用ThreadLocal时主要就是使用了其的get/set方法,这就是我们分析的切入口。先来看下ThreadLocalset方法。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

可以看到,ThreadLocalset方法逻辑大致如下:

  1. 首先,通过Thread.currentThread获取到当前的线程
  2. 然后,获取到线程当中的属性ThreadLocalMap。接着,对ThreadLocalMap进行判断,如果不为空,就直接更新要保存的变量值;否则,创建一个threadLocalMap,并且完成赋值。

进一步,下图展示了Thrad,ThreadLocal,ThredLocalMap三者间的关系。

image.png

回到我们例子,那导致出现访问错乱的原因是什么呢?其实很简单,原因就是 Tomcat 内部会维护一个线程池,从而使得线程被重用。从图中可以看到两次请求的线程都是同一个线程: http-nio-8080-exec-1,所以导致数据访问出现错乱。

image.png

那有什么解决办法吗?其实很简单,每次使用完记得执行remove方法即可。因为如果不调用remove方法,当面临线程池或其他线程重用机制可能会导致不同任务之间共享ThreadLocal数据,这可能导致意外的数据污染或不一致性。就如我们的例子那样。

总结

至此,我们以一个实际生产中遇到的一个问题为例由浅入深的分析了ThreadLocal使用不规范所带来的线程不安全问题。可以看到排查问题时,我们用到的不仅仅只有ThreadLocal的知识,更有多线程相关的知识。

可能平时我们也会抱怨学了很多线程知识,但工作中却很少使用。因为日常代码中基本写不到多线程相关的功能。但事实却是,很多时候只是我们没有意识到多线程的使用。例如,在Tomcat 这种 Web 服务器下跑的业务代码,本来就运行在一个多线程环境,否则接口也不可能支持这么高的并发,并不能单纯认为没有显式开启多线程就不会有线程安全问题。此外,虽然jdk提供很多线程安全的工具类,但其也有特定的使用规范,如果不遵循规范依旧会导致线程安全问题, 并不是使用了线程安全的工具类就一定不会出问题!

最后,再多提一嘴,学了的知识一定要用起来,可能你为了应付面试也曾看过ThreadLocal相关的面经,也知道使用ThreadLocal要执行remove,否则可能会导致内存泄露但编程的很多东西,确实需要自己实际操作,否则知识并不会凭空进入你的脑海。

选择了程序员这条路,注定只能不断的学习,大家一起共勉啦!另外,祝大家双节快乐!