本章,我将对Netty中的FastThreadLocal这个线程本地工具类进行讲解。我曾经在《透彻理解Java并发编程》系列中介绍过JDK中的ThreadLocal,Netty 官方表示FastThreadLocal 是比 JDK 的 ThreadLocal 性能更高。
那么,FastThreadLocal 到底比 ThreadLocal 快在哪里呢?
一、ThreadLocal
我先来带大家回顾下JDK中的ThreadLocal。ThreadLocal 可以理解为线程本地变量,它是 Java 并发编程中非常重要的一个工具类。ThreadLocal 为变量在每个线程中都创建了一个副本,该副本只能被当前线程访问,多线程之间是隔离的,变量不能在多线程之间共享。这样每个线程修改变量副本时,不会对其他线程产生影响。
1.1 使用示例
通过一个例子看下 ThreadLocal 如何使用:
public class ThreadLocalTest {
private static final ThreadLocal<String> THREAD_NAME_LOCAL = new ThreadLocal<>();
private static final ThreadLocal<TradeOrder> TRADE_THREAD_LOCAL = new ThreadLocal<>();
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
int tradeId = i;
new Thread(new Runnable() {
@Override
public void run() {
TradeOrder tradeOrder = new TradeOrder(tradeId, tradeId % 2 == 0 ? "已支付" : "未支付");
TRADE_THREAD_LOCAL.set(tradeOrder);
THREAD_NAME_LOCAL.set(Thread.currentThread().getName());
System.out.println("threadName: " + THREAD_NAME_LOCAL.get());
System.out.println("tradeOrder info:" + TRADE_THREAD_LOCAL.get());
}
}).start();
}
}
static class TradeOrder {
long id;
String status;
public TradeOrder(int id, String status) {
this.id = id;
this.status = status;
}
@Override
public String toString() {
return "id=" + id + ", status=" + status;
}
}
}
上述示例中,我构造了THREAD_NAME_LOCAL
和TRADE_THREAD_LOCAL
两个 ThreadLocal 变量,分别用于记录当前线程名称和订单交易信息。
输出结果如下,可以看出 Thread-0 和 Thread-1 虽然操作的是相同的 ThreadLocal 对象,但是它们取到了不同的线程名称和订单交易信息:
threadName: Thread-0
tradeOrder info:id=0, status=已支付
threadName: Thread-1
tradeOrder info:id=1, status=未支付
1.2 数据结构
既然多线程访问 ThreadLocal 变量时都会有自己独立的实例副本,那么很容易想到的方案就是在 ThreadLocal 中维护一个 Map,记录线程与实例之间的映射关系。当新增线程和销毁线程时都需要更新 Map 中的映射关系,因为会存在多线程并发修改,所以需要保证 Map 是线程安全的。
那么 JDK 的 ThreadLocal 是这么实现的吗?答案是 NO。因为在高并发的场景并发修改 Map 需要加锁,势必会降低性能。JDK 为了避免加锁,采用了相反的设计思路: 以 Thread 入手,在每个 Thread 中维护 Map,记录 ThreadLocal 与本地变量之间的映射关系 ,这样在同一个线程内,Map 就不需要加锁了。
每个线程内部有一个 ThreadLocalMap ,它是一种使用线性探测法实现的哈希表,底层采用数组存储数据。数组(默认大小16)的每一项是一个Entry
对象,用于保存 key-value 键值对,key 就是 ThreadLocal 对象,value 就是线程本地变量:
当线程调用ThreadLocal.set(XXX)
添加对象时,会执行以下操作:
- 首先,根据ThreadLocal对象的
threadLocalHashCode
与内部数组长度进行取余,计算应该存放到哪个索引位置的Entry中; - 如果出现Hash冲突,也就是说这个索引位置已经其它ThreadLocal对象占用了,则依次向后查找第一个空的索引位置;
- 最后,更新Entry中的变量值。
由此可见,ThreadLocal.set()/get()
方法在数据密集时很容易出现 Hash 冲突,需要O(n)
时间复杂度解决冲突问题,效率较低。
每个 ThreadLocal 在初始化时都会有一个 Hash 值
threadLocalHashCode
,每增加一个 ThreadLocal, Hash 值就会固定增加一个魔数HASH_INCREMENT = 0x61c88647
。为什么取0x61c88647
这个魔数呢?因为实验证明,通过 0x61c88647 累加生成的threadLocalHashCod
与2的幂取模,得到的结果可以较为均匀地分布在长度为2的幂次的数组中。
下面我们再聊聊 ThreadLocalMap 中 Entry 的设计原理。
Entry 继承自弱引用类WeakReference
,Entry 的 key 是弱引用,value 是强引用。在 JVM 垃圾回收时,只要发现了弱引用的对象,不管内存是否充足,都会进行回收。
那么 为什么 Entry 的 key 要设计成弱引用呢? 我们试想下,如果 key 都是强引用,当 ThreadLocal 不再使用时,然而 ThreadLocalMap 中还是存在对 ThreadLocal 的强引用,那么 GC 是无法回收的,从而造成内存泄漏。
虽然 Entry 的 key 设计成了弱引用,但是当 ThreadLocal 不再使用被 GC 回收后,ThreadLocalMap 中可能出现 Entry 的 key 为 NULL,而 Entry 的 value 则一直会强引用数据而得不到释放,只能等待线程销毁,最终造成内存泄漏。
那么应该 如何避免 ThreadLocalMap 内存泄漏呢?
- 首先,ThreadLocal已经帮助我们做了一定的保护措施,在执行
ThreadLocal.set()/get()
方法时,ThreadLocal 会清除 ThreadLocalMap 中 key 为 NULL 的 Entry 对象; - 对开发者而言,需要保持良好的编码意识:当线程中某个 ThreadLocal 对象不需要使用时,应立即调用 remove() 方法删除 Entry 对象。如果是在异常的场景中,应该在 finally 代码块中进行清理。
二、FastThreadLocal
JDK 的 ThreadLocal 已经在实际开发中运用的非常成熟了,Netty 为什么还要自己实现一个 FastThreadLocal ?我们来一探究竟。
FastThreadLocal 的实现与 ThreadLocal 非常类似,Netty 为 FastThreadLocal 量身打造了 FastThreadLocalThread
和 InternalThreadLocalMap
两个重要的类。
FastThreadLocalThread 是对 Thread 类的一层包装,每个线程都有一个 InternalThreadLocalMap 实例。所以,只有 FastThreadLocal 和 FastThreadLocalThread 组合使用,才能发挥 FastThreadLocal 的性能优势。对应关系如下:
- ThreadLocal <-> FastThreadLocal
- Thread <-> FastThreadLocalThread
- ThreadLocalMap <-> InternalThreadLocalMap
// FastThreadLocalThread.java
public class FastThreadLocalThread extends Thread {
private InternalThreadLocalMap threadLocalMap;
//...
}
2.1 使用示例
我们先通过一个例子来看如何使用FastThreadLocal 。可以看到,用法几乎和JDK中的ThreadLocal完全一样,只需要把代码中 Thread、ThreadLocal 分别替换为 FastThreadLocalThread 和 FastThreadLocal 即可:
public class FastThreadLocalTest {
private static final FastThreadLocal<String> THREAD_NAME_LOCAL = new FastThreadLocal<>();
private static final FastThreadLocal<ThreadLocalTest.TradeOrder> TRADE_THREAD_LOCAL = new FastThreadLocal<>();
public static void main(String[] args) {
for (int i = 0; i < 2; i++) {
int tradeId = i;
new FastThreadLocalThread(new Runnable() {
@Override
public void run() {
ThreadLocalTest.TradeOrder tradeOrder = new ThreadLocalTest.TradeOrder(tradeId, tradeId % 2 == 0 ? "已支付" : "未支付");
TRADE_THREAD_LOCAL.set(tradeOrder);
THREAD_NAME_LOCAL.set(Thread.currentThread().getName());
System.out.println("threadName: " + THREAD_NAME_LOCAL.get());
System.out.println("tradeOrder info:" + TRADE_THREAD_LOCAL.get());
}
}).start();
}
}
static class TradeOrder {
long id;
String status;
public TradeOrder(int id, String status) {
this.id = id;
this.status = status;
}
@Override
public String toString() {
return "id=" + id + ", status=" + status;
}
}
}
输出结果如下:
threadName: Thread-2
threadName: Thread-1
tradeOrder info:id=0, status=已支付
tradeOrder info:id=1, status=未支付
2.2 性能优势
我之前讲到 ThreadLocal 的一个缺点:内部的 ThreadLocalMap 采用线性探测法解决 Hash 冲突,性能较差。我们看看Netty中的 InternalThreadLocalMap 是如何优化的:
- 每个FastThreadLocal对象,在初始化时会分配一个数组索引
index
,索引值由InternalThreadLocalMap类采用AtomicInteger生成,递增且全局唯一; - 当线程(FastThreadLocal)通过FastThreadLocal读写数据时,通过索引下标,可以定位到 FastThreadLocal 在InternalThreadLocalMap中的位置,时间复杂度为
O(1)
;
// FastThreadLocal.java
private final int index;
public FastThreadLocal() {
index = InternalThreadLocalMap.nextVariableIndex();
}
// InternalThreadLocalMap.java
public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {
private static final AtomicInteger nextIndex = new AtomicInteger();
private Object[] indexedVariables;
public static int nextVariableIndex() {
int index = nextIndex.getAndIncrement();
if (index < 0) {
nextIndex.decrementAndGet();
throw new IllegalStateException("too many thread-local indexed variables");
}
return index;
}
//...
}
上述这种方案可以有效解决Hash冲突问题,当然,如果InternalThreadLocalMap内部的数组下标递增到非常大,那么数组也会比较大,所以,FastThreadLocal 是通过以 空间换时间 的思想提升读写性能。
2.3 数据结构
我们继续来看 InternalThreadLocalMap
和 FastThreadLocal
的数据结构:
InternalThreadLocalMap 使用 Object 数组替代了 Entry 数组,Object[0] 存储的是一个Set > 集合,从数组下标 1 开始都是直接存储的 value 数据,不再采用 ThreadLocal 的键值对形式进行存储,数组默认大小32,默认每个元素的值都是 UNSET
这个缺省Object对象的引用。
最新版本的Netty中,不再是在索引0处存放Set集合,而是在
variablesToRemoveIndex
处,我这里为了便于后续讲解,沿用0。
举个例子,假设现在我们有一批数据需要添加到数组中,分别为 value1、value2、value3、value4,对应的 FastThreadLocal 在初始化的时候生成的数组索引分别为 1、2、3、4。如下图所示:
2.4 源码分析
本节,我来分析FastThreadLocal的源码,帮助大家从底层理解FastThreadLocal的原理。
set方法
先来看设置线程本地变量的set
方法:
// FastThreadLocal.java
public final void set(V value) {
// 如果value不是缺省值
if (value != InternalThreadLocalMap.UNSET) {
// 获取当前线程内部的InternalThreadLocalMap
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
// 将InternalThreadLocalMap中的数据替换为新的value
setKnownNotUnset(threadLocalMap, value);
} else {
remove();
}
}
上述set() 的过程主要分为三步:
- 首先,判断 value 是否为缺省值,如果是则调用remove()直接清除(这个后面专门分析);
- 如果 value 不等于缺省值,则获取当前线程的 InternalThreadLocalMap;
- 最后,将 InternalThreadLocalMap 中对应数据替换为新 value。
来看InternalThreadLocalMap.get()
方法,目的就是获取线程内部的InternalThreadLocalMap对象,根据线程类型的不同,采取了不同逻辑:
// InternalThreadLocalMap.java
private static final ThreadLocal<InternalThreadLocalMap> slowThreadLocalMap =
new ThreadLocal<InternalThreadLocalMap>();
public static InternalThreadLocalMap get() {
Thread thread = Thread.currentThread();
// 1.如果当前线程为FastThreadLocalThread
if (thread instanceof FastThreadLocalThread) {
return fastGet((FastThreadLocalThread) thread);
}
// 2.当前线程是普通Thread线程
else {
return slowGet();
}
}
private static InternalThreadLocalMap fastGet(FastThreadLocalThread thread) {
InternalThreadLocalMap threadLocalMap = thread.threadLocalMap();
if (threadLocalMap == null) {
thread.setThreadLocalMap(threadLocalMap = new InternalThreadLocalMap());
}
return threadLocalMap;
}
private static InternalThreadLocalMap slowGet() {
InternalThreadLocalMap ret = slowThreadLocalMap.get();
if (ret == null) {
ret = new InternalThreadLocalMap();
slowThreadLocalMap.set(ret);
}
return ret;
}
- 对于FastThreadLocalThread类型的线程:直接取ThreadLocalThread线程对象内部的InternalThreadLocalMap,如果不存在就创建并关联;
- 对于普通Thread线程:从 JDK的ThreadLocal中获取 InternalThreadLocalMap。
下面通过一幅图描述两种不同线程获取 InternalThreadLocalMap 的方式,便于大家理解:
再来看如何将InternalThreadLocalMap中的数据替换为新的value,也就是setKnownNotUnset
方法:
- 找到数组下标 index 位置,设置新的 value;
- 将 FastThreadLocal 对象保存到待清理的 Set 中。
// FastThreadLocal.java
private void setKnownNotUnset(InternalThreadLocalMap threadLocalMap, V value) {
if (threadLocalMap.setIndexedVariable(index, value)) { // 找到数组下标 index 位置,设置新value
// 将 FastThreadLocal 对象保存到待清理的 Set 中
addToVariablesToRemove(threadLocalMap, this);
}
}
InternalThreadLocalMap的setIndexedVariable方法,index
就是FastThreadLocal对象创建时分配的唯一索引:
// InternalThreadLocalMap.java
public boolean setIndexedVariable(int index, Object value) {
// indexedVariables就是InternalThreadLocalMap中用于存放数据的数组
Object[] lookup = indexedVariables;
if (index < lookup.length) {
Object oldValue = lookup[index]; // 时间复杂度为 O(1)
lookup[index] = value;
return oldValue == UNSET;
} else {
// 数组扩容
expandIndexedVariableTableAndSet(index, value);
return true;
}
}
InternalThreadLocalMap 以 index 为基准进行扩容,将数组扩容后的容量向上取整为 2 的次幂,然后将原数组内容拷贝到新的数组中,空余部分填充缺省对象 UNSET,最终把新数组赋值给 indexedVariables。
回到 setKnownNotUnset() 的主流程,向 InternalThreadLocalMap 添加完数据之后,接下就是将 FastThreadLocal 对象保存到待清理的 Set 中。我们继续看下 addToVariablesToRemove() 是如何实现的:
// FastThreadLocal.java
private static void addToVariablesToRemove(InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {
// 获取数组下标为 variablesToRemoveIndex 的元素
Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);
Set<FastThreadLocal<?>> variablesToRemove;
if (v == InternalThreadLocalMap.UNSET || v == null) {
// 创建 FastThreadLocal 类型的 Set 集合
variablesToRemove = Collections.newSetFromMap(new IdentityHashMap<FastThreadLocal<?>, Boolean>());
// 将 Set 集合填充到数组下标 0 的位置
threadLocalMap.setIndexedVariable(variablesToRemoveIndex, variablesToRemove);
} else {
// 如果不是 UNSET,Set 集合已存在,直接强转获得 Set 集合
variablesToRemove = (Set<FastThreadLocal<?>>) v;
}
// 将 FastThreadLocal 添加到 Set 集合中
variablesToRemove.add(variable);
}
为什么 InternalThreadLocalMap 要在数组下标为 variablesToRemoveIndex
的位置存放一个 FastThreadLocal 类型的 Set 集合呢?与remove方法有关。
remove方法
我们回过头看下 remove() 方法:
// FastThreadLocal.java
public final void remove() {
remove(InternalThreadLocalMap.getIfSet());
}
public final void remove(InternalThreadLocalMap threadLocalMap) {
if (threadLocalMap == null) {
return;
}
// 删除数组下标 index 位置对应的 value
Object v = threadLocalMap.removeIndexedVariable(index);
// 从数组下标 0 的位置取出 Set 集合,并删除当前 FastThreadLocal
removeFromVariablesToRemove(threadLocalMap, this);
if (v != InternalThreadLocalMap.UNSET) {
try {
// 空方法,用户可以继承实现
onRemoval((V) v);
} catch (Exception e) {
PlatformDependent.throwException(e);
}
}
}
在调用remove方法时,InternalThreadLocalMap 会从数组中定位到下标 index 位置的元素,并将 index 位置的元素覆盖为缺省对象 UNSET。接下来就需要清理当前的 FastThreadLocal 对象,此时 InternalThreadLocalMap 会取出数组下标 0 位置的 Set 集合,然后删除当前 FastThreadLocal:
// FastThreadLocal.java
private static void removeFromVariablesToRemove(
InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {
Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);
if (v == InternalThreadLocalMap.UNSET || v == null) {
return;
}
Set<FastThreadLocal<?>> variablesToRemove = (Set<FastThreadLocal<?>>) v;
variablesToRemove.remove(variable);
}
最后 onRemoval() 方法起到什么作用呢?Netty 只是留了一处扩展,并没有实现,用户需要在删除的时候做一些后置操作,可以继承 FastThreadLocal 实现该方法。
get方法
最后,我们来看下FastThreadLocal的get方法:
// FastThreadLocal.java
public final V get() {
// 获取当前线程内部的InternalThreadLocalMap
InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
// 获取本地变量
Object v = threadLocalMap.indexedVariable(index);
if (v != InternalThreadLocalMap.UNSET) {
return (V) v;
}
// 为缺省值则初始化
return initialize(threadLocalMap);
}
private V initialize(InternalThreadLocalMap threadLocalMap) {
V v = null;
try {
// 调用用户重写的 initialValue 方法构造需要存储的对象数据
v = initialValue();
} catch (Exception e) {
PlatformDependent.throwException(e);
}
threadLocalMap.setIndexedVariable(index, v);
// 把当前 FastThreadLocal 对象保存到待清理的 Set 中
addToVariablesToRemove(threadLocalMap, this);
return v;
}
// InternalThreadLocalMap.java
public Object indexedVariable(int index) {
Object[] lookup = indexedVariables;
return index < lookup.length? lookup[index] : UNSET;
}
三、总结
本章,我对Netty中的线程本地工具类FastThreadLocal进行了深入讲解。FastThreadLocal 真的一定比 ThreadLocal 快吗?答案是不一定的,只有使用FastThreadLocalThread 类型的线程才会更快,如果是普通线程反而会更慢。最后,我对FastThreadLocal 进行一个总结:
- 高效查找 。FastThreadLocal 在定位数据的时候可以直接根据数组下标 index 获取,时间复杂度 O(1)。而 JDK 原生的 ThreadLocal 在数据较多时容易发生 Hash 冲突,线性探测法在解决 Hash 冲突时需要不停地向后寻找,效率较低。此外,FastThreadLocal 相比 ThreadLocal 数据扩容更加简单高效,FastThreadLocal 以 index 为基准向上取整到 2 的次幂作为扩容后容量,然后把原数据拷贝到新数组。而 ThreadLocal 由于采用的哈希表,所以在扩容后需要再做一轮 rehash。
- 安全性更高 。JDK 原生的 ThreadLocal 使用不当可能造成内存泄漏,只能等待线程销毁。在使用线程池的场景下,ThreadLocal 只能通过主动检测的方式防止内存泄漏,从而造成了一定的开销。而 FastThreadLocal 不仅提供了 remove() 主动清除对象的方法,而且在线程池场景中 Netty 还封装了 FastThreadLocalRunnable,FastThreadLocalRunnable 最后会执行 FastThreadLocal.removeAll() 将 Set 集合中所有 FastThreadLocal 对象都清理掉,
Java 面试宝典是大明哥全力打造的 Java 精品面试题,它是一份靠谱、强大、详细、经典的 Java 后端面试宝典。它不仅仅只是一道道面试题,而是一套完整的 Java 知识体系,一套你 Java 知识点的扫盲贴。
它的内容包括:
- 大厂真题:Java 面试宝典里面的题目都是最近几年的高频的大厂面试真题。
- 原创内容:Java 面试宝典内容全部都是大明哥原创,内容全面且通俗易懂,回答部分可以直接作为面试回答内容。
- 持续更新:一次购买,永久有效。大明哥会持续更新 3+ 年,累计更新 1000+,宝典会不断迭代更新,保证最新、最全面。
- 覆盖全面:本宝典累计更新 1000+,从 Java 入门到 Java 架构的高频面试题,实现 360° 全覆盖。
- 不止面试:内容包含面试题解析、内容详解、知识扩展,它不仅仅只是一份面试题,更是一套完整的 Java 知识体系。
- 宝典详情:https://www.yuque.com/chenssy/sike-java/xvlo920axlp7sf4k
- 宝典总览:https://www.yuque.com/chenssy/sike-java/yogsehzntzgp4ly1
- 宝典进展:https://www.yuque.com/chenssy/sike-java/en9ned7loo47z5aw
目前 Java 面试宝典累计更新 400+ 道,总字数 42w+。大明哥还在持续更新中,下图是大明哥在 2024-12 月份的更新情况:
想了解详情的小伙伴,扫描下面二维码加大明哥微信【daming091】咨询
同时,大明哥也整理一套目前市面最常见的热点面试题。微信搜[大明哥聊 Java]或扫描下方二维码关注大明哥的原创公众号[大明哥聊 Java] ,回复【面试题】 即可免费领取。