🐻大家好,我是木木熊
🌍️公众号:「程序员木木熊 」
本文以学习交流和分享为目的,如有不正确的地方,欢迎大家批评指正!!
前“戏”
一直996写代码的猿猿/媛媛们,你们是否经常出现,以下症状:
- 线上问题找不到日志,日志无法串联,问题定位困难
- 方法调用链路长,数据传递不畅
- 长期使用SimpleDateFormat,导致时间混乱
- 分页插件PageHelper问题频发
长此以往,心力交瘁,难以下班。现在只需要阅读此文,你就能轻松掌握:
- ThreadLocal的实现原理
- 轻松解决多线程环境下的数据隔离问题
- 优雅的实现方法间上下文传递
- 自定义TraceId,查日志不再困难
突然戏精上身,hhh。
MOVE回来,本文将介绍一下ThreadLocald常用的使用场景
,通过源码解析原理
,展示具体代码示例
,总结实践指南
。
大家以后面试遇到ThreadLocal题,再也不用打面试官
啦!
ThreadLocal常见的使用场景
1.解决线程安全问题
对于一些线程不安全的类,如SimpleDateFormat,多线程场景下,使用ThreadLocal为每个线程维护一个独立的副本
,避免出现线程安全的问题。
2.上下文信息传递
接口请求链路中,一些上下文信息
(如用户信息)常常需要在方法间
一直传递。可以把这些信息放在ThreadLocal中,而不必将它们作为方法的参数逐层传递,代码实现会更加优雅。
3.Spring事务实现
Spring通过@Transactional注解来实现数据库事务,为了保证所有的操作都是在同一个连接
上完成的,使用ThreadLocal来存储数据库连接Connection对象。
4.PageHelper分页信息传递
PageHelper.startPage方法,将分页信息存储在ThreadLocal常量LOCAL_PAGE
中,分页拦截器PageInterceptor
基于LOCAL_PAGE判断是否执行分页和组装分页SQL,执行完成后清除LOCAL_PAGE。
5.MDC记录TraceId
为了串联一个请求的日志,通常会为请求设置TraceId
,一般通过MDC
来添加TraceId,并打印到日志中,而MDC底层实现也是使用的ThreadLocal。
ThreadLocal源码解析
1.如何初始化
ThreadLocal构造方法为空,没有任何逻辑
// 构造方法
public ThreadLocal() {
}
进行set方法
或get方法
调用时,如果判断当前线程中threadLocals
变量为空,就会调用createMap
方法完成初始化。
createMap方法的逻辑也比较简单,即为当前线程成员变量threadLocals赋值。
void createMap(Thread t, T firstValue) {
//初始化ThreadLocalMap
t.threadLocals = new ThreadLocalMap(this, firstValue);
}
2.核心类ThreadLocalMap
ThreadLocalMap是ThreadLocal的一个静态内部类,其本质就是一个哈希表
。
哈希表的key
为ThreadLocal,用来为某一个线程,存储不同类型的ThreadLocal值。
哈希表的value
为使用者设置的具体对象值。
每个线程都独立持有
自己的ThreadLocalMap,即成员变量threadLocals,通过上文提到的createMap
方法进行初始化。
//ThreadLocalMap
static class ThreadLocalMap {
private static final int INITIAL_CAPACITY = 16;
//Hash表头数组
private Entry[] table;
private int size = 0;
private int threshold;
...
//map的set方法
private void set(ThreadLocal<?> key, Object value {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
...
}
//map的get方法
private Entry getEntry(ThreadLocal<?> key) {
int i = key.threadLocalHashCode
& (table.length - 1);
Entry e = table[i];
...
}
}
这里有两点需要注意
- ThreadLocalMap是存储在Thread中的,即成员变量threadLocals
- ThreadLocalMap的key不是线程对象,而是所使用的ThradLocal对象,即方法中的this
这两点是实现ThreadLocal线程隔离和ThradLocalMap随线程销毁而销毁的关键。
ThreadLocalMap的静态内部类Entry继承了WeakReference(弱引用
),若一个对象只被弱引用所引用,那么它将在下一次GC中被回收掉。后续当写一篇JAVA中四种引用的区别。
// Entry继承WeakReference
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
ThreadLocal使用弱引用是为了避免发生内存泄漏
,是ThreadLocal的保护机制。
3.设值,取值和清空
ThreadLocal为了实现线程隔离,所有的操作,其实底层都是基于每个线程自己独有的ThreadLocalMap进行操作,下面的set/get/remove操作,实际都是基于ThreadLocalMap底层的方法进行的。
设值-set方法
,方法在进行set操作时,会有一个对Entry中key的判断,如果为null,会进行清除。在后面的取值set方法和清除remove方法中也有类似的操作,这也是ThreadLocal应对内存泄漏的保护机制。
//ThreadLocal的set方法
public void set(T value) {
// 获取当前线程
Thread t = Thread.currentThread();
// 获取当前线程的 ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
map.set(this, value);
} else {
//当前线程的ThreadLocalMap初始化
createMap(t, value);
}
}
//ThreadLocalMap的set方法
private void set(ThreadLocal<?> key, Object value {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
...
for (Entry e = tab[i]; e != null; e = tab[i = nextIndex(i, len)]) {
ThreadLocal<?> k = e.get();
// k == key 把值设置到Entry的value中
if (k == key) {
e.value = value;
return;
}
// 这里的操作是在ThreadLocal被回收是,避免产生内存泄漏
if (k == null) {
//清除Key为null的数据
replaceStaleEntry(key, value, i);
return;
}
}
}
取值-get方法
,这里有一段逻辑getEntryAfterMiss
,也是对key为null的数据进行清除操作。
// ThreadLocal的get方法
public T get() {
Thread t = Thread.currentThread();
//获取当前线程的ThreadLocalMap
ThreadLocalMap map = getMap(t);
if (map != null) {
//ThreadLocalMap的getEntry方法进行取值
ThreadLocalMap.Entry e = map.getEntry(this);
if (e != null) {
@SuppressWarnings("unchecked")
T result = (T)e.value;
return result;
}
}
//map为null时,初始化map,并最终初始化value值为null
return setInitialValue();
}
//ThreadLocalMap的getEntry方法
private Entry getEntry(ThreadLocal<?> key) {
//正常的取值操作
int i = key.threadLocalHashCode & (table.length - 1);
Entry e = table[i];
if (e != null && e.get() == key)
return e;
else
//未取到值时,进行后续清理操作等
return getEntryAfterMiss(key, i, e);
}
清理-remove方法
,Entry的clear方法,把弱引用置为null,后续的expungeStaleEntry方法同上面一样,也是对key值为null的数据进行清理,防止内存泄漏。
private void remove(ThreadLocal<?> key) {
Entry[] tab = table;
int len = tab.length;
int i = key.threadLocalHashCode & (len-1);
for (Entry e = tab[i];
e != null;
e = tab[i = nextIndex(i, len)]) {
if (e.get() == key) {
//去掉弱应用的引用
e.clear();
expungeStaleEntry(i);
return;
}
}
}
//Reference类的clear操作
public void clear() {
this.referent = null;
}
4.ThreadLocal内存泄漏分析
下图大致描绘了ThreadLocal的对象内存关系(可能不够严谨)
先说一下ThreadLocal导致内存泄漏需要满足的三个条件:
- ThreadLocal被回收,即使用完后,且只存在弱引用时被GC
- 线程被复用,如线程池
- 未再调用set/get/remove方法
我们来描述一下这个过程:
- 在方法中new了一个局部变量ThreadLocal对象,使用完后,跳出方法,相当于失去强引用。后续GC中,因为只存在弱引用key,改对象会被回收。
- ThreadLocal被回收,但是因为Thread线程复用,依然持有对ThreadLocalMap的引用,之前ThradLocal对应的Entry依然被引用,只是它的key已经变成null,value还是之前的值,这些key为null的Entry节点的value无法被访问。
- 如果线程迟迟不结束或者一直被复用,那么这些value会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,导致value永远不会回收。
ThreadLocal内存泄漏的根源是ThreadLocalMap的生命周期跟Thread一样长,如果没有手动删除对应key的value就会导致内存泄漏,而不是因为弱引用。
因此,使用ThreadLocal,谨记使用完后一定要进行remove操作,进行清除,避免内存泄漏的发生。并且如果不及时清理,除了内存泄漏,更严重的是导致业务数据的错乱,进而出现莫名奇妙的bug。
ThreadLocal代码实践
1.存储用户登录信息,上下文传递
通常配合鉴权切面使用,在通过token获取到用户信息后,存储到ThreadLocal中,后续只需要通过ThreadLocal静态常量就可以方便获取登录用户信息,而不需要在方法和类之间传递。具体实现如下
LoginUserContext类,持有ThreadLocal常量,并提供静态get/set/clear方法,方便调用
// 持有登录信息的类
public class LoginUserContext {
// ThreadLocal静态常量
public static final ThreadLocal<LoginUser> LOCAL_USER = new ThreadLocal<>();
//设置用户信息
public static void setUser(LoginUser loginUser) {
LOCAL_USER.set(loginUser);
}
//获取用户信息
public static LoginUser getUser() {
return LOCAL_USER.get();
}
//清除用户信息
public static void clear() {
LOCAL_USER.remove();
}
}
鉴权切面,执行完成后finally中清除登录用户信息
@Aspect
@Component
public class PermissionAspect {
// 对所有的Controller进行拦截
@Pointcut("execution(* *..*Controller.*(..))")
public void controllerAspect() {
}
@Around("controllerAspect()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Object ret = null;
try {
//获取登录用户信息
LoginUser loginUser = getLoginUser();
if (loginUser == null) {
throw new RuntimeException("token已过期或未登录");
}
//用户信息设置到ThreadLocal
LoginUserContext.setUser(loginUser);
//执行业务逻辑
ret = joinPoint.proceed();
} finally {
//清理用户信息ThreadLocal
LoginUserContext.clear();
}
return ret;
}
//根据token获取登录用户信息
private LoginUser getLoginUser() {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String accessToken = request.getHeader("token");
if (StringUtils.isBlank(accessToken)) {
return null;
}
//...此处逻辑省略
return new LoginUser();
}
}
业务方法在调用时,只需要通过LoginUserContext的静态方法就可以获取到登录用户信息
@Service
public class OrderLogic {
public void createOrder(){
//获取用户信息
LoginUser loginUser = LoginUserContext.getUser();
//业务逻辑...
}
}
2.MDC存储TraceId,串联日志
MDC底层实现是一个记录Map<String,String>的ThreadLocal,不同key值都可以放在MDC中。
public class LogbackMDCAdapter implements MDCAdapter {
final ThreadLocal<Map<String, String>> copyOnThreadLocal = new ThreadLocal();
...
}
如果想要串联请求日志,需要设计一个TraceId,通过拦截器或者Filter的方式,设置到MDC中,配置对应的日志格式,让日志打印对应的traceId,这样我们就能通过TraceId完整跟踪一次请求的日志。
public class WebAuthInterceptor extends HandlerInterceptorAdapter {
@Autowired
private WebUserUtil webUserUtil;
@Override
public boolean preHandle(...)
//生成和设置traceId
MDC.put("traceId",traceId);
}
@Override
public void afterCompletion(...)
//清除traceId
MDC.clear();
}
}
切记拦截器的后置逻辑需要清除TraceId
日志格式配置,%X{traceId}
来获取MDC中的traceId,注意与put时key的名字保持一致
//此处省略了其他格式配置
<property name="PATTERN" value="...[%X{traceId}]..."/>
3.解决SimpleDateFormat线程安全问题
把SimpleDateFormat设置到ThreadLocal中,每个线程保留自己的副本,实现线程隔离,保证线程安全
public class ThreadLocalDateFormat {
private static final ThreadLocal<SimpleDateFormat> dateFormatHolder =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
public static SimpleDateFormat getDateFormat() {
return dateFormatHolder.get();
}
}
// 使用方法
SimpleDateFormat dateFormat = ThreadLocalDateFormat.getThreadLocalDateFormat();
String formattedDate = dateFormat.format(new Date());
当然,也可以通过使用DateTimeFormatter,DateTimeFormatter是Java8引入的,它是不可变的且线程安全的。
4.PageHelper存储分页信息
pageHelper的实现也使用到了ThreadLocal,调用startPage是把分页信息存储到LOCAL_PAGE中,在对应SQL上拼接分页信息,执行完SQL的逻辑后,会把分页信息clear掉。
public abstract class PageMethod {
protected static final ThreadLocal<Page> LOCAL_PAGE =
new ThreadLocal<Page>();
}
PageHelper使用不当极易导致bug,详见我的另一篇文章介绍一次排查PageHelper的坑爹问题,
《坑爹啊,注释无用代码竟会导致bug!又被PageHelper坑了
ThreadLocal最佳实践
1.使用完后一定要显示的进行remove
每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。
所以,使用ThreadLocal就跟加锁完要解锁
一样,用完就清理。
利用ThreadLocal来实现的一些组件和工具,也要按照这个最佳实践,如PageHelper和MDC等。
2.把ThreadLocal设置为静态常量
把ThreadLocal设置成静态常量,并提供专门对外使用的静态方法set/get/clear,这样能避免重复创建,且使用更加方便。注意,此条恰好构成了导致内存泄漏的条件,必须配和第一条使用
。
以上就是木木熊,对于ThreadLocal的简单介绍。部分问题并没有进行深入探究,如果大家有什么疑问和建议,欢迎评论区讨论~~
欢迎大家点赞-评论-关注,另外也可以关注公众号【程序员木木熊】,了解更多后端技术知识!!
微信公众号海量Java、架构、面试、算法资料免费送~
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。