ThreadLocal 在 session 管理中的应用与原理

本贴最后更新于 1475 天前,其中的信息可能已经天翻地覆

hello,大家好,欢迎来到银之庭。我是 Z,一个普通的程序员。今天我们来看下 Java 里 ThreadLocal 相关 API 在 web 应用场景中的应用:用来管理用户 session,以及 ThreadLocal 的底层实现原理。

1. 背景

在我维护的一个 Javaweb 应用中,我看到前人写了个拦截器,在请求开始时校验用户登录态,如果没登录就返回错误(前端会校验这个错误,引导用户登录),如果登录了,会去公司统一的 session 服务中获取用户信息(session 服务内部应该就是把用户登录信息存到 redis 里了),构造一个 session 对象,保存到本地的 ThreadLocal 中,然后在业务处理完成后清空 ThreadLocal。而在业务处理过程中,就可以随时从这个 session 里取用用户信息了,算是个方便开发的小手段。抽象的代码逻辑大概如下,大家也可以从我的 git 仓库里下载完整项目代码。

注意:以下代码有大量模拟的逻辑,只是为了演示,线上可千万别这么写!!!

拦截器逻辑:

package top.zhengliwei.threadLocalTest.interceptor;

import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import top.zhengliwei.threadLocalTest.model.RequestContext;
import top.zhengliwei.threadLocalTest.model.UserSession;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class LoginInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        // 线上是从cookie中取的,这个只是演示
        String token = request.getParameter("token");
        if (!hasLogin(token)) {
            // 线上会用response直接写返回值,这里省略
            return false;
        }

        // 调用户服务拿到用户信息,写入requestContext的userSession里,这里的userSession实际上是个threadLocal变量
        RequestContext.setUserSession(getUserInfo(token, request));
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           ModelAndView modelAndView) {
        // 处理完成后删除userSession
        RequestContext.deleteUserSession();
    }

    // 根据token校验是否登录,也是调用公司统一的用户登录服务校验的,这里直接返回true
    public boolean hasLogin(String token) {
        return true;
    }

    // 线上是根据token查询用户服务,这里为了方便直接从请求参数里读取用户信息
    private UserSession getUserInfo(String token, HttpServletRequest request) {
        String userId = request.getParameter("userId");
        String userName = request.getParameter("userName");
        return UserSession.builder().userId(Integer.valueOf(userId)).userName(userName).build();
    }
}

注册拦截器:

package top.zhengliwei.threadLocalTest.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import top.zhengliwei.threadLocalTest.interceptor.LoginInterceptor;

@Configuration
public class InterceptorConfig implements WebMvcConfigurer {
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        InterceptorRegistration registration = registry.addInterceptor(new LoginInterceptor());
        registration.addPathPatterns("/**");
    }
}

UserSession 类

package top.zhengliwei.threadLocalTest.model;

import lombok.Builder;
import lombok.Data;

@Data
@Builder
public class UserSession {
    private Integer userId;
    private String userName;
}

RequestContext 类

package top.zhengliwei.threadLocalTest.model;

public class RequestContext {
    private static final ThreadLocal<UserSession> USER_SESSION = new ThreadLocal<>();

    public static void setUserSession(UserSession userSession) {
        USER_SESSION.set(userSession);
    }

    public static UserSession getUserSession() {
        return USER_SESSION.get();
    }

    public static void deleteUserSession() {
        USER_SESSION.remove();
    }
}

示例接口,在接口中使用 userSession:

package top.zhengliwei.threadLocalTest.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import top.zhengliwei.threadLocalTest.model.RequestContext;

@RestController
@RequestMapping
public class DemoController {

    @RequestMapping("/hello")
    public String sayHello() {
        return "hello: " + RequestContext.getUserSession().getUserName();
    }
}

项目跑起来后,访问本地的 http://127.0.0.1:9981/hello?token=a&userId=1&userName=a 接口,正常的话就会调用上面的 hello 接口,返回 hello: a 了,如下:

image.png

那么问题来了,为什么要用 ThreadLocal 对象保存 session 对象呢?能不能直接用个全局变量?

2. ThreadLocal 的作用

上面的问题答案,其实是不能用全局变量的,threadLocal 还是有作用的。先从原理上分析一下,SpringBoot 启动时,会启动 tomcat 或 netty 作为应用服务器进程,而不管是 tomcat 还是 netty 接收客户端请求都是用的 IO 多路复用模型,也就是会有个工作线程池,当同时有多个请求过来时,是可能有多个工作线程在运行的,而这些工作线程是共用 jvm 里的堆内存的(但实际上,在我们这个例子里,RequestContext 并没有实例化,我们是直接用它的类变量的,而类变量是和类信息一起存在方法区里的,当然,方法区也是线程间共享的),自然会访问到同一个全局变量,这时,如果两个线程并发执行顺序不合适,就可能导致线程 A 读到了线程 B 写入全局变量的用户信息,造成业务逻辑出错。下面,我们来验证一下,先写个不用 ThreadLocal 的 RequestContext 实现:

package top.zhengliwei.threadLocalTest.model;

public class BadRequestContext {
    private static UserSession USER_SESSION;

    public static void setUserSession(UserSession userSession) {
        USER_SESSION = userSession;
    }

    public static UserSession getUserSession() {
        return USER_SESSION;
    }

    public static void deleteUserSession() {
        USER_SESSION = null;
    }
}

在拦截器里,把用户信息写入这个新的 RequestContext 实现里:

          //RequestContext.setUserSession(getUserInfo(token, request));
          BadRequestContext.setUserSession(getUserInfo(token, request));

顺便可以把 postHandle 方法里的删除 userSession 的代码注释掉,防止影响我们的验证:

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
                           ModelAndView modelAndView) {
        // 处理完成后删除userSession
//        RequestContext.deleteUserSession();
    }

最后,为了方便验证,在 controller 里休眠 5s,模拟处理请求时间比较长的情况:

@RestController
@RequestMapping
public class DemoController {

    @RequestMapping("/hello")
    public String sayHello() throws InterruptedException {
//        return "hello: " + RequestContext.getUserSession().getUserName();
        Thread.sleep(5000);
        return "hello: " + BadRequestContext.getUserSession().getUserName();
    }
}

重新启动项目,在浏览器里开两个页面,先访问 http://127.0.0.1:9981/hello?token=a&userId=1&userName=a,再访问 http://127.0.0.1:9981/hello?token=a&userId=2&userName=b,只要两次访问时间不超过 5s,就会发现,第一次请求返回了 hello: b,这就证明,出现了两个线程并发操作全局变量导致的并发问题了,如下:

image.png

而 ThreadLocal 维护的对象保证的语义是:该对象和当前线程绑定,只有本线程可以读写该对象,其他线程要读写 ThreadLocal 类型的对象,实际上是新建了个对象来和这个线程绑定。这样,就防止了多线程并发操作同一个对象的问题了。

下面,我们来看下 ThreadLocal 内部的实现原理。

3. ThreadLocal 的实现

点击 RequestContext 代码里的 USER_SESSION.set() 方法即可进到 ThreadLocal 的源代码里。我把 setgetremove 方法的代码贴上来方便大家查看:

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

    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

     public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null)
             m.remove(this);
     }

可以看到,这三个方法都是操作当前 thread 对象的一个 ThreadLocalMap 类型的 map,而 ThreadLocal 对象本身是不存储任何外部可修改变量的,这也解释了为什么我们可以安全地多线程操作 ThreadLocal 对象,因为它只负责操作数据,不负责存储数据,数据是在每个 thread 对象里存储的,自然没有并发安全问题。

下面我们来看看 ThreadLocalMap 类,这个类是 ThreadLocal 的静态内部类,可以看作是个简化版的 hashmap,它内部定义了 Entry 类,并维护了 Entry 的数组,entry 的 key 的 hash 值经过映射后作为数组的下标,如果有 hash 冲突,直接向后取第一个为空的位置插入,算是线性探测再散列的解决方案。

4. 为什么是弱引用

如果我们注意观察 Entry 类,会发现它继承了 WeakReference 类,来持有一个 ThreadLocal 对象的弱引用作为 key,为什么这里要用弱引用呢?我们来思考一下,假如这里用强引用的话,如果我们的业务代码已经释放了 ThreadLocal 对象的引用,那么这个 ThreadLocal 对象能否被 GC 回收呢?答案是不能的,因为只要当前线程还存活,那么 thread 对象就会存在,thread 对象会持有 ThreadLocalMap 的引用,而 ThreadLocalMap 又持有内部每个 Entry 的引用,而 Entry 又持有前面的 ThreadLocal 对象的引用(作为 key),有这样一条引用链存在,GC 自然不会回收 ThreadLocal 对象,但从业务代码来看,我们已经没有任何方法可以再次访问到前面的 ThreadLocal 对象了(我们能不能直接通过 Thread 的 API 操作线程的 ThreadLocalMap 对象呢?实际上是不能的,因为线程的 threadLocalMap 属性是 protected 的,只有 JDK 内部代码能直接操作它,那么有什么办法能间接操作它吗?还是有的,比如我们再定义一个 ThreadLocal 对象出来,通过这个 ThreadLocal 对象间接操作当前线程的 threadLocalMap 对象,这个操作下面会介绍使用场景),它不能被回收,就可以看作是个内存泄露的 bug。而使用弱引用的话,只要业务代码里释放了 ThreadLocal 对象的引用,那么就只存在 Entry 来的一个弱引用,这时 GC 就会选择回收这个对象。

5. 关于内存泄露

网上有很多关于 ThreadLocal 内存泄露的文章,但我看完后还是有点疑问,经过自己仔细地思考才大概想清楚。下面我把我的思考过程记录下来,供大家参考。首先要明确的一点是,所谓内存泄露,是指有个对象,我们已经无法访问它了,但它不能被 GC 回收,因为存在某种强引用关系在引用它。而在 ThreadLocal 里,如果存在这种对象,一定是 Entry 的 value 对象,因为 key 对象是弱引用,是可以被回收的。那么,什么情况下 value 会成为被泄露的对象呢?假设,我们业务代码里清除了对 ThreadLocal 对象的引用,但在清除之前没有调用 remove 方法,这时,entry 的 key(也就是 ThreadLocal 对象)只有一个弱引用存在,可以被清除,但 value 对象有来自 entry 的强引用,因此不能被清除。这时就有可能出现内存泄露了,之所以说可能,是因为 JDK 开发者考虑到了这一点,在 ThreadLocal 的 set, get, remove 方法中检查了当前 thread 对象的 ThreadLocalMap 中是否有 key 为 null 的 entry 存在,如果有,则清理这些 entry。我认为,这是 JDK 开发者提供的一个默认兜底措施,如果业务中先后用了两个 ThreadLocal 对象的话,假如第一个没有执行 remove 直接清除引用,只要后续调用了第二个 ThreaadLocal 对象的 get 或 set 或 remove 方法,还是有机会修复第一个错误操作导致的内存泄露问题的(JDK 开发者真是为业务开发者操碎了心啊)。到这,我认为,要用 ThreadLocal 构造出一个内存泄露的场景其实挺难的,需要:

  1. 创建 ThreadLocal 对象,并 set 一个值;
  2. 直接清除这个 ThreadLocal 对象的引用,在这之前不调用 remove 方法;
  3. 确保线程不被销毁,因为销毁线程时,从线程出发引用的 ThreadLocalMap,entry 和 value 自然都会被 GC 回收;
  4. 确保后续不会再使用 ThreadLocal 对象,因为,如果使用的话,在 get,set 等方法内,会尝试检查当前线程的 ThreadLocalMap 里,key 为 null 的 entry,清理掉(这里需要注意的是,由于我们已经没有了上一个 ThreadLocal 对象的引用,所以不再能调用它的 get,set 等方法了,所以,假如我们不小心没有调用 remove 方法,直接清除了 ThreadLocal 对象的引用,可能需要再建个 ThreadLocal 对象,调用一下 get,set 方法,来清除上一个 ThreadLocal 对象关联的 value 对象。。。开个玩笑)。

综上,我觉得,ThreadLocal 的内存泄露问题,更像是个理论上的问题,实际出现的概率不大,除非程序员特别不走心。。。

6. 使用建议

虽然我觉得 ThreadLocal 使用不当,造成内存泄露的情况不多见,不过,还是给大家提供两个 ThreadLocal 使用上的建议吧,这样使用 ThreadLocal 会更正规:

  1. 在确定不再使用 ThreadLocal 对象后,显式地调用 remove 方法。
  2. 把 ThreadLocal 对象定义成 static 的,即定义成类变量,这样,我们会一直持有 ThreadLocal 对象,从根源上断绝了内存泄露的情况(不知道大家有没有注意到,上面讨论的弱引用也好,内存泄露也好,都是在 ThreadLocal 对象会被业务代码释放引用的前提下进行讨论的)。

以上,就是我对 ThreadLocal 的全部认识了,如果有讲的不对的地方,欢迎大家讨论 ~

吾生也有涯,而知也无涯。庄子诚不我欺。

  • Java

    Java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的。Java 技术具有卓越的通用性、高效性、平台移植性和安全性。

    3187 引用 • 8213 回帖
  • 线程
    122 引用 • 111 回帖 • 3 关注
  • Session
    14 引用 • 6 回帖
1 操作
zhengliwei 在 2020-11-07 20:58:08 更新了该帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • wizardforcel

    Controller 的字段要么加锁,要么用 ThreadLocal,任何语言的任何 Web 框架都适用。