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()
方法用末尾元素替换被删除元素时:
此时新元素可能处于以下三种状态之一:
- 需要上浮:新元素比父节点小(最小堆)
- 需要下沉:新元素比子节点大
- 已在正确位置:既不需要上浮也不需要下沉
关键点:
- 上浮操作只会让元素向上移动 ,移动后该元素的新位置必然满足:
- 新父节点 ≤ 当前节点(堆序成立)
- 但子节点可能更大(需要检查下沉)
- 下沉操作只会让元素向下移动 ,移动后:
- 当前节点 ≤ 新子节点(堆序成立)
- 但父节点可能更大(需要检查上浮)
通过连续调用上浮和下沉,实际上覆盖了所有可能:
- 若需要上浮 →
lift()
将其移到正确位置 →sink()
发现无需操作 - 若需要下沉 →
lift()
不操作 →sink()
将其移到正确位置 - 若已在正确位置 → 两个操作都不触发
堆调整操作是幂等 的。连续调用
lift+sink
或sink+lift
最终都会收敛到元素的理论正确位置。
虽然顺序可互换,但 Netty 选择先 lift()
后 sink()
是出于性能优化:
-
概率优势 :
当替换元素来自堆末尾时(通常是较大值),需要下沉的概率 > 需要上浮的概率。
→ 先调用
lift()
可快速跳过(大概率不触发)→ 减少不必要的比较操作
-
局部性原理 :
上浮操作只需比较父节点(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: 状态)。两者协同工作:IntPriorityQueue
快速获取最小可用内存块句柄- 通过句柄从
LongLongHashMap
中查询内存地址和状态
- 统一优化目标 :
两者均使用基本类型数组,避免对象开销,构成 Netty 内存池的高效底层基础设施。
为什么不用第三方库?
- 零依赖原则 :Netty 核心模块坚持不依赖外部库,确保:
- 兼容性:无版本冲突风险
- 可调试性:直接控制关键路径代码
- 轻量化:减少最终部署体积
总结
Netty 自实现 IntPriorityQueue
的核心目的是:在内存池这一关键路径上,通过消除对象开销、优化 CPU 缓存利用和简化操作逻辑,实现亚微秒级的内存分配性能。这与其"在高负载下最小化延迟和 GC 压力"的设计哲学一致。这种深度优化在通用库中无法实现,却是 Netty 成为高性能网络框架基石的关键。
设计总结
- 最小堆特性:保证堆顶始终为最小值,适合需要高效取最小元素的场景(如内存分配)。
- 动态扩容:数组按需扩展,避免频繁内存分配。
- 非标准操作 :
remove(int)
遍历查找效率较低(O(n)
),但满足特定需求(如Netty内存池管理)。 - 索引优化 :位运算(
>>1
、<<1
)替代乘除,提升计算效率。
此实现是Netty内存池(
PoolChunk
)的核心组件,用于高效管理内存块优先级。
LongLongHashMap
LongLongHashMap
是一个为 long
类型键值对优化的哈希表实现,专为 Netty 内存池 (PoolChunk
) 设计。它采用开放寻址法 处理冲突,结合双倍哈希间隔探测策略,针对长整型键进行了特殊优化。以下是逐段解析:
- 成员变量
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
清除最低位),确保索引计算后仍是偶数。
- 构造函数
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处理 :重置
zeroVal
为emptyVal
。 - 惰性删除 :仅将键位置置
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)
,容量越大允许探测次数越多。
设计总结
-
开放寻址优化:
- 双倍间隔探测 :每次跳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压力)。自实现数据结构针对高频、小规模、基本类型操作 的场景,消除标准库在装箱、内存布局、功能冗余上的开销,满足高并发内存分配的严苛要求。