生产问题一则:ThreadLocal使用不当导致的内存泄露

Posted 北亮bl

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了生产问题一则:ThreadLocal使用不当导致的内存泄露相关的知识,希望对你有一定的参考价值。

先简单介绍一下 ThreadLocal,它是一个线程级的数据存储对象,生命周期是从一个线程的创建到该线程销毁。ThreadLocal里的数据,只能被所持有的线程读取,所以是线程安全的(前提是该数据不是多线程共享的)。
ThreadLocal的get、set、remove等方法,其实是对Thread.threadLocals字段的封装,而Thread.threadLocals的类型是ThreadLocalMap,它维护了一个key/value键值对,
ThreadLocalMap.set方法简略代码如下:

// 注意这是缩减版本,不是原始版本
private Entry[] table;		// 数组数据,index为key的hash值
private int threshold; 
private void set(ThreadLocal<?> key, Object value) {
    Entry[] tab = this.table; 			
    int i = key.threadLocalHashCode & (tab.length-1); // 计算hash,存入数组指定位置
    tab[i] = new Entry(key, value);
}
static class Entry extends WeakReference<ThreadLocal<?>> {
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

从代码可以看到,ThreadLocalMap存储的数据Entry有key和value,key是WeakReference弱引用的数据,value就是我们往里面放的内容。
注:弱引用,就是在GC回收时,这个数据如果只有弱引用,没有强引用,依旧会被回收,下面贴一段弱引用被回收的测试代码:

@Test
public void gcTest() {
    //Dto dto = new Dto(123, "abc"); // 不能用局部变量定义,会有强引用
    WeakReference weak = new WeakReference<>(new Dto(123, "abc"));
    Assert.notNull(weak.get(), "还没有GC,Dto对象存在,不为null");
    System.gc();  // 强制GC回收
    Assert.isNull(weak.get(), "Dto对象被回收了,所以是null");
}

由于ThreadLocalMap的生命周期是线程销毁,在Web服务中通常使用的是线程池,所以基本上都不会销毁,数据无法自动清理(注:ThreadLocal的get、set、remove等方法会对数据的key为null的进行清理,set只是部分清理),从而导致泄露。


言归正传,昨天,群里收到系统内存达到80%的告警,因为高峰期,且系统能支撑,就只进行监控。 在高峰期过后,上去打了一个内存dump,命令如下:

sudo -u nobody jmap -dump:format=b,file=dump.hprof xxx
注:命令行里的xxx就是Java进程id,打dump可能出现的问题:

  • Error attaching to core file: cannot open binary file 这是要用进程启动用户执行命令,如 sudo -u nobody
  • Error attaching to core file: debuggee is 32 bit, use 32 bit java for debugger 这是要用64位,如 jmap -J-d64

dump打好后,zip压缩再拖回来,启动Windows环境下,JDK里的 jvisualvm命令,点菜单文件->装入,加载这个堆dump文件。
等加载完成后,点击界面右侧的:最大的对象->查找,会返回内存占用最大的20个对象,这个过程需要几分钟,比较慢,结果如下图:

从上图看到,在ThreadLocalMap里存储了42M的对象,随便点开一个java.lang.ThreadLocal$ThreadLocalMap$Entry#1335,再依次展开该对象的属性,可以看到,在这个Map里存储了一堆的ChildRequestDto对象,如图:

分析到这里,打开对应项目的代码,查找ChildRequestDto类,以及关联的ThreadLocal代码,定位到了问题所在:

@Around("controller()")
public Object doControllerAround(ProceedingJoinPoint pjp) throws Throwable {
    Object result = null;
    try {
        JoinPointExexute joinPointExexute = new JoinPointExexute(pjp).invoke();
        result = joinPointExexute.getResult();
    } catch (Exception e) {
        // 业务逻辑
        throw e;
    }
    ApiRequestContext.clear(); // 清理ThreadLocal数据
    return result;
}

上面的代码,在抛出异常的时候,会跳出方法,没有清理数据,从而导致的内存泄露的可能。
而且,这个代码,因为没有清理,也会导致数据泄露或篡改可能,比如:

  • 张三登录系统,处理完业务后退出,他的线程放回线程池
  • 李四登录系统,系统从线程池取出张三的线程为李四服务,这样代码处理不当时,李四就可能看到张三的数据,甚至修改张三的数据

解决方法就是把clear放在finally里了。
更好的做法,是把ThreadLocal的数据清理,放在HandlerInterceptor.afterCompletion里,或者Filter里


进一步思考,在常规的业务代码里:
  • 不推荐使用Aspect这样的aop能力,容易与框架层代码逻辑冲突,也影响业务问题的排查;
  • 也不推荐使用ThreadLocal这样的上下文数据传输能力,影响问题排查,也影响单元测试。
  • aop或ThreadLocal一般建议用在框架层,做一些底层支持和通用的业务支持,如果业务层需要传输数据,可以使用HandlerMethodArgumentResolver 给Controller添加参数。

以上是关于生产问题一则:ThreadLocal使用不当导致的内存泄露的主要内容,如果未能解决你的问题,请参考以下文章

生产问题一则:ThreadLocal使用不当导致的内存泄露

生产问题一则:ThreadLocal使用不当导致的内存泄露

生产排查 | MySQL主从同步时报错1864之slave_pending_jobs_size_max设置不当导致的主从失效案例

Java Review - 线程池中使用ThreadLocal不当导致的内存泄漏案例&源码分析

Java Review - 线程池中使用ThreadLocal不当导致的内存泄漏案例&源码分析

K8S集群事故分析一则:普通用户域名不当配置却导致集群Ingress崩溃