作者 | 雷架
来源 | 爱笑的架构师(ID:DancingOnYourCode)
组内来了一个实习生,看这小伙子春光满面、精神抖擞、头发微少,我心头一喜:绝对是个潜力股。于是我找经理申请亲自来带他,为了帮助小伙子快速成长,我给他分了一个需求,这不需求刚上线几天就出网上问题了,后台监控服务发现内存一直在缓慢上升,初步怀疑是内存泄露。
把实习生的PR都找出来仔细 review,果然发现问题了。由于公司内部代码是保密的,这里简单写一个 demo 还原场景(忽略代码风格问题)。
public class ThreadPoolDemo {private static final ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(5, 5, 1, TimeUnit.MINUTES, new LinkedBlockingQueue());public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 100; ++i) { poolExecutor.execute(new Runnable() {@Overridepublic void run() { ThreadLocal threadLocal = new ThreadLocal(); threadLocal.set(new BigObject());// 其他业务代码 } }); Thread.sleep(1000); } }static class BigObject {// 100Mprivate byte[] bytes = new byte[100 * 1024 * 1024]; }}
代码分析:
创建一个核心线程数和最大线程数都为10的线程池,保证线程池里一直会有10个线程在运行。
使用for循环向线程池中提交了100个任务。
定义了一个 ThreadLoca 类型的变量,Value 类型是大对象。
每个任务会向 threadLocal 变量里塞一个大对象,然后执行其它业务逻辑。
由于没有调用线程池的 shutdown 方法,线程池里的线程还是会在运行。
乍一看这代码好像没有什么问题,那为什么会导致服务 GC 后内存还高居不下呢?
代码中给 threadLocal 赋值了一个大的对象,但是执行完业务逻辑后没有调用remove 方法,最后导致线程池中10个线程的 threadLocals 变量中包含的大对象没有被释放掉,出现了内存泄露。
大家说说这样的实习生还能留不?
实习生说他以为线程任务结束了 threadLocal 赋值的对象会被 JVM 垃圾回收,很疑惑为什么会出现内存泄露。作为师傅我肯定要给他把原理讲透呀。
ThreadLocal 类提供 set/get 方法存储和获取 value 值,但实际上 ThreadLocal 类并不存储value值,真正存储是靠ThreadLocalMap这个类,ThreadLocalMap 是 ThreadLocal 的一个静态内部类,它的 key 是ThreadLocal 实例对象,value 是任意 Object 对象。
ThreadLocalMap 类的定义:
static class ThreadLocalMap {// 定义一个table数组,存储多个threadLocal对象及其value值 private Entry[] table; ThreadLocalMap(ThreadLocal firstKey, Object firstValue) { table = new Entry[INITIAL_CAPACITY]; int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1); table[i] = new Entry(firstKey, firstValue); size = 1; setThreshold(INITIAL_CAPACITY); }// 定义一个Entry类,key是一个弱引用的ThreadLocal对象// value是任意对象static class Entry extends WeakReference> {/** The value associated with this ThreadLocal. */Object value; Entry(ThreadLocal k, Object v) {super(k); value = v; } }// 省略其他}
下面解释一下常见的几种引用概念。
1、强引用一直活着:类似“Object obj=new Object()”这类的引用,只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象实例。
2、弱引用回收就会死亡:被弱引用关联的对象实例只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。在 JDK 1.2之后,提供了 WeakReference 类来实现弱引用。
3、软引用有一次活的机会:软引用关联着的对象,在系统将要发生内存溢出异常之前,将会把这些对象实例列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2之后,提供了 SoftReference 类来实现软引用。
4、虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象实例是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象实例被收集器回收时收到一个系统通知。在 JDK 1.2之后,提供了 PhantomReference 类来实现虚引用。
从表面上看内存泄漏的根源在于使用了弱引用,但是另一个问题也同样值得思考:为什么 ThreadLocalMap 使用弱引用而不是强引用?
翻看官网文档的说法:
To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys.
为了处理非常大和长期的用途,哈希表条目使用weakreference作为键。
分两种情况讨论:
(1)key 使用强引用
引用 ThreadLocal 的对象被回收了,但是 ThreadLocalMap 还持有ThreadLocal 的强引用,如果没有手动删除,ThreadLocal 不会被回收,导致Entry 内存泄漏。
(2)key 使用弱引
引用 ThreadLocal 的对象被回收了,由于 ThreadLocalMap 持有ThreadLocal 的弱引用,即使没有手动删除,ThreadLocal 也会被回收。value在下一次 ThreadLocalMap 调用 set、get、remove 的时候会被清除。
比较两种情况,我们可以发现:由于 ThreadLocalMap的生命周期跟 Thread一样长,如果都没有手动删除对应 key,都会导致内存泄漏,但是使用弱引用可以多一层保障:弱引用ThreadLocal被清理后 key 为 null,对应的 value 在下一次 ThreadLocalMap 调用 set、get、remove 的时候可能会被清除。
因此,ThreadLocal 内存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟 Thread 一样长,如果没有手动删除对应 key 就会导致内存泄漏,而不是因为弱引用。
通过前面几小节我们分析了 ThreadLocal 的类设计以及内存模型,同时也重点分析了发生内存泄露的条件和特定场景。最后结合项目中的经验给出建议使用ThreadLocal 的场景:
当需要存储线程私有变量的时候。
当需要实现线程安全的变量时。
当需要减少线程资源竞争的时候。
综合上面的分析,我们可以理解 ThreadLocal 内存泄漏的前因后果,那么怎么避免内存泄漏呢?
答案就是:每次使用完 ThreadLocal,建议调用它的 remove() 方法,清除数据。另外需要强调的是并不是所有使用 ThreadLocal 的地方,都要在最后 remove(),因为他们的生命周期可能是需要和项目的生存周期一样长的,所以要进行恰当的选择,以免出现业务逻辑错误!
更多精彩推荐
☞实名羡慕!蚂蚁员工激励达 1376.9 亿,人均能在杭州买套 283 平的房子?
☞一个程序猿学生的心里路程 | 每日趣闻
☞专访华为杨海松:立足合作伙伴价值,构建健康HarmonyOS生态
☞或许,人工智能比你还要老
☞当飞猪遇上 Serverless | 云原生 Talk
☞Harvest遭受闪电贷攻击,黑客通过Curve盗走2300万美元
点分享点点赞点在看