并发——深入分析ThreadLocal的实现原理

一、前言

  这篇博客来分析一下ThreadLocal的实现原理以及常见问题,由于现在时间比较晚了,我就不废话了,直接进入正题。


二、正文

2.1 ThreadLocal是什么

  在讲实现原理之前,我先来简单的说一说ThreadLocal是什么。ThreadLocal被称作线程局部变量,当我们定义了一个ThreadLocal变量,所有的线程共同使用这个变量,但是对于每一个线程来说,实际操作的值是互相独立的。简单来说就是,ThreadLocal能让线程拥有自己内部独享的变量。举一个简单的例子:

// 定义一个线程共享的ThreadLocal变量
static ThreadLocal<Integer> tl = new ThreadLocal<>();

public static void main(String[] args) {
    
    // 创建第一个线程
    Thread t1 = new Thread(() -> {
        // 设置ThreadLocal变量的初始值,为1
        tl.set(1);
        // 循环打印ThreadLocal变量的值
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "----" + tl.get());
            // 每次打印完让值 + 1
            tl.set(tl.get() + 1);
        }
    }, "thread1");
    
    // 创建第二个线程
    Thread t2 = new Thread(() -> {
         // 设置ThreadLocal变量的初始值,为100,与上一个线程区别开
        tl.set(100);
        // 循环打印ThreadLocal变量的值
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName() + "----" + tl.get());
            // 每次打印完让值 - 1
            tl.set(tl.get() - 1);
        }
    }, "thread2");
	// 开启两个线程
    t1.start();
    t2.start();
    
    tl.remove();
}

  上面的代码,运行结果如下(注:每次运行的结果可能不同):

thread1----1
thread2----100
thread1----2
thread2----99
thread1----3
thread2----98
thread1----4
thread2----97
thread1----5
thread2----96
thread1----6
thread2----95
thread1----7
thread2----94
thread1----8
thread2----93
thread1----9
thread2----92
thread1----10
thread2----91

  通过上面的输出结果我们可以发现,线程1线程2虽然使用的是同一个ThreadLocal变量存储值,但是输出结果中,两个线程的值却互不影响,线程11输出到10,而线程2100输出到91。这就是ThreadLocal的功能,即让每一个线程拥有自己独立的变量,多个线程之间互不影响。


2.2 ThreadLocal的实现原理

  下面我就就来说一说ThreadLocal是如何做到线程之间相互独立的,也就是它的实现原理。这里我直接放出结论,后面再根据源码分析:每一个线程都有一个对应的Thread对象,而Thread类有一个成员变量,它是一个Map集合,这个Map集合的key就是ThreadLocal的引用,而value就是当前线程在key所对应的ThreadLocal中存储的值。当某个线程需要获取存储在ThreadLocal变量中的值时,ThreadLocal底层会获取当前线程的Thread对象中的Map集合,然后以ThreadLocal作为key,从Map集合中查找value值。这就是ThreadLocal实现线程独立的原理。也就是说,ThreadLocal能够做到线程独立,是因为值并不存在ThreadLocal中,而是存储在线程对象中。下面我们根据ThreadLocal中两个最重要的方法来确认这一点。


2.3 ThreadLocal中的get方法

  get方法的作用非常简单,就是线程向ThreadLocal中取值,下面我们来看看它的源码:

public T get() {
    // 获取当前线程的Thread对象
    Thread t = Thread.currentThread();
    // getMap方法传入Thread对象,此方法将返回Thread对象中存储的一个Map集合
    // 这个Map集合的类型为ThreadLocalMap,这是ThreadLoacl的一个内部类
    // 当前线程存放在ThreadLocal中的值,实际上存放在这个Map集合中
    ThreadLocalMap map = getMap(t);
    // 如果当前Map集合已经初始化,则直接从Map集合中查找
    if (map != null) {
        // ThreadLocalMap的key其实就是ThreadLoacl对象的引用
        // 所以要找到线程在当前ThreadLoacl中存放的值,就需要以当前ThreadLoacl作为key
        // getEntry方法就是通过key获取map中的一个key-value,而这里使用的key就是this
        ThreadLocalMap.Entry e = map.getEntry(this);
        // 如果返回值不为空,表示查找成功
        if (e != null) {
            @SuppressWarnings("unchecked")
            // 于是获取对应的value并返回
            T result = (T)e.value;
            return result;
        }
    }
    // 若当前线程的ThreadLocalMap还未初始化,或者查找失败,则调用以下方法
    return setInitialValue();
}

private T setInitialValue() {
    // 此方法默认返回null,但是可以由子类进行重新,根据需求返回需要的值
    T value = initialValue();
    // 获取当前线程的Thread对象
    Thread t = Thread.currentThread();
    // 获取对应的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 如果Map已经初始化了,就直接往map中加入一个key-value
    // key就是当前ThreadLocal对象的引用,而value就是上面获取到的value,默认为null
    if (map != null)
        map.set(this, value);
    // 若还没有初始化,则调用createMap创建ThreadLocalMap对象
    else
        createMap(t, value);
    // 返回initialValue方法返回的值,默认为null
    return value;
}

void createMap(Thread t, T firstValue) {
    // 创建ThreadLocalMap对象,构造方法传入的是第一对放入其中的key-value
    // 这个key也就是当前线程第一次调用get方法的ThreadLocal对象,也就是当前ThreadLocal对象
    // 而firstValue则是initialValue方法的返回值,默认为null
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

  上面的代码非常直观的验证了我之前说过的ThreadLocal的实现原理。通过上面的代码,我们可以非常直观的看到,线程向ThreadLocal中存放的值,最后都放入了线程自己的ThreadLocalMap中,而这个mapkey就是当前ThreadLocal的引用。而ThreadLocal中,获取线程的ThreadLocalMap的方法getMap的代码如下:

ThreadLocalMap getMap(Thread t) {
    // 直接返回Thread对象的threadLocals成员变量
    return t.threadLocals;
}

  我们再看看Thread类中的threadLocals变量:

/** 可以看到,ThreadLocalMap是ThreadLocal的内部类 */
ThreadLocal.ThreadLocalMap threadLocals = null;

2.4 ThreadLocal中的set方法

  下面再来看一看ThreadLocalset方法的实现,set方法用来使线程向ThreadLocal中存放值(实际上是存放在线程自己的Map中):

public void set(T value) {
    // 获取当前线程的Thread对象
    Thread t = Thread.currentThread();
    // 获取当前线程的ThreadLocalMap
    ThreadLocalMap map = getMap(t);
    // 若map已经初始化,则之际将value放入Map中,对应的key就是当前ThreadLocal的引用
    if (map != null)
        map.set(this, value);
    // 若没有初始化,则调用createMap方法,为当前线程t创建ThreadLocalMap,
    // 然后将key-value放入(此方法已经在上面讲解get方法是看过)
    else
        createMap(t, value);
}

  这就是set方法的实现,比较简单。看完上面两个关键方法的实现,相信大家对ThreadLocal的实现已经有了一个比较清晰的认识,下面我们来更加深入的分析ThreadLocal,看看ThreadLocalMap的一些实现细节。


2.5 ThreadLocalMap的中的弱引用

  ThreadLocalMap的实现其实就是一个比较普通的Map集合,它的实现和HashMap类似,所以具体的实现细节我们就不一一讲解了,这里我们只关注它最特别的一个地方,即它内部的节点Entry。我们先来看看Entry的代码:

// Entry是ThreadLocalMap的内部类,表示Map的节点
// 这里继承了WeakReference,这是java实现的弱引用类,泛型为ThreadLocal
// 表示在这个Map中,作为key的ThreadLocal是弱引用
// (这里value是强引用,因为没用WeakReference)
static class Entry extends WeakReference<ThreadLocal<?>> {
    /** 存储value */
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        // 将key的值传入父类WeakReference的构造方法,用弱引用来引用key
        super(k);
        // value则直接使用上面的强引用
        value = v;
    }
}

  可以看到,上面的Entry比较特殊,它继承自WeakReference类型,这是Java实现的弱引用。在具体讲解前,我们先来介绍一下不同类型的引用:

强引用:这是Java中最常见的引用,在没有使用特殊引用的情况下,都是强引用,比如Object o = new Object()就是典型的强引用。能让程序员通过强引用访问到的对象,不会被JVM垃圾回收,即使内存空间不够,JVM也不会回收这些对象,而是抛出内存溢出异常;

软引用:软引用描述的是一些还有用,但不是必须的对象。被软引用所引用的对象,也不会被垃圾回收,直到JVM将要发生内存溢出异常时,才会将这些对象列为回收对象,进行回收。在JDK1.2之后,提供了SoftReference类实现软引用;

弱引用:弱引用描述的是非必须的对象,被弱引用所引用的对象,只能生存到下一次垃圾回收前,下一次垃圾回收来临,此对象就会被回收。在JDK1.2之后,提供了WeakReference类实现弱引用(也就是上面Entry继承的类);

虚引用:这是最弱的一种引用关系,一个对象是否有虚引用,完全不会对其生存时间产生影响,我们也不能通过一个虚引用访问对象,使用虚引用的唯一目的就是,能在这个对象被回收时,受到一个系统的通知。JDK1.2之后,提供了PhantomReference实现虚引用;

  介绍完各类引用的概念,我们就可以来分析一下Entry为什么需要继承WeakReference类了。从代码中,我们可以看到,Entrykey值,也就是ThreadLocal的引用传入到了WeakReference的构造方法中,也就是说在ThreadLocalMap中,key的引用是弱引用。这表明,当没有其他强引用指向key时,这个key将会在下一次垃圾回收时被JVM回收。

  为什么需要这么做呢?这么做的目的自然是为了有利于垃圾回收了。如果了解过JVM的垃圾回收算法的应该知道,JVM判断一个对象是否需要被回收,判断的依据是这个对象还能否被我们所使用,举个简单的例子:

public static void main(String[] args) {
    Object o = new Object();
    o = null;
}

  上面的代码中,我们创建了一个对象,并使用强引用o指向它,然后我们将o置为空,这个时候刚刚创建的对象就丢失了,因为我们无法通过任何引用找到这个对象,从而使用它,于是这个对象就需要被回收,这种判断依据被称为可达性分析。关于JVM的垃圾回收算法,可以参考这篇博客:Java中的垃圾回收算法详解

  好,回归正题,我们开始分析为什么ThreadLocalMap需要让key使用弱引用。假设我们创建了一个ThreadLocal,使用完之后没有用了,我们希望能够让它被JVM回收,于是有了下面这个过程:

// 创建ThreadLocal对象
ThreadLocal tl = new ThreadLocal();

// .....省略使用的过程...

// 使用完成,希望被JVM回收,于是执行以下操作,解除强引用
tl = null;

  我们在使用完ThreadLocal之后,解除对它的强引用,希望它被JVM回收。但是JVM无法回收它,因为我们虽然在此处释放了对它的强引用,但是它还有其它强引用,那就是Thread对象的ThreadLocalMapkey。我们之前反复说过,ThreadLocalMapkey就是ThreadLocal对象的引用,若这个引用是一个强引用,那么在当前线程执行完毕,被回收前,ThreadLocalMap不会被回收,而ThreadLocalMap不会被回收,它的key引用的ThreadLocal也就不会回收,这就是问题的所在。而使用弱引用就可以保证,在其他对ThreadLocal的强引用解除后,ThreadLocalMap对它的引用不会影响JVM对它进行垃圾回收。这就是使用弱引用的原因。


2.6 ThreadLocal造成的内存溢出问题

  上面描述了对ThreadLocalMapkey使用弱引用,来避免JVM无法回收ThreadLocal的问题,但是这里却还有另外一个问题。我们看上面Entry的代码发现,key值虽然使用的弱引用,但是value使用的却是强引用。这会造成一个什么问题?这会造成key被JVM回收,但是value却无法被收,key对应的ThreadLocal被回收后,key变为了null,但是value却还是原来的value,因为被ThreadLocalMap所引用,将无法被JVM回收。若value所占内存较大,线程较多的情况下,将持续占用大量内存,甚至造成内存溢出。我们通过一段代码演示这个问题:

public class Main {

    public static void main(String[] args) {
        // 循环创建多个TestClass
        for (int i = 0; i < 100; i++) {
            // 创建TestClass对象
            TestClass t = new TestClass(i);
            // 调用反复
            t.printId();
            // *************注意此处,非常关键:为了帮助回收,将t置为null
            t = null;
        }
    }

    static class TestClass {
        int id;
        // 每个TestClass对象对应一个很大的数组
        int[] arr = new int[100000000];
        // 每个TestClass对象对应一个ThreadLocal对象
        ThreadLocal<int[]> threadLocal = new ThreadLocal<>();

        TestClass(int id) {
            this.id = id;
            // threadLocal存放的就是这个很大的数组
            threadLocal.set(arr);
        }

        public void printId() {
            System.out.println(id);
        }
    }
}

  上面的代码多次创建所占内存非常大的对象,并在创建后,立即解除对象的强引用,让对象可以被JVM回收。按道理来说,上面的代码运行应该不会发生内存溢出,因为我们虽然创建了多个大对象,占用了大量空间,但是这些对象立即就用不到了,可以被垃圾回收,而这个对象被垃圾回收后,对象的id,数组,和threadLocal成员都会被回收,所以所占内存不会持续升高,但是实际运行结果如下:

0
1
2
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at Main$TestClass.<init>(Main.java:23)
	at Main.main(Main.java:10)

  可以看到,很快就发生了内存溢出异常。为什么呢?需要注意到,在TestClass的构造方法中,我们将数组arr放入了ThreadLocal对象中,也就是被放进了当前线程的ThreadLocalMap中,作为value存在。我们前面说过,ThreadLocalMapvalue是强引用,这也就意味着虽然ThreadLocal可以被正常回收,但是作为value的大数组无法被回收,因为它仍然被ThreadLocalMap的强引用所指向。于是TestClass对象的超大数组就一种在内存中,占据大量空间,我们连续创建了多个TestClass,内存很快就被占满了,于是发生了内存溢出。而JDK的开发人员自然发现了这个问题,于是有了下面这个解决方案:

public class Main {

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            TestClass t = new TestClass(i);
            t.printId();
            // **********注意,与上面的代码只有此处不同************
            // 此处调用了ThreadLocal对象的remove方法
            t.threadLocal.remove();
            t = null;
        }
    }

    static class TestClass {
        int id;
        int[] arr;
        ThreadLocal<int[]> threadLocal;

        TestClass(int id) {
            this.id = id;
            arr = new int[100000000];
            threadLocal = new ThreadLocal<>();
            threadLocal.set(arr);
        }

        public void printId() {
            System.out.println(id);
        }
    }
}

  上面的代码中,我们在将t置为空时,先调用了ThreadLocal对象的remove方法,这样做了之后,再看看运行结果:

0
1
2
// ....神略中间部分
98
99

  做了上面的修改后,没有再发生内存溢出异常,程序正常执行完毕。这是为什么呢?ThreadLocalremove方法究竟有什么作用。其实remove方法的作用非常简单,执行remove方法时,会从当前线程的ThreadLocalMap中删除key为当前ThreadLocal的那一个记录,keyvalue都会被置为null,这样一来,就解除了ThreadLocalMapvalue的强引用,使得value可以正常地被JVM回收了。所以,今后如果我们确认不再使用的ThreadLocal对象,一定要记得调用它的remove方法。

  我们之前说过,如果我们没有调用remove方法,那就会导致ThreadLocal在使用完毕后,被正常回收,但是ThreadLocalMap中存放的value无法被回收,此时将会在ThreadLocalMap中出现keynull,而value不为null的元素。为了减少已经无用的对象依旧占用内存的现象,ThreadLocal底层实现中,在操作ThreadLocalMap的过程中,线程若检测到keynull的元素,会将此元素的value置为null,然后将这个元素从ThreadLocalMap中删除,占用的内存就可以让JVM将其回收。比如说在getEntry方法中,或者是Map扩容的方法中等。


三、总结

  ThreadLocal实现线程独立的方式是直接将值存放在Thread对象的ThreadLocalMap中,Mapkey就是ThreadLocal的引用,且为了有助于JVM进行垃圾回收,key使用的是弱引用。在使用ThreadLocal后,一定要记得调用remove方法,有助于JVMvalue的回收。


四、参考

posted @ 2020-04-16 16:38  特务依昂  阅读(4096)  评论(0编辑  收藏  举报