JAVA并发编程——ThreadLocal的介绍、使用及源码分析

1.ThreadLocal是什么

2.ThreadLocal能干什么

3.ThreadLocal在生产中的应用

4.ThreadLocal源码分析

5.ThreadLocal内存泄露问题

6.总结

1.ThreadLocal是什么
在开始了解ThreadLocal是什么之前,我们可以在java里搜一下这个类,看一下源码的注释:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第1张图片
意思大概就是:
ThreadLocal提供了线程私有的局部变量,这些变量不同于普通变量,因为每一个线程在访问ThreadLocal实例的时候(通过get或者set方法)都有自己的,独立初始化的副本
ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态(用户id或者事务id)与线程关联起来。

2.ThreadLocal能干什么
可能听了上面的描述,你还是一头雾水。简单地来说,ThreadLocal能办到这种事情:
它实现了每一个线程都有自己的专属本地变量副本(自己用自己的变量不用麻烦别人,不和其他人共享,人各一份)。
主要解决了让每个线程绑定自己的值,通过使用get()和set()方法,获取默认值或者将其值更改为当前线程所存的副本的值从而避免了线程安全问题

3.ThreadLocal在生产中的应用
通过上面的讲解,我们应该明白了ThreadLocal到底是什么,以及怎么用的一个概念,但是具体还是不会用,以及一时半会儿不会想到适合的使用场景(有什么场景是需要在多线程操作的时候,人手一份?)

此时我们举一个例子,便能马上明白它的使用场景:

我们的切入点便是————SimpleDateFormat

对于SimpleDateFormat,我们应该都很熟悉,我们通常拿这个工具类来转换日期格式,看起来没什么问题,但是一旦使用不小心会有大问题!

我们先来看一下SimpleDateFormat的源码注释:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第2张图片
SimpleDateFormat不是线程安全的,推荐为每一个线程创建独立的格式实例。如果多个线程同时访问一个格式,则它必须保持外部同步

假设我们在工作的时候,经常使用SimpleDateForma,我们便将它抽出来独立为一个工具类,可能我们会new 一个SimpleDateForma对象,然后对此对象进行复用,殊不知,这种写法的多线程下的危险性!


public class DateUtils {
    //先新建一个日期转换类
    public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    /**
     * 模拟并发环境下使用SimpleDateFormat的parse方法将字符串转换成Date对象
     *
     * @param stringDate
     * @return
     * @throws Exception
     */
    public static Date parseDate(String stringDate) throws Exception {
        return sdf.parse(stringDate);
    }
    
    public static void main(String[] args) throws Exception {
        for (int i = 1; i < 30; i++) {
            new Thread(() -> {
                try {
                    System.out.println(DateUtils.parseDate("2020-11-11 11:11:11"));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, String.valueOf(i)).start();
        }
    }
}

就如上述代码,在多线程并发的情况下,使用同一个工具类,会出现什么问题呢?我们来运行一下代码看看:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第3张图片

由此看出,在多线程下对一个日期转换工具类进行操作,很容易出bug原因是:
SimpleDateFormat类内部有一个Calendar对象引用,它用来存储和当前SimpleDateFormat对象相关的日期信息,这就会导致一个问题:
如果多个线程引用这个SimpleDateFormat,那么多个线程之间就会共享这个类,同时也共享这个Calendar引用,那么可能这个线程还没有转化完成日期,这个日期就被替换掉了,出现各种并发操作的问题。

针对以上这种情况,我们有以下四个方法可以解决这个问题:
1)将SimpleDateFormat定义成局部变量。
缺点:每调用一次转换方法都会创建一个SimpleDateFormat对象,方法结束后又要作为垃圾回收。
2)加锁。
将这个方法加上synchronized串行化,但是会严重影响吞吐量

3)使用别的工具类
阿里巴巴开发手册嵩山版就指出:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第4张图片
4)就是我们今天要介绍的,使用ThreadLocal.

public class DateUtils {
    //public static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    public static final ThreadLocal SIMPLE_DATE_FORMAT_THREAD_LOCAL = ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));


    /**
     * 模拟并发环境下使用SimpleDateFormat的parse方法将字符串转换成Date对象
     *
     * @param stringDate
     * @return
     * @throws Exception
     */
    public static Date parseDate(String stringDate) throws Exception {
        return SIMPLE_DATE_FORMAT_THREAD_LOCAL.get().parse(stringDate);
    }

    public static void main(String[] args) throws Exception {
        for (int i = 1; i < 30; i++) {
            new Thread(() -> {
                try {
                    System.out.println(DateUtils.parseDate("2020-11-11 11:11:11"));
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }, String.valueOf(i)).start();
        }
    }
}

这样,调用它的每一个线程就都使用了一个工具类的拷贝副本,每个线程一份,就不会出现这样的问题了。

4.ThreadLocal源码分析

那么ThreadLocal究竟是如何办到,每个Thread人手一份变量拷贝的呢?我们来进行一下源码分析:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第5张图片

我们发现,Thread对象里面有一个ThreadLocal对象中的static类,ThreadLocalMap。

而ThreadLocalMap又是由Entry(ThreadLocal k, Object v) 组成的。
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第6张图片

由此我们可以得到下面这幅图:

JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第7张图片

threadLocalMap实际上是一个以ThreadLocal为key,Object为value的对象。当我们为threadLocalMap变量赋值,就是以当前的ThreadLocal值为key,传进来的值为value的一个Etry,并在这个threadLocalMap中存放。

近似的理解为:

1)每一个Thread都有自己的一个ThreadLocal。
2)每一个Thread都有一个ThreadLocalMap,里面保存了一个键值对,以ThreadLocal为key,以传进来的值为value。
3)每个线程要用到ThreadLocal的时候,用当前的线程去Map里面获取,这样每个线程便拥有了自己的独立变量。
4)人手一份,竞争条件被彻底消除,在并发模式下是绝对的安全变量。

5.ThreadLocal内存泄露问题

其实ThreadLocal是存在着一些问题的:内存泄漏问题。

什么是内存泄漏:不再会被使用的对象或者变量占用的内存不能被回收,就是内存泄漏。

在解释这个现象之前,我们先看一下Entry,继承了WeakReference类,弱引用。
关于强软弱虚引用,我们在前面这篇博客介绍过。
深入理解JVM(八)——强软弱虚引用

强引用:当内存不足,JVM开始垃圾回收,对于强引用的对象,就算出现了OOM也不会对该对象进行回收。
软引用:内存足够的前提下,不回收该对象。内存不够的前提下,回收该对象
弱引用:不管内存是否够用,只要是弱引用,一律回收。
虚引用:**在这个对象呗收集器回收的时候收到一个系统通知或者后序添加进一步的处理,是jvm的技术人员用的,
**

为什么Entry用了弱引用?

每当一个function执行完毕之后,栈帧销毁,此时ThreadLocal也就没有了,但此时线程的ThreadLocalMap里的某个entry的key引用还指向这个对象。

弱这个key是强引用,就会导致key指向的ThreadLocal对象及v指向的对象不能被gc回收,造成内存泄漏。
弱这个key是弱引用,就能大概率减少内存泄漏的问题(还有一个key为null的问题),使用弱引用,就能将key引用指向为null

由此看出,通过弱引用,确实能够一定程度解决内存泄漏问题。

不过gc完毕之后,ThreadLocalMap就会出现key为null的Entry,就没有办法访问这些key为null的entry的value,如果当前线程迟迟不结束(比如线程池的常驻核心线程)这些key为null的entry就会一直存在一条强引用链,而且value可能会超级大,更进一步地拖垮了服务器的性能。

因此弱引用不能100%保证内存不泄露。我们要在不使用某个ThreadLocal对象后,手动调用remoev方法来删除它,尤其是在线程池中,不仅仅是内存泄露的问题,因为线程池中的线程是重复使用的,意味着这个线程的ThreadLocalMap对象也是重复使用的,如果我们不手动调用remove方法,那么后面的线程就有可能获取到上个线程遗留下来的value值,造成bug。

JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第8张图片

JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第9张图片

上图这个方法就是用来解决null key的内存泄漏问题的。

此外set()方法:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第10张图片

get()方法:
JAVA并发编程——ThreadLocal的介绍、使用及源码分析_第11张图片

都做了空key的判断

6.总结
1)ThreadLocal 并不解决线程间共享数据的问题
2)ThreadLocal 适用于变量在线程间隔离且在方法间共享的场景
3)ThreadLocal 通过隐式的在不同县城内创建独立实例副本且避免了实例线程安全的问题
4)每个线程都持有一个专属于自己的Map,并维护了ThreadLocal对象与具体实例的映射。(该map只能被自己访问,所以没有线程安全问题)
5)ThreadLocalMap的Entry对ThreadLocal的引用为弱引用,避免了ThreadLocal对象无法被回收的问题。
6)都会通过expungeStaleEntry,cleanSomeSlots,replaceStaleEntry这三个方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏,属于安全加固的方法

你可能感兴趣的