ThreadLocal“你”真的了解吗?(二)

本文深入探讨了ThreadLocal和ThreadLocalMap。先回顾ThreadLocal的set(T)方法,验证线程中存储数据的特性。接着详细介绍ThreadLocalMap,包括其概述、set(T)操作、扩容操作、Hash冲突解决方法,还讲解了Java中的引用类型和ThreadLocal的内存泄漏问题,最后提出相关思考问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

《ThreadLocal“你”真的了解吗?(一)》这篇文章梳理了ThreadLocal的基础知识,同时还梳理了java中线程的创建方法以及这两者之间的关系,本篇文章我们将继续梳理与ThreadLocal相关,在上一节也提过的另一组件ThreadLocalMap。在开始梳理ThreadLocalMap之前,让我们会先回顾一下前篇文章讲的一些知识点。

ThreadLocal之set(T)方法

这里再次梳理这个方法,主要目的不是讲解这个方法的存储流程,而是想验证和强化一下上篇文章末尾那个故事中的一个说法:在Thread的run()方法中无论调用多少次set(T)方法,最终存储到Thread中的threadLocals中的值只有一个。这个问题有跟同事探讨过。在验证之前,我们先来看一下这个方法的源码(含ThreadLocal中的createMap()方法的源码及ThreadLocalMap构造方法的源码):

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        map.set(this, value);
    } else {
        createMap(t, value);
    }
}

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

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

从源码中不难看出,当当前线程中的ThreadLocalMap对象不为空时,会直接调用ThreadLocalMap对象上的set(T)方法将要保存的值V保存到线程本地ThreadLocalMap中,这个值V对应的key是当前对象(this),即ThreadLocal对象。上一篇提到过ThreadLocalMap是一个和Map类似的工具(这里先这么理解,后面一小节会对本类进行详细介绍),所以结合源码我们有理由相信:在线程的run()方法中无论你调用多少次set(T),只要ThreadLocal对象是同一个,那Map中的数据只会是最后一次调用set()时存进去的数据V。这个可以参看后面的数据对比图。下面再来啰嗦一下当当前线程中的ThreadLocalMap对象为空时的处理逻辑,直接调用createMap(Thread, T)方法创建一个ThreadLocalMap对象,该对象持有当前的ThreadLocal对象和当前值。通过ThreadLocalMap的构造方法不难发现这两个值,即ThreadLocal对象和当前值,被包装进了Entry对象中,然后会将当前Entry对象赋值给ThreadLocal中的Entry[]数组中。下面是一组数据对比图:

图一 

图二

上篇文章末尾的故事中,还提出了一个小问题:为什么要这么定义呢?这肯定和线程中存储的数据量和向线程中存储数据的调用地方有关系。跟同事探讨这个问题时,他是这样说的:如果要存储很多数据,为什么要定义多个ThreadLocal呢?直接将ThreadLocal的泛型定义为Map不就可以了吗?是的他这个说法没毛病(如果要在一个run()方法执行过程中存储多个数据到当前线程中,泛型使用Map结构不失为上上策。他有这个说法,是因为我给他展示代码是在同一个类中定义多个 ThreadLocal,然后分别调用,具体可以参照下面的图片),但如果要在run()方法调用的方法调用的另一个方法中向线程中存储数据呢?比如线程A的run()方法调用类B的某个方法job(),而job又调用了C类的某个方法doJob(),注意B类和C类相互独立,即这两者并非互为内部类,这个时候如果在C#doJob()方法中向线程中存储一个数据(线程私有数据),按照同事的说法就需要先拿到线程本地变量中的Map,然后从Map中拿到泛型Map,接着向泛型Map中存放数据,最后再把泛型Map放回到ThreadLocalMap中。这就比较麻烦了。不如直接在C类中再定义一个ThreadLocal对象,然后直接调用ThreadLocal的set(T)方法存储数据方便。所以Thread中的私有变量ThreadLocalMap定义成与Map类似的结构是为了方便不同类同时向当前线程中存储线程私有变量的

好了,这两个萦绕我多年的疑惑终于解决了,那我们对这个一直被提及的ThreadLocalMap究竟有多少认识呢?1) 和Map类似;2) 可以存储数据;3) 通过它可以解决多线程间共享数据线程安全的问题。了解这么多就够了吗?

ThreadLocalMap深入了解

本小节我们将继续深入学习实现线程本地存储的关键数据结构ThreadLocalMap,学习它的目的有这样几个:

  1. 了解其设计思想,为后续梳理HashMap打下基础
  2. 梳理该数据结构中涉及的一些基础知识,比如java中的引用类型
  3. 梳理ThreadLocal内存泄漏这个知识点

概述

ThreadLocalMap是Java中ThreadLocal类中的一个静态内部类,其主要作用是用于实现线程的本地存储(ThreadLocalStorage,即TLS)的功能每个线程都有一个与之关联的ThreadLocalMap,在这个map中,键是ThreadLocal对象,值则是我们真正想要在当前线程中保存和隔离的变量

当我们在一个线程中调用ThreadLocal的get()或set()方法时,实际上就是在操作该线程对应的ThreadLocalMap。这样就能保证每个线程只能访问到自己线程局部变量的副本,而不会影响其他线程中的副本,从而有效地避免了多线程环境下的数据共享问题。

需要注意的是,ThreadLocalMap使用弱引用(WeakReference)来存储ThreadLocal实例作为其键。这意味着如果只有ThreadLocalMap引用了ThreadLocal实例,而没有其他强引用指向ThreadLocal实例,那么在垃圾回收时,这个ThreadLocal实例及其在ThreadLocalMap中对应的值都可能被回收,以防止内存泄漏。但这也可能导致一些不易察觉的问题,比如预期的数据无法获取,因此在使用ThreadLocal时应确保正确管理其生命周期。关于ThreadLocalMap的源码,请参照上一章节或者直接翻看源码。下面将详细梳理我们开发过程中遇到的几个高频操作及相关知识吧。

set(T)操作

第一小节提到过ThreadLocal的set(T)方法最终调用的是这个类的set(ThreadLocal, T)方法或该类的构造方法,首先看该类的构造方法:

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

该类的构造方法完成的逻辑非常简单:创建table对象(一个Entry类型的数组);确定当前ThreadLocal对象在table中的存储位置;创建Entry对象,并将其赋值到Entry数组中下标为i的位置上;初始化表长度;计算threshold的大小,算法见下述代码。这里解释一下INITIAL_CAPACITY变量是一个固定值,为16,表示Entry表的初始长度。

private void setThreshold(int len) {
    threshold = len * 2 / 3;
}

这里的Entry又是什么呢?它是ThreadLocalMap中的一个静态内部类,其继承了java中的弱引用类,即WeakReference类。该类持有的目标对象为ThreadLocal。前面说过“ThreadLocalMap使用弱引用(WeakReference)来存储ThreadLocal实例作为其键。这意味着如果只有ThreadLocalMap引用了ThreadLocal实例,而没有其他强引用指向ThreadLocal实例,那么在垃圾回收时,这个ThreadLocal实例及其在ThreadLocalMap中对应的值都可能被回收,以防止内存泄漏”。不过根据网上资料,这也是造成ThreadLocal内存泄漏的根本原因。真的是这样吗?后面再一起分析这个问题。这个Entry中还有一个Object类型的value属性,记录的就是你存放到线程中的值,比如前面案例中local.set("1234567890")这句中的1234567890最终会被Entry中的value承接。

上面我们一起梳理了ThreadLocalMap的构造方法及其处理逻辑,下面再让我们一起来梳理一下ThreadLocalMap中的set(ThreadLocal, T)方法。先来看一下这个方法的源码:

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)]) {
        if (e.refersTo(key)) {
            e.value = value;
            return;
        }

        if (e.refersTo(null)) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }

    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

这段代码的处理逻辑非常清晰:把ThreadLocalMap中的table属性拷贝一份出来赋值给Entry[]类型的数组对象tab获取table属性的长度,并赋值给len计算将要保存的数据在table表中的位置(这段代码是理解“用同一个ThreadLocal对象存储数据,最终只会保存最后一个值”这个说法的关键,由于是同一个ThreadLocal对象,所以最终计算出来的数组下标是同一个,因此调用两次ThreadLocal的set(T)方法,最终存储的数据是最后一次调用传递进去的数据遍历Entry类型的数组,如果数组下标i所表示的位置存在值,则判断Entry对象和方法接收的ThreadLocal对象是否一致,如果一致则直接替换其中的value值,然后结束如果下标i所表示的位置没有数据,则直接创建Entry对象,然后将其赋值到i所表示的位置上,接着将数组长度自增1接着调用cleanSomeSlots(数组下标-3,数组实际长度-表示数组中的哪些下标中有数据-10)【注意这里所讲的3和10是按照下述案例代码执行到local9.set(Thread.currentThread().getName() + " - 9abcdefghij")时遇到的】,总之cleanSomeSlots()这个方法的主要作用就是清理下标位置到实际长度(Entry数组中槽位不为空的数量)间的数据。由于Entry继承了WeakReference类,在内存不足时,该Entry对象包裹的ThreadLocal对象极易被回收,这会导致数组中一些数据无效,所以这样做可将一些已经被标记为无效的槽位重新利用起来,如果有清理,该方法会返回true,这样就不会调用if分支中的rehash()方法;如果该方法返回false则表示没有清理,然后判断当前map的长度是否达到了需要清理的标准(threshold,默认值为10)【案例中执行到local9.set(∙∙∙),当前map的长度已经达到10了,所以会调用rehash()方法,经过该方法后,map的长度由原来的16变成了32,具体见下图】。

public class SpringTransactionApplication {

    static ThreadLocal<String> local = new ThreadLocal<>();
    static ThreadLocal<String> local1 = new ThreadLocal<>();
    static ThreadLocal<String> local2 = new ThreadLocal<>();
    static ThreadLocal<String> local3 = new ThreadLocal<>();
    static ThreadLocal<String> local4 = new ThreadLocal<>();
    static ThreadLocal<String> local5 = new ThreadLocal<>();
    static ThreadLocal<String> local6 = new ThreadLocal<>();
    static ThreadLocal<String> local7 = new ThreadLocal<>();
    static ThreadLocal<String> local8 = new ThreadLocal<>();
    static ThreadLocal<String> local9 = new ThreadLocal<>();
    static ThreadLocal<String> local10 = new ThreadLocal<>();
    static ThreadLocal<String> local11 = new ThreadLocal<>();
    static ThreadLocal<String> local12 = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {

        local.set(Thread.currentThread().getName() + " - 0987654321");

        Thread t = new Thread() {
            @Override
            public void run() {
                local.set(Thread.currentThread().getName() + " - 1234567890");
                local.set(Thread.currentThread().getName() + " - abcdefghij");
                local1.set(Thread.currentThread().getName() + " - 1abcdefghij");
                local2.set(Thread.currentThread().getName() + " - 2abcdefghij");
                local3.set(Thread.currentThread().getName() + " - 3abcdefghij");
                local4.set(Thread.currentThread().getName() + " - 4abcdefghij");
                local5.set(Thread.currentThread().getName() + " - 5abcdefghij");
                local6.set(Thread.currentThread().getName() + " - 6abcdefghij");
                local7.set(Thread.currentThread().getName() + " - 7abcdefghij");
                local8.set(Thread.currentThread().getName() + " - 8abcdefghij");
                local9.set(Thread.currentThread().getName() + " - 9abcdefghij");
                local10.set(Thread.currentThread().getName() + " - 10abcdefghij");
                local11.set(Thread.currentThread().getName() + " - 11abcdefghij");
                local12.set(Thread.currentThread().getName() + " - 12abcdefghij");
            }
        };
        t.start();
        t.join();

    }

}

Thread私有的ThreadLocalMap对象扩容前后的对比图,其中图一时扩容前的效果,图二时扩容后的效果:

图一

图二

扩容前后前后数据存放位置的对比见下表(图中标红的表示扩容前后数据存放位置有发生变化):

数据

扩容前数组下标

扩容后数组下标

Thread-0-abcdefghij

4

20

Thread-0-1abcdefghij

11

27

Thread-0-2abcdefghij

2

2

Thread-0-3abcdefghij

9

9

Thread-0-4abcdefghij

0

16

Thread-0-5abcdefghij

7

23

Thread-0-6abcdefghij

14

30

Thread-0-7abcdefghij

5

5

Thread-0-8abcdefghij

12

12

Thread-0-9abcdefghij

3

19

Thread-0-10abcdefghij

Thread-0-11abcdefghij

Thread-0-12abcdefghij

通过这段梳理我们明白了在向线程本地变量ThreadLocalMap中存放数据的时候,如果数据超过最开始初始化的threshold(默认值为10)且没有数据被垃圾收集器回收时,会进行扩容操作。那具体的扩容过程是怎样的呢?

扩容操作

我们常常听别人讲:艺术源于生活,却又高于生活。那计算机呢?在日常生活中我们经常看到有些餐厅人满为患,而有的却门可罗雀。为了给顾客提供更好的服务,优化自己的营业收入,生意火爆的老板通常会选择租一间更大的店面,生意相对较差的老板则会将现有店面换掉。这里隐含的处理思路就是扩容、缩容,通过这个,生意好的餐馆会更上一层楼,生意差的也能免于扼杀。ThreadLocalMap这种具有存储功能的组件,会基于所使用的资源情况动态地调整自己所占的内存空间,比如这里要梳理的扩容(前提是内存容量充足)就是动态调整内存容量的一种实现。ThreadLocalMap通过rehash()方法来扩大自己的内存容量。下面先来看一下这个方法的源码:

private void rehash() {
    expungeStaleEntries();

    // Use lower threshold for doubling to avoid hysteresis
    if (size >= threshold - threshold / 4)
        resize();
}

该方法上有这样一段注释(翻译不准,还望海涵):重新包装和/或调整表格的大小。首先扫描整个表,删除陈旧的条目。如果这不能充分缩小表的大小,则将表的大小增加一倍。如果这段翻译准确的话,那么第一行中的expungeStaleEntries()方法的主要作用就是删除数据集中陈旧的条目,而resize()这个方法的主要作用就是对数据集进行扩容。先来看一下第一行涉及的方法的源码:

private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.refersTo(null))
            expungeStaleEntry(j);
    }
}

通过源码个人理解就是遍历表中的所有数据,然后判断每个数据Entry中的key是否是null对象,如果是就调用expungeStaleEntry(index)方法做进一步处理,这里不再对该方法进行详细梳理,有兴趣的可以自己跟踪一下。下面让我们一起看一下resize()这个方法的源码:

private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;

    for (Entry e : oldTab) {
        if (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
                e.value = null; // Help the GC
            } else {
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }

    setThreshold(newLen);
    size = count;
    table = newTab;
}

从源码可以看出这个方法的处理逻辑非常清晰:首先备份原来的数据集,用Entry类型的数组对象oldTab来承接;然后拿到这个数据集的长度,由int类型的oldLen变量来承接;接着指定新的数据集长度,为老数据集长度的2倍,并由int类型的变量newLen来承接;再次创建Entry类型的新数组对象,其长度为原来数组长度的2倍,最后就是创建int类型的count变量,用于存储新数组中的实际长度(数组下标对应的Entry元素不为空的数量)。接着遍历老的Entry类型的数组集合,将其中的数据和新长度重新运算,得出数据在新数组中的存储下标,然后将数据存放进相应位置。然后重新计算threshold数据。最后设置新数组的实际长度到ThreadLocal中的size属性上,设置新的数组到ThreadLocal中Entry类型的的table属性上

这么看来扩容操作也并不复杂嘛!就是将一个数组中的数据拷贝到另外一个新创建的数组中,这个新数组的长度是原来数组长度的2倍。这不就是大学数据结构这门课程数组那节的案例吗?看来是我以小人之心度君子之腹了!这么一看,还是有必要仔细梳理一下expungeStaleEntry(index)这个方法,可这该怎么梳理呢?我想还是暂时放一放吧,先来看一下ThreadLocalMap中涉及的java引用吧!

Hash冲突

前一小节,我们梳理了ThreadLocalMap的扩容操作,下面我们来看一下ThreadLocalMap中的hash冲突。首先来看一个生活场景:去看电影时,我们都会有一个座位,人多时座无虚席,人少时稀稀疏疏。本小节中提到的hash槽和这些的座位有着相同的作用,只不过前者用来存放数据,后者供人使用。生活中我们经常看到这样的情况,影院中人头攒动,放映室的座位无法同时为这么多人提供服务,所以影院管理者会通过增加电影播放频次来满足这些人的需求。在计算机的世界里,生活中的这种情况变成了现有的存储空间无法满足这么多数据同时使用,那怎么办呢?对,加磁盘!那如果出现冲突呢?生活中我们一般会通过某种方式来解决,比如同时满足这两个人的需求;那在计算机中如何解决呢?比如本节要介绍的哈希冲突:有限的哈希槽要用来存储无限空间的数据,而存储的思路就是让这些无限的数据通过某种方式计算得到一个值,然后将其存到对应的位置上。由于一个范围非常大,而另一个范围却非常小,这就必然会出现存储位置重复的现象(这就是数学中的抽屉模型)。那这种情况该怎么解决呢?

哈希冲突(Hash Collision)是指在使用哈希表(Hash Table)存储数据时,由于哈希函数将不同输入(键,Key)转换成有限长度的哈希值,当两个不同的键通过哈希函数计算后得到相同的哈希值时,就会出现冲突这种情况下,同一个哈希桶(bucket)内可能需要存储多个键值对,这会降低哈希表的性能,特别是查找、插入和删除操作

解决哈希冲突的主要方法有以下几种:

  1. 开放地址法线性探测法——当冲突发生时,从当前位置开始,按照固定步长(通常为1)顺序检查下一个位置是否为空,直到找到一个空位为止二次探测法——探测步长不再是固定的1,而是以每次+1,-1,+2,-2,…的平方序列来调整下一次探测的位置双重哈希法——使用第二个哈希函数来确定冲突时的下一个存储位置,避免了简单的线性探测可能导致的“聚集”问题
  2. 链地址法(也称拉链法)在每个哈希桶内存储一个链表或其它结构(如平衡树等等),当冲突发生时,新元素被添加到对应桶内的链表中。Java中的HashMap类就采用了这种方法,在内部实现了一个数组,数组的每个元素都是一个链表头节点。
  3. 再哈希法使用一系列哈希函数,当第一个哈希函数产生冲突时,尝试使用第二个、第三个哈希函数,直到找到一个不冲突的位置
  4. 建立公共溢出区:当主哈希表的某个位置发生冲突时,将冲突的元素放入一个单独设立的溢出区,而不是在原地解决冲突。
  5. 布隆过滤器:布隆过滤器是一种概率型数据结构,可以用于判断一个元素是否存在于集合中。它通过多个哈希函数和位数组来实现,可以有效地解决哈希冲突问题。

选择哪种方法取决于应用场景和需求,比如预期的数据分布、负载因子、内存开销等因素。现代哈希表设计通常结合了这些策略来达到高效且稳定的性能。了解这些概念后,让我们来看一下ThreadLocalMap是如何解决冲突的。下面还是先来看一下set(ThreadLocal, Object)方法的源码:

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)]) {
        if (e.refersTo(key)) {
            e.value = value;
            return;
        }
        if (e.refersTo(null)) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    tab[i] = new Entry(key, value);
    int sz = ++size;
    if (!cleanSomeSlots(i, sz) && sz >= threshold) rehash();
}

在前面梳理set()时说过ThreadLocal的哈希值和(Entry[]数组的长度减去1的值)做按位与后会得到一个值,这个值表示value将要存储到的Entry[]数组中的下标。后面又讲过如果tab[i]代表的数据为空,则会跳过循环直接创建新的Entry对象,并将其存储到Entry[]的第i个位置上。如果tab[i]代表的数据不为空,则会进入循环,并首先判断元素e中的key值和传递进来的ThreadLocal值是否一致,如果一致则直接覆盖原来的值,否则继续判断元素e中的key值是否为null,如果是null,则调用replaceStaleEntry()方法,否则调用nextIndex()方法继续寻找下一个可用的数组下标。该方法源码为:

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

 可以这样想象一下,某个时刻调用了set()方法,很不幸出现了哈希冲突,这个时候程序不得不按照nextIndex()方法确定的规则,从已经计算出来的下标i处开始依次向后寻找可用的数组下标,如果直至找到数组长度减一处依旧没有找到,那就从数组的第一个位置继续向后寻找可用的空间。这刚好符合前面讲的线性探测法。因此ThreadLocalMap是采用开放地址法中的线性探测法的方式来解决哈希冲突的

java中的引用

在ThreadLocalMap中我们看到了WeakReference,不知大家对它有没有印象。对,它就是弱引用。不过在java中我们经常看到的是T t = new T()这种写法,这里的t就是一个比WeakReference更强的引用。那在java中究竟有多少中引用呢?想必这个问题大家在面试中经常见到吧!那这个问题该怎么回答呢?

在Java中,引用类型是用来指向对象的变量。它们并不直接存储对象的数据,而是存储对象在内存中的地址(或称为引用)Java中有以下四种类型的引用,它们分别为:

  1. 强引用 (Strong Reference):强引用是默认的引用类型当一个对象被强引用变量所引用时,只要强引用还在,垃圾回收器就永远不会回收该对象,即使系统内存不足也不会回收只有当不再有强引用指向该对象时,它才会成为垃圾回收的目标。强引用一般是这样子:Object strongRef = new Object();
  2. 软引用 (Soft Reference):软引用是可选的间接引用通过SoftReference类实现,该类位于java.lang.ref包中持有软引用的对象,在系统将要发生内存溢出,即OutOfMemoryError,之前,垃圾回收器会把这些对象列入回收范围进行回收如果回收后仍无法满足内存分配需求,则抛出OOM异常。软引用通常用来实现内存敏感的缓存。软引用一般是这样的:SoftReference<Object> softRef = new SoftReference<>(new Object());
  3. 弱引用 (Weak Reference):弱引用通过java.lang.ref.WeakReference类实现弱引用的对象拥有更短暂的生命周期,只要垃圾回收器发现存在弱引用的对象,不管当前内存是否足够,都会回收该对象弱引用经常用于防止内存泄漏的情形,比如维护一种“无主”数据结构。弱引用一般是这样的:WeakReference<Object> weakRef = new WeakReference<>(new Object());
  4. 虚引用 (Phantom Reference):虚引用是最弱的一种引用关系,也称为幽灵引用,通过java.lang.ref.PhantomReference类实现一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获取对象实例虚引用的主要用途是在对象被垃圾回收器回收之前,可以收到一个系统通知。虚引用在java中一般是这样定义的:PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue); // 这里queue是一个ReferenceQueue,用于接收虚引用关联对象被回收时的通知。

通过这些引用类型开发人员能够更加精细地控制对象的生命周期和内存占用,以优化程序性能、避免内存泄漏等问题。看到这里我就有点疑惑了,ThreadLocalMap中的Entry继承了WeakReference,真的会出现网上讲的那种threadLocal失效而value存在的场景吗?

ThreadLocal中的内存泄漏

内存泄漏?java中还会有这种问题?java不是号称自动内存管理吗?好吧,看来是我孤陋寡闻了。那就让我们一起认识一下这个高深的问题吧!内存泄漏(Memory Leak)是计算机程序设计中的一个严重问题,特别是在长时间运行的程序中。它指的是程序在申请分配了一块内存空间后,未能在不再需要这块内存时及时释放,导致系统无法回收这部分内存供其他程序使用随着时间推移,这种未被释放的内存会不断积累,从而消耗掉系统的可用内存资源。造成内存泄漏的原因主要有以下几点:

  1. 忘记释放内存:程序员在使用动态内存分配函数(如C++中的`new`操作符或C语言中的malloc()函数)分配了内存之后,没有在适当的时候调用相应的内存释放函数(如C++中的delete或C语言中的free())
  2. 悬挂指针:即使内存已被释放,但仍然存在指向该内存区域的指针,使得系统误以为该内存仍在使用,从而不能回收
  3. 循环引用:在某些支持垃圾回收的语言中,如果对象之间形成了循环引用关系,而这些对象已经不再需要,但由于彼此仍保持引用,可能会造成垃圾收集器无法正确回收它们的内存
  4. 单例和其他长生命周期对象持有无用对象引用:单例模式下,如果单例类持有对其他不再使用的对象的引用,由于单例在整个应用程序生命周期内不会被销毁,因此所引用的对象也无法被回收

知道了内存泄漏的定义,又知道了导致内存泄漏的原因,那我们到底该如何解决内存泄漏呢?常见方法有以下几种:

  1. 手动管理内存:在像C++这样的手动内存管理语言中,遵循“谁分配,谁释放”的原则,并确保每个new操作都有对应的delete操作
  2. 智能指针:在C++中使用智能指针(如std::unique_ptr、std::shared_ptr等)可以自动管理内存,当智能指针析构时,它会自动删除其所管理的对象,从而避免内存泄漏
  3. 内存泄漏检测工具:利用各种静态代码分析工具和动态分析工具检测内存泄漏,例如Valgrind、LeakCanary等
  4. 编程规范与设计模式:采用良好的编程习惯,如尽量减少动态内存分配,或者设计合适的对象生命周期管理策略

知道这些是不是就万事大吉了,不是的,要想预防内存泄漏,关键还得靠程序员,其要具有强烈的内存管理意识,并通过适当的编程技术来保证程序在使用完内存后能够将其正确释放回操作系统。

通过上面的描述,我们可以用一句简单的话来描述一下内存泄漏:所谓内存泄漏,就是申请的内存无法被及时回收,导致其不断积累,最终导致系统可用内存逐渐减少,从而导致系统崩溃。回到本小节的标题,ThreadLocal中的内存泄漏究竟是怎么一会事儿?它是怎么发生的?先让我们一起来看一下下面这个案例(抱歉这个案例在测试时未复现出内存泄漏的现象,后续会继续尝试):

public class ThreadLocalMemoryLeakDemo {

    private static final ThreadLocal<byte[]> THREAD_LOCAL = new ThreadLocal<>();

    public static void main(String[] args) throws InterruptedException {

        ExecutorService executorService = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                byte[] data = new byte[10240 * 5024];
                THREAD_LOCAL.set(data);
                // 不调用 remove() 方法,会导致内存泄漏
                // THREAD_LOCAL.remove();
            });
        }
        System.out.println("数据输出");
        executorService.shutdown();
        executorService.awaitTermination(2, TimeUnit.MINUTES);

    }

}

上面这个案例展示了ThreadLocal的内存泄漏。为了详细了解这个现象发生的原因,根据网络资料整理并绘制了下面这幅图,具体参见下图(这张图展示了ThreadLocal、Thread及ThreadLocalMap在内存中的存储逻辑):

关于这幅图片的详细解释,我们会在梳理jvm相关知识后进行更新。先跟大家说声抱歉。至此我们已经把与ThreadLocalMap相关的知识点都梳理了一遍,但在结束这篇文章之前,我还想问这样几个问题:1. ThreadLocalMap是map吗?首先个人认为ThreadLocalMap是一个map,只不过它没有实现Map接口,不过它和HashMap很相似。2. 那它的数据结构是怎样的呢?ThreadLocalMap的底层就是一个Entry类型的数组,这个Entry用ThreadLocal的弱引用作为key,用Object作为value。3. ThreadLocalMap用哈希取余的方式来确定当前Entry元素在Entry数组中的存储位置,取余的计算方法为ThreadLocal的哈希值和数组长度减一做按位与运算。这里再补充一点:每创建一个ThreadLocal对象,它就会增加0x61c88647,这是一个很特殊的值,它是一个斐波那契数,也叫黄金分割数,使用它的好处是ThreadLocal对象的哈希分布非常均匀。最后想给大家留一个思考题:ThreadLocalMap是如何实现父子线程通信的?如果您有答案,可以在评论区留言。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

机器挖掘工

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值