《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,学习它的目的有这样几个:
- 了解其设计思想,为后续梳理HashMap打下基础
- 梳理该数据结构中涉及的一些基础知识,比如java中的引用类型
- 梳理ThreadLocal内存泄漏这个知识点
概述
ThreadLocalMap是Java中ThreadLocal类中的一个静态内部类,其主要作用是用于实现线程的本地存储(ThreadLocalStorage,即TLS)的功能。每个线程都有一个与之关联的ThreadLocalMap,在这个map中,键是ThreadLocal对象,值则是我们真正想要在当前线程中保存和隔离的变量。
当我们在一个线程中调用ThreadLocal的get()或set()方法时,实际上就是在操作该线程对应的ThreadLocalMap。这样就能保证每个线程只能访问到自己线程局部变量的副本,而不会影响其他线程中的副本,从而有效地避免了多线程环境下的数据共享问题。
需要注意的是,ThreadLocalMap使用弱引用(WeakReference)来存储ThreadLocal实例作为其键。这意味着如果只有ThreadLocalMap引用了ThreadLocal实例,而没有其他强引用指向ThreadLocal实例,那么在垃圾回收时,这个ThreadLocal实例及其在ThreadLocalMap中对应的值都可能被回收,以防止内存泄漏。但这也可能导致一些不易察觉的问题,比如预期的数据无法获取,因此在使用ThreadLocal时应确保正确管理其生命周期。关于ThreadLocalMap的源码,请参照上一章节或者直接翻看源码。下面将详细梳理我们开发过程中遇到的几个高频操作及相关知识吧。
set操作
第一小节提到过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() {@Overridepublic 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这种具有存储功能的组件,会基于所使用的资源情况动态地调整自己所占的内存空间,比如这里要梳理的扩容(前提是内存容量充足)就是动态调整内存容量的一种实现。ThreadLocalMap通过rehash()方法来扩大自己的内存容量。下面先来看一下这个方法的源码:
private void rehash() {expungeStaleEntries();// Use lower threshold for doubling to avoid hysteresisif (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引用吧!
java中的引用
在ThreadLocalMap中我们看到了WeakReference,不知大家对它有没有印象。对,它就是弱引用。不过在java中我们经常看到的是T t = new T()这种写法,这里的t就是一个比WeakReference更强的引用。那在java中究竟有多少中引用呢?想必这个问题大家在面试中经常见到吧!那这个问题该怎么回答呢?
在Java中,引用类型是用来指向对象的变量。它们并不直接存储对象的数据,而是存储对象在内存中的地址(或称为引用)。Java中有以下四种类型的引用,它们分别为:
- 强引用 (Strong Reference):强引用是默认的引用类型,当一个对象被强引用变量所引用时,只要强引用还在,垃圾回收器就永远不会回收该对象,即使系统内存不足也不会回收。只有当不再有强引用指向该对象时,它才会成为垃圾回收的目标。强引用一般是这样子:Object strongRef = new Object();
- 软引用 (Soft Reference):软引用是可选的间接引用,通过SoftReference类实现,该类位于java.lang.ref包中。持有软引用的对象,在系统将要发生内存溢出,即OutOfMemoryError,之前,垃圾回收器会把这些对象列入回收范围进行回收。如果回收后仍无法满足内存分配需求,则抛出OOM异常。软引用通常用来实现内存敏感的缓存。软引用一般是这样的:SoftReference<Object> softRef = new SoftReference<>(new Object());
- 弱引用 (Weak Reference):弱引用通过java.lang.ref.WeakReference类实现。弱引用的对象拥有更短暂的生命周期,只要垃圾回收器发现存在弱引用的对象,不管当前内存是否足够,都会回收该对象。弱引用经常用于防止内存泄漏的情形,比如维护一种“无主”数据结构。弱引用一般是这样的:WeakReference<Object> weakRef = new WeakReference<>(new Object());
- 虚引用 (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)是计算机程序设计中的一个严重问题,特别是在长时间运行的程序中。它指的是程序在申请分配了一块内存空间后,未能在不再需要这块内存时及时释放,导致系统无法回收这部分内存供其他程序使用。随着时间推移,这种未被释放的内存会不断积累,从而消耗掉系统的可用内存资源。造成内存泄漏的原因主要有以下几点:
- 忘记释放内存:程序员在使用动态内存分配函数(如C++中的`new`操作符或C语言中的malloc()函数)分配了内存之后,没有在适当的时候调用相应的内存释放函数(如C++中的delete或C语言中的free())
- 悬挂指针:即使内存已被释放,但仍然存在指向该内存区域的指针,使得系统误以为该内存仍在使用,从而不能回收
- 循环引用:在某些支持垃圾回收的语言中,如果对象之间形成了循环引用关系,而这些对象已经不再需要,但由于彼此仍保持引用,可能会造成垃圾收集器无法正确回收它们的内存
- 单例和其他长生命周期对象持有无用对象引用:单例模式下,如果单例类持有对其他不再使用的对象的引用,由于单例在整个应用程序生命周期内不会被销毁,因此所引用的对象也无法被回收
知道了内存泄漏的定义,又知道了导致内存泄漏的原因,那我们到底该如何解决内存泄漏呢?常见方法有以下几种:
- 手动管理内存:在像C++这样的手动内存管理语言中,遵循“谁分配,谁释放”的原则,并确保每个new操作都有对应的delete操作
- 智能指针:在C++中使用智能指针(如std::unique_ptr、std::shared_ptr等)可以自动管理内存,当智能指针析构时,它会自动删除其所管理的对象,从而避免内存泄漏
- 内存泄漏检测工具:利用各种静态代码分析工具和动态分析工具检测内存泄漏,例如Valgrind、LeakCanary等
- 编程规范与设计模式:采用良好的编程习惯,如尽量减少动态内存分配,或者设计合适的对象生命周期管理策略
知道这些是不是就万事大吉了,不是的,要想预防内存泄漏,关键还得靠程序员,其要具有强烈的内存管理意识,并通过适当的编程技术来保证程序在使用完内存后能够将其正确释放回操作系统。
通过上面的描述,我们可以用一句简单的话来描述一下内存泄漏:所谓内存泄漏,就是申请的内存无法被及时回收,导致其不断积累,最终导致系统可用内存逐渐减少,从而导致系统崩溃。