Netty性能利器 从ThreadLocal到FastThreadLocal


Netty作为高性能的网络通信框架,在jdk的基础上,做很多的基础工具的优化。本篇介绍Netty对ThreadLocal的改进版,FastThreadLocal。看看FastThreadLocal如何做到高性能。

本文参考Netty源码版本为 netty-4.1.56.Final

1. JDK ThreadLocal

ThreadLocal是日常工作中常用的一种工具,如果你需要变量在多线程之间隔离,或者在同线程内的类和方法中共享,就可以用到ThreadLocal,线程本地变量。
ThreadLocal 为变量在每个线程中都创建了一个副本,该副本只能被当前线程访问,多线程之间是隔离的,变量不能在多线程之间共享。这样每个线程修改变量副本时,不会对其他线程产生影响。

1.1 基本使用

先通过一个例子看下 ThreadLocal 简单使用:

public class ThreadLocalDemo {
    private static final ThreadLocal<String> DEMO_THREAD_LOCAL = new ThreadLocal<>();
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            String threadName = "线程" + i;
            new Thread(() -> {
                DEMO_THREAD_LOCAL.set(threadName);
                System.out.println(DEMO_THREAD_LOCAL.get());
            }, threadName).start();
        }
    }
}

打印结果:

线程1
线程0

可以看到在同一个ThreadLocal变量,不同线程可以存储不同的值。

1.2 实现原理

既然需要实现不同线程访问变量得到不同的值,很容易想到的方案就是在 ThreadLocal 中维护一个 Map,记录线程与实例之间的映射关系。这就需要保证这个Map是线程安全的,加锁在高并发情况下性能较差。JDK 为了避免加锁,采用了相反的设计思路。以 Thread 入手,在 Thread 中维护一个 Map,记录 ThreadLocal 与实例之间的映射关系,这样在同一个线程内,Map 就不需要加锁了。
查看源码,Thread里有一个ThreadLocal.ThreadLocalMap类型的threadLocals变量,

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

ThreadLocalMap 其实与 HashMap 的数据结构类似,但是 ThreadLocalMap 不具备通用性,它是为 ThreadLocal 量身定制的。
ThreadLocalMap 是一种使用线性探测法实现的哈希表,底层采用数组存储数据。如下图所示,ThreadLocalMap 会初始化一个长度为 16 的 Entry 数组,每个 Entry 对象用于保存 key-value 键值对。与 HashMap 不同的是,Entry 的 key 就是 ThreadLocal 对象本身,value 就是用户具体需要存储的值。
在这里插入图片描述

我们知道,HashMap通过链表和红黑树来解决Hash冲突,而ThreadLocal.set() 添加 Entry 对象时,使用线性探测法解决Hash冲突。
每个 ThreadLocal 在初始化时都会有一个 Hash 值为 threadLocalHashCode,用于计算entry在数组中的位置。每增加一个 ThreadLocal, Hash 值就会固定增加一个魔法值 HASH_INCREMENT = 0x61c88647。为什么取 0x61c88647 这个魔数呢?实验证明,通过 0x61c88647 累加生成的 threadLocalHashCode 与 2n取模,得到的结果可以较为均匀地分布在长度为 2n 大小的数组中,减少Hash冲突。
那么ThreadLocal.set() 添加 Entry的过程大致是如下几步:

  1. 通过ThreadLocal的threadLocalHashCode值与 数组长度 取模(int i = key.threadLocalHashCode & (len-1);)得到数组下标 i。
  2. 然后判断下标位置 Entry 对象与待查询 Entry 对象的 key 是否相同,如果不同,继续向下查找。如果数组大小达到阈值,则扩容和rehash。

get方法原理类似。可以看到,发生hash冲突时,ThreadLocalMap继续向下查找,数据密集时,时间复杂度为O(n),效率较低。Netty的FastThreadLocal就针对这一问题进行改进。

1.3 内存泄露问题

在看FastThreadLocal之前,先回顾ThreadLocal 内存泄露的问题,
ThreadLocalMap 中 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 对象,让它还能够被 GC 回收。除此之外,当线程中某个 ThreadLocal 对象不再使用时,立即调用 remove() 方法删除 Entry 对象。如果是在异常的场景中,记得在 finally 代码块中进行清理,保持良好的编码意识。

2. Netty FastThreadLocal

2.1 原理分析

为了解决线性探测法解决Hash冲突效率不高的问题,Netty自行实现了FastThreadLocal代替jdk的 ThreadLocal,同时为 FastThreadLocal 量身打造了 FastThreadLocalThread 和 InternalThreadLocalMap 两个重要的类。分别对标jdk的Thread和ThreadLocalMap。
FastThreadLocalThread 是对 Thread 类的一层包装,每个线程持有一个 InternalThreadLocalMap 实例。
FastThreadLocal的使用与ThreadLocal基本一致,值得注意的是,只有 FastThreadLocal 和 FastThreadLocalThread 组合使用时,才能发挥 FastThreadLocal 的性能优势

public class FastThreadLocalThread extends Thread {
    private InternalThreadLocalMap threadLocalMap;
    // ...
}

解决Hash冲突效率不高的问题,核心逻辑是重新实现 ThreadLocalMap,也就是InternalThreadLocalMap,下面看看它的关键结构代码,

public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {
    private static final int DEFAULT_ARRAY_LIST_INITIAL_CAPACITY = 8;
    private static final int STRING_BUILDER_INITIAL_SIZE;
    private static final int STRING_BUILDER_MAX_SIZE;
    // 默认占位对象,不同下标位置使用同一个对象占位,可减少内存占用
    public static final Object UNSET = new Object();
    private BitSet cleanerFlags;

    private InternalThreadLocalMap() {
        super(newIndexedVariableTable());
    }
    private static Object[] newIndexedVariableTable() {
    	// 32在源码里是静态常量,这里直接写魔法值
        Object[] array = new Object[32];
        // 填充默认占位对象
        Arrays.fill(array, UNSET);
        return array;
    }

    public static int nextVariableIndex() {
        int index = nextIndex.getAndIncrement();
        if (index < 0) {
            nextIndex.decrementAndGet();
            throw new IllegalStateException("too many thread-local indexed variables");
        }
        return index;
    }
    // 省略其他代码
}

// 父类,主要声明结构,子类实现逻辑
class UnpaddedInternalThreadLocalMap {
	// jdk的ThreadLocal
    static final ThreadLocal<InternalThreadLocalMap> slowThreadLocalMap = new ThreadLocal<InternalThreadLocalMap>();
    // 数组下标
    static final AtomicInteger nextIndex = new AtomicInteger();

    Object[] indexedVariables;
    UnpaddedInternalThreadLocalMap(Object[] indexedVariables) {
        this.indexedVariables = indexedVariables;
    }
    // 省略其他代码
}

InternalThreadLocalMap与 ThreadLocalMap 一样都是采用数组的存储方式。但是 InternalThreadLocalMap 并没有使用线性探测法来解决 Hash 冲突,而是在 FastThreadLocal 初始化的时候分配一个数组索引 index,index 的值采用原子类 AtomicInteger 保证顺序递增,通过调用 InternalThreadLocalMap.nextVariableIndex() 方法获得。然后在读写数据的时候通过数组下标 index 直接定位到 FastThreadLocal 的位置,时间复杂度为 O(1)。如果数组下标递增到非常大,那么数组也会比较大,所以 FastThreadLocal 是通过空间换时间的思想提升读写性能。下图是InternalThreadLocalMap的结构:
在这里插入图片描述

InternalThreadLocalMap 使用 Object 数组替代了 Entry 数组,Object[0] 存储的是一个Set<FastThreadLocal<?>> 集合,方便后续回收。从数组下标 1 开始都是直接存储的 value 数据,未使用的位置用默认占位对象UNSET填充,不再采用 ThreadLocal 的键值对形式进行存储。

2.2 源码分析

前面对FastThreadLocal的结构和设计有了大体的认识,下面详细看看FastThreadLocal#set()的具体实现。

2.2.1 set方法主干流程

FastThreadLocal#set():

public final void set(V value) {
       if (value != InternalThreadLocalMap.UNSET) {
           InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();
           setKnownNotUnset(threadLocalMap, value);
       } else {
           remove();
       }
   }

通过这段主干代码,set() 的过程主要分为三步:

  1. 判断 value 是否为缺省值,如果 value 不等于缺省值,获取当前线程的 InternalThreadLocalMap。
  2. 然后将 InternalThreadLocalMap 中对应数据替换为新的 value。
  3. 如果等于缺省值,那么直接调用 remove() 方法。这里我们还不知道缺省值和 remove() 之间的联系是什么,我们暂且把 remove() 放在最后分析。
2.2.2 获取InternalThreadLocalMap

InternalThreadLocalMap.get()

	public static InternalThreadLocalMap get() {
        Thread thread = Thread.currentThread();
        // 判断当前线程是否使用FastThreadLocalThread创建
        if (thread instanceof FastThreadLocalThread) {
            return fastGet((FastThreadLocalThread) thread);
        } else {
            return slowGet();
        }
    }

    private static InternalThreadLocalMap fastGet(FastThreadLocalThread thread) {
    	//获取 FastThreadLocalThread 的 threadLocalMap 属性
        InternalThreadLocalMap threadLocalMap = thread.threadLocalMap();
        if (threadLocalMap == null) {
            thread.setThreadLocalMap(threadLocalMap = new InternalThreadLocalMap());
        }
        return threadLocalMap;
    }

    private static InternalThreadLocalMap slowGet() {
        ThreadLocal<InternalThreadLocalMap> slowThreadLocalMap = UnpaddedInternalThreadLocalMap.slowThreadLocalMap;
        // 从jdk原生ThreadLocal中获取InternalThreadLocalMap
        InternalThreadLocalMap ret = slowThreadLocalMap.get();
        if (ret == null) {
            ret = new InternalThreadLocalMap();
            slowThreadLocalMap.set(ret);
        }
        return ret;
    }

如果当前线程是 FastThreadLocalThread 类型,那么直接通过 fastGet() 方法获取 FastThreadLocalThread 的 threadLocalMap 属性即可。如果此时 InternalThreadLocalMap 不存在,直接创建一个返回。关于 InternalThreadLocalMap 的初始化在上文中已经介绍过,它会初始化一个长度为 32 的 Object 数组,数组中填充着 32 个缺省对象 UNSET 的引用,这里UNSET都是同一个实例,所以不对存在大量对象占用内存的情况。
如果当前线程不是FastThreadLocalThread类型,则执行slowGet()。slowGet() 是针对非 FastThreadLocalThread 类型的线程发起调用时的一种兜底方案。如果当前线程不是 FastThreadLocalThread,而是普通的Thread类型,Thread内部是没有 InternalThreadLocalMap 属性的,Netty 在 UnpaddedInternalThreadLocalMap 中保存了一个 JDK 原生的 ThreadLocal,ThreadLocal 中存放着 InternalThreadLocalMap,此时获取 InternalThreadLocalMap 就退化成 JDK 原生的 ThreadLocal 获取。

2.2.3 设值

setKnownNotUnset

    private void setKnownNotUnset(InternalThreadLocalMap threadLocalMap, V value) {
        if (threadLocalMap.setIndexedVariable(index, value)) {
            addToVariablesToRemove(threadLocalMap, this);
        }
    }

setKnownNotUnset() 主要做了两件事:

  1. 找到数组下标 index 位置,设置新的 value。
  2. 将 FastThreadLocal 对象保存到待清理的 Set 中。

继续看 setIndexedVariable

public boolean setIndexedVariable(int index, Object value) {
    Object[] lookup = indexedVariables;
    if (index < lookup.length) {
        Object oldValue = lookup[index]; 
        // 直接将数组 index 位置设置为 value,时间复杂度为 O(1)
        lookup[index] = value; 
        return oldValue == UNSET;
    } else {
    	// 容量不够,先扩容再设置值
        expandIndexedVariableTableAndSet(index, value); 
        return true;
    }
}

如果数组容量大于 FastThreadLocal 的 index 索引,那么直接找到数组下标 index 位置将新 value 设置进去,事件复杂度为 O(1)。在设置新的 value 之前,会将之前 index 位置的元素取出,如果旧的元素还是 UNSET 缺省对象,那么返回成功。
容量不够则扩容再设值,扩容代码 expandIndexedVariableTableAndSet

    private void expandIndexedVariableTableAndSet(int index, Object value) {
        Object[] oldArray = indexedVariables;
        final int oldCapacity = oldArray.length;
        int newCapacity = index;
        newCapacity |= newCapacity >>>  1;
        newCapacity |= newCapacity >>>  2;
        newCapacity |= newCapacity >>>  4;
        newCapacity |= newCapacity >>>  8;
        newCapacity |= newCapacity >>> 16;
        newCapacity ++;

        Object[] newArray = Arrays.copyOf(oldArray, newCapacity);
        Arrays.fill(newArray, oldCapacity, newArray.length, UNSET);
        newArray[index] = value;
        indexedVariables = newArray;
    }

InternalThreadLocalMap 以 index 为基准进行扩容,扩容方式与HashMap基本一致,将数组扩容后的容量向上取整为 2 的次幂。然后将原数组内容拷贝到新的数组中,空余部分填充缺省对象 UNSET,最终把新数组赋值给 indexedVariables。

回到setKnownNotUnset主流程,设值成功后,第二步要将 FastThreadLocal 对象保存到待清理的 Set 中,这个Set放在数组的下标0的位置。
看下这块代码,addToVariablesToRemove

private static void addToVariablesToRemove(InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {
	// 获取数组下标为 0 的元素
    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); 
}

将FastThreadLocal放到Set是为了方便清理,下面就看看前面跳过了的清理方法remove,

2.2.4 清理
public final void remove() {
    remove(InternalThreadLocalMap.getIfSet());
}

public static InternalThreadLocalMap getIfSet() {
    Thread thread = Thread.currentThread();
    if (thread instanceof FastThreadLocalThread) {
        return ((FastThreadLocalThread) thread).threadLocalMap();
    }
    return slowThreadLocalMap.get();
}

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时,判断当前线程是否为FastThreadLocalThread类型。
找到 InternalThreadLocalMap 之后,主要做三件事:

  1. 定位到下标 index 位置的元素,并将 index 位置的元素覆盖为缺省对象 UNSET。
  2. 取出数组下标 0 位置的 Set 集合,然后删除当前 FastThreadLocal。
  3. 执行 onRemoval() 方法,该方法由用户自己实现。

可以看到,FastThreadLocal的InternalThreadLocalMap采用Object数组模式,直接存储value,自己维护AtomicInteger类型的index,保证有序和安全,通过index直接定位value,没有Hash冲突。将待清理的FastThreadLocal通过Set集合放在Object数组0下标位置,方便清理。

理解了set的原理,get方法也就很容易看懂了,值得注意的事,FastThreadLocal在get的时候,如果获取到的数组元素是缺省对象,会执行初始化操作,这个初始化也是Netty留下的扩展点,给用户自己实现。

3. 总结

本文对比介绍了ThreadLocal 和 FastThreadLocal,简单总结下 FastThreadLocal 的优势。

  1. 高效查找。JDK 原生的 ThreadLocal 在数据较多时哈希表很容易发生 Hash 冲突,线性探测法在解决 Hash 冲突时需要不停地向下寻找,效率较低。而FastThreadLocal 在定位数据的时候可以直接根据数组下标 index 获取,时间复杂度 O(1)。此外,FastThreadLocal 相比 ThreadLocal 数据扩容更加简单高效,FastThreadLocal 以 index 为基准向上取整到 2 的次幂作为扩容后容量,然后把原数据拷贝到新数组。而 ThreadLocal 由于采用的哈希表,所以在扩容后需要再做一轮 rehash。
  2. 更安全。JDK 原生的 ThreadLocal 使用不当可能造成内存泄漏,只能等待线程销毁。在使用线程池的场景下,ThreadLocal 只能通过主动检测的方式防止内存泄漏,从而造成了一定的开销。然而 FastThreadLocal 不仅提供了 remove() 主动清除对象的方法,而且在线程池场景中 Netty 还封装了 FastThreadLocalRunnable,FastThreadLocalRunnable 最后会执行 FastThreadLocal.removeAll() 将 Set 集合中所有 FastThreadLocal 对象都清理掉。
  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值