思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜
ThreadLocal
是Java
中的一个重要的类,其提供了一种创建线程局部变量机制。从而使得每个线程都有自己独立的副本,互不影响。此外,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 getUserData(Integer uid) {
return userService.getUserInfoById(uid);
}
服务层
@Service
public class UserService {
ThreadLocal locals = new ThreadLocal();
public CommonResult 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
信息肯定是用户A
的uid
信息;进一步,传到控制层getUserData
处的uid
信息肯定是用户A
的uid
。所以,发生问题一定发生在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 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 msgs = Arrays.asList(firstMsg,secondMsg);
return CommonResult.success(msgs);
}
}
uid=1
uid=2
可以看到,对于第二次uid=2
的访问,这次就出现了 Bug
,显然第二次获取到了用户1
的信息。其实,从这里就可以看出,我们最开始的猜测没有任何问题。
拆解问题发生原因
既然知道了发生问题的原因在于ThreadLocal
的使用,那究竟是什么导致了这个问题呢?事实上,我们在使用ThreadLocal
时主要就是使用了其的get/set
方法,这就是我们分析的切入口。先来看下ThreadLocal
的set
方法。
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value);
}
可以看到,ThreadLocal
的set
方法逻辑大致如下:
Thread.currentThread
获取到当前的线程ThreadLocalMap
。接着,对ThreadLocalMap
进行判断,如果不为空,就直接更新要保存的变量值;否则,创建一个threadLocalMap
,并且完成赋值。进一步,下图展示了Thrad,ThreadLocal,ThredLocalMap
三者间的关系。
回到我们例子,那导致出现访问错乱的原因是什么呢?其实很简单,原因就是 Tomcat
内部会维护一个线程池,从而使得线程被重用。从图中可以看到两次
请求的线程都是同一个线程: http-nio-8080-exec-1
,所以导致数据访问出现错乱。
那有什么解决办法吗?其实很简单,每次使用完记得执行remove
方法即可。因为如果不调用remove
方法,当面临线程池或其他线程重用机制可能会导致不同任务之间共享ThreadLocal
数据,这可能导致意外的数据污染或不一致性。就如我们的例子那样。
总结
至此,我们以一个实际生产中遇到的一个问题为例由浅入深的分析了ThreadLocal
使用不规范
所带来的线程不安全问题。可以看到排查问题时,我们用到的不仅仅只有ThreadLocal
的知识,更有多线程相关的知识。
可能平时我们也会抱怨学了很多线程知识,但工作中却很少使用。因为日常代码中基本写不到多线程相关的功能。但事实却是,很多时候只是我们没有意识到多线程的使用。例如,在Tomcat
这种 Web
服务器下跑的业务代码,本来就运行在一个多线程环境,否则接口也不可能支持这么高的并发,并不能单纯认为没有显式开启多线程就不会有线程安全问题。此外,虽然jdk
提供很多线程安全的工具类,但其也有特定的使用规范,如果不遵循规范依旧会导致线程安全问题, 并不是使用了线程安全的工具类就一定不会出问题!
最后,再多提一嘴,学了的知识一定要用起来,可能你为了应付面试也曾看过ThreadLocal
相关的面经,也知道使用ThreadLocal
要执行remove
,否则可能会导致内存泄露
,但编程的很多东西,确实需要自己实际操作,否则知识并不会凭空进入你的脑海。
选择了程序员这条路,注定只能不断的学习,大家一起共勉啦!另外,祝大家双节快乐!