Netty PoolChunk依赖的自定义数据结构:IntPriorityQueue和LongLongHashMap
IntPriorityQueue
IntPriorityQueue
是一个基于最小堆(Min-Heap) 的优先级队列实现,用于高效管理整数元素。堆结构使用数组存储(索引从1开始),核心操作(插入、删除)的时间复杂度为 O(log n)
。以下是对其实现的逐段解析:
成员变量
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)
- 插入元素
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)
- 删除指定元素
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()
方法用末尾元素替换被删除元素时:
此时新元素可能处于以下三种状态之一:
- 需要上浮:新元素比父节点小(最小堆)
- 需要下沉:新元素比子节点大
- 已在正确位置:既不需要上浮也不需要下沉
关键点:
- 上浮操作只会让元素向上移动,移动后该元素的新位置必然满足:
- 新父节点 ≤ 当前节点(堆序成立)
- 但子节点可能更大(需要检查下沉)
- 下沉操作只会让元素向下移动,移动后:
- 当前节点 ≤ 新子节点(堆序成立)
- 但父节点可能更大(需要检查上浮)
通过连续调用上浮和下沉,实际上覆盖了所有可能:
- 若需要上浮 →
lift()
将其移到正确位置 →sink()
发现无需操作 - 若需要下沉 →
lift()
不操作 →sink()
将其移到正确位置 - 若已在正确位置 → 两个操作都不触发
堆调整操作是幂等的。连续调用
lift+sink
或sink+lift
最终都会收敛到元素的理论正确位置。
虽然顺序可互换,但 Netty 选择先 lift()
后 sink()
是出于性能优化:
-
概率优势:
当替换元素来自堆末尾时(通常是较大值),需要下沉的概率 > 需要上浮的概率。
→ 先调用lift()
可快速跳过(大概率不触发)
→ 减少不必要的比较操作 -
局部性原理:
上浮操作只需比较父节点(1次比较),下沉操作需比较两个子节点(2次比较)。
→ 优先执行更轻量的操作
上浮和下沉的连续调用构成了堆调整的双保险机制,无论顺序如何都能保证堆性质,但特定顺序可能在统计上带来轻微性能优势。在工程实践中,这种差异通常可以忽略。
poll()
- 移除堆顶元素
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()
public int peek() {return (size == 0) ? NO_VALUE : array[1]; // 直接返回堆顶
}
public boolean isEmpty() {return size == 0;
}
- 堆顶访问:
O(1)
直接返回索引1的值。 - 空队列判断:检查
size
是否为0。
核心辅助方法 lift / sink
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 操作。
高频操作优化
public int poll() {if (size == 0) {return NO_VALUE; // 直接返回特殊值,无需异常}// ... 堆操作
}
设计优势:
- 无异常设计:返回特殊值而非抛异常,提高性能
- 直接数组访问:避免集合框架的额外开销
特定内存管理需求
-
与
PoolChunk
协同设计:
该堆是PoolChunk
内存分配的核心组件,用于管理内存块句柄(handle)。句柄是整数编码,包含内存块大小、偏移量等信息。- 需要高效获取最小可用内存块(堆顶元素)
- 支持动态插入(分配后分裂)和删除(释放后合并)
-
特殊值处理:
定义了NO_VALUE = -1
作为空值标记,避免使用null
(标准库需包装对象)。
与 LongLongHashMap
协同
- 高效元数据管理:
LongLongHashMap
用于存储内存块元数据(Key: 内存地址, Value: 状态)。两者协同工作:IntPriorityQueue
快速获取最小可用内存块句柄- 通过句柄从
LongLongHashMap
中查询内存地址和状态
- 统一优化目标:
两者均使用基本类型数组,避免对象开销,构成 Netty 内存池的高效底层基础设施。
为什么不用第三方库?
- 零依赖原则:Netty 核心模块坚持不依赖外部库,确保:
- 兼容性:无版本冲突风险
- 可调试性:直接控制关键路径代码
- 轻量化:减少最终部署体积
总结
Netty 自实现 IntPriorityQueue
的核心目的是:在内存池这一关键路径上,通过消除对象开销、优化 CPU 缓存利用和简化操作逻辑,实现亚微秒级的内存分配性能。这与其"在高负载下最小化延迟和 GC 压力"的设计哲学一致。这种深度优化在通用库中无法实现,却是 Netty 成为高性能网络框架基石的关键。
设计总结
- 最小堆特性:保证堆顶始终为最小值,适合需要高效取最小元素的场景(如内存分配)。
- 动态扩容:数组按需扩展,避免频繁内存分配。
- 非标准操作:
remove(int)
遍历查找效率较低(O(n)
),但满足特定需求(如Netty内存池管理)。 - 索引优化:位运算(
>>1
、<<1
)替代乘除,提升计算效率。
此实现是Netty内存池(
PoolChunk
)的核心组件,用于高效管理内存块优先级。
LongLongHashMap
LongLongHashMap
是一个为 long
类型键值对优化的哈希表实现,专为 Netty 内存池 (PoolChunk
) 设计。它采用开放寻址法处理冲突,结合双倍哈希间隔探测策略,针对长整型键进行了特殊优化。以下是逐段解析:
1. 成员变量
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
清除最低位),确保索引计算后仍是偶数。
2. 构造函数
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)
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)
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)
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处理:重置
zeroVal
为emptyVal
。 - 惰性删除:仅将键位置置
0
,值保留(后续插入覆盖)。 - 这里删除第一个,对应put只是替换第一个,删除之后的,因为之后的重复是旧的
辅助方法
哈希函数 index(long key)
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()
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()
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)
,容量越大允许探测次数越多。
设计总结
-
开放寻址优化:
- 双倍间隔探测:每次跳2个位置(
index = (index+2) & mask
),减少聚类。 - 惰性删除:仅标记键为
0
,避免数据移动。
- 双倍间隔探测:每次跳2个位置(
-
长整型特化:
- 高效哈希:三步混合哈希确保键分布均匀。
- 键0优化:独立变量存储,避免哈希冲突。
-
动态扩容:
- 双倍扩容:容量不足时扩大一倍(
O(n)
)。 - 重哈希:旧数据重新插入新表(利用改进的哈希分布)。
- 双倍扩容:容量不足时扩大一倍(
-
性能平衡:
- 探测次数:
maxProbe = log(length)
平衡查找效率与空间利用率。 - 位运算优化:
& mask
替代取模,索引计算高效。
- 探测次数:
该实现针对 Netty 内存池中
long
类型的内存地址管理优化,在保证高效查找的同时,最小化内存开销。
PoolChunk中的应用分析
LongLongHashMap的应用
-
作用场景:
在PoolChunk
中,LongLongHashMap
(命名为runsAvailMap
)用于跟踪可用内存块(runs)的位置和元数据。键是内存块的起始页偏移(runOffset
),值是一个编码的句柄(handle
),包含:- 起始偏移(
runOffset
) - 页数(
pages
) - 使用状态(
isUsed
) - 子页标识(
isSubpage
) - 位图索引(
bitmapIdx
)
- 起始偏移(
-
关键操作:
- 插入:分配内存块时,记录新的可用块(
insertAvailRun
)。 - 查找:释放内存时,通过偏移快速定位相邻块以进行合并(
collapseRuns
)。 - 删除:内存块分配或合并后移除旧记录(
removeAvailRun
)。
- 插入:分配内存块时,记录新的可用块(
-
自实现原因:
- 性能优化:
- 内存分配/释放是高频操作,需避免
java.util.HashMap
的自动装箱(Long
→long
)开销。 - 开放寻址法减少内存碎片,比链式结构更紧凑。
- 内存分配/释放是高频操作,需避免
- 特定需求:
- 键为
long
(偏移量),值也为long
(编码句柄),需原生支持长整型存储。 - 合并操作需快速访问相邻偏移(
runOffset±1
),开放寻址法局部性更好。
- 键为
- 轻量级:
- 无需红黑树等复杂结构,哈希冲突通过线性探测解决,简化实现。
- 性能优化:
IntPriorityQueue的应用
-
作用场景:
IntPriorityQueue
(作为runsAvail
数组的元素)用于按偏移排序可用内存块。每个队列存储相同大小的内存块句柄(高32位),确保:- 分配时优先选择最小偏移的块(减少碎片)。
- 高效查找最佳匹配块(
runFirstBestFit
)。
-
关键操作:
- 插入:可用块加入队列(
insertAvailRun
)。 - 删除:分配时移除块(
removeAvailRun
)。 - 堆调整:合并块后重新排序(
sink
/lift
)。
- 插入:可用块加入队列(
-
自实现原因:
- 性能优化:
- 避免
java.util.PriorityQueue
的装箱开销(Integer
→int
)。 - 直接操作
int[]
数组,缓存局部性更优。
- 避免
- 内存效率:
- 元素为基本类型
int
,比对象指针更节省内存(尤其在大量小块场景)。
- 元素为基本类型
- 其它优势见 IntPriorityQueue 一节
- 性能优化:
总结
自实现数据结构的必要性
数据结构 | 标准库替代方案 | 自实现优势 |
---|---|---|
LongLongHashMap | HashMap<Long, Long> | 避免装箱;开放寻址法缓存友好;长整型键值原生支持;快速相邻块查找。 |
IntPriorityQueue | PriorityQueue<Integer> | 避免装箱;数组存储+堆操作更紧凑;支持高效随机删除;基本类型操作无额外开销。 |
核心目的:Netty的内存池作为底层基础设施,需极致优化性能(纳秒级操作)和内存效率(减少GC压力)。自实现数据结构针对高频、小规模、基本类型操作的场景,消除标准库在装箱、内存布局、功能冗余上的开销,满足高并发内存分配的严苛要求。