【多线程系列-05】深入理解ThreadLocal的底层原理和基本使用

简介: 【多线程系列-05】深入理解ThreadLocal的底层原理和基本使用

一,ThreadLocal

1,ThreadLocal简介

在官网中是这样介绍ThreadLocal的:ThreadLocal提供线程局部变量,这些变量与正常的变量不同,每一个线程在访问ThreadLocal实例的时候,都有自己的、独立的初始化的变量副本。ThreadLocal实例通常是类中的私有静态字段,使用它的目的是希望将状态与线程关联起来。


也就是说 ThreadLocal 为每个线程都提供了变量的副本,使得每个线程在某一时间访问到的并非同一个对象,这样就隔离了多个线程对数据的数据共享。


因此也可以看出 ThreadLocal 和 Synchonized 都用于解决多线程并发访问。但是 ThreadLocal 与 synchronized 有着本质的差别,synchronized 是利用锁的机制,使变量或代码块在某一时该仅仅能被一个线程访问,而ThreadLocal 则是副本机制,此时不论多少线程并发访问都是线程安全的。


简而言之就是:假设篮球场上10个人,只有一个篮球,那么这十个人都得抢这一个篮球,并且还要考虑同时抢大打出手的问题,就需要加锁,这无疑是效率太低;而ThreadLocal为了解决这种资源竞争的问题,就引用了副本机制,就是人手一个篮球,每个篮球和一个人一一对应,这样就即提高了效率,也不会出现抢占的问题。用一句话形容synchronized,lock等这些锁:群雄逐鹿起纷争;用一句话形容ThreadLocal就是:人手一份天下安


2,ThreadLocal的基本使用

可以先到官网中先查看其api:https://docs.oracle.com/javase/8/docs/api/index.html


主要有以下的方法,可以说这个类的方法是很少的了,因此想要用的好只需要注意里面的细节即可

d9c4a334c4e74b75b5bcd58dde0f7162.png



可以通过new 关键字创建一个ThreadLocal,并且重写里面的 initialValue 方法来初始化局部变量的副本的值

ThreadLocal<Integer> threadLocal = new ThreadLocal<Integer>(){
    @Override
    protected Integer initialValue() {
        return 0;
    }
};

也可以直接通过匿名内部类的方式创建一个ThreadLocal,此时可以直接通过调用类方法 withInitial 来初始化局部变量副本的值,开发中更加推荐使用这种方式

ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);

set,get和remove就比较简单了,直接通过实例调用即可。

threadLocal.set("xxx");
threadLocal.get();
threadLocal.remove();

如下,写一个小demo,在测试类内部创建一个静态的内部类,并继承Thread类

/**
 * @author zhenghuisheng
 * @date : 2023/8/9
 */
public class ThreadLocalTest {
    //初始化threadLocal
    static ThreadLocal<Integer> threadLocal = ThreadLocal.withInitial(() -> 0);
    //主线程
    public static void main(String[] args) {
        t t = new t(10);
        t.start();
    }
    //静态内部线程类
    static class t extends Thread{
        private Integer i;
        public t(Integer i){
            this.i = i;
        }
        //重写run方法
        @Override
        public void run() {
            System.out.println(i);
            threadLocal.set(i+100);
            System.out.println(threadLocal.get());
            //防止内存泄漏
            threadLocal.remove();
        }
    }
}

3,ThreadLocal的底层源码(重点)

3.1,ThreadLocalMap的底层结构和原理

在threadLocal类中,是一个带有泛型的类,该类中主要有一些初始化,get,set,remove等方法

public class ThreadLocal<T> {...}

由于每个线程中可能会存在多个副本,因此在这个ThreadLocal类内部,又有一个 ThreadLocalMap 静态内部类,主要用于存储这些ThreadLocal副本,由于map结构查询数据的时间复杂度为O(1),因此优先考虑使用map这种数据结构存储数据

static class ThreadLocalMap {...}

既然是用到了map这种数据结构,就要考虑hash冲突的问题,hashMap解决hash碰撞是通过数组加链表再加红黑树实现的(jdk1.8),而这个 ThreadLocalMap里面解决这个hash碰撞是引入了 Entry 数组实例


5e9c5f162406460fb9eb3b2c9142b0e6.png


Entry是一个类,他是 ThreadLocalMap 里面的一个静态内部类

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

从他的构造方法中可以看出,有两个参数,key是这些线程的副本,value就是对应的值

Entry(ThreadLocal<?> k, Object v)

又由于这个 ThreadLocalMap 的构造方法中,会初始化一个最大整型容量的table数组,里面主要存储这个entry对象,因此实现这个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);
}

而又由于这个ThreadLocal和他的静态内部类ThreadLocalMap都是 Thread 类的成员变量

public class Thread implements Runnable {
    ...
  ThreadLocal.ThreadLocalMap threadLocals = null;
}

因此根据层层关系,可以得知这几个类之间的关系如下图,ThreadLocalMap是thread实例的一个成员变量,创建一个ThreadLocalMap会创建一个Table数组,主要是存放Entry的实例,该实例由键值对组成,key值就是ThreadLocal副本,value值就是存到该副本的值

06ca5f2d81904292a4109ba12f6f3979.png


3.2,set,get,remove方法底层实现

分析完底层的存储结构和原理,那么再来分析threadLocal的set,get,remove等方法就很简单了。


首先看set方法的源码,首先会先获取到当前线程,随后通过getMap获取到这个ThreadLocalMap,如果map为空则创建一个map,并且将值加入到map中,不为空则直接将值加入到map中。

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的方法如下,就是将ThreadLocal副本作为key,需要存储的value作为值,期间会经过一些位运算,来解决hash冲突的问题,最终将生成一个Entry对象,随后将这个对象存储在数组里面


8d1868645c464b43903bd5f618e08690.png


其次再来看看get方法,其实现也很简单,也是先获取到当前线程,然后获取到ThreadLocalMap,随后去ThreadLocalMap里面的数组取值就行

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();
}

在获取这个Entry实例时,也会经过一些位运算来获取值

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);
}

最后剩下一个remove方法了,也是先获取到这个ThreadLocalMap,随后删除数组里面的值


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

由于ThreadLocalMap底层是由Entry数组实现的,因此主要删除Entry数组里面的值即可,也要做一个hash位运算

private void remove(ThreadLocal<?> key) {
    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)]) {
        if (e.get() == key) {
            e.clear();
            expungeStaleEntry(i);
            return;
        }
    }
}

因此threadLocal的set,get和remove方法讲完了,其实就是对一个数组的操作,期间可能需要处理一些hash冲突问题。


4,Hash冲突解决方式

hash冲突指的是数据在压缩映射的时候冲突,如有10桶水,要将这10桶水倒入到5个桶里面,桶大小一样,那么肯定会装不下,这就是所谓的冲突。


常见解决hash冲突的方法有开放定址法、再Hash法、链地址法。在hashMap中,所采用的就是链地址法,就是说当发生hash冲突之后,就把后加进来的值存放到链表以及红黑树里面。


但是在这个ThreadLocal中,并没有采用这种链地址法,很明显在源码中,只看到了只有一个数组,并没有看到链表红黑树等的出现,而是采用的是开放定址法。开放定址法就是说,如果发生hash冲突,后进来的就往后找空位,如果为空则将值插入进去。


开放定址法实现方式主要有:线性探测再散列、二次探测再散列、伪随机探测再散列,这几种方式区别在于每次定位下一个地址的方式不同。线性是每当发生hash冲突时,往后一步再次判断;二次是每n的平方步判断,如这次验证第一个格子,下次跳2的平方个格子,再下次就是跳3的平方个格子,以此类推…;伪随机就是随机步数判断。


在ThreadLocal中,采用的是线性探测再散列的开放定址法。在set元素时也可以发现,如果出现了hash冲突,就会依次的往下一个元素找。

private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}

5,ThreadLocal造成内存泄漏的原因

在谈这个内存泄漏之前,一定需要有点jvm的基础和常识,可以先查看的jvm系列:https://blog.csdn.net/zhenghuishengq/category_11862872.html ,至少需要知道堆存什么,栈存什么,对象是否能被回收,垃圾回收的方法等等


接下来就举一个简单的例子,就是每次,随后使用 JProfiler 工具打开

public class ThreadLocalTest {
    //初始化threadLocal
    static ThreadLocal<byte[]> threadLocal = ThreadLocal.withInitial(() -> new byte[0]);
    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10; i++) {
            new t().start();
            Thread.sleep(500);
        }
        System.out.println(threadLocal.get());
    }
    static class t extends Thread{
        @Override
        public void run() {
            byte[] b = new byte[1024*20];
            threadLocal.set(b);
        }
    }
}

随后查看这个内存的结果,很明显我的内存的使用一直在增加,按理来说我这个对象set进去了,但是get的值却是空的,按理来说虽然逃逸分析可以随着入栈和出栈将不被引用的对象给当做垃圾回收,但是这个对象是存储在entry对象里面的,由于jvm主流的还是使用gc root可达性分析算法来判断对象是否能回收的,因此这里也可以猜测这个entry对象是被引用这的,不可能被回收,而且看内存的增高也知道是没有被回收的,那么为啥get的时候获取到的值为空呢?


8f071b57c96a4bedb8bec542263d7985.png


因此又得回到源码里面来找出路,后面才知道是这个Entry这个类的问题,因为这个类继承了 WeakReference这个弱引用类,并且里面有着泛型ThreadLocal

static class Entry extends WeakReference<ThreadLocal<?>>{}

引用分为强引用、软引用、弱引用、虚引用,弱引用指的就是无论空间是否存在,下次gc都会被回收。看似这里是被entry引用着这个key和value,但是又与这个ThreadLocal是一个弱引用,也就是说这个entry的key是一个弱引用,因此在run方法出栈的后,下次gc就会将这个key给回收掉,但是value是还存在的,value还存在entry里面,key被回收了,因此在调用get()方法的时候,获取到的值为空,而value没有被回收,因此这个内存一直在增加,并且由于是被强引用着,因此gc不掉,这就是典型的内存泄漏问题,即应该被回收的内容没有被回收。


如下图,在入栈run方法中,由于这个threadLocal是一个变量,因此存储在当前线程的栈帧里面,即被当前线程所引用着,但是当该方法出栈之后,该栈帧就被销毁了,那么就只剩一个key指向这这个threadLocal,而由于threadLocal是一个弱引用,那么在下次gc的时候,threadLocal就直接给回收掉了

4a5237a8b3e74250848f663e7258e233.png



那么使用这个threadLocal时,就需要在每次使用完后,即时的remove掉,才能避免这种内存泄漏问题

threadLocal.remove();
相关文章
|
3月前
|
安全 Java 数据库
一天十道Java面试题----第四天(线程池复用的原理------>spring事务的实现方式原理以及隔离级别)
这篇文章是关于Java面试题的笔记,涵盖了线程池复用原理、Spring框架基础、AOP和IOC概念、Bean生命周期和作用域、单例Bean的线程安全性、Spring中使用的设计模式、以及Spring事务的实现方式和隔离级别等知识点。
|
3月前
|
编解码 网络协议 API
Netty运行原理问题之Netty的主次Reactor多线程模型工作的问题如何解决
Netty运行原理问题之Netty的主次Reactor多线程模型工作的问题如何解决
|
2月前
|
存储 缓存 Java
什么是线程池?从底层源码入手,深度解析线程池的工作原理
本文从底层源码入手,深度解析ThreadPoolExecutor底层源码,包括其核心字段、内部类和重要方法,另外对Executors工具类下的四种自带线程池源码进行解释。 阅读本文后,可以对线程池的工作原理、七大参数、生命周期、拒绝策略等内容拥有更深入的认识。
139 29
什么是线程池?从底层源码入手,深度解析线程池的工作原理
|
1月前
|
Java 编译器 程序员
【多线程】synchronized原理
【多线程】synchronized原理
59 0
|
1月前
|
Java 应用服务中间件 API
nginx线程池原理
nginx线程池原理
34 0
|
2月前
|
存储 缓存 Java
JAVA并发编程系列(11)线程池底层原理架构剖析
本文详细解析了Java线程池的核心参数及其意义,包括核心线程数量(corePoolSize)、最大线程数量(maximumPoolSize)、线程空闲时间(keepAliveTime)、任务存储队列(workQueue)、线程工厂(threadFactory)及拒绝策略(handler)。此外,还介绍了四种常见的线程池:可缓存线程池(newCachedThreadPool)、定时调度线程池(newScheduledThreadPool)、单线程池(newSingleThreadExecutor)及固定长度线程池(newFixedThreadPool)。
|
3月前
|
存储 NoSQL Java
线程池的原理与C语言实现
【8月更文挑战第22天】线程池是一种多线程处理框架,通过复用预创建的线程来高效地处理大量短暂或临时任务,提升程序性能。它主要包括三部分:线程管理器、工作队列和线程。线程管理器负责创建与管理线程;工作队列存储待处理任务;线程则执行任务。当提交新任务时,线程管理器将其加入队列,并由空闲线程处理。使用线程池能减少线程创建与销毁的开销,提高响应速度,并能有效控制并发线程数量,避免资源竞争。这里还提供了一个简单的 C 语言实现示例。
|
3月前
|
存储 Java
线程池的底层工作原理是什么?
【8月更文挑战第8天】线程池的底层工作原理是什么?
117 8
|
2月前
|
安全 Java API
Java线程池原理与锁机制分析
综上所述,Java线程池和锁机制是并发编程中极其重要的两个部分。线程池主要用于管理线程的生命周期和执行并发任务,而锁机制则用于保障线程安全和防止数据的并发错误。它们深入地结合在一起,成为Java高效并发编程实践中的关键要素。
32 0
|
3月前
|
存储 Java 调度
深入浅出Java线程池原理
本文深入分析了Java线程池的原理和实现,帮助读者更好地理解Java并发编程中线程池的创建、工作流程和性能优化。