likes
comments
collection
share

【ThreadLocal】实现原理

作者站长头像
站长
· 阅读数 5

在上期,介绍了ThreadLocal的概述以及基本用法后,我们本期则来研究一下ThreadLocal是怎么实现以及内部工作原理。

ThreadLocal存放位置

首先来了解一下ThreadLocal是存放在内存中的哪个位置的,调用ThreadLocal的set方法时,可以看到一个跟ThreadLocal相似的一个类:ThreadLocalMap【ThreadLocal】实现原理

ThreadLocalMap的底层数据结构

ThreadLocalMap的结构与日常使用的HashMap类似,底层都是使用数组进行存储。唯一不同的点就是底层的数组对象类型为了避免内存泄漏,继承了弱引用。但是在使用不当的情况下,弱引用也无法解决内存泄漏的问题。【ThreadLocal】实现原理除了存储ThreadLocal的Entry数组之外,就只有size以及扩容的阈值了。【ThreadLocal】实现原理整个ThreadLocalMap的内部属性也是比较少的。

hash算法

我们想快速的在数组中找到该元素存放的位置的话,就需要一个公式的存在。传入一个固定的参数,返回的结果也是固定的,那么就再极短的时间内找到存放的位置。无需遍历整个数组,浪费时间了,这也是hash算法出现的原因。在ThreadLocalMap中,使用的算法是:

int index = threadLocalHashCode & (tableLength - 1)

根据ThreadLocal的一个属性threadLocalHashCode数组的长度-1进行与运算,计算出该元素应该存放的位置。由于Entry并非为链式对象,所以计算出来的位置可能已经被占用了,此时ThreadLocal的操作就是从该位置往后找,直至找到一个空的位置进行存放。

ThreadLocalMap的扩容机制

一般来说,ThreadLocal的数量不会太多,要是真的使用过多那就有可能是程序在设计之初就有问题了。不过还是看一下ThreadLocalMap是怎么扩容的。

名词描述

有效的ThreadLocal:指Entry对象引用的ThreadLocal仍未被回收,不是null值

扩容路径

扩容机制由resize方法实现,该方法的唯一入口在set方法中。这个也合理,在新增或重新设置值时发现数组中有效的ThreadLocal数量超过阈值时,就进行扩容。

set() -> 当前大小超过阈值 -> rehash() -> 删除空的Entry对象后,仍超过阈值的四分之三 -> resize()

所以实际上扩容有两个前提

  1. 调用set方法时数组中有效的ThreadLocal数量超过阈值
  2. 清理一波后,数组中有效的ThreadLocal数量仍超过阈值的四分之三

HashMap的阈值75%不一样,ThreadLocalMap阈值为数组大小的三分之二。而且在超过阈值时,会针对数组进行一波清理,要是在清理过后,还是超过阈值的四分之三,才真正进行扩容。

扩容逻辑

【ThreadLocal】实现原理

  1. 创建一个新数组,长度为旧数组的两倍
  2. 遍历旧数组,将有效的ThreadLocal根据hash算法计算出新的位置并存放。
  3. 重新设置阈值以及数量等属性

ThreadLocal的生命周期

ThreadLocal从它的名字就能简单的得出一个结论:

ThreadLocal的生命周期一定是与线程息息相关的

与线程的关系

我们来回顾一下ThreadLocal的存放位置【ThreadLocal】实现原理ThreadLocal间接被线程引用了,所以在引用关系不被打破时,ThreadLocal的生命周期与线程的生命周期是一致的。那么问题就变成了线程的生命周期有多长呢?众所周知,只要线程中的代码片段还在执行的话,那么线程就会一直存在。


上面提到了线程和ThreadLocal是有引用关系的,那么这个引用关系在什么时候被打破了呢?有一个关键点:Entry类继承了弱引用并且ThreadLocal正是被引用的对象所以在每次GC的时候垃圾回收器都会尝试将引用的对象(ThreadLocal)给回收掉。

有关Java中的引用原理可以去我的专栏《Java引用关系》了解一下

弱引用在处理引用对象的回收时,会先去看引用对象是否还在被使用。若是没有在使用,就会在GC的时候被回收了。

总结

ThreadLocal的生命周期分为两个阶段:

  1. 线程还在运行并且线程中变量仍在使用ThreadLocal,那么ThreadLocal就会一直存在
  2. 线程还在运行,但是线程上下文没有使用ThreadLocal了,那么ThreadLocal就会被回收。

在第2点中,虽然ThreadLocal没有被使用并且被垃圾回收器回收掉了,要是没有调用remove方法的话,就会出现内存泄漏的问题了。

内存泄漏问题及解决方案

在ThreadLocal的生命周期中提到了,没有显示调用remove方法时,会造成内存泄漏。那么我们来看一下为什么会产生内存泄漏呢?

问题

先提供一段会产生内存泄漏的问题代码:

public static void main(String[] args) {
    new Thread(() -> {
        while (true) {
            ThreadLocal<int[]> threadLocal = new ThreadLocal<>();
            threadLocal.set(new int[1 * 1024 * 1024]);
            try {
                Thread.sleep(1000L);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }
    }, "ThreadLocal").start();
}

说明一下这段代码的作用:创建了一个新线程,在线程中,每隔1秒创建一个4M大小的数组并把数组放进ThreadLocal中。

产生原因

在代码运行过程中,把内存快照给dump下来然后导入到MAT中打开,我们就能得知ThreadLocal的存活情况了。如何找到线程中的ThreadLocalMap对象呢?先点击工具栏上的小齿轮按钮【ThreadLocal】实现原理,找到对应的线程。【ThreadLocal】实现原理就能在左边信息栏中找到线程中threadLocals属性了,紧接着用内存地址找到对应的ThreadLocalMap对象。【ThreadLocal】实现原理这是运行了一段时间后的ThreadLocalMap对象,可以看到已经存放了许多的Entry对象了。此时,我们随机选一个Entry对象来分析一下。【ThreadLocal】实现原理这是其中的一个Entry对象属性,可以看到value是创建的int数组,但是referent却是null。说明ThreadLocal已经被垃圾回收器回收掉了,但是int数组还存在着。回到之前ThreadLocal的存放位置的图中,此时ThreadLocal已经被回收了,但是Entry对象中还存在着value属性的引用关系,所以Entry对象无法被正常回收,出现了内存泄漏的问题。【ThreadLocal】实现原理初步总结一下:运行过程中,ThreadLocal对象被回收掉了,但是所存储的对象却没有被回收,出现了内存泄漏问题了。


但是,为什么上面的程序运行了很久,都没有出现OOM呢?先说结论,那是因为我们每隔1秒就创建一个ThreadLocal对象,在调用set方法时,ThreadLocal内部条件达到后会进行数组的清理工作,致使程序在运行过程中没有出现OOM。有关内部的清理逻辑,会开一篇新的文章进行说明。

解决方案

解决ThreadLocal的内存泄漏问题解决方案很简单,不再使用的时候及时调用remove方法即可。针对一般Web程序来说,我们都会在拦截器Filter中进行ThreadLocal的初始化以及清空操作,只需要在初始化后,finally中调用remove方法就可以了。

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    ThreadLocal<String> threadLocal = new ThreadLocal<>();
    try {
        chain.doFilter(request, response);
    } finally {
        threadLocal.remove();
    }
}