一个BUG搞懂ThreadLocal、InheritableThreadLocal、TransmittableThreadLocal

引言

最近我收到一个非常诡异的线上BUG,触发BUG的业务流程大概是这样的:A系统新建任务数据需要同步到B系统,数据是多租户的,比如C租户在A系统新建了一条任务,那么C租户登录B系统后会看到这条任务,如果在A系统修改这条任务,任务信息会也会同步到B系统,本来是一个很简单的数据同步问题,但是诡异的事情发生了。

二、问题排查

BUG现象:

  • C租户在A系统新建任务后,去B系统找不到这条任务

  • C租户在A系统修改任务后,去B系统又看到了这条任务

开发初步排查:

  • C租户在A系统新建任务后数据确实入了B系统的数据库,只是租户ID变成了D租户,所以在B系统中查不到这条任务
  • C租户在A系统修改任务后B系统的任务租户ID又被改成了租户C的,所以在B系统中又能查到这条任务

排查思路:

我收到这个问题时,另一个开发已经做了初步排查,说这个问题太诡异了,他找不出原因,于是请我帮忙排查一下,因为这个项目不是我开发的,业务也不是很熟悉,我的排查思路大致是这样的:

  1. 最近有没有发过版

我找到运维看线上包是6月17号发的版,而出现问题是从7月23号,所以不太可能是发版导致的问题

  1. 诡异的租户ID

因为新增任务租户ID被改成固定值25678,我在代码及配置中搜索了这个关键字,没有搜索到,然后怀疑有人改了代码未合并Master

  1. 线上代码反编译

为了排除是代码不一致导致的问题,我反编译了一下线上环境的代码,发现和Master是一样的,所以排除了代码不一致的情况,也排除了代码中有固定用租户ID的情况

  1. 查看代码

代码其实是比较简单的,我省略其它不必要的逻辑,主要功能就是接收一个对象,然后使用taskRepository.save()保存到数据库中,添加和修改都是调用这个接口,那为什么添加任务租户ID会被改,修改任务租户ID就不会被改呢?从代码初步看是没有问题的,好像可以排除代码的问题

 

kotlin

代码解读

复制代码

@PostMapping("/api/sync") public ResponseEntity<String> sync(@RequestBody TaskDO taskDO) { taskRepository.save(taskDO); return ResponseEntity.ok("ok"); } public class TaskDO { private Long id; private String name; private Long tenantId; }

  1. 线上Debug

然后我使用Arthas 在线上追踪了一个这个sync方法,在save()之前taskDO租户ID是正确的,然而save()之后taskDO的tenantId就被改掉了,这时我断定肯定是有拦截之类的东西修改了租户ID

  1. 重启大法

问题一时没找到,然后我使用了重启大法,试下重启后能不能解决问题,结果重启后真的就好了,这时可以肯定的是25678租户ID不是代码和配置里取的,肯定是从内存里取的,因为重启后内存里没有了这个数字,内存取不到也就好了

  1. 继续耐心看代码

重启大法确实好用,但是问题没有找到,说不定过段时间问题又会出现,于是我还是耐心去看代码,因为代码比较老也不是我写的,而且老代码你们都知道,基本上就是一座屎山,看起来还是非常要耐心的,当我看到代码里使用了ThreadLocal保存租户ID,这里我就知道是啥原因了,大概率是使用了ThreadLocal后没有清理,Tomcat处理请求使用了线程池复用导致的。

三、问题还原

这里我简化一下不必要的代码,大致复原一下核心代码,首先有一个UserContext使用了ThreadLocal保存TenantId

整理了一份好像面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题

需要全套面试笔记【点击此处即可】免费获取

csharp

代码解读

复制代码

public class UserContext { private static ThreadLocal<Long> userTenant=new ThreadLocal<>(); public static void setTenantId(Long tenantId){ userTenant.set(tenantId); } public static Long getTenantId(){ return userTenant.get(); } public static void remove(){ userTenant.remove(); } }

然后有一个获取当前租户任务的接口,这里的租户ID是从UserContext中获取的

 

less

代码解读

复制代码

@GetMapping("/task") public ResponseEntity<List<TaskDO>> list() { log.info("GET /task use threadId: {}",Thread.currentThread().getId()); return ResponseEntity.ok(taskRepository.findAllByTenantId(UserContext.getTenantId())); }

另外有一个登录拦截器,大致逻辑是从请求Token里解析出tenantId然后设置到UserContext

 

java

代码解读

复制代码

public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String tenantId = request.getHeader("X-TENANT-ID"); if (tenantId != null) { UserContext.setTenantId(Long.parseLong(tenantId)); } return true; } }

在数据实体对象上有一个@EntityListeners

 

less

代码解读

复制代码

@Entity @Data @Table(name = "task") @EntityListeners(value = { AddListener.class }) public class TaskDO { @Id private Long id; private String name; private Long tenantId; }

在AddListener里使用  @PrePersist拦截了添加操作,将当前对象的tenantId设置成UserContext的租户ID

 

ini

代码解读

复制代码

public class AddListener { @PrePersist public void preSetTenantId(Object entity) throws Exception { Long tenantId = UserContext.getTenantId(); if (tenantId == null) { return; } Field tenantidField = entity.getClass().getDeclaredField("tenantId"); if (tenantidField == null) { return; } tenantidField.setAccessible(true); tenantidField.set(entity, tenantId); } }

为了更快的模拟出效果我们将Tomcat的最大线程数量设置为1

 

yaml

代码解读

复制代码

server: tomcat: threads: max: 1

然后先调用租户1的获取任务列表,再调用/api/sync,第一次调用/api/sync接口是新增任务的租户ID为1,第二次调用是修改操作租户ID被改成2,完全和线上的BUG一样。

 

bash

代码解读

复制代码

### GET localhost/task X-TENANT-ID: 1 ### POST localhost/api/sync Content-Type: application/json { "id": 8, "name": "赵侠客任务8", "tenantId": 2 }

四、ThreadLocal总结

其实ThreadLocal并不是什么很高明的设计,它只是对Thread对象中一个Map成员变量的封装,说白了你完全可以在Thread对象中定义一个Map,然后通过Thread.currentThread().getMap()来获取这个Map,然后直接通过map.put()保存当前线程的数据,也能达到ThreadLocal一样的效果,而且使用起来更简单方便。我们可以简单看下ThreadLocal的实现:

ThreadLocal的set()方法:

ThreadLocal的set()方法主要通过以下三步:

  • 通过Thread.currentThread()获取当前线程对象

  • 通过 getMap(t)获取当前线程对象中的ThreadLocalMap

  • 将当前ThreadLocal对象当作Key,要设置的value当作值添加到map中

 

scss

代码解读

复制代码

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的getMap()方法

getMap()方法直接返回了Thread对象中的threadLocals, 如果map对象是空会调用createMap()方法将Thread对象的中ThreadLocalMap变量创建一个新的对象

 

javascript

代码解读

复制代码

ThreadLocalMap getMap(Thread t) { return t.threadLocals; } void createMap(Thread t, T firstValue) { t.threadLocals = new ThreadLocalMap(this, firstValue); }

ThreadLocalMap对象

ThreadLocalMap对象并不是JAVA中的Map,而是ThreadLocal中定义的一个简单Map,使用Entry存储MAP中的数据,这里值得注意的是Entry继承了WeakReference是不个弱引用。

弱引用:弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了「只具有弱引用」的对象,不管当前内存空间足够与否,都会回收它的内存

关于这里为什么要使用弱引用,主要是因为使用强应用会导致Entry对象一直不被回收从而产生内存泄露,具体原因网上有很多文章详细分析了,有兴趣可以搜下ThreadLocal为什么使用弱引用

 

scala

代码解读

复制代码

static class ThreadLocalMap { static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } } }

ThreadLocal对象原理是非常简单的,使用后一定要即使的清理,本次BUG解决方法就是在请求结束后调用UserContext.remove()清理当前线程中的保存ThreadLocal对象中的值就好了

 

java

代码解读

复制代码

public class LoginInterceptor implements HandlerInterceptor { @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { UserContext.remove(); } }

五、InheritableThreadLocal

在Thread对象中除了有一个  ThreadLocal.ThreadLocalMap threadLocals对象外还有一个成员变量 ThreadLocal.ThreadLocalMap inheritableThreadLocals,操作它对应的封装类叫InheritableThreadLocal

 

ini

代码解读

复制代码

ThreadLocal.ThreadLocalMap threadLocals = null; ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;

InheritableThreadLocal是继承了是ThreadLocal,只是在getMap()是返回了Thread对象中的inheritableThreadLocals,在createMap()时将ThreadLocalMap对象符给Thread对象中的 inheritableThreadLocals成员变量。

 

scala

代码解读

复制代码

public class InheritableThreadLocal<T> extends ThreadLocal<T> { public InheritableThreadLocal() {} protected T childValue(T parentValue) { return parentValue; } ThreadLocalMap getMap(Thread t) { return t.inheritableThreadLocals; } void createMap(Thread t, T firstValue) { t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue); } }

InheritableThreadLocal和ThreadLocal的区别就是InheritableThreadLocal在子线程中可以获取到主线程中的值,我们看下面的Demo

 

csharp

代码解读

复制代码

public class TestThreadLocal implements Runnable { private static ThreadLocal<String> threadLocal = new ThreadLocal<>(); private static ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); public static void main(String[] args) { threadLocal.set("公众号"); inheritableThreadLocal.set("赵侠客"); Thread tt = new Thread(new TestThreadLocal()); tt.start(); } @Override public void run() { System.out.println("子线程中的值threadLocal:" + threadLocal.get()); System.out.println("子线程中的值inheritableThreadLocal:" + inheritableThreadLocal.get()); } }

▲子线程可获取主线程中的变量

从图中可以看出在子线程中可以通过InheritableThreadLocal获取主线程中的值,但是ThreadLocal获取不到的

InheritableThreadLocal原理

我们看Thread对象的构造方法里有一段如下代码:

 

ini

代码解读

复制代码

if (inheritThreadLocals && parent.inheritableThreadLocals != null) this.inheritableThreadLocals = ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);

在创建线程时,如果父级线程的parent.inheritableThreadLocals不为空,则将父级线程中的inheritableThreadLocals给当前线程,也就是说使用InheritableThreadLocal只能在创建线程时同步父级线程中的值,后面父级线中的值修改是不会同步到子线程的。

我们看下面的代码:在创建子线程后我们在主线程里将inheritableThreadLocal中的值修改

 

csharp

代码解读

复制代码

public class TestThreadLocal implements Runnable { private static ThreadLocal<String> inheritableThreadLocal = new InheritableThreadLocal<>(); public static void main(String[] args) throws InterruptedException { inheritableThreadLocal.set("公众号-赵侠客"); Thread tt = new Thread(new TestThreadLocal()); tt.start(); inheritableThreadLocal.set("掘金-赵侠客"); System.out.println("主线程inheritableThreadLocal:"+inheritableThreadLocal.get()); tt.join(); } @Override public void run() { System.out.println("子线程inheritableThreadLocal:" + inheritableThreadLocal.get()); } }

▲主线程修改变量子线程不会更新

可以看出主线程中修改了InheritableThreadLocal中的值,在子线程是不会更新的,获取的还是老的值。

那么有没有什么破解之法呢?当然有了,这时就轮到我们TransmittableThreadLocal登场了,TransmittableThreadLocal是阿里开源的一个框架指在解决InheritableThreadLocal主线程对象修改无法同步子线程的问题

六、TransmittableThreadLocal

官网地址:github.com/alibaba/tra…

 

xml

代码解读

复制代码

<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.12.6</version> </dependency>

使用方法:

 

csharp

代码解读

复制代码

public class TestThreadLocal implements Runnable { public static ExecutorService executorService = Executors.newFixedThreadPool(1); private static TransmittableThreadLocal<String> transmittableThreadLocal = new TransmittableThreadLocal<>(); public static void main(String[] args) { transmittableThreadLocal.set("公众号-赵侠客"); Runnable task = new TestThreadLocal(); //首次提交任务 executorService.submit(TtlRunnable.get(task)); //主线程修改值后需要再次提交任务 transmittableThreadLocal.set("掘金-赵侠客"); executorService.submit(TtlRunnable.get(task)); } @Override public void run() { System.out.println("子线程transmittableThreadLocal:" + transmittableThreadLocal.get()); } }

▲主线程修改变量子线程中可以获取到更新后的值

可以看出子线程中成功获取到了主线程中修改后的值。

总结

本文从排查一个线程的BUG总结了ThreadLocal的基本用法主注意事项并引出了InheritableThreadLocal和TransmittableThreadLocal,针对这三个类可以做以下总结:

  • ThreadLocal中的变量作用域为当前线程,解决了多线程并发问题
  • ThreadLocal中的变量使用后要及时清理
  • ThreadLocal中Map对象是Key是自己,值为需要保存的对象
  • ThreadLocal子线程无法获取主线程中的值
  • InheritableThreadLocal 解决了子线程中获取主线程值的问题
  • InheritableThreadLocal 在主线程中修改变量后,子线程不会同步
  • TransmittableThreadLocal 解决了线程池复用时主线程变量修改同步子线程的问题
  • 7
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值