ThreadLocal原理介绍以及内存泄漏分析

ThreadLocal简单介绍

ThreadLocal同ReentrantLock,CyclicBarrier等都属于并发工具类,他们都是为了解决多线程数据一致性问题而出现的。与ReentrantLock不同的是,ThreadLocal采取的是一种以空间换时间的策略。
举个简单的例子,假设现在有100个人填写信息表,可是只有一支笔,为了防止哄抢,以ReentrantLock为代表的锁所使用的思路是,通过控制人员使用笔的顺序,来达到防止哄抢的目的。而ThreadLocal采取的思想则是给每个人发一只笔,这样大家只使用自己手头里的笔,也不就不存在竞争问题了。
正如ThreadLocal其名,ThreadLocal所拥有的变量是线程私有的,既然多个线程同时访问一个共享变量会造成线程安全问题,那么我为什么不给每一个线程分配一个变量,这样就避免了多线程之间的同步,没有了同步,代码的运行所需要的时间就会减少。虽然ThreadLocal的使用可以减少时间上的开销,可是我们也很容易发现其会增大内存空间上的开销。

ThradLocal使用场景

SimpleDateFormat作为一个格式化日期的常用类,却存在着线程不安全的问题,运行下面的代码

public static void main(String[] args)
{
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
    ExecutorService es = Executors.newFixedThreadPool(100);
    for (int i = 0; i < 1000; i++)
    {
        es.execute(()->{
            try
            {
                Date date = sdf.parse("2019-11-13 08:23:" + new Random().nextInt(60));
            } catch (ParseException e)
            {
                e.printStackTrace();
            }
        });
    }
    es.shutdown();
}

会报出如下异常

java.lang.NumberFormatException: For input string: ""
    at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65)

究其原因,在于SimpleDateFormat类内部有一个Calendar对象引用,它用来储存和这个SimpleDateFormat相关的日期信息,而这个对象又是线程共享的且没有做任何同步处理。

有了上面的分析,要解决这些异常也就不是什么难事了。大家首先想到的可能是加锁,即用synchronize或ReeentrantLock把调用parse方法那一部分包起来,但是这种方法在多线程竞争激烈的时候会带来效率问题,代码这里我就不写了。除了加锁,还有一种更好的方法,那便是使用ThreadLocal。

public static void main(String[] args)
{
   ThreadLocal<SimpleDateFormat> threadLocal = new ThreadLocal<>();
   ExecutorService es = Executors.newFixedThreadPool(100);
   for (int i = 0; i < 1000; i++)
   {
       es.execute(()->{
           try
           {
               if(threadLocal.get() == null)
                   threadLocal.set(new SimpleDateFormat("yyyy-MM-dd hh:mm:ss"));
               Date date = threadLocal.get().parse("2019-11-13 08:23:" + new Random().nextInt(60));
           } catch (ParseException e)
           {
               e.printStackTrace();
           }
       });
   }
   es.shutdown();
}

ThreadLocal 可以确保每个线程都可以得到单独的一个 SimpleDateFormat 的对象,那么自然也就不存在竞争问题了。


除了常用的SimpleDateFormat,我们还可以在Spring框架中找到ThreadLocal的身影。

@RestController
public class TestController
{
    @Autowired
    private HttpServletRequest request;
    
    @GetMapping("/hello")
    public String hello()
    {
        String token = request.getHeader("token");
        return token;
    }
}

对于上面的代码,细心的人可能会问,直接把request当作一个成员变量注入,这样所有请求将共享一个request对象,程序肯定会乱套啊。但是当我们运行上述代码的时候,我们会发现程序并没有什么问题,这时为什么呢。原因很简单,spring是一个非常成熟的框架,当我们要注入一个HttpServletRequest对象作为一个成员变量时,它会以ThreadLocal的形式进行注入,这样每个请求的request对象都是不同的。

ThreadLocal原理分析

看了上面的介绍,或许有人不禁要问,ThreadLocal这么强大,那它是怎么实现的呢。其实ThreadLocal与JUC中其他类的最大不同点是,ThreadLocal本身不存储数据,它更像一个工具类,负责变量的维护与获取,就像java.utilCollections类,它本身并不存储任何数据结构,但是可以完成许多数据结构的操作。当我们对ThreadLocal对象进行set操作时,ThreadLocal并没有把那些对象保存在自己这里,而是保存在了调用该方法的Thread对象里

Thread类

Thread类内部有许多成员变量,其中182行声明的ThreadLocal.ThreadLocalMap对象就是用来保存Threadlocal执行set方法时的对象
这样,当调用get方法时,ThreadLocal会去调用该方法的Thread对象里去取之前set的value并返回。看上去一切事那么的完美,可是当一个线程有多个ThreadLocal对象来进行get操作时,我们要怎么才能获取到该ThreadLocal对应的值呢?这还不简单嘛,直接用一个map取维护ThreadLocal和value的映射不就行了。对,java里就是这么做的,只不过这个map跟我们平常所见的HashMap、TreeMap不太一样,是一个被称为ThreadLocalMap的Map,这个类被定义为ThreadLocal的一个内部静态类,我们可以把它当成一个HashMap来看待(如果仔细阅读其源码我们会发现其处理Hash冲突所采用的是线性探测法)

其三者UML关系如图所示,其中Entry对象代表了ThreadLocalMap里的一个键值对。


ThreadLocal的get方法源码如下

public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

首先第二行获取执行该方法的当前线程,然后第三行调用getMap方法来获取该线程对应的ThreadLocalMap,其方法声明如下

    ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }

获取到了Map之后首先进行判空处理,我们知道一个Map实际上是有许多Entry聚合而成的,而这些Entry保存的是所有的键值对(键为ThreadLocal,值为指定的泛型)信息。我们现在已经获取到了Map和键(当前ThreadLocal),我们要获取对应的值,需要先去在该Map中根据该键去查找对应的键值对,然后从这个键值对里获取value。而map.getEntry(this)所做的就是去从这个当前线程对应的Map中去查找键位该ThreadLocal的键值对。

ThreadLocal内存泄漏问题

我们前面说过,ThreadLocalMap虽然可以当作一个Map来使用,但是其和一般的Map还是有一定的差别的。在这里最重要的一点就是其键值对对象Entry的声明

static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

我们发现其继承了一个WeakReference的对象。那么这个WeakReference对象是个什么鬼呢,要讲这个就必须要牵涉到jvm的垃圾回收了。现代jvm采用的垃圾回收方法一般都是可达性分析,而一个对象是否可达则取决于是否存在一条从GCRoot到当前对象的引用链。java虚拟机规范里规定了四种引用类型,分别是强引用,软引用,弱引用和虚引用。其中弱引用也就是WeakReference,每次垃圾回收时,如果发现有弱引用对象,就将其回收。
我们看到Entry继承自WeakReference,并指定泛型为ThreadLocal,在构造函数时调用了super(k);,这表明只要这个ThreadLocal失去了其他的强引用,该Entry就会被回收。

如图,此时Entry对象不会被回收,虽然ThreadLocal对象和Entry之间是弱引用,但ThreadLocal引用和ThreadLocal是强引用。当代码执行出ThreadLocal的作用域时,在栈上的ThreadLocal引用会被清除,此时在堆上的ThreadLocal对象只有一个Entry对象的引用,由于此引用是弱引用,所以在下一次垃圾回收来临时,该ThreadLocal对象会被垃圾回收器回收。我们在不难发现,ThreadLocal的Entry之所以设计成一个弱引用的对象,就是为了防止ThreadLocal对象内存泄露。虽然解决了ThreadLocal对象的内存泄漏,但是会产生一个新的问题,那就是value对象的内存泄露。当ThreadLocal被回收后,ThreadLocalMap中就会出现key为null的Entry,就没有办法访问这些key为null的Entry的value,如果当前线程再迟迟不结束的话,这些key为null的Entry的value就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value永远无法回收,造成内存泄漏

其实,ThreadLocalMap的设计中已经考虑到这种情况,也加上了一些防护措施:在ThreadLocal的get(),set(),remove()的时候都会清除线程ThreadLocalMap里所有key为null的value。

但是这些被动的预防措施并不能保证不会内存泄漏:

  • 使用static的ThreadLocal,延长了ThreadLocal的生命周期,可能导致的内存泄漏
  • 分配使用了ThreadLocal又不再调用get(),set(),remove()方法,那么就会导致内存泄漏。

综合上面的分析,我们可以理解ThreadLocal内存泄漏的前因后果,那么怎么避免内存泄漏呢?

  • 每次使用完ThreadLocal,都调用它的remove()方法,清除数据。
  • 在使用线程池的情况下,没有及时清理ThreadLocal,不仅是内存泄漏的问题,更严重的是可能导致业务逻辑出现问题。所以,使用ThreadLocal就跟加锁完要解锁一样,用完就清理。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,530评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 86,403评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,120评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,770评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,758评论 5 367
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,649评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,021评论 3 398
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,675评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,931评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,659评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,751评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,410评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,004评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,969评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,042评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,493评论 2 343