头图
🐻大家好,我是木木熊
🌍️公众号:「程序员木木熊 」
本文以学习交流和分享为目的,如有不正确的地方,欢迎大家批评指正!!

前“戏”

一直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导致内存泄漏需要满足的三个条件:

  1. ThreadLocal被回收,即使用完后,且只存在弱引用时被GC
  2. 线程被复用,如线程池
  3. 未再调用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、架构、面试、算法资料免费送~


程序员木木熊
1 声望0 粉丝