Netty PoolChunk依赖的自定义数据结构:IntPriorityQueue和LongLongHashMap

IntPriorityQueue

IntPriorityQueue 是一个基于​​最小堆(Min-Heap)​ ​ 的优先级队列实现,用于高效管理整数元素。堆结构使用数组存储(索引从1开始),核心操作(插入、删除)的时间复杂度为 O(log n)。以下是对其实现的逐段解析:


成员变量​

java 复制代码
private int[] array = new int[9]; // 堆存储数组,索引0未使用
private int size; // 当前元素数量

public static final int NO_VALUE = -1;
  • ​数组设计​:初始大小为9(包含索引0占位),实际存储从索引1开始,符合二叉堆的数组表示惯例。
  • ​索引规则​ :节点 k 的父节点为 k/2,左子节点为 2k,右子节点为 2k+1

offer(int handle) - 插入元素​

java 复制代码
public void offer(int handle) {
    if (handle == NO_VALUE) throw new IllegalArgumentException();
    size++;
    // 动态扩容:数组满时按 2n - 1 扩容
    if (size == array.length) {
        array = Arrays.copyOf(array, 1 + (array.length - 1) * 2);
    }
    array[size] = handle; // 新元素置于末尾
    lift(size); // 上浮调整堆结构
}
  • ​扩容策略​ :当数组满时,容量扩展为 2n + 1(如初始9 → 17 → 33)。
  • ​上浮调整​:新元素从末尾开始向上交换,直到满足堆序(父节点 ≤ 子节点)。

remove(int value) - 删除指定元素​

java 复制代码
public void remove(int value) {
    for (int i = 1; i <= size; i++) {
        if (array[i] == value) {
            array[i] = array[size]; // 末尾元素覆盖删除位
            size--;
            lift(i); // 尝试上浮
            sink(i); // 尝试下沉
            return;
        }
    }
}
  • ​查找删除​ :遍历数组找到目标元素(O(n),非堆标准操作,特定场景使用)。
  • ​覆盖调整​:用末尾元素替换被删元素,先后执行上浮和下沉确保堆序正确。

在堆数据结构中,​​上浮(lift)和下沉(sink)操作的调用顺序对堆的最终状态没有影响​ ​,但需要理解为什么在 remove() 方法中需要连续调用这两个操作,以及它们如何协同工作保证堆性质。

remove() 方法用末尾元素替换被删除元素时:

此时新元素可能处于以下三种状态之一:

  1. ​需要上浮​:新元素比父节点小(最小堆)
  2. ​需要下沉​:新元素比子节点大
  3. ​已在正确位置​:既不需要上浮也不需要下沉

​关键点​​:

  • ​上浮操作只会让元素向上移动​ ,移动后该元素的新位置必然满足:
    • 新父节点 ≤ 当前节点(堆序成立)
    • ​子节点可能更大​(需要检查下沉)
  • ​下沉操作只会让元素向下移动​ ,移动后:
    • 当前节点 ≤ 新子节点(堆序成立)
    • ​父节点可能更大​(需要检查上浮)

通过​​连续调用上浮和下沉​​,实际上覆盖了所有可能:

  1. 若需要上浮 → lift() 将其移到正确位置 → sink() 发现无需操作
  2. 若需要下沉 → lift() 不操作 → sink() 将其移到正确位置
  3. 若已在正确位置 → 两个操作都不触发

​堆调整操作是​​幂等​ ​的。连续调用 lift+sinksink+lift 最终都会收敛到元素的理论正确位置。

虽然顺序可互换,但 Netty 选择先 lift()sink() 是出于​​性能优化​​:

  1. ​概率优势​ ​:

    当替换元素来自堆末尾时(通常是较大值),需要下沉的概率 > 需要上浮的概率。

    → 先调用 lift() 可快速跳过(大概率不触发)

    → 减少不必要的比较操作

  2. ​局部性原理​ ​:

    上浮操作只需比较父节点(1次比较),下沉操作需比较两个子节点(2次比较)。

    → 优先执行更轻量的操作

上浮和下沉的连续调用构成了堆调整的​​双保险机制​​,无论顺序如何都能保证堆性质,但特定顺序可能在统计上带来轻微性能优势。在工程实践中,这种差异通常可以忽略。


poll() - 移除堆顶元素​

java 复制代码
public int poll() {
    if (size == 0) return NO_VALUE;
    int val = array[1]; // 保存堆顶(最小值)
    array[1] = array[size]; // 末尾元素移至堆顶
    array[size] = 0; // 清空末尾
    size--;
    sink(1); // 下沉调整堆顶
    return val;
}
  • ​堆顶移除​:取堆顶后,将末尾元素移至堆顶,执行下沉操作维持堆序。
  • ​下沉逻辑​:父节点与较小子节点交换,直到满足堆序或到达叶节点。

peek()isEmpty()

java 复制代码
public int peek() {
    return (size == 0) ? NO_VALUE : array[1]; // 直接返回堆顶
}
public boolean isEmpty() {
    return size == 0;
}
  • ​堆顶访问​O(1) 直接返回索引1的值。
  • ​空队列判断​ :检查 size 是否为0。

​核心辅助方法 ​​lift / sink

java 复制代码
    private void lift(int index) {
        int parentIndex;
        while (index > 1 && subord(parentIndex = index >> 1, index)) {
            swap(index, parentIndex);
            index = parentIndex;
        }
    }

private void sink(int index) {
    int child;
    while ((child = index << 1) <= size) {
        // 选择较小子节点
        if (child < size && subord(child, child + 1)) child++;
        if (!subord(index, child)) break;
        swap(index, child); // 与子节点交换
        index = child;
    }
}

private boolean subord(int a, int b) {
    return array[a] > array[b]; // 检查是否违反堆序(父 > 子)
}

private void swap(int a, int b) {
    int temp = array[a];
    array[a] = array[b];
    array[b] = temp;
}
  • ​上浮(lift)​:从叶节点向上交换,直到父节点 ≤ 当前节点。
  • ​下沉(sink)​ :从根节点向下交换,每次选择​较小子节点​确保最小堆性质。
  • ​堆序检查​subord(a, b) 判断 array[a] > array[b],用于触发交换。

为什么自己实现一个堆

Netty 选择自己实现 IntPriorityQueue(最小堆)而非使用 Java 标准库的 PriorityQueue,主要基于以下关键原因,这些原因与 Netty 的高性能、零拷贝和内存管理目标紧密相关:


极致性能优化​

  • ​避免装箱开销​ ​:

    Java 的 PriorityQueue<Integer> 需要将 int 装箱为 Integer 对象,导致:

    • 额外内存开销(每个对象增加 12-16 字节头部)
    • GC 压力增大(频繁创建/销毁对象)
    • 缓存局部性差(对象分散在堆内存)
      ​Netty 方案​ :直接使用 int[] 存储数据,内存紧凑,CPU 缓存命中率高。
  • ​位运算替代乘除​ ​:

    堆操作中大量使用 index >> 1(除2)和 index << 1(乘2)等位运算,比标准库的乘除指令快数倍。

  • ​避免 JDK 实现约束​
    JDK 的 PriorityQueue 依赖 Comparator 接口,引入虚方法调用开销,而 Netty 直接内联比较逻辑:

    复制代码
    private boolean subord(int a, int b) {
        return array[a] > array[b]; // 直接比较数组值
    }
  • ​规避锁开销​
    标准库的 PriorityQueue 是非线程安全的,但仍有冗余的并发检查。Netty 明确设计为​单线程使用​(内存分配在线程本地进行),彻底去除锁和 CAS 操作。

高频操作优化

java 复制代码
public int poll() {
    if (size == 0) {
        return NO_VALUE;  // 直接返回特殊值,无需异常
    }
    // ... 堆操作
}

设计优势

  • 无异常设计:返回特殊值而非抛异常,提高性能
  • 直接数组访问:避免集合框架的额外开销

​特定内存管理需求​

  • ​与 PoolChunk 协同设计​ ​:

    该堆是 PoolChunk 内存分配的核心组件,用于管理​​内存块句柄(handle)​​。句柄是整数编码,包含内存块大小、偏移量等信息。

    • 需要高效获取​最小可用内存块​(堆顶元素)
    • 支持动态插入(分配后分裂)和删除(释放后合并)
  • ​特殊值处理​ ​:

    定义了 NO_VALUE = -1 作为空值标记,避免使用 null(标准库需包装对象)。


​与 LongLongHashMap 协同​

  • ​高效元数据管理​
    LongLongHashMap 用于存储内存块元数据(Key: 内存地址, Value: 状态)。两者协同工作:
    1. IntPriorityQueue 快速获取最小可用内存块句柄
    2. 通过句柄从 LongLongHashMap 中查询内存地址和状态
  • ​统一优化目标​
    两者均使用基本类型数组,避免对象开销,构成 Netty 内存池的高效底层基础设施。

为什么不用第三方库?

  • ​零依赖原则​ :Netty 核心模块坚持不依赖外部库,确保:
    • 兼容性:无版本冲突风险
    • 可调试性:直接控制关键路径代码
    • 轻量化:减少最终部署体积

总结

Netty 自实现 IntPriorityQueue 的核心目的是:​​在内存池这一关键路径上,通过消除对象开销、优化 CPU 缓存利用和简化操作逻辑,实现亚微秒级的内存分配性能​​。这与其"在高负载下最小化延迟和 GC 压力"的设计哲学一致。这种深度优化在通用库中无法实现,却是 Netty 成为高性能网络框架基石的关键。


设计总结

  • ​最小堆特性​:保证堆顶始终为最小值,适合需要高效取最小元素的场景(如内存分配)。
  • ​动态扩容​:数组按需扩展,避免频繁内存分配。
  • ​非标准操作​remove(int) 遍历查找效率较低(O(n)),但满足特定需求(如Netty内存池管理)。
  • ​索引优化​ :位运算(>>1<<1)替代乘除,提升计算效率。

此实现是Netty内存池(PoolChunk)的核心组件,用于高效管理内存块优先级。

LongLongHashMap

LongLongHashMap 是一个为 long 类型键值对优化的哈希表实现,专为 Netty 内存池 (PoolChunk) 设计。它采用​​开放寻址法​ ​处理冲突,结合​​双倍哈希间隔探测​​策略,针对长整型键进行了特殊优化。以下是逐段解析:


  1. ​成员变量​
java 复制代码
private static final int MASK_TEMPLATE = ~1; // 掩码模板(保证偶数)
private int mask;          // 哈希掩码(数组长度-1,且为偶数)
private long[] array;      // 键值对存储数组(键在偶数索引,值在奇数索引)
private int maxProbe;      // 最大探测次数
private long zeroVal;      // 键为0的特殊值
private final long emptyVal; // 空值标记(构造时传入)
  • ​键值存储​array 数组中键值对相邻存储(键在 [0,2,4...],值在 [1,3,5...])。
  • ​特殊键处理​ :键 0 由独立变量 zeroVal 存储,避免哈希冲突。
  • ​掩码设计​mask 保证为偶数(MASK_TEMPLATE = ~1 清除最低位),确保索引计算后仍是偶数。

  1. ​构造函数​
java 复制代码
LongLongHashMap(long emptyVal) {
    this.emptyVal = emptyVal;
    zeroVal = emptyVal; // 初始化键0的值
    array = new long[32]; // 初始容量32(2的幂)
    mask = array.length - 1;
    computeMaskAndProbe(); // 计算掩码和探测次数
}
  • ​初始容量​:32(必须是2的幂,方便位运算替代取模)。
  • ​初始化​zeroVal 设为 emptyVal,表示键0未存储。

put(long key, long value)

java 复制代码
public long put(long key, long value) {
    if (key == 0) {
        long prev = zeroVal;
        zeroVal = value;
        return prev; // 直接更新键0的值
    }
    for (;;) {
        int index = index(key); // 计算初始索引
        for (int i = 0; i < maxProbe; i++) {
            long existing = array[index];
            if (existing == key || existing == 0) {
                long prev = (existing == 0) ? emptyVal : array[index+1];
                array[index] = key; // 写入键
                array[index+1] = value; // 写入值
                // 清理可能重复的键(相同键在后续位置)
                for (; i < maxProbe; i++) {
                    index = (index + 2) & mask; // 双倍间隔探测
                    if (array[index] == key) {
                        array[index] = 0; // 删除重复键
                        prev = array[index+1]; // 保存旧值
                        break;
                    }
                }
                return prev;
            }
            index = (index + 2) & mask; // 双倍间隔探测
        }
        expand(); // 探测失败后扩容
    }
}
  • ​键0处理​ :直接更新 zeroVal
  • ​哈希探测​
    • 使用 index(key) 计算初始索引(偶数)。
    • ​双倍间隔探测​ :每次跳2个位置(index = (index + 2) & mask),避免聚类。
  • ​冲突解决​
    • 找到空槽(0)或相同键时插入/更新。
    • 插入后检查后续位置,删除可能的重复键。【因为之前删除后产生空槽,如果空槽插入,因为哈希冲突是间隔探测,之后可能会有原来的key】
  • ​扩容触发​ :当探测次数超过 maxProbe 时扩容。

get(long key)

java 复制代码
public long get(long key) {
    if (key == 0) return zeroVal;
    int index = index(key);
    for (int i = 0; i < maxProbe; i++) {
        if (array[index] == key) {
            return array[index+1]; // 返回相邻值
        }
        index = (index + 2) & mask;
    }
    return emptyVal; // 未找到
}
  • ​键0处理​ :直接返回 zeroVal
  • ​探测逻辑​:沿双倍间隔路径查找,命中返回相邻值。
  • 限制maxProbe,对数次查找。
  • 限制查找maxProbe步 是不是可能实际有,但是查不到?实际上put的时候检查了,如果maxProbe没有散列到,会扩容的。

remove(long key)

java 复制代码
public void remove(long key) {
    if (key == 0) {
        zeroVal = emptyVal;
        return;
    }
    int index = index(key);
    for (int i = 0; i < maxProbe; i++) {
        if (array[index] == key) {
            array[index] = 0; // 置0标记删除(惰性删除)
            return;
        }
        index = (index + 2) & mask;
    }
}
  • ​键0处理​ :重置 zeroValemptyVal
  • ​惰性删除​ :仅将键位置置 0,值保留(后续插入覆盖)。
  • 这里删除第一个,对应put只是替换第一个,删除之后的,因为之后的重复是旧的

辅助方法

​哈希函数 index(long key)

java 复制代码
private int index(long key) {
    key ^= key >>> 33;           // 三步混合哈希(类似MurmurHash)
    key *= 0xff51afd7ed558ccdL; 
    key ^= key >>> 33;
    key *= 0xc4ceb9fe1a85ec53L;
    key ^= key >>> 33;
    return (int) key & mask;     // 位运算替代取模
}
  • ​高效哈希​:三次移位和乘法混合,确保分布均匀。
  • ​位运算优化​& mask 替代取模(要求 array.length 是2的幂)。

扩容方法 expand()

java 复制代码
private void expand() {
    long[] prev = array;
    array = new long[prev.length * 2]; // 双倍扩容
    computeMaskAndProbe(); // 更新掩码和探测次数
    for (int i = 0; i < prev.length; i += 2) {
        long key = prev[i];
        if (key != 0) {
            put(key, prev[i+1]); // 重新插入旧数据
        }
    }
}
  • ​双倍扩容​:数组扩大一倍(保持2的幂)。
  • ​重哈希​:遍历旧数组,非空键值对重新插入新数组。

掩码与探测计算 computeMaskAndProbe()

java 复制代码
private void computeMaskAndProbe() {
    int length = array.length;
    mask = (length - 1) & MASK_TEMPLATE; // 保证偶数
    maxProbe = (int) Math.log(length);    // 探测次数 = log(容量)
}
  • ​掩码更新​mask = (length-1) & ~1 确保偶数索引。
  • ​探测次数​maxProbe = log2(length),容量越大允许探测次数越多。

设计总结

  1. ​开放寻址优化​​:

    • ​双倍间隔探测​ :每次跳2个位置(index = (index+2) & mask),减少聚类。
    • ​惰性删除​ :仅标记键为 0,避免数据移动。
  2. ​长整型特化​​:

    • ​高效哈希​:三步混合哈希确保键分布均匀。
    • ​键0优化​:独立变量存储,避免哈希冲突。
  3. ​动态扩容​​:

    • ​双倍扩容​ :容量不足时扩大一倍(O(n))。
    • ​重哈希​:旧数据重新插入新表(利用改进的哈希分布)。
  4. ​性能平衡​​:

    • ​探测次数​maxProbe = log(length) 平衡查找效率与空间利用率。
    • ​位运算优化​& mask 替代取模,索引计算高效。

该实现针对 Netty 内存池中 long 类型的内存地址管理优化,在保证高效查找的同时,最小化内存开销。

PoolChunk中的应用分析

LongLongHashMap的应用​

  • ​作用场景​ ​:

    PoolChunk中,LongLongHashMap(命名为runsAvailMap)用于​​跟踪可用内存块(runs)的位置和元数据​ ​。键是内存块的起始页偏移(runOffset),值是一个编码的句柄(handle),包含:

    • 起始偏移(runOffset
    • 页数(pages
    • 使用状态(isUsed
    • 子页标识(isSubpage
    • 位图索引(bitmapIdx
  • ​关键操作​​:

    • ​插入​ :分配内存块时,记录新的可用块(insertAvailRun)。
    • ​查找​ :释放内存时,通过偏移快速定位相邻块以进行合并(collapseRuns)。
    • ​删除​ :内存块分配或合并后移除旧记录(removeAvailRun)。
  • ​自实现原因​​:

    1. ​性能优化​
      • 内存分配/释放是高频操作,需避免java.util.HashMap的自动装箱(Longlong)开销。
      • 开放寻址法减少内存碎片,比链式结构更紧凑。
    2. ​特定需求​
      • 键为long(偏移量),值也为long(编码句柄),需原生支持长整型存储。
      • 合并操作需快速访问相邻偏移(runOffset±1),开放寻址法局部性更好。
    3. ​轻量级​
      • 无需红黑树等复杂结构,哈希冲突通过线性探测解决,简化实现。

​IntPriorityQueue的应用​

  • ​作用场景​ ​:
    IntPriorityQueue(作为runsAvail数组的元素)用于​​按偏移排序可用内存块​​。每个队列存储相同大小的内存块句柄(高32位),确保:

    • 分配时优先选择最小偏移的块(减少碎片)。
    • 高效查找最佳匹配块(runFirstBestFit)。
  • ​关键操作​​:

    • ​插入​ :可用块加入队列(insertAvailRun)。
    • ​删除​ :分配时移除块(removeAvailRun)。
    • ​堆调整​ :合并块后重新排序(sink/lift)。
  • ​自实现原因​​:

    1. ​性能优化​
      • 避免java.util.PriorityQueue的装箱开销(Integerint)。
      • 直接操作int[]数组,缓存局部性更优。
    2. ​内存效率​
      • 元素为基本类型int,比对象指针更节省内存(尤其在大量小块场景)。
    3. 其它优势见 IntPriorityQueue 一节

​总结

自实现数据结构的必要性​

​数据结构​ ​标准库替代方案​ ​自实现优势​
LongLongHashMap HashMap<Long, Long> 避免装箱;开放寻址法缓存友好;长整型键值原生支持;快速相邻块查找。
IntPriorityQueue PriorityQueue<Integer> 避免装箱;数组存储+堆操作更紧凑;支持高效随机删除;基本类型操作无额外开销。

​核心目的​ ​:Netty的内存池作为底层基础设施,需极致优化性能(纳秒级操作)和内存效率(减少GC压力)。自实现数据结构针对​​高频、小规模、基本类型操作​ ​的场景,消除标准库在​​装箱、内存布局、功能冗余​​上的开销,满足高并发内存分配的严苛要求。