性能文章>ThreadLocal全攻略:使用实战,源码分析,内存详解>

ThreadLocal全攻略:使用实战,源码分析,内存详解原创

412504

前言

说起ThreadLocal的即便你没有直接用到过,它也间接的出现在你使用过的框架里,比如春天的事物管理,休眠的会话管理,的logback(和log4j的)中的MDC功能实现等。而在项目中,接触的一些分页功能的实现可能会开发ThreadLocal。

正是ThreadLocal的操作在,所以在面试的时候也经常会被问到它的实现原理、核心API的使用以及内存泄露的问题。

基于这些问题还可以推导出线程安全方面、JVM 内存管理和分析哈希算法等等知识点。可见线程本地人员是多么的、多么的。如果你还没有全面的,那么这篇文章值得你深入学习一下。

什么是ThreadLocal

ThreadLocal 是 Thread 的局部变量的维护类,在 Java 中是作为一个特定的变量存储在。当使用 ThreadLocal 维护变量时,ThreadLocal 为每个使用变量的线程提供独立的变量,所以每个线程都可以独立地改变自己的副本,而不会影响其他线程所对应的副本。

因为每个线程内有自己的实例副本,而且该线程只能由当前线程实现,也不会存在多线程间使用共享的问题。

总之,每个线程本地都适用于线程需要自己独立的实例且该实例需要在多个方法中被使用,也可以即变量在线程间隔离而在方法或类间共享的场景。

比如,有一个变量计数,在多个线程并发时操作计数++会出现线程安全问题。但通过ThreadLocal就可以为每个线程创建只属于当前线程的计数,不会出现其他线程的操作副本,影响到其他线程的副本。

从另一个角度说,ThreadLocal 是一个数据结构,有点像HashMap,保存“key:value”键值对,但是一个ThreadLocal 只能保存一个键值对,各个线程的数据互不干扰。

@Test
public void test1(){
    ThreadLocal<String> localName = new ThreadLocal<>();
    // 只提供了一个set方法;
    localName.set("程序新视界");
    // 同时只提供了一个get方法
    String name = localName.get();
    System.out.println(name);
}

线程中线程A初始化了一个线程本地对象,并调用了方法,保持了一个值。而这个值只能线程调用获取方法。如果此时线程B调用获取方法是无法获取的。至于实现这个功能的,我们如何在源代码分析中进行讲解,这里知道其功能清单。

ThreadLocal使用实例

上面介绍了使用场景和基本的实现理论,下面我们就通过一个简单的实例来看一下如何使用ThreadLocal。

public class ThreadLocalMain {

    /**
     * ThreadLocal变量,每个线程都有一个副本,互不干扰
     */
    public static final ThreadLocal<String> HOLDER = new ThreadLocal<>();

    public static void main(String[] args) throws Exception {
        new ThreadLocalMain().execute();
    }

    public void execute() throws Exception {
        // 主线程设置值
        HOLDER.set("程序新视界");
        System.out.println(Thread.currentThread().getName() + "线程ThreadLocal中的值:" + HOLDER.get());

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "线程ThreadLocal中的值:" + HOLDER.get());
            // 设置当前线程中的值
            HOLDER.set("《程序新视界》");
            System.out.println("重新设置之后," + Thread.currentThread().getName() + "线程ThreadLocal中的值:" + HOLDER.get());
            System.out.println(Thread.currentThread().getName() + "线程执行结束");
        }).start();
        // 等待所有线程执行结束
        Thread.sleep(1000L);
        System.out.println(Thread.currentThread().getName() + "线程ThreadLocal中的值:" + HOLDER.get());
    }

}

示例中定义了一个静态final的ThreadLocal变量HOLDER,在main方法中模拟通过两个线程来操作HOLDER中的存储值。先对HOLDER设置一个值,然后打印获取得到的值,然后新起一个线程去修改HOLDER中的值,然后分别在新线程和主线程两处获取对应的值。

执行程序,打印结果如下:

main线程ThreadLocal中的值:程序新视界
Thread-0线程ThreadLocal中的值:null
重新设置之后,Thread-0线程ThreadLocal中的值:《程序新视界》
Thread-0线程执行结束
main线程ThreadLocal中的值:程序新视界

对照程序和输出结果,你会发现,主线程和Thread-0各自独享自己的变量存储。主线程并没有因为Thread-0调用了HOLDER的set方法而被改变。

之所以能达到这个效果,正是因为在ThreadLocal中,每个线程Thread拥有一份自己的副本变量,多个线程互不干扰。那么,你会疑惑,ThreadLocal是如何实现这一功能的呢?

ThreadLocal原理分析

在学习ThreadLocal的原理之前,我们先来看一些相关的理论知识和数据结构。

基本流程与源码实现

一个线程内可以存多个ThreadLocal对象,存储的位置位于Thread的ThreadLocal.ThreadLocalMap变量,在Thread中有如下变量:

/* ThreadLocal values pertaining to this thread. This map is maintained
 * by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

ThreadLocalMap是由ThreadLocal维护的静态内部类,正如代码中注解所说这个变量是由ThreadLocal维护的。

我们在使用ThreadLocal的get()、set()方法时,其实都是调用了ThreadLocalMap类对应的get()、set()方法。

Thread中的这个变量的初始化通常是在首次调用ThreadLocal的get()、set()方法时进行的。

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

上述set方法中,首先获取当前线程对象,然后通过getMap方法来获取当前线程中的threadLocals:

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

如果Thread中的对应属性为null,则创建一个ThreadLocalMap并赋值给Thread:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

如果已经存在,则通过ThreadLocalMap的set方法设置值,这里我们可以看到set中key为this,也就是当前ThreadLocal对象,而value值则是我们要存的值。

对应的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();
}

可以看到同样通过当前线程,拿到当前线程的threadLocals属性,然后从中获取存储的值并返回。在get的时候,如果Thread中的threadLocals属性未进行初始化,则也会间接调用createMap方法进行初始化操作。

下面我们通过一个流程图来汇总一下上述流程:
IMG_8794 2.JPG

上述流程中给Thread的threadLocals属性初始化的操作,在JDK8和9中通过debug发现,都没有走createMap方法,暂时还不清楚JVM是如何进行初始化赋值的。而在测试JDK13和JDK14的时候,很明显走了createMap方法。

ThreadLoalMap的数据结构

ThreadLoalMap是ThreadLocal中的一个静态内部类,类似HashMap的数据结构,但并没有实现Map接口。

ThreadLoalMap中初始化了一个大小16的Entry数组,Entry对象用来保存每一个key-value键值对。通过上面的set方法,我们已经知道其中的key永远都是ThreadLocal对象。

看一下相关的源码:

static class ThreadLocalMap {

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

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

    private static final int INITIAL_CAPACITY = 16;

    // ...
}

ThreadLoalMap的类图结构如下:
IMG_8795.JPG

这里需要留意的是,ThreadLocalMap类中的Entry对象继承自WeakReference,也就是说它是弱引用。这里会出现内存泄露的情况,后续会讲到。

由于hreadLocalMaps是延迟创建的,因此在构造时至少要创建一个Entry对象。这里可以从构造方法中看到:

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);
}

上述构造方法,创建了一个默认长度为16的Entry数组,通过hashCode与length位运算确定索引值i。而上面也提到,每个Thread都有一个ThreadLocalMap类型的变量。

至此,结合Thread,我们可以看到整个数据模型如下:
IMG_8796.JPG

hash冲突及解决

我们留意到构造方法中Entry在table中存储位置是通过hashcode算法获得。每个ThreadLocal对象都有一个hash值threadLocalHashCode,每初始化一个ThreadLocal对象,hash值就增加一个固定的大小0x61c88647。

在向ThreadLocalMap中的Entry数值存储Entry对象时,会根据ThreadLocal对象的hash值,定位到table中的位置i。这里分三种情况:

  • 如果当前位置为空的,直接将Entry存放在对应位置;

  • 如果位置i已经有值且这个Entry对象的key正好是即将设置的key,那么重新设置Entry中的value;

  • 如果位置i的Entry对象和即将设置的key没关系,则寻找一个空位置;

计算hash值便会有hash冲突出现,常见的解决方法有:再哈希法、开放地址法、建立公共溢出区、链式地址法等。

上面的流程可以看出这里采用的是开放地址方法,如果当前位置有值,就继续寻找下一个位置,注意table[len-1]的下一个位置是table[0],就像是一个环形数组,所以也叫闭散列法。如果一直都找不到空位置就会出现死循环,发生内存溢出。当然有扩容机制,一般不会找不到空位置的。

ThreadLocal内存泄露

ThreadLocal使用不当可能会出现内存泄露,进而可能导致内存溢出。下面我们就来分析一下内存泄露的原因及相关设计思想。

内存引用链路

根据前面对ThreadLocal的分析,得知每个Thread维护一个ThreadLocalMap,它key是ThreadLocal实例本身,value是业务需要存储的Object。也就是说ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

仔细观察ThreadLocalMap,这个map是使用ThreadLocal的弱引用作为Key的,弱引用的对象在GC时会被回收。因此使用了ThreadLocal后,引用链如图所示:
IMG_8797.JPG

其中虚线表示弱引用。下面我们先来了解一下Java中引用的分类。

Java中的引用

Java中通常会存在以下类型的引用:强引用、弱引用、软引用、虚引用。

  • 强引用:通常new出来的对象就是强引用类型,只要引用存在,垃圾回收器将永远不会回收被引用的对象,哪怕内存不足的时候;

  • 软引用:使用SoftReference修饰的对象被称为软引用,软引用指向的对象在内存要溢出的时候被回收。如果回收之后,还没有足够的内存,才会抛出内存溢出异常;

  • 弱引用:使用WeakReference修饰的对象被称为弱引用,只要发生垃圾回收,无论当前内存是否足够,都会回收掉只被弱引用关联的对象实例。

  • 虚引用:虚引用是最弱的引用,在Java中使用PhantomReference进行定义。虚引用中唯一的作用就是用队列接收对象即将死亡的通知。

泄露原因分析

正常来说,当Thread执行完会被销毁,Thread.threadLocals指向的ThreadLocalMap实例也随之变为垃圾,它里面存放的Entity也会被回收。这种情况是不会发生内存泄漏的。

发生内存泄露的场景一般存在于线程池的情况下。此时,Thread生命周期比较长(存在循环使用),threadLocals引用一直存在,当其存放的ThreadLocal被回收(弱引用生命周期比较短)后,对应的Entity就成了key为null的实例,但value值不会被回收。如果此Entity一直不被get()、set()、remove(),就一直不会被回收,也就发生了内存泄漏。

所以,通常在使用完ThreadLocal后需要调用remove()方法进行内存的清除。

比如在web请求当中,我们可以通过过滤器等进行回收方法的调用:

public void doFilter(ServeletRequest request, ServletResponse){
    try{
        //设置ThreadLocal变量
        localName.set("程序新视界");
        chain.doFilter(request, response)
    }finally{
        //调用remove方法溢出threadLocal中的变量
        localName.remove();
    }
}

为什么使用弱引用而不是强引用?

从表面上看内存泄漏的根源在于使用了弱引用,但为什么JDK采用了弱引用的实现而不是强引用呢?

先来看ThreadLocalMap类上的一段注释:

To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. 

为了协助处理数据比较大并且生命周期比较长的场景,hash table的条目使用了WeakReference作为key。
这跟我们想象的有些不同,弱引用反而是为了解决内存存储问题而专门使用的。

我们先来假设一下,如果key使用强引用,那么在其他持有ThreadLocal引用的对象都回收了,但ThreadLocalMap依旧持有ThreadLocal的强引用,这就导致ThreadLocal不会被回收,从而导致Entry内存泄露。

对照一下,弱引用的情况。持有ThreadLocal引用的对象都回收了,ThreadLocalMap持有的是ThreadLocal的弱引用,会被自动回收。只不过对应的value值,需要在下次调用set/get/remove方法时会被清除。

综合对比会发现,采用弱引用反而多了一层保障,ThreadLocal被清理后key为null,对应的value在下一次ThreadLocalMap调用set、get、remove的时候可能会被清除。

所以,内存泄露的根本原因是是否手动清除操作,而不是弱引用。

ThreadLocal应用场景

最后,我们再来回顾一下ThreadLocal的应用场景:

  • 线程间数据隔离,各线程的ThreadLocal互不影响;

  • 方便同一个线程使用某一对象,避免不必要的参数传递;

  • 全链路追踪中的traceId或者流程引擎中上下文的传递一般采用ThreadLocal;

  • Spring事务管理器采用了ThreadLocal;

  • Spring MVC的RequestContextHolder的实现使用了ThreadLocal;

小结

本篇文章我们从ThreadLocal的使用场景、源码、类结构、内存结构等进行分析说明,最后分析了其引起内存泄露的根本原因。通过本篇文章的学习,基本上能掌握ThreadLocal百分之百九十的核心知识点。你学到了吗?

分类:标签:
请先登录,感受更多精彩内容
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

字符串字面量长度是有限制的
前言 偶然在一次单元测试中写了一个非常长的字符串字面量。 正文 在一次单元测试中,我写了一个很长的字符串字面量,大概10万个字符左右,编译时,编译器给出了异常告警 `java: constant
多次字符串相加一定要用StringBuilder而不用-吗?
今天在写一个读取Java class File并进行分析的Demo时,偶然发现了下面这个场景(基于oracle jdk 1.8.0_144): ``` package test; public c
如何通过反射获得方法的真实参数名(以及扩展研究)
前段时间,在做一个小的工程时,遇到了需要通过反射获得方法真实参数名的场景,在这里我遇到了一些小小的问题,后来在部门老大的指导下,我解决了这个问题。通过解决这个问题,附带着我了解到了很多新的知识,我觉得
高吞吐、低延迟 Java 应用的 GC 优化实践
本篇原文作者是 LinkedIn 的 Swapnil Ghike,这篇文章讲述了 LinkedIn 的 Feed 产品的 GC 优化过程,虽然文章写作于 April 8, 2014,但其中的很多内容和
「每日五分钟,玩转 JVM」:久识你名,初居我心
聊聊 JVMJVM,一个熟悉又陌生的名词,从认识Java的第一天起,我们就会听到这个名字,在参加工作的前一两年,面试的时候还会经常被问到JDK,JRE,JVM这三者的区别。JVM可以说和我们是老朋友了
据说99.99%的人都会答错的类加载的问题
概述首先还是把问题抛给大家,这个问题也是我厂同学在做一个性能分析产品的时候碰到的一个问题。 同一个类加载器对象是否可以加载同一个类文件多次并且得到多个Class对象而都可以被java层使用吗请仔细注意
Java多线程——并发测试
编写并发程序时候,可以采取和串行程序相同的编程方式。唯一的难点在于,并发程序存在不确定性,这种不确定性会令程序出错的地方远比串行程序多,出现的方式也没有固定规则。那么如何在测试中,尽可能的暴露出这些问
Java多线程知识小抄集(一)
本文主要整理笔者遇到的Java多线程的相关知识点,适合速记,故命名为“小抄集”。本文没有特别重点,每一项针对一个多线程知识做一个概要性总结,也有一些会带一点例子,习题方便理解和记忆。 1.interr