线程局部变量的实现 ThreadLocal

2,212 阅读8分钟

前言

离职回老家,实在太无聊,于是乎给自己整了一套台式机配置,总价 1W+,本以为机器到位后可以打打游戏,学学技术打发无聊的时光。但是我早已不是从前那个少年了,打 Dota 已经找不到大学时巅峰的自己,当年我一手 SF 真的是打遍天下无敌手......,和朋友打 LOL 又没有精力去学一个新的游戏,贼坑。。。

学技术又不想学,太懒了!!!于是乎,写文章吧...正好年后找工作用得上!今天我们来谈一谈 Java 中存储线程局部变量的类 ThreadLocal

ThreadLocal 介绍

This class provides thread-local variables. These variables differ from their normal counterparts in that each thread that accesses one (via its {@code get} or {@code set} method) has its own, independently initialized copy of the variable. {@code ThreadLocal} instances are typically private static fields in classes that wish to associate state with a thread (e.g., a user ID or Transaction ID)

上面这段是该类的注释:该提供线程局部变量。这些变量不同于它们正常的对应变量,因为每个通过 get()、set() 访问 ThreadLocal 变量的线程都有自己的、独立初始化的变量副本。ThreadLocal 实例通常是类中的私有静态字段,希望将状态与线程关联(例如,用户ID或事务ID)。

简单来说它的作用是作为一个数据结构,可以为每个线程分别存储他们私有的数据。我们可以暂时简单理解为下面这张图(实际上这个图是错的)

后面我们会详细介绍它的设计原理。

常用 API

方法作用
public ThreadLocal()实例化对象
ThreadLocal.withInitial(Supplier<? extends S> supplier )实例化对象并赋予它每个线程初始值
public void set(T value)设置当前线程绑定的变量
public T get()获取当前线程绑定的变量
public void remove()移除当前线程绑定的变量

ThreadLocal 使用场景

Spring 事务管理器

在 Spring 事务实现中,TransactionSynchronizationManager 类中声明了多个 ThreadLocal 类型的成员变量用以将事务执行过程中各种上下文信息绑定到当前线程,包括当前事务连接对象、是否可读、事务名称、隔离级别等

public abstract class TransactionSynchronizationManager {

   private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");

   private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal<>("Transaction synchronizations");

   private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal<>("Current transaction name");

   private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal<>("Current transaction read-only status");

   private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal<>("Current transaction isolation level");

   private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal<>("Actual transaction active");
   //......
  }

SpringMVC 存储上下文 Request 数据

RequestContextHolder 这个类是 SpringMVC 中提供的持有上下文 Request 的一个类,内部实现就是有两个 ThreadLocal 属性去存储请求对象数据。

private static final ThreadLocal<RequestAttributes> requestAttributesHolder =
      new NamedThreadLocal<>("Request attributes");

private static final ThreadLocal<RequestAttributes> inheritableRequestAttributesHolder =
      new NamedInheritableThreadLocal<>("Request context");

以便于我们在业务代码中在没有 HttpServletRequest 对象的位置也可以通过 ThreadLocal 获取请求头等信息,比如我前面一篇关于 OpenFeign 向下游传递 header 的文章就用到了它。

PageHelper 分页的实现

之前流行的分页插件之一 PageHelper 其分页原理也是通过 ThreadLocal 实现,我们使用它进行分页时只需要在代码中调用静态方法

PageHelper.startPage(pageNum,pageSize);

接下来的第一条 SQL 就会自动进行分页,其实原理就是它将分页参数封装到一个 Page 对象中,然后将 Page 放进 ThreadLocal 中以达到 web 环境中多个线程互相分页不影响,后面就是都雷同的 SQL 拼接了。

存储用户身份信息

在很久之前我们用户登录信息的存储通常都是在 Session 中,后来大多是逐渐用 ThreadLocal 去代替从 Session 获取用户登录信息了。首先我们在用户每次请求需要授权的接口时,会让用户携带请求头 token ,后端在拦截器中拿到这个 tokenredis 查询用户信息,或者如果这个 tokenjwt 的话,直接解析它得到用户信息然后放进 ThreadLocal

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
    String header = request.getHeader("x-auth-token");
    //如果你的实现是 token 唯一字符串,从 Redis 拿用户信息
    User user = redisTemplate.opsForValue().get(header);
    //如果你的实现是 token 是jwt,那直接解析 jwt 拿到用户信息
    //.......
    if (user != null) {
        CurrentUser.set(user);
        return true;
    }
    return false;
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    CurrentUser.clear();//请求结束之后不要忘记清除
}

CurrentUser

public class CurrentUser {
    public static final ThreadLocal<User> USER_THREAD_LOCAL = new ThreadLocal<>();

    public static void set(User user){
        USER_THREAD_LOCAL.set(user);
    }

    public static User get(){
        return USER_THREAD_LOCAL.get();
    }

    public static void clear(){
        USER_THREAD_LOCAL.remove();
    }
}

这样我们在任何地方只要使用 CurrentUser.get() 就能轻松获取到当前登录用户。

以上就是几个 ThreadLocal 常见的场景,其核心理念就是利用 ThreadLocal 的线程隔离特性。

ThreadLocal 和 synchronized

值得注意的是 ThreadLocal 在解决线程安全问题上提供了一种不同于传统并发安全的解决思路,传统的 synchronized 或者 Lock 类是出于并发操作时让多个线程排队去访问共享数据,但是这样的弊端就是会造成锁竞争,这是以时间换空间。

ThreadLocal 将这个问题换了一个角度看待,既然并发安全的问题原因是因为多个线程共享一份数据,那么我现在就让每个线程都拥有一份独立数据,它们各自操作自己私有的本地变量,这样就不会有并发安全问题,也没有锁竞争.但是每个线程都要维护一份数据,会有额外的内存开销,这是以空间换时间。

实际项目中我们应该用哪种方式,最终还是取决于业务场景更适合哪一种。

线程隔离的原理

ThreadLocal.set(T value) 源码解读

前面说了一些使用场景,这里我们探究一下 ThreadLocal 是如何实现线程隔离的。这里我们写个极致简单的例子

ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
new Thread(() -> local.set(new User())).start();

这个例子只有两行代码,我们来看 local.set(T value) 方法的源码

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

代码很简单,首先拿到当前线程,然后根据当前线程拿到一个 Map 数据结构,将我们传进来的值设置到这个 Map 中,那么重点就在这个 getMap() 中,查看其源码

ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

我们发现这个代码就更简单了,直接返回了当前线程的一个成员变量,Thread 类中是这样定义的

public class Thread implements Runnable {
    //...
    ThreadLocal.ThreadLocalMap threadLocals = null;
    //...
 }

从这里就可以明白它是如何实现线程隔离的,我们设置的值全都放进了当前线程对象的一个成员变量中存着呢,那当然是线程隔离的,你在哪个线程中去 set(),那就会保存到哪个线程对象的成员变量中。

接着我们再看这行代码 map.set(this, value);,很重要,这里的 this 是什么?是当前的 ThreadLocal<Integer> local; 对象,也就是说我们 set() 的值实际上是以当前方法的调用者 localkey,传入的值为 value 保存起来的键值对。简单的理解为下图

QQ图片20230111210016.png

我们对于 ThreadLocal 的操作其实是对 Thread 的成员变量 threadLocals 进行操作。那么这个时候我们就要改变一下固有的思维,因为在正常的思维中,我们看到这行代码 local.set(new User()); 脑海中浮现的第一印象都是向 local 的成员变量中进行一个数据的赋值,然而在 ThreadLocal 的实现中,这行代码的意思是将 ThreadLocal 作为 key,传入的值作为 value 存入到当前 Thread 对象的一个成员变量中。

ThreadLocalMap

上面我们看到了 Thread 类中的成员变量是 ThreadLocalMap 类型的,ThreadLocal、Thread、ThreadLocalMap 三者的类图关系为

QQ图片20230115192343.png

首先由于我们程序中可能会声明多个 ThreadLocal 对象,那么自然用于存放的数据结构就需要类似集合,需要一个线程可以存储多个以 ThreadLocalkey 数据,考虑到查询的时间复杂度以及各方面综合考虑,Map 结构再适合不过。

ThreadLocalMapThreadLocal 的一个静态内部类,它的内部又声明了一个静态内部类 Entry 来实现 K/V,这一点类似于 HashMap

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

不同的是这里的 Entry 是 弱引用 WeakReference 的子类,那么在了解弱引用之后我们会发现在特定的场景下,如果不这么设计可能造成内存泄漏。

弱引用

在分析内存泄漏之前我们必须知道 Jvm 中几种引用类型以及它们的特点。这里不详细介绍,只说结论

引用类型回收机制
强引用我们程序中声明的对象其引用都是强引用,只要其不指向 null ,GC 时候就不会被回收,即使内存溢出
软引用使用 SoftReference 类构造一个软引用,与强引用的区别是当内存不足,GC 会回收软引用指向的对象
弱引用使用 WeakReference 类构造一个弱引用,与软引用的区别是,只要触发 GC 就会回收弱引用指向的对象

上面的结论都可以通过简单的代码来验证,这里我们主要介绍结论。

内存泄漏

内存泄漏与内存溢出

  • 内存溢出 —— 程序中真的内存不够用了。
  • 内存泄漏 —— 由于代码问题导致程序中本该被释放的内存没有被释放,最终造成 “内存不够用” 的假象。

内存图

这里我们通过上面的两行样例代码。

ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
new Thread(() -> local.set(new User())).start();

结合分析 ThreadLocalMap、Thread、ThreadLocal 的源码可以得到一张完整的内存图。

QQ图片20230112113507.png

值得注意的是我们是没有办法直接声明弱引用的,必须通过 WeakReference 去包裹一个对象持有弱引用,以下面代码为例

WeakReference<User> wr = new WeakReference<>(new User());

它在内存中是这样的

QQ图片20230112112042.png

所以完整的内存图应该能够理解。

为什么需要弱引用

使用反证法,假设我们的 Entry 不用弱引用,那么会出现这样的情况,我们声明出来的 ThreadLocal 对象,如果我们不想用它了或者说在程序中它的生命周期结束了(实际上这种场景很少,一般来说我们的 ThreadLocal 对象都是以 static final 的形式定义在全局,这里只是存在这个可能),想让 GC 回收掉它占用的内存,那么我们只需要让没有引用指向它即可 ,也就是将 1号线 干掉。

但是由于 ThreadLocalMap 里面也有持有我们声明的 ThreadLocal 对象的强引用,如果我们想要回收的话就必须把这里的强引用也干掉,最好的方法是使用 remove() 方法移除。否则就需要干掉线程里面的 ThreadLocal.ThreadLocalMap threadLocals = null; 这个属性,想要干掉这个属性就得等线程销毁,然而实际业务中有的线程是 24h 不间断执行的,也有的线程是位于线程池要被复用的,所以只要有一个线程不销毁,这个 ThreadLocal 对象就不会被回收,这就会产生内存泄漏。

但是如果这里 Entrykey 是弱引用,只要我们将 1号线 干掉,下次 GC 的时候发现这个 ThreadLocal 对象只有一个 2号线 弱引用指向它,就会将它回收掉。

public static void function1() {
    ThreadLocal<User> local = ThreadLocal.withInitial(User::new);
    local.set(new User());
    local.get();
    new Thread(() -> {
        local.set(new User());
        User user = local.get();
        while (true) {
            Thread.sleep(1000);
            System.out.println("测试");
        }
    }).start();
}

上面这段代码如果 Entry 是强引用,当 function1() 结束之后 local 指向的内存不会被回收,如果是弱引用,就会被回收。

remove() 防止内存泄漏

ThreadLocal 为了防止内存泄漏,已经用弱引用帮我们解决了一大隐患,难道使用弱引用就能完全避免内存泄漏吗?并不是,还有一种情况,接着上面的章节,当我们 GC 将弱引用指向的 ThreadLocal 内存回收之后, ThreadLocalMap 里面的 Entrykey 就变成 null 了,这样我们就无法访问到它原先对应的 value,所以这个 value 将不会被回收,这才是实际场景中真正的内存泄漏问题。

所以我们在用完之后一定需要手动的调用 remove() 清除当前线程的局部变量值,也就是将对应的 Entry(K/V) 删掉,这样即使后来 ThreadLocal 对象被回收,也不会造成内存泄漏问题。

值得注意的是我们观察 set()、get() 源码会发现它其实都调用了一个方法

private int expungeStaleEntry(int staleSlot) {
//......
    if (k == null) {
        e.value = null;
        tab[i] = null;
        size--;
    }
//......
}

在每次操作的时候都会判断是否存在 keynull 的键值对,如果存在就会删掉,以此来尽量的避免内存泄漏的问题,那这是不是意味着即使我们不手动 remove() 也可以呢?其实不然,因为实际业务中可能会出现长时间不调用 set()、get() 方法的情况,所以当后面的流程里不再需要使用这个值得时候,手动 remove() 是一个好习惯,也是阿里巴巴规范里面的一个强制规定。

remove() 防止数据错乱

实际 Web 项目中我们很多场景都会用到线程池,当用完之后将线程对象归还到线程池,如果没有 remove() ,下个请求到来,这个线程被复用时发现这个数据已经存在了,就直接拿过来用了,这个问题是很严重的,因为相当于一个线程用了另一个线程的数据,这会造成严重的业务 bug 。

结语

如果这篇文章对你有帮助,记得点赞加关注!你的支持就是我继续创作的动力!