阅读 339

【底层原理之旅—ThreadLocal深入浅出的源码分析|Java 刷题打卡

本文正在参加「Java主题月 - Java 刷题打卡」,活动链接

题目

ThreadLocal深入浅出的源码分析

知识点

基本介绍

  • ThreadLocal是对Thread内部的局部变量ThreadLocalMap的维护类当线程持有多个ThreadLocal的操作时,会在ThreadLocalMap中通过key进行寻找。

  • 每个Thread里面维护了一个ThreadLocal.ThreadLocalMap变量,底层存储结构为Entry[],ThreadLocal实例作为ThreadLocalMap的key,set/get的值为Map的value,其中,key的引用为弱引用

  • 当执行ThreadLocal.set时,实际是将ThreadLocal对象和值通过key-value的形式放进了Thread中的ThreadLocal.ThreadLocalMap属性中,完成了线程隔离存储,保证了线程安全,所以每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本。

使用场景

  • ThreadLocal即便你没有直接用到过,它也间接的出现在你使用过的框架里:

    1. Spring的事务管理
    2. Hibernate的会话管理
    3. logback(和log4j)中的MDC功能实现等
    4. 比如用到的一些分页功能的实现往往也会借助于ThreadLocal
    5. 全链路追踪中的traceId或者流程引擎中上下文的传递一般采用ThreadLocal
    6. Spring MVC的RequestContextHolder的实现使用了ThreadLocal

总体概述

ThreadLocal常用来做线程隔离,下面将对ThreadLocal的实现原理、设计理念、内部实现细节(Map、弱引用)、还有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方法进行初始化操作。

数据结构

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

  • ThreadLocalMap中初始化了一个大小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的类图结构如下:

这里需要留意的是,ThreadLocalMap类中的Entry对象继承自WeakReference,也就是说它是弱引用。

由于ThreadLocalMaps是延迟创建的,因此在构造时至少要创建一个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,我们可以看到整个数据模型如下:

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

内存引用链路
  • 每个Thread维护一个ThreadLocalMap,它key是ThreadLocal实例本身,value是业务需要存储的Object。

  • ThreadLocal本身并不存储值,它只是作为一个key来让线程从ThreadLocalMap获取value。

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

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

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

  • 如果此Entity一直不被get()、set()、remove(),就一直不会被回收,也就发生了内存泄漏

所以,通常在使用完ThreadLocal后需要调用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对象,存储的位置位于Thread的ThreadLocal.ThreadLocalMap变量,在Thread中有如下变量

为什么将Map放在每一个Thread里

应为如果将Map放在ThreadLocal中进行维护,即使使用ConcurrentHashMap减少并发竞争,但在形式上还是存在线程间的竞争,而放在各个线程中独立维护,就十分满足线程隔离的设计理念。

ThreadLocal.ThreadLocalMap 与 HashMap有什么不同
解决hash冲突方法不同
  • HashMap采用的是数组加链表的结构进行存储,当出现hash冲突时,进行链表追加。

  • ThreadLocal.ThreadLocalMap采用的是开放定址法,即寻找下一个没有存储数据的位置。

拓展: 解决hash冲突的方式1. 开放定址法 2. 再hash 3. 链地址法 4. 公共溢出区。

扩容机制不同

当ThreadLocal.ThreadLocalMap的size大于数据1/2时,会扩容2倍。

为什么Entry的key存储采用弱引用

当ThreadLocal没有引用时,ThreadLocal.ThreadLocalMap依旧存在于Thread中,而ThreadLocal对应的Entry永远不会被使用到,所以采用了弱引用,当ThreadLocal没有引用时,自动key就被GC回收

为什么Entry的value存储没有采用弱引用

我们存储的对象除了ThreadLocalMap的Value就没有其他的引用了,value一但是对象的弱引用,GC的时候被回收,对象就无法访问了,这显然不是我们想要的。

如何解决

  • 在ThreadLocal不使用时,调用remove方法,将Entry从Map中移除,即可解决。

  • 对于Java8 ThreadLocalMap 的 set 方法通过调用 replaceStaleEntry 方法回收键为 null 的 Entry 对象的值(即为具体实例)以及 Entry 对象本身从而防止内存泄漏

  • get方法会间接调用expungeStaleEntry 方法将键和值为 null 的 Entry 设置为 null 从而使得该 Entry 可被回收

文章分类
后端
文章标签