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
了,如下:
那么问题来了,为什么要用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
,这就证明,出现了两个线程并发操作全局变量导致的并发问题了,如下:
而ThreadLocal维护的对象保证的语义是:该对象和当前线程绑定,只有本线程可以读写该对象,其他线程要读写ThreadLocal类型的对象,实际上是新建了个对象来和这个线程绑定。这样,就防止了多线程并发操作同一个对象的问题了。
下面,我们来看下ThreadLocal内部的实现原理。
3. ThreadLocal的实现
点击RequestContext
代码里的USER_SESSION.set()
方法即可进到ThreadLocal的源代码里。我把set
,get
和remove
方法的代码贴上来方便大家查看:
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构造出一个内存泄露的场景其实挺难的,需要:
- 创建ThreadLocal对象,并set一个值;
- 直接清除这个ThreadLocal对象的引用,在这之前不调用remove方法;
- 确保线程不被销毁,因为销毁线程时,从线程出发引用的ThreadLocalMap,entry和value自然都会被GC回收;
- 确保后续不会再使用ThreadLocal对象,因为,如果使用的话,在get,set等方法内,会尝试检查当前线程的ThreadLocalMap里,key为null的entry,清理掉(这里需要注意的是,由于我们已经没有了上一个ThreadLocal对象的引用,所以不再能调用它的get,set等方法了,所以,假如我们不小心没有调用remove方法,直接清除了ThreadLocal对象的引用,可能需要再建个ThreadLocal对象,调用一下get,set方法,来清除上一个ThreadLocal对象关联的value对象。。。开个玩笑)。
综上,我觉得,ThreadLocal的内存泄露问题,更像是个理论上的问题,实际出现的概率不大,除非程序员特别不走心。。。
6. 使用建议
虽然我觉得ThreadLocal使用不当,造成内存泄露的情况不多见,不过,还是给大家提供两个ThreadLocal使用上的建议吧,这样使用ThreadLocal会更正规:
- 在确定不再使用ThreadLocal对象后,显式地调用remove方法。
- 把ThreadLocal对象定义成
static
的,即定义成类变量,这样,我们会一直持有ThreadLocal对象,从根源上断绝了内存泄露的情况(不知道大家有没有注意到,上面讨论的弱引用也好,内存泄露也好,都是在ThreadLocal对象会被业务代码释放引用的前提下进行讨论的)。
以上,就是我对ThreadLocal的全部认识了,如果有讲的不对的地方,欢迎大家讨论~
吾生也有涯,而知也无涯。庄子诚不我欺。