【Java面试】谈一谈你对ThreadLocal的理解

@[toc] 在多线程情况下,对于一个共享变量或者资源对象进行读或者写操作时,就必须考虑线程安全问题。而ThreadLocal采用的是完全相反的方式来解决线程安全问题。他实现了对资源对象的线程隔离,让每个线程各自使用各自的资源对象,避免争用引发的线程安全问题。ThreadLocal同时实现了线程内的资源共享。 例如方法1对ThreadLocal中的变量进行了设置,那么方法2中只要是同一个线程,那么他也能访问到线程1在ThreadLocal中设置的变量。

ThreadLocal原理

java 复制代码
package com.example.scheduledlovetoobject.threadPoolTest;

import lombok.SneakyThrows;

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;

/**
 * @author: Zhangjinbiao
 * @Date: 2022/12/7 10:33
 * @Connection: qq460219753 wx15377920718
 * Description:
 * Version: 1.0.0
 */
public class ThreadLocalTest {
    public static void main(String[] args) {
        test1();
        test2();
    }

    public static void test1() {
        for (int i = 0; i < 5; i++) {
            new Thread(() -> {
                System.out.println(Utils.getConnection());
            }, "t" + i).start();
        }
    }

    public static void test2() {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + Utils.getConnection());
                System.out.println(Thread.currentThread().getName() + Utils.getConnection());
                System.out.println(Thread.currentThread().getName() + Utils.getConnection());
            }, "t" + i).start();
        }
    }

    static class Utils {
        public static final ThreadLocal<Connection> tl = new ThreadLocal<>();

        public static Connection getConnection() {
            Connection conn = tl.get();
            if (conn == null) {
                conn = innerGetConnection();
                tl.set(conn);
            }
            return conn;
        }

        private static Connection innerGetConnection() {
            try {
                return DriverManager.getConnection("jdbc:mysql://localhost:3306/yoshino?useSSL=false", "root", "root");
            } catch (SQLException e) {
                throw new RuntimeException(e);
            }
        }
    }

}

大致设计

上面的代码很容易就看懂了,问题在于,ThreadLocal是怎么做到不同的线程存放不同的对象,而同一个线程能取出同样的对象的呢? 其实当你看到它使用的是set和get方法的时候,你就应该大致能猜到,他的底层应该有一个Map结构来存储线程以及这个线程中的资源,当然,这是JDK早期的设计了,下面是早期的设计 在JDK8中,ThreadLocal的结构已经改变了。 可以发现,我们并不是在ThreadLocal中存储我们的资源,而是在每一个线程中存储,由于每一个线程对象都有一个ThreadLocalMap的局部变量,因此每一个线程之间的ThreadLocalMap都是互相隔离的,所以同一个线程能访问到同一个ThreadLocalMap对象中的数据。我们使用ThreadLocal的set方法的时候其实是向Thread中的ThreadLocalMap设置值。那么下次我们调用ThreadLocal的get方法的时候,他其实会先获取当前线程对象,然后使用这个线程对象去访问ThreadLocalMap中的数据。

只有在你第一次使用这个Map集合的时候,他才会创建,也就是它使用的是懒加载。

因此ThreadLocal的原理是, 每一个线程对象中都有一个ThreadLocalMap类型的成员变量,用来存储资源对象。这个Map的key是ThreadLocal对象,value才是真正要存储的资源。 具体的过程是这样的: ( 1 ) 每个Thread线程内部都有一个Map (ThreadLocalMap) ( 2 ) Map里面存储ThreadLocal对象( key )和线程的变量副本 ( value ) ( 3 ) Thread内部的Map是由ThreadLocal维护的,由ThreadLocal负责向map获取和设置线程的变量值。 ( 4 )对于不同的线程,每次获取副本值时,别的线程并不能获取到当前线程的副本值,形成了副本的隔离,互不干扰。

那么现在的设计的好处在哪里呢?

  • 每个Map存储的Entry变少,在早期的设计中key为Thread对象,而我们知道Thread对象的个数是很多的,而现在的设计key为ThreadLocal,我们一般设定ThreadLocal为static,所以能保证ThreadLocalMap存储的键值对更少。
  • 现在在Thread销毁之后,ThreadLocalMap也会自动销毁,减少对内存的时候。而早期的设计,即使线程消失了,ThreadLocalMap依旧存在,还是要维护它。

方法介绍

  • 调用set方法,就是以ThreadLocal自己作为key,资源对象作为value,放入当前线程的ThreadLocalMap集合中
  • 调用get方法,就是以ThreadLocal自己作为key,到当前线程中查找关联的资源值
  • 调用remove方法,就是以ThreadLocal自己作为key,移除当前线程关联的资源值

set方法 执行流程: 首先获取当前线程,并根据当前线程获取一个Map 如果获取的Map不为空,则将次数设置到Map中 如果Map为空,则给该线程创建Map,并设置初始值

java 复制代码
    public void set(T value) {
    //获取当前线程对象
        Thread t = Thread.currentThread();
        //获取此线程对象中的Map对象
        ThreadLocalMap map = getMap(t);
        if (map != null) {
        //已经创建那么直接放入此entry
            map.set(this, value);
        } else {
        //不存在则创建 并将当前线程t和value值作为第一个entry存放到Map中
            createMap(t, value);
        }
    }
	//返回当前线程对应的Map
	 ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    } 
    //这里的this是调用createMap的ThreadLocal对象
    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

get方法 执行流程: A.首先获取当前线程,根据当前线程获取一个Map B.如果获取的Map不为空,则在Map中以ThreadLocal的引用作为key来在Map中获取对应的Entry e,否则转到D C.如果e不为null,则返回e.value,否则转到D D.Map为空或者e为空,则通过initialValue函数获取初始值value,然后用ThreadLocal的引用和value作为firstKey和firstValue创建一个新的Map 总结:先获取当前线程的ThreadLocalMap变量,如果存在则返回值,不存在则创建并返回初始值。

java 复制代码
public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
        //以当前ThreadLocal为key,调用getEntry获取对应的存储实体e
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                return result;
            }
        }
        //初始化,有两种情况会执行这个代码
        //map不存在,表示此线程没有维护ThreadLocalMap对象
        //map存在,但是没有查询到与当前ThreadLocal关联的entry
        return setInitialValue();
    }
	ThreadLocalMap getMap(Thread t) {
        return t.threadLocals;
    }
    
 	private Entry getEntry(ThreadLocal<?> key) {
            int i = key.threadLocalHashCode & (table.length - 1);
            Entry e = table[i];
            if (e != null && e.get() == key)
                return e;
            else
                return getEntryAfterMiss(key, i, e);
   }
	 private T setInitialValue() {
	 //调用initialValue获取初始化的值
	 //子类可以重写,不重写返回null
        T value = initialValue();
        //获取当前线程对象
        Thread t = Thread.currentThread();
        //获取当前线程对象维护的Map
        ThreadLocalMap map = getMap(t);
        if (map != null) { //map存在设置实体entry
            map.set(this, value);
        } else {
        //1:当前线程不存在ThreadLocalMap对象
        //2:则调用这个方法创建Map
        //并且将t和value设置进去
            createMap(t, value);
        }
        if (this instanceof TerminatingThreadLocal) {
            TerminatingThreadLocal.register((TerminatingThreadLocal<?>) this);
        }
        return value;
    }

remove方法 执行流程: 首先获取当前线程,并且根据该线程获取一个Map 如果获取的Map不为空,则移除当前ThreadLocal对象对应的entry

java 复制代码
 public void remove() {
         ThreadLocalMap m = getMap(Thread.currentThread());
         if (m != null) {
             m.remove(this);
         }
     }

initialValue方法 ( 1 )这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。 ( 2 )这个方法缺省实现直接返回一个null。 ( 3 )如果想要一个除null之外的初始值,可以重写此方法。(备注∶该方法是一个protected的方法显然是为了让子类覆盖而设计的)

java 复制代码
返回当前线程对应的ThreadLocal的初始值
此方法的第一次调用发生在,当线程通过get方法访问此钱程的
ThreadLocal值时除非线程先调用了set方法,在这种情况下,
initialvalue 才不会被这个线程调用。通常情况下,每个线程最多调用一次这个方法。
这个方法仅仅简单的返回nu1l {@code nu11};
如果程序员想ThreadLoca1线程局部变量有一个除nu11以外的初始值,
必须通过子类继承{@code ThreadLocaT]的方式去重写此方法
通常,可以通过匿名内部类的方式实现

  protected T initialValue() {
        return null;
    }

底层理解

ThreadLocalMap是ThreadLocal的内部类,没有实现Map接口,用独立的方式实现了Map功能,其内部的Entry也是独立实现的。

在ThreadLocalMap中,也是用Entry来保存K-V结构数据的。不过Entry中的key只能是ThreadLocal对象,这点在构造方法中已经限定死了。| 另外,Entry继承WeakReference,也就是key ( ThreadLocal)是弱引用,其目的是将ThreadLocal对象的生命周期和线程生命周期解绑。

java 复制代码
 static class ThreadLocalMap {
        static class Entry extends WeakReference<ThreadLocal<?>> {
            Object value;
		//这里要注意的是它的entey的key必须是ThreadLocal
            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }
        // 容量必须是2的n次幂
        private static final int INITIAL_CAPACITY = 16;
		//存放键值对的数组
        private Entry[] table;
		//table中元素个数
        private int size = 0;
		//扩容阈值
        private int threshold; // Default to 0
 }

下面是我对ThreadLocal的理解的结构图 首先我们知道,你使用ThreadLocal的时候,它的实例对象只有一个无参的get方法,也就是说你不能使用传统的Map结构去根据key获取value,而上面的ThreadLocalMap中的Entry结构也已经说明了,你使用那个ThreadLocal对象去调用这个get方法,那你就是获取那个ThreadLocal在某个Thread中的ThreadLocalMap中对应的value值。 如果你整个项目中只有一个ThreadLocal,但是有多个线程,那么这个ThreadLocal其实就是在不同的Thread中的ThreadLocalMap的key而已。 也就是其实ThreadLocal:Thread:ThreadLocalMap的关系为1:n:n,所以能做到在不同线程调用ThreadLocal对象的get或者set方法的时候,是对不同的Thread中的ThreadLocalMap对象获取和设置值。

如果还不理解,那么用下面的代码理解一下:

java 复制代码
   static class T {
        public ThreadLocal<Object> tl1 = new ThreadLocal<>();
        public ThreadLocal<Object> tl2 = new ThreadLocal<>();
    }

    public static void test3() {
        T t = new T();
        new Thread(()->{
            t.tl1.set("t1线程设置的tl1");
            t.tl2.set("t1线程设置的tl2");
            System.out.println(t.tl1.get());
            System.out.println(t.tl2.get());
        },"t1").start();
        new Thread(()->{
            t.tl1.set("t2线程设置的tl1");
            t.tl2.set("t2线程设置的tl2");
            System.out.println(t.tl1.get());
            System.out.println(t.tl2.get());
        },"t2").start();

    }

可以发现我在同一个线程中使用了多个ThreadLocal,但是即使是同一个线程,使用不同的ThreadLocal去去获取数据,得到的数据也是不同的。这也就是因为对于同一个Thread,他其实只有一个Map,但是这个Map的结构要求他的key必须是ThreadLocal类型,而值随意。因此你能做到如果使用不同的ThreadLocal作为key,那么就能取得不同的值。此时我们就可以得到下面的图: 而如果我们使用不同的ThreadLocal作为key,那么他们也是通过hash方法来找到对应的数组下标的。

因此,我们使用线程池的时候,按照上面的原理,由于这些线程被复用,所以如果我们在使用线程池中的线程的时候,如果这个对象中的ThreadLocalMap没有被清理,就可能导致我们会得到上一次的值,也就是"前世"。所以我们在使用ThreadLocal的时候要求使用完毕应该调用remove方法来清空ThreadLocalMap中的数据。当然,我们也可以利用线程池的这一点,来减少我们new对象的次数,来复用这些对象。

【Java面试】说说你对ThreadLocal内存泄漏问题的理解

hash冲突的解决

hash冲突的解决是Map中的一个重要内容。 而ThreadLocal也是使用了Map结构,因此也需要解决hash冲突。 首先看一下ThreadLocalMap的构造函数

java 复制代码
 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);
        }
	//获取hash值
   private final int threadLocalHashCode = nextHashCode();

  	//原子类
    private static AtomicInteger nextHashCode =
        new AtomicInteger();

  	//hash值增量
    private static final int HASH_INCREMENT = 0x61c88647;

 	//返回下一个hash值
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

构造函数首先创建了一个长度为16的Entry数组,然后计算出firstKey对应的索引,然后存储到table中,并且设置size和thresholad。

这里定义了一个AtomicInteger类型,每次获取当前值并加上HASH_INCREMENT, HASH_INCREMENT等于0x61c88647,这个值跟斐波那契数列(黄金分割数)有关,其主要目的就是为了让哈希码能均匀的分布在2的n次方的数组里,也就是Entry[] table中,这样做可以尽量避免hash冲突。 关于 & (INITIAL_CAPACITY - 1) 计算hash的时候里面采用了hashCode & (size - 1)的算法,这相当于取模运算hashCode % size的一个更高效的实现。正是因为这种算法,我们要求size必须是2的整次幂,这也能保证保证在索引不越界的前提下,使得hash发生冲突的次数减小。

java 复制代码
 private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            //计算索引
            int i = key.threadLocalHashCode & (len-1);

            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {
                ThreadLocal<?> k = e.get();
				//如果已经有这个key则直接覆盖
                if (k == key) {
                    e.value = value;
                    return;
                }
				//key为null,但是值补位null,说明ThreadLocal对象已经被回收了
				//当前数组中的Entry是一个陈旧的stale元素
                if (k == null) {
                //用新元素替换旧的,这个方法做了很多垃圾回收操作,防止内存泄漏
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }
			//key不存在且没有旧元素,直接在空元素位置创建一个新的Entry
            tab[i] = new Entry(key, value);
            int sz = ++size;
            //cleanSomeSlots用于清除e.get()==null的元素
            //这种数据key关联的对象已经被回收,所以此时可以把对应的位置设置为null
            //如果没有清除任何entry,表示当前使用量达到了负载因子,2/3,
            //那么就rehash(会执行一次全表的扫描操作)
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }
    	//线性探测    
	 private static int nextIndex(int i, int len) {
            return ((i + 1 < len) ? i + 1 : 0);
        }

代码执行流程: A.首先还是根据key计算出索引 i ,然后查找 i 位置上的Entry , B.若是Entry已经存在并且key等于传入的key,那么这时候直接给这个Entry赋新的value值, C.若是Entry存在,但是key为null,则调用replaceStaleEntry来更换这个key为空的Entry, D.不断循环检测,直到遇到为null的地方,这时候要是还没在循环过程中return,那么就在这个null的位置新建一个Entry,并且插入,同时size增加1。 最后调用cleanSomeSlots,清理key为null的Entry,最后返回是否清理了Entry,接下来再判断sz是否>=threshold达到了rehash的条件,达到的话就会调用rehash函数执行一次全表的扫描清理。

重点分析: ThreadLocalMap使用线性探测法来解决哈希冲突的。 该方法一次探测下一地址,直到有空的地址后插入,若整个空间都找不到空余的地址,则产生溢出。 举个例子,假设当前table长度为16,也就是说如果计算出来key的hash值为14,如果table[14]上已经有值,并且其key与当前key不一致,那么就发生了hash冲突,这个时候将14加1得到15,取table[15]进行判断,这个时候如果还是冲突会回到0,取table[0],以此类推,直到可以插入。 按照上面的描述,可以把Entry[] table看成一个环形数组。

get/set/remove方法的一些细节

对于get方法,如果get的时候发现key是null,也就是此时ThreadLocal已经被回收了,那么此时就会把对应的value也设定为空来释放空间,但是会把key再次设定为当前ThreadLocal。

对于set方法,如果发现set的时候得到的索引处的key为null,那么说明已经被回收掉了,那么此时会把数据放入进去,然后使用一种启发式扫描,他会扫描邻近的key是否为null,如果为null就进行对value的清理。相比全表扫描效率更高。启发次数与元素个数,是否发现null key有关。

对于get和set方法,他们只有在没有引用key(null key)的时候才会触发垃圾回收。 但是我们使用ThreadLocal一般都是静态的,所以这个ThreadLocal一般不会被回收,也就是他是一个强引用,所以一般不会出现null key的情况。

因此一般我们都使用remove方法,在某一个key不使用的时候,手动使用remove方法来设定其为null。

总结

每个线程都可以独立修改属于自己的副本而不会互相影响,从而隔离了线程和线程. 避免了线程访问实例变量发生安全问题. 同时我们也能得出下面的结论: (1)ThreadLocal只是操作Thread中的ThreadLocalMap对象的集合; (2)ThreadLocalMap变量属于线程的内部属性,不同的线程拥有完全不同的ThreadLocalMap变量; (3)线程中的ThreadLocalMap变量的值是在ThreadLocal对象进行set或者get操作时创建的; (4)使用当前线程的ThreadLocalMap的关键在于使用当前的ThreadLocal的实例作为key来存储value 值; (5) ThreadLocal模式至少从两个方面完成了数据访问隔离,即纵向隔离(线程与线程之间的 ThreadLocalMap不同)和横向隔离(不同的ThreadLocal实例之间的互相隔离); (6)一个线程中的所有的局部变量其实存储在该线程自己的同一个map属性中; (7)线程死亡时,线程局部变量会自动回收内存; (8)线程局部变量时通过一个 Entry 保存在map中,该Entry 的key是一个 WeakReference包装的 ThreadLocal, value为线程局部变量,key 到 value 的映射是通过: ThreadLocal.threadLocalHashCode & (INITIAL_CAPACITY - 1) 来完成的; (9)当线程拥有的局部变量超过了容量的2/3(没有扩大容量时是10个),会涉及到ThreadLocalMap中 Entry的回收;

对于多线程资源共享的问题,同步机制采用了"以时间换空间"的方式,而ThreadLocal采用了"以空间换 时间"的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量, 因此可以同时访问而互不影响。

相关推荐
考虑考虑33 分钟前
JDK9中的dropWhile
java·后端·java ee
想躺平的咸鱼干41 分钟前
Volatile解决指令重排和单例模式
java·开发语言·单例模式·线程·并发编程
hqxstudying1 小时前
java依赖注入方法
java·spring·log4j·ioc·依赖
·云扬·1 小时前
【Java源码阅读系列37】深度解读Java BufferedReader 源码
java·开发语言
martinzh2 小时前
Spring AI 项目介绍
后端
Bug退退退1232 小时前
RabbitMQ 高级特性之重试机制
java·分布式·spring·rabbitmq
小皮侠2 小时前
nginx的使用
java·运维·服务器·前端·git·nginx·github
前端付豪2 小时前
20、用 Python + API 打造终端天气预报工具(支持城市查询、天气图标、美化输出🧊
后端·python
爱学习的小学渣2 小时前
关系型数据库
后端
武子康2 小时前
大数据-33 HBase 整体架构 HMaster HRegion
大数据·后端·hbase