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

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

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

ThreadLocal原理

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,并设置初始值

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变量,如果存在则返回值,不存在则创建并返回初始值。

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

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

initialValue方法

( 1 )这个方法是一个延迟调用方法,从上面的代码我们得知,在set方法还未调用而先调用了get方法时才执行,并且仅执行1次。

( 2 )这个方法缺省实现直接返回一个null。

( 3 )如果想要一个除null之外的初始值,可以重写此方法。(备注∶该方法是一个protected的方法显然是为了让子类覆盖而设计的)

返回当前线程对应的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对象的生命周期和线程生命周期解绑。

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对象获取和设置值。

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

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的构造函数

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发生冲突的次数减小。

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采用了“以空间换

时间”的方式。前者仅提供一份变量,让不同的线程排队访问,而后者为每一个线程都提供了一份变量,

因此可以同时访问而互不影响。


相关文章
|
1天前
|
JavaScript 前端开发 Java
【JAVA面试题】什么是引用传递?什么是值传递?
【JAVA面试题】什么是引用传递?什么是值传递?
|
3天前
|
存储 安全 Java
[Java基础面试题] Map 接口相关
[Java基础面试题] Map 接口相关
|
3天前
|
Java
[Java 面试题] ArrayList篇
[Java 面试题] ArrayList篇
|
4天前
|
Java 调度
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
Java面试必考题之线程的生命周期,结合源码,透彻讲解!
32 1
|
4天前
|
设计模式 搜索推荐 Java
面试官不按套路出牌,上来就让聊一聊Java中的迭代器(Iterator ),夺命连环问,怎么办?
面试官不按套路出牌,上来就让聊一聊Java中的迭代器(Iterator ),夺命连环问,怎么办?
12 0
|
4天前
|
存储 安全 Java
每日一道Java面试题:说一说Java中的泛型?
今天的每日一道Java面试题聊的是Java中的泛型,泛型在面试的时候偶尔会被提及,频率不是特别高,但在日后的开发工作中,却是是个高频词汇,因此,我们有必要去认真的学习它。
15 0
|
4天前
|
Java 编译器
每日一道Java面试题:方法重载与方法重写,这把指定让你明明白白!
每日一道Java面试题:方法重载与方法重写,这把指定让你明明白白!
15 0
|
9天前
|
XML 缓存 Java
Java大厂面试题
Java大厂面试题
20 0
|
9天前
|
存储 安全 Java
Java大厂面试题
Java大厂面试题
15 0
|
9天前
|
存储 安全 Java
Java大厂面试题
Java大厂面试题
16 0