【Java】ThreadLocal 解析

182 阅读5分钟

这是我参与8月更文挑战的第28天,活动详情查看:8月更文挑战

一、前言

为什么多线程并发会有安全问题?

多个线程并发访问同一个共享数据的时候,才会出现安全问题。 因为 Java 内存模型,多个线程并发修改同一个数据的时候,可能会导致数据错乱,所以需要加并发同步机制。

那么这样的话,避免访问共享数据,不就没有这个问题了。

每个线程访问自己本地的变量,就跟别的线程没有冲突了。

ThreadLocal 解决线程安全问题:就给在每个线程拷贝对应变量副本。

举个栗子:

  1. 线程1 设置1,获取对应数据
  2. 线程2 设置2, 获取对应数据
public class Test {
​
    private static ThreadLocal<Long> requestId = new ThreadLocal<>();
​
    public static void main(String[] args) {
​
        Thread thread1 = generateThread("线程1", 1);
        Thread thread2 = generateThread("线程2", 2);
​
        thread1.start();
        thread2.start();
    }
​
    private static Thread generateThread(String name, long num) {
        Thread thread = new Thread(){
​
            public void run() {
                requestId.set(num);
                System.out.println(this.getName() + " : " + requestId.get());
            }
        };
        thread.setName(name);
        return thread;
    }
}

输出结果如下:

线程1 : 1
线程2 : 2

(1)源码剖析:ThreadLocal 线程本地副本的实现原理

JDK 中有个 ThreadLocal 类,其内部有一个 ThreadLoaclMap 内部类。

对应源码如下:

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 Entry[] table;
//...
}

解释下 Entry,可以理解为一个 Map,其键值对为:

  • key 键:为当前的 ThreadLocal
  • value 值:实际需要存储的变量

ThreadLocalMap 既然叫 Map,那么就和 HashMap 类似,但是具体实现会有一些不同:

  • HashMaphash 冲突的时候,采用的是拉链法,长度大于 8会转为红黑树。
  • ThreadLocalMaphash 冲突的时候,采用线性探测法,发生冲突,并不会用链表的形式往下链,而是会继续寻找下一个空的格子。

ThreadThreadLocalThreadLocalMap 三者之间的关系

查看 Thread 源码可以发现,有对应 ThreadLocalMap 引用。

public class Thread implements Runnable {
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;
    // ... ...
}

这三者关系如图:

concurrent-ThreadLocal.png

ThreadLocal 就是 key

// key 要提前声明
private static ThreadLocal<Long> requestId = new ThreadLocal<>();
​
Thread thread = new Thread(){
    public void run() {
        requestId.set(1L);
        System.out.println(this.getName() + " : " + requestId.get());
    }
};

有个比较好玩的点,ThreadLocalMap 里装着是 ThreadLocal; 但在类定义中 ThreadLocalMapThreadLocal 的静态内部类,这设计可以揣摩下。

(2)ThreadLocalSynchronized 有什么关系?

ThreadLocalsynchronized 它们两个都能解决线程安全问题,那么 ThreadLocalsynchronized 是什么关系呢?

不同点如下:

  • ThreadLocal 是通过让每个线程独享自己的副本,避免了资源的竞争。
  • synchronized 主要用于临界资源的分配,在同一时刻限制最多只有一个线程能访问该资源。

相比于 ThreadLocal 而言,synchronized 的效率会更低一些,但是花费的内存也更少。在这种场景下,ThreadLocalsynchronized 虽然有不同的效果,不过都可以达到线程安全的目的。

二、ThreadLocal 内存泄漏问题

问题:为什么每次用完 ThreadLocal 都要调用 remove() ?

内存泄漏指的是:当某一个对象不再有用的时候,占用的内存却不能被回收,这就叫作内存泄漏。

因为通常情况下,如果一个对象不再有用,那么垃圾回收器 GC,就应该把这部分内存给清理掉。

  • 这样的话,被清理掉这部分内存后续重新分配到其他的地方去使用;

  • 否则,如果对象没有用,但一直不能被回收,这样的垃圾对象如果积累的越来越多,则会导致可用的内存越来越少,最后发生内存不够用的 OOM 错误。

(1)内存泄漏问题

显而意见,ThreadLocal 内存泄漏分为两个方面:

  1. key 泄漏
  2. value 泄漏

先来回顾下 Thread 引用情况:

Thread -> ThreadLocalMap -> Entry -> <key, value>

concurrent-ThreadLocal.png

1)Key 的泄漏

线程在访问了 ThreadLocal 之后,都会在它的 ThreadLocalMap 里面的 Entry 中去维护该 ThreadLocal 变量与具体实例的映射。

key 是什么?

key 就是 ThreadLocal 线程本地变量,需要提前声明。

再回顾下这个 demo

private static ThreadLocal<Long> requestId = new ThreadLocal<>();
​
Thread thread = new Thread(){
    public void run() {
        requestId.set(1L);
        System.out.println(this.getName() + " : " + requestId.get());
    }
};

可能会在业务代码中执行了 ThreadLocal requestId = null 操作,想清理掉这个 ThreadLocal 实例。

但是假设在 ThreadLocalMapEntry 中强引用了 ThreadLocal 实:

  • 那么,虽然在业务代码中把 ThreadLocal 实例置为了 null,但是在 Thread 类中依然有这个引用链的存在。
  • GC 在垃圾回收的时候会进行可达性分析,它会发现这个 ThreadLocal 对象依然是可达的,所以对于这个 ThreadLocal 对象不会进行垃圾回收,这样的话就造成了内存泄漏的情况。

所以 ThreadLocalMap 中的 Entry 继承了 WeakReference 弱引用,源代码如下所示:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

可以看到,这个 Entryextends WeakReference

弱引用的特点是:如果这个对象只被弱引用关联,而没有任何强引用关联,那么这个对象就可以被回收,所以弱引用不会阻止 GC

因此,这个弱引用的机制就避免了 ThreadLocal 的内存泄露问题。

2)Value 的泄漏

ThreadLocalMap 的每个 Entry 都是一个对 key 的弱引用,但是这个 Entry 包含了一个对 value 的强引用。

源代码如下:

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;
    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}

可以看到,value = v 这行代码就代表了强引用的发生。

TipsJVM 垃圾回收是根据可达性进行分析。

情况可分为两种情况:

  1. 线程终止:线程生命周期都结束了,其下的 ThreadLocalMap 就没有被引用,那就会被 GC 回收。

    引用关系:Thread -> ThreadLocalMap -> Entry -> <ThreadLocal, Value>

  2. 线程运行:线程运行在线程池中,可以被反复使用,长时间内不会被销毁。这时候就可能出现 value 泄漏

    因为 Value 不再使用,但 线程Thread 依然存活着。可达性分析,这个 value 仍是可达的。

JDK 同样也考虑到了这个问题,在执行 ThreadLocalsetremoverehash 等方法时,它都会扫描 keynullEntry,如果发现某个 Entrykeynull,则代表它所对应的 value 也没有作用了,所以它就会把对应的 value 置为 null,这样,value 对象就可以被正常回收了。

但是假设 ThreadLocal 已经不被使用了,那么实际上 setremoverehash 方法也不会被调用,与此同时,如果这个线程又一直存活、不终止的话,那么刚才的那个调用链就一直存在,也就导致了 value 的内存泄漏。

(2)如何避免内存泄露

分析完这个问题之后,该如何解决呢?

解决方法就是:调用 ThreadLocalremove 方法。

调用这个方法就可以删除对应的 value 对象,可以避免内存泄漏。

remove 方法的源码:

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

可以看出,它是先获取到 ThreadLocalMap 这个引用的,并且调用了它的 remove 方法。这里的 remove 方法可以把 key 所对应的 value 给清理掉,这样一来,value 就可以被 GC 回收了。

所以,在使用完了 ThreadLocal 之后,应该手动去调用它的 remove 方法,目的是防止内存泄漏的发生。