当前位置: 首页 > news >正文

Netty内存池核心:PoolChunk深度解析

目录

Arena 和 Chunk 的关系是什么?

关键数据结构分析

PoolChunk 核心成员

内存管理相关结构

统计与配置信息

run 是什么意思?

runsAvailMap 的作用是什么?

allocate 函数入口

路径1:小内存分配 (Small Allocation)

路径2:标准内存分配 (Normal Allocation)

allocateRun(int runSize)

allocateSubpage

总结

PoolChunk.initBuf

PoolChunk.initBufWithSubpage

PooledByteBuf.init

内存分配与管理机制解析

free

子页(Subpage)内存释放

运行块(Run)内存释放与合并

Run合并算法 collapseRuns

ollapsePast:合并前驱空闲块​​​

​​collapseNext:合并后继空闲块​​​

内存合并数据结构操作

调用链路总结

特性


PoolChunk是Netty内存池中最核心的内存管理单元,它将一大块连续内存(默认16MB)组织成可分配的小块。

核心设计思想

  • 分层管理: 将大块内存按页(Page)为单位进行管理
  • 位图索引: 使用long型handle编码内存位置信息
  • 双重分配: 支持大块分配(run)和小块分配(subpage)

Arena 和 Chunk 的关系是什么?

我们可以用一个比喻来理解:

  • PoolChunk: 就像一个 仓库。它实实在在地持有一大块内存(比如 16MB),是物理内存的拥有者。所有内存的“切割”(无论是切成 run 还是 subpage)都发生在这个仓库内部。chunk.subpages 数组是这个仓库自己的 “台账”,记录着“第几号货架(runOffset)被改造成了用于存放小件物品的格子架(PoolSubpage)”。

  • PoolArena: 就像一个 总调度中心。它不直接持有内存,但它管理着旗下所有的仓库(PoolChunk)。arena.smallSubpagePools 是调度中心的 “可用资源看板”。这个看板上,按照小件物品的规格(sizeIdx)分门别类,挂着所有仓库里 “尚未装满的格子架(PoolSubpage)” 的快捷入口。

  • Chunk 是内存的 物理持有者
  • Arena 是 Subpage 的 逻辑管理者和调度者
  • chunk.subpages 是 Chunk 的私有台账,用于根据地址快速找到 Subpage 对象。
  • arena.smallSubpagePools 是 Arena 的全局可用 Subpage 链表,用于快速分配。

一个 PoolSubpage 对象,它物理上属于一个 Chunk,但逻辑上被 Arena 所管理和调度。

subpage 本身就是一种特殊的 run(通常只占1个page),所以更准确的说法是,一个 Chunk 内部由不同状态的 run 组成。

下面是一个更详细的示意图:

+--------------------------------------------------------------------------+
|                                PoolChunk (16MB)                          |
|                                                                          |
|  +--------------------------------------------------+                    |
|  |                   Allocated Run                  |  <-- 已分配的普通内存块 (例如 64KB)
|  |                (由 8 个 page 组成)               |
|  +--------------------------------------------------+                    |
|                                                                          |
|  +--------------------------------------------------+                    |
|  |                    Free Run                      |  <-- 空闲的内存块,等待分配
|  |                (由 4 个 page 组成)               |
|  +--------------------------------------------------+                    |
|                                                                          |
|  +--------------------------------------------------+  <-- 这是一个 run (1个page, 8KB), 但被特殊管理
|  |                  Subpage Run                     |      |
|  | (由 PoolSubpage 对象管理, 内部用 bitmap 划分)    |      |
|  |  /--------------------------------------------\  |      |
|  |  | ele| ele| ele| ele| ele| ... | ele| ele|free|  |      |
|  |  \--------------------------------------------/  |      |
|  +--------------------------------------------------+      |
|                                                            |
|  +--------------------------------------------------+      |
|  |                    Free Run                      |      |
|  |                (由 N 个 page 组成)               |      |
|  +--------------------------------------------------+      |
|                                                            |
|                           ...                              |
|                                                            |
+--------------------------------------------------------------------------+^|+-------------------------------------------------------------------+|
+------------------------------------------------------+   +---------------------------------+
|                 PoolArena (总调度中心)               |   |      PoolSubpage 对象           |
|                                                      |   | (一个 Java 对象, 不是物理内存)  |
| smallSubpagePools[sizeIdx]:                          |   |---------------------------------|
|  +------+      +----------------------------------+  |   | - chunk: 指向上面的 PoolChunk   |
|  | head | <--> | 指向 Subpage Run 的快捷入口(指针) | <----| - runOffset: 记录在 Chunk 中的位置 |
|  +------+      +----------------------------------+  |   | - bitmap: 管理内部小块的分配状态 |
|                                                      |   | - ...                           |
+------------------------------------------------------+   +---------------------------------+

这个图展示了:

  1. PoolChunk 是一整块连续的物理内存。
  2. 内部被划分为不同状态的 run(已分配、空闲、被当做Subpage使用)。
  3. 一个 Subpage Run 物理上仍在 Chunk 里,但它由一个单独的 PoolSubpage 对象来管理。
  4. PoolArena 通过它的 smallSubpagePools 链表,持有对这个 PoolSubpage 对象的引用(快捷入口),从而实现了跨 Chunk 的高效调度。

关键数据结构分析

Handle编码机制

// handle的位布局:
// oooooooo ooooooos ssssssss ssssssue bbbbbbbb bbbbbbbb bbbbbbbb bbbbbbbb
// o: runOffset (页偏移), 15bit
// s: size (页数), 15bit  
// u: isUsed (是否使用), 1bit
// e: isSubpage (是否子页), 1bit
// b: bitmapIdx (子页位图索引), 32bitprivate static final int SIZE_BIT_LENGTH = 15;private static final int INUSED_BIT_LENGTH = 1;private static final int SUBPAGE_BIT_LENGTH = 1;private static final int BITMAP_IDX_BIT_LENGTH = 32;
static final int IS_SUBPAGE_SHIFT = BITMAP_IDX_BIT_LENGTH;      // 32
static final int IS_USED_SHIFT = SUBPAGE_BIT_LENGTH + IS_SUBPAGE_SHIFT;  // 33
static final int SIZE_SHIFT = INUSED_BIT_LENGTH + IS_USED_SHIFT;         // 34
static final int RUN_OFFSET_SHIFT = SIZE_BIT_LENGTH + SIZE_SHIFT;        // 49

正如代码注释中 handle 的位布局所示: oooooooo ooooooos ssssssss ssssssue bbbbbbbb bbbbbbbb bbbbbbbb bbbbbbbb

  • private static final int BITMAP_IDX_BIT_LENGTH = 32;

    • 含义:用于 subpage(子页)中位图索引(bitmap index)的位数,占 32 位。
    • 作用:当分配的是一个小内存块(subpage allocation)时,这 32 位用来定位这块内存在 PoolSubpage 的位图中的具体位置。如果不是 subpage 分配,这部分为 0。
  • private static final int SUBPAGE_BIT_LENGTH = 1;

    • 含义:标记是否为 subpage 分配的位数,占 1 位。
    • 作用:这是一个布尔标记位。如果为 1,表示这次分配是 subpage 级别的小内存;如果为 0,表示是 run 级别的内存(一个或多个 page)。
  • private static final int INUSED_BIT_LENGTH = 1;

    • 含义:标记是否“已被使用”的位数,占 1 位。
    • 作用:这也是一个布尔标记位。1 表示这块内存正在被使用,0 表示空闲。
  • private static final int SIZE_BIT_LENGTH = 15;

    • 含义:表示分配的内存大小的位数,占 15 位。
    • 作用:这个大小不是以字节为单位,而是以 page 的数量为单位。
  • 剩下的 15 位(64 - 32 - 1 - 1 - 15 = 15)用于 runOffset

下面这些 SHIFT 常量是根据上面的位长度计算出的位移量,用于通过位运算快速地从 handle 中存取信息。

  • static final int IS_SUBPAGE_SHIFT = BITMAP_IDX_BIT_LENGTH;

    • 含义isSubpage 标志位的起始偏移量,值为 32。
  • static final int IS_USED_SHIFT = SUBPAGE_BIT_LENGTH + IS_SUBPAGE_SHIFT;

    • 含义isUsed 标志位的起始偏移量,值为 32 + 1 = 33。
  • static final int SIZE_SHIFT = INUSED_BIT_LENGTH + IS_USED_SHIFT;

    • 含义size(页面数量)的起始偏移量,值为 1 + 33 = 34。
  • static final int RUN_OFFSET_SHIFT = SIZE_BIT_LENGTH + SIZE_SHIFT;

    • 含义runOffset(run 在 chunk 内的页面偏移量)的起始偏移量,值为 15 + 34 = 49。

PoolChunk 核心成员

  • final PoolArena<T> arena;

    • 含义:指向拥有这个 PoolChunk 的 PoolArenaPoolArena 是更高一级的内存池管理器,它管理着多个 PoolChunk
  • final CleanableDirectBuffer cleanable;

    • 含义:当 PoolChunk 管理的是堆外内存(Direct Memory)时,这个对象用于在 PoolChunk 被垃圾回收时自动清理底层的堆外内存,防止内存泄漏。
  • final Object base;

    • 含义:底层内存的基对象。对于堆内存,它通常是 byte[] 数组;对于堆外内存,它是 ByteBuffer 对象。
  • final T memory;

    • 含义PoolChunk 所管理的实际内存块。泛型 T 可以是 byte[](堆内存)或 ByteBuffer(堆外内存)。
  • final boolean unpooled;

    • 含义:一个布尔值,标记这个 Chunk 是否是池化的。true 表示这是一个特殊的、非池化的 Chunk,通常用于分配超过 chunkSize 的超大内存,用完即弃。false 表示是标准的、可复用的池化 Chunk

内存管理相关结构

  • private final LongLongHashMap runsAvailMap;

    • 含义:一个特殊的 HashMap,键和值都是 long
    • 作用:用于快速查找空闲的 runkey 是 run 的起始页偏移量(runOffset),value 是这个 run 对应的 handle。它同时存储了一个 run 的首页和尾页的偏移量,这样在释放内存时,可以快速检查前后是否有相邻的空闲 run 并进行合并。
  • private final IntPriorityQueue[] runsAvail;

    • 含义:一个 IntPriorityQueue(整数优先队列)数组。
    • 作用:用于管理所有可用的 run。数组的索引根据 run 的大小(包含的 page 数量)来决定。每个队列内部的 run(以 handle 的高32位整数形式存储)按 runOffset 排序。这样可以保证总是优先分配地址最低的 run,有助于减少内存碎片。
  • private final ReentrantLock runsAvailLock;

    • 含义:一个可重入锁。
    • 作用:在多线程环境下,对 runsAvailMap 和 runsAvail 的访问需要加锁,以保证线程安全。
  • private final PoolSubpage<T>[] subpages;

    • 含义PoolSubpage 对象数组。
    • 作用:当一个 page 被用于小内存分配时,它会被包装成一个 PoolSubpage 对象来管理其内部更细粒度的内存块。这个数组的索引是 run 的起始页偏移量,值是对应的 PoolSubpage 对象。

两个结构分析见:

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

统计与配置信息

  • private final LongAdder pinnedBytes = new LongAdder();

    • 含义:一个 LongAdder,是 AtomicLong 的高性能替代,用于并发计数。
    • 作用:记录当前 Chunk 中已经被分配出去并且正在被 ByteBuf 实例使用的总字节数。这个值对于内存使用率的监控非常重要。
  • final int pageSize;

    • 含义:页大小,单位是字节。默认是 8192 (8KB)。是 PoolChunk 中内存管理的基本单位。
  • final int pageShifts;

    • 含义pageSize 以 2 为底的对数,即 log2(pageSize)。例如,如果 pageSize 是 8192 (2^13),pageShifts 就是 13。
    • 作用:用于通过位移运算快速计算偏移量,比乘除法效率高。
  • final int chunkSize;

    • 含义:整个 PoolChunk 的总大小,单位是字节。默认是 16MB。
  • final int maxPageIdx;

    • 含义Chunk 内最大的页索引,等于 (chunkSize / pageSize) - 1
  • private final Deque<ByteBuffer> cachedNioBuffers;

    • 含义:一个双端队列,用于缓存 ByteBuffer 对象。
    • 作用:当 PoolChunk 管理的是堆外内存时,分配出的 PooledDirectByteBuf 需要一个 ByteBuffer 对象作为视图来访问内存。为了避免每次都创建新的 ByteBuffer 对象造成 GC 压力,这里会缓存一些用过的 ByteBuffer 对象以供复用。
  • int freeBytes;

    • 含义:当前 Chunk 中剩余的空闲字节数。

run 是什么意思?

在 PoolChunk 的设计中,run 是一个核心的内存管理概念。可以把它理解为 “一段连续的内存页(page)”

在 PoolChunk.java 文件开头的注释中,有非常清晰的定义:

//...* Notation: The following terms are important to understand the code* > page  - a page is the smallest unit of memory chunk that can be allocated* > run   - a run is a collection of pages* > chunk - a chunk is a collection of runs* > in this code chunkSize = maxPages * pageSize
//...
  • Page(页): 内存管理的最小单位,默认大小是 8KB。
  • Chunk(块): 一个大的内存块,由多个 page 组成,默认大小是 16MB。
  • Run(段): 介于 page 和 chunk 之间。当需要分配一块不大不小(通常是大于一个 page)的内存时,PoolChunk 不会一页一页地分配,而是直接分配一个 run,这个 run 包含了一个或多个连续的 page

简单来说,run 就是 PoolChunk 中一次中等或大型内存分配的基本单位。

runOffset指的是 一个 run 在其所属的 PoolChunk 中的起始位置,这个位置是以 page 为单位的偏移量

我们再次看 PoolChunk.java 中关于 handle 的注释:

//...* handle:* -------* a handle is a long number, the bit layout of a run looks like:** oooooooo ooooooos ssssssss ssssssue bbbbbbbb bbbbbbbb bbbbbbbb bbbbbbbb** o: runOffset (page offset in the chunk), 15bit* s: size (number of pages) of this run, 15bit* u: isUsed?, 1bit* e: isSubpage?, 1bit* b: bitmapIdx of subpage, zero if it's not subpage, 32bit
//...

从这里可以看出:

  • runOffset 是 handle(一个64位的 long 值,用于唯一标识一次内存分配)的一部分。
  • 它被明确定义为 page offset in the chunk,即在 chunk 内的 页偏移量
  • 它占据了 handle 的高15位。

举个例子:

假设一个 PoolChunk 的 pageSize 是 8KB。

  • 如果一个 run 的 runOffset 是 0,意味着这个 run 从这个 chunk 的第 0 个 page 开始,也就是从字节偏移量 0 开始。
  • 如果一个 run 的 runOffset 是 5,意味着这个 run 从这个 chunk 的第 5 个 page 开始,其在 chunk 内的实际字节偏移量就是 5 * pageSize = 5 * 8192 = 40960

总结一下:

  • run: 一段连续的内存页,是 PoolChunk 中进行中/大型内存分配的单位。
  • runOffsetrun 在 PoolChunk 中的起始页编号(索引)。

runsAvailMap 的作用是什么?

简单来说,runsAvailMap 的核心作用是 支持高效的内存块合并,以对抗内存碎片化

在内存管理中,当一块内存被释放时,一个非常重要的优化是检查它前后的内存块是否也是空闲的。如果是,就应该将这些连续的空闲块合并成一个更大的空闲块。这样做可以有效减少小块内存碎片,提高内存的利用率。

runsAvailMap 就是实现这个“检查并合并”机制的关键数据结构。它是一个 HashMap,存储了所有 空闲 run 的边界信息。

  • Keyrun 的页偏移量 (runOffset)。
  • Value: 这个 run 对应的 handle

它的巧妙之处在于:对于每一个空闲的 run,它会同时存储两条记录:一条以“起始页”的偏移量为 Key,另一条以“结束页”的偏移量为 Key。两条记录的 Value 都是同一个 handle

runsAvailMap 主要在以下三个场景被使用:

  1. 插入 (当一个 run 变为空闲时)

    • 场景PoolChunk 初始化时、一个大 run被切分后剩余部分、一个 run 被释放时。
    • 方法insertAvailRun(int runOffset, int pages, long handle)
    • 行为: 向 runsAvailMap 中添加两条记录(如果 pages > 1),分别对应这个空闲 run 的起始页和结束页。【如果这个 run 只包含 1 页,那么它的起始页就是结束页。只有当页数大于 1 时,起始页和结束页才不相同。】
  2. 查找 (当需要合并内存时)

    • 场景: 当一个 run 被释放,需要检查其前后是否有可以合并的空闲 run 时。
    • 方法getAvailRunByOffset(int runOffset)
    • 行为: 在 free() -> collapseRuns() -> collapsePast() / collapseNext() 方法中被调用。
      • collapsePast(handle): 检查当前释放块 B 前面 是否有空闲块 A。它会用 B 的起始页偏移量减 1(即 A 的结束页偏移量)作为 Key 去 runsAvailMap 中查找。
      • collapseNext(handle): 检查当前释放块 B 后面 是否有空闲块 C。它会用 B 的结束页偏移量加 1(即 C 的起始页偏移量)作为 Key 去查找。
  3. 删除 (当一个空闲 run 被分配使用时)

    • 场景: 当一个空闲 run 被 allocateRun 方法选中并分配出去时。
    • 方法removeAvailRun(long handle) -> removeAvailRun0(long handle)
    • 行为: 从 runsAvailMap 中删除这个 run 对应的记录,因为它不再是空闲状态。

allocate 函数入口

allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) 方法。这是一个非常核心的方法,它负责在单个 PoolChunk 内存块中为 ByteBuf 分配内存。

这个函数的整体逻辑是:根据请求的内存大小,决定是进行“小内存(Subpage)分配”还是“标准内存(Run)分配”,然后执行相应的分配策略,并最终用分配到的内存信息来初始化传入的 PooledByteBuf 对象。

下面我们来递归地分析它的执行流程和所有关键的子函数。

// ... existing code ...boolean allocate(PooledByteBuf<T> buf, int reqCapacity, int sizeIdx, PoolThreadCache cache) {final long handle;if (sizeIdx <= arena.sizeClass.smallMaxSizeIdx) {// 路径1: 小内存分配 (Small allocation)// ...} else {// 路径2: 标准内存分配 (Normal allocation)// ...}ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;initBuf(buf, nioBuffer, handle, reqCapacity, cache);return true;}
// ... existing code ...
  • 参数:
    • buf: 一个待初始化的 PooledByteBuf 对象。
    • reqCapacity: 用户请求的原始容量。
    • sizeIdx: 规格化后的容量索引。Netty 将所有请求大小归一化到预定义的规格中,sizeIdx 就是这个规格的 ID。
    • cache: 当前线程的 PoolThreadCache,用于线程本地缓存。
  • 核心逻辑: 通过 sizeIdx 判断请求的是小内存还是标准内存。arena.sizeClass.smallMaxSizeIdx 是区分这两种内存的阈值索引。

路径1:小内存分配 (Small Allocation)

当请求的内存小于等于某个阈值时(默认是 pageSize,即 8KB),会进入这个分支。小内存分配以 PoolSubpage 为单位进行管理。

// ... existing code ...if (sizeIdx <= arena.sizeClass.smallMaxSizeIdx) {final PoolSubpage<T> nextSub;// small// 从 Arena 的 subpage 池中获取一个可用的 subpagePoolSubpage<T> head = arena.smallSubpagePools[sizeIdx];head.lock();try {nextSub = head.next;if (nextSub != head) {// Case 1.1: 找到了一个可用的 subpagehandle = nextSub.allocate();assert handle >= 0;assert isSubpage(handle);nextSub.chunk.initBufWithSubpage(buf, null, handle, reqCapacity, cache);return true;}// Case 1.2: 没有可用的 subpage,需要新分配一个 run 来创建 subpagehandle = allocateSubpage(sizeIdx, head);if (handle < 0) {return false;}assert isSubpage(handle);} finally {head.unlock();}} 
// ... existing code ...

Case 1.1: PoolArena 中有现成的 PoolSubpage

  1. arena.smallSubpagePools[sizeIdx]PoolArena 中为每种规格的小内存维护了一个 PoolSubpage 的双向链表。这里根据 sizeIdx 找到对应规格的链表头 head
  2. head.lock(): 对链表头加锁,因为多个线程可能同时访问这个链表。
  3. nextSub = head.next: 尝试从链表中获取第一个 PoolSubpage。如果 nextSub != head,说明链表不为空。
  4. nextSub.allocate(): 这是 PoolSubpage 的核心方法。它在一个 page(8KB)内部进行更细粒度的分配。
    • 内部实现PoolSubpage 内部用一个 long 数组 bitmap 来标记每个内存块的使用情况(0代表可用,1代表已用)。
    • allocate() 会调用 findNextAvailable() 在 bitmap 中找到第一个为 0 的位,这个位的位置就是这次分配的内存块的索引。
    • 然后,它将该位置 1,并根据这个索引和 subpage 自身的信息生成一个 handle 返回。这个 handle 的低32位就是 bitmap 索引。
  5. initBufWithSubpage(...): 用上一步获取的 handle 初始化 ByteBuf
    • 它会从 handle 中解析出 runOffset (subpage 在 chunk 中的页偏移) 和 bitmapIdx (内存在 subpage 中的块索引)。
    • 计算最终的物理偏移量:offset = (runOffset << pageShifts) + bitmapIdx * s.elemSize
    • 调用 buf.init(...) 完成初始化。

Case 1.2: 没有现成的 PoolSubpage,需要创建新的

如果 nextSub == head,说明 PoolArena 中没有可用的 PoolSubpage 了,此时需要先从当前 PoolChunk 中分配一个 run,然后将这个 run 初始化为一个新的 PoolSubpage

  1. allocateSubpage(sizeIdx, head):
    • calculateRunSize(sizeIdx): 首先,计算创建一个能容纳这种规格(sizeIdx)小内存的 PoolSubpage 需要多大的 run。这个大小通常是一个 pageSize (8KB)。
    • allocateRun(runSize)(递归调用) 调用下面的标准内存分配逻辑,从当前 Chunk 中分配一个 run。这是整个流程的关键一步,我们稍后详细分析。如果分配失败,返回-1。
    • new PoolSubpage<T>(...): 如果 allocateRun 成功,就用分配到的 run 的信息(runOffsetrunSize)创建一个新的 PoolSubpage 对象。
    • subpages[runOffset] = subpage: 将新创建的 subpage 存入当前 Chunk 的 subpages 数组中,方便后续管理。
    • subpage.allocate(): 在这个全新的 subpage 上调用 allocate(),分配第一个内存块并返回其 handle

路径2:标准内存分配 (Normal Allocation)

当请求的内存大于小内存阈值时,直接分配一个或多个连续的 page,即一个 run

// ... existing code ...} else {// normal// runSize 必须是 pageSize 的倍数int runSize = arena.sizeClass.sizeIdx2size(sizeIdx);handle = allocateRun(runSize);if (handle < 0) {return false;}assert !isSubpage(handle);}ByteBuffer nioBuffer = cachedNioBuffers != null? cachedNioBuffers.pollLast() : null;initBuf(buf, nioBuffer, handle, reqCapacity, cache);
// ... existing code ...
  1. runSize = arena.sizeClass.sizeIdx2size(sizeIdx): 获取规格化后的内存大小。
  2. allocateRun(runSize): 这是标准分配的核心,也是小内存分配中创建 Subpage 的基础。
  3. initBuf(...): 用 allocateRun 返回的 handle 初始化 ByteBuf
    • 它从 handle 中解析出 runOffset
    • 计算物理偏移量:offset = runOffset(handle) << pageShifts
    • 计算最大长度:maxLength = runSize(pageShifts, handle)
    • 调用 buf.init(...) 完成初始化。

allocateRun(int runSize)

此函数负责在 PoolChunk 中找到并分配一块指定大小、连续的 run

// ... existing code ...private long allocateRun(int runSize) {int pages = runSize >> pageShifts;int pageIdx = arena.sizeClass.pages2pageIdx(pages);runsAvailLock.lock();try {// 1. 寻找最合适的可用 runint queueIdx = runFirstBestFit(pageIdx);if (queueIdx == -1) {return -1;}// 2. 从优先队列中取出 runIntPriorityQueue queue = runsAvail[queueIdx];long handle = queue.poll();// ...// 3. 从 runsAvailMap 中移除removeAvailRun0(handle);// 4. 如果取出的 run 比需要的大,则进行切分handle = splitLargeRun(handle, pages);// 5. 更新空闲字节数并返回 handleint pinnedSize = runSize(pageShifts, handle);freeBytes -= pinnedSize;return handle;} finally {runsAvailLock.unlock();}}
// ... existing code ...
  1. runFirstBestFit(pageIdx): 采用 "Best Fit" 策略寻找最合适的空闲 run
    • runsAvail 是一个 IntPriorityQueue 数组,按 run 的大小分桶。
    • 此方法从 pageIdx(请求的大小)开始遍历 runsAvail 数组,找到第一个不为空的队列。这确保了找到的 run 是 "大于等于请求大小的最小 run",从而减少内存碎片。
  2. queue.poll(): 从找到的优先队列中取出一个 run。因为是优先队列,且按 runOffset 排序,所以能保证取出的总是地址最低的 run
  3. removeAvailRun0(handle): 将这个 run 从 runsAvailMap 中移除,因为它不再是空闲状态。
  4. splitLargeRun(handle, needPages): 这是内存管理的核心优化。
    • 如果找到的 runtotalPages)比需要的 needPages 大,就把它切分成两部分。
    • 第一部分,大小为 needPages,被标记为“已使用”(inUsed=1),然后作为本次分配的结果返回。
    • 第二部分,即剩余的 remPages,被构造成一个新的空闲 run 的 handle,并通过 insertAvailRun 方法重新放回空闲 run 管理体系(runsAvail 和 runsAvailMap)中,以备将来使用。
    • 如果大小正好,就直接标记为“已使用”并返回。
  5. 更新 freeBytes 并返回最终的 handle

allocateSubpage

allocateSubpage 的核心逻辑就是先从当前 PoolChunk 中分配一个 run,然后将这个 run 包装成一个 PoolSubpage 对象,用于进行后续更细粒度的内存分配。

所以,你可以理解为:PoolSubpage 是一个 run 的“特殊形态”,这个 run 被专门用来管理小内存的分配。

我们来逐行解析这个函数,看看它是如何工作的。

调用时机: 这个方法会在 allocate() 函数中被调用,但前提是:

  1. 用户请求的是一个小内存(sizeIdx <= arena.sizeClass.smallMaxSizeIdx)。
  2. PoolArena 中对应规格的 smallSubpagePools 链表为空,即没有现成的、未满的 PoolSubpage 可供使用。

此时,系统就必须创建一个全新的 PoolSubpage 来满足分配需求。

// ... existing code ...private long allocateSubpage(int sizeIdx, PoolSubpage<T> head) {// 1. 分配一个新的 runint runSize = calculateRunSize(sizeIdx);// runSize 必须是 pageSize 的倍数long runHandle = allocateRun(runSize);if (runHandle < 0) {return -1;}// 2. 获取 run 的信息int runOffset = runOffset(runHandle);assert subpages[runOffset] == null;int elemSize = arena.sizeClass.sizeIdx2size(sizeIdx);// 3. 创建并初始化 PoolSubpagePoolSubpage<T> subpage = new PoolSubpage<>(head, this, pageShifts, runOffset,runSize(pageShifts, runHandle), elemSize);// 4. 在 Chunk 中记录这个新的 Subpagesubpages[runOffset] = subpage;// 5. 在新的 Subpage 上进行第一次分配return subpage.allocate();}
// ... existing code ...

步骤分解:

  1. int runSize = calculateRunSize(sizeIdx);

    • 这一步不是简单地分配一个 pageSize (8KB) 的 run。它会计算一个最优的 runSize。这个大小需要是 pageSize 的整数倍,并且能被要分配的元素大小 elemSize 整除,以避免内存浪费。
  2. long runHandle = allocateRun(runSize);

    • 这是回答你问题的关键。它直接调用了我们之前分析过的 allocateRun 方法。
    • allocateRun 会在 runsAvail 中寻找一个大小最合适的空闲 run
    • 如果找到的空闲 run 比 runSize 大,allocateRun 内部的 splitLargeRun 方法就会将其 分裂 成两块:一块返回给当前调用(大小为 runSize),另一块(剩余部分)重新放回空闲列表。
    • 所以,allocateSubpage 的内存来源,正是通过分配(和可能的 分裂)一个 run 得到的。
  3. new PoolSubpage<T>(...)

    • 用上一步分配到的 run 的信息(runOffsetrunSize)来创建一个新的 PoolSubpage 对象。
    • 这个 PoolSubpage 对象从此刻起就接管了这块 run 的内存,并负责将其划分为N个更小的 elemSize 块进行管理。
    • head 参数很重要,它会将这个新创建的 subpage 添加到 PoolArena 的 smallSubpagePools 链表中,以便后续的分配可以直接复用它。
  4. subpages[runOffset] = subpage;

    • PoolChunk 自身也需要一个引用来找到它的 subpage。这个 subpages 数组就起到了这个作用,它的索引就是 run 的偏移量 runOffset
  5. return subpage.allocate();

    • PoolSubpage 已经创建好了,但别忘了调用 allocateSubpage 的初衷是为了满足一次内存分配请求。
    • 所以最后一步,是在这个全新的 subpage 上调用 allocate() 方法,分配出第一个小内存块,并将其 handle 返回给最初的调用者。

总结

PoolChunk.allocate 的分配过程是一个精巧的多层级策略:

  1. 大小判断: 首先根据请求大小,决定走 小内存(Subpage) 还是 标准内存(Run) 的分配路径。
  2. 小内存路径:
    • 优先从 PoolArena 的 smallSubpagePools 缓存中获取一个现成的 PoolSubpage
    • 在 PoolSubpage 内部通过 bitmap 快速分配一个槽位。
    • 如果 Arena 中没有,则退回到当前 Chunk,调用 标准内存分配 逻辑 (allocateRun) 分配一个 page,并将其包装成一个新的 PoolSubpage,再进行分配。
  3. 标准内存路径:
    • 直接调用 allocateRun
    • allocateRun 使用 "Best Fit" 策略在 runsAvail 队列中找到最合适的空闲 run
    • 如果找到的 run 过大,则执行 splitLargeRun 将其一分为二,一部分用于本次分配,另一部分(余料)重新放回空闲池,最大化内存利用率。
  4. 初始化: 无论通过哪种路径,最终都会得到一个 handle,它包含了内存块的所有元信息(偏移、大小等),并用它来初始化 PooledByteBuf,完成分配。

这个递归和分层的设计,使得 Netty 的内存池能够高效地处理各种大小的内存请求,同时通过 split 和 merge(释放时)机制,最大限度地减少内存碎片。


 

PoolChunk.initBuf

整个 initBuf 的调用链是一个分层初始化的过程:

  1. PoolChunk.allocate : 负责从内存池中分配一个 run 或 subpage ,并生成一个 handle 。

  2. PoolChunk.initBuf / initBufWithSubpage : 负责解析 handle ,计算出内存块在 PoolChunk 中的具体位置和大小。

  3. PooledByteBuf.init : 负责将计算出的物理内存信息( chunk 、 offset 、 length 等)与 ByteBuf 的逻辑视图关联起来,完成最终的初始化。

这个过程确保了 PooledByteBuf 能够正确地引用和操作由 PoolChunk 管理的底层内存。

这是初始化 PooledByteBuf 的入口函数。它的作用是根据 handle 的类型( run 或 subpage )来选择不同的初始化路径。

  • 输入参数 : PooledByteBuf 实例、 ByteBuffer 、 handle (包含分配信息的长整型)、请求容量 reqCapacity 、 PoolThreadCache 。

  • 逻辑 :

    • 使用 isSubpage(handle) 检查 handle 是否代表一个 subpage 。

    • 如果是 subpage : 调用 initBufWithSubpage 进行初始化。

    • 如果不是 subpage (即是一个 run ) :

      1. 计算最大长度 maxLength = runSize(pageShifts, handle) 。

      2. 调用 buf.init(...) 来初始化 ByteBuf ,传入 run 的相关信息(如偏移量 runOffset(handle) << pageShifts )。

void initBuf(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,PoolThreadCache threadCache) {if (isSubpage(handle)) {initBufWithSubpage(buf, nioBuffer, handle, reqCapacity, threadCache);} else {int maxLength = runSize(pageShifts, handle);buf.init(this, nioBuffer, handle, runOffset(handle) << pageShifts,reqCapacity, maxLength, arena.parent.threadCache());}
}

PoolChunk.initBufWithSubpage

这个函数专门处理从 PoolSubpage 分配的内存的初始化。

// ... existing code ...void initBufWithSubpage(PooledByteBuf<T> buf, ByteBuffer nioBuffer, long handle, int reqCapacity,PoolThreadCache threadCache) {int runOffset = runOffset(handle);int bitmapIdx = bitmapIdx(handle);PoolSubpage<T> s = subpages[runOffset];assert s.isDoNotDestroy();assert reqCapacity <= s.elemSize : reqCapacity + "<=" + s.elemSize;int offset = (runOffset << pageShifts) + bitmapIdx * s.elemSize;buf.init(this, nioBuffer, handle, offset, reqCapacity, s.elemSize, threadCache);}
// ... existing code ...
  • 输入参数 : 与 initBuf 相同。

  • 逻辑 :

    • 从 handle 中解码出 runOffset 和 bitmapIdx 。

    • 使用 runOffset 从 subpages 数组中找到对应的 PoolSubpage 实例。

    • 计算内存的精确偏移量: offset = (runOffset << pageShifts) + bitmapIdx * s.elemSize 。

    • 调用 buf.init(...) 来初始化 ByteBuf ,传入 subpage 的相关信息(如 maxLength 等于 s.elemSize )。

PooledByteBuf.init

这是最终执行初始化的地方,它设置 PooledByteBuf 的内部状态。它由 init0 私有方法实现。

// ... existing code ...void init(PoolChunk<T> chunk, ByteBuffer nioBuffer,long handle, int offset, int length, int maxLength, PoolThreadCache cache) {init0(chunk, nioBuffer, handle, offset, length, maxLength, cache);}// ... existing code ...private void init0(PoolChunk<T> chunk, ByteBuffer nioBuffer,long handle, int offset, int length, int maxLength, PoolThreadCache cache) {
// ... existing code ...chunk.incrementPinnedMemory(maxLength);this.chunk = chunk;memory = chunk.memory;tmpNioBuf = nioBuffer;allocator = chunk.arena.parent;this.cache = cache;this.handle = handle;this.offset = offset;this.length = length;this.maxLength = maxLength;}
// ... existing code ...
  • 输入参数 : PoolChunk 实例、 ByteBuffer 、 handle 、偏移量 offset 、长度 length 、最大长度 maxLength 、 PoolThreadCache 。

  • 逻辑 :

    • 增加 PoolChunk 中“固定”内存的引用计数 ( chunk.incrementPinnedMemory(maxLength) )。

    • 设置 PooledByteBuf 的所有核心成员变量,包括 chunk , memory , handle , offset , length , maxLength , cache 等。

内存分配与管理机制解析

  1. 内存申请流程

调用链:

PoolArena.allocate() → PoolArena.newChunk()

内存分配方式:

  • ​直接内存 (DirectArena)​​:

    • 调用 newDirectChunk()
    • 执行 PlatformDependent.allocateDirect(chunkSize)ByteBuffer.allocateDirect(chunkSize)
    • 通过 JNI 调用底层系统调用 (如 malloc) 在堆外分配连续内存
    • 最终包装成 ByteBuffer 对象传递给 PoolChunk 构造函数
  • ​堆内存 (HeapArena)​​:

    • 调用 newHeapChunk()
    • 执行 PlatformDependent.allocateUninitializedArray(chunkSize) (等价于 new byte[chunkSize])
    • 在 JVM 堆上分配字节数组传递给 PoolChunk

关键点:

  • 实际内存申请发生在 PoolArena 创建新 PoolChunk 时
  • PoolChunk 是内存管理者而非申请者,负责切分和管理已申请的大块内存

memory 与 ByteBuf 的关联机制

关联要素:

  • 对整个 PoolChunk 的引用
  • 大内存块 memory 的引用
  • 偏移量 (offset): 内存起始位置
  • 长度 (length): 可用内存长度

操作原理:

读写操作示例: buf.setByte(index, value)执行过程:
1. 获取底层 memory 对象
2. 计算绝对地址: final_index = this.offset + index
3. 在 memory 的 final_index 位置进行读写

具体实现:

​堆内存实现 (PooledHeapByteBuf)​​:【实际过程使用了HeapByteBufUtil.setByte】

@Override
protected void _setByte(int index, int value) {// memory 为 byte[]((byte[]) memory)[idx(index)] = (byte) value;  // idx(index) = offset + index
}

​直接内存实现 (PooledDirectByteBuf)​​:

@Override
protected void _setByte(int index, int value) {// memory 为 ByteBuffer((ByteBuffer) memory).put(idx(index), (byte) value);
}

设计优势

  • ​轻量级视图​​: PooledByteBuf 作为内存视图/指针,不包含实际数据
  • ​高效管理​​: 通过 (chunk → memory, offset, length) 精确标识内存区域
  • ​快速回收​​: 对象轻量便于快速回收重用
  • ​操作代理​​: 所有操作代理到指定内存区域,避免不必要的内存复制

这种设计实现了内存的高效管理和快速对象回收,是 Netty 高性能内存管理的关键机制。

free

此方法是 Netty 内存池回收机制的核心入口之一。当一个 ByteBuf 被释放时,其占用的内存最终会通过调用这个方法归还给它所属的 PoolChunk。它的主要职责是:

  1. 接收一个代表内存块的句柄(handle
  2. 将其标记为可用
  3. 尝试与相邻的空闲内存块合并以减少碎片
  4. 最后更新 PoolChunk 的空闲状态

参数解析

参数类型说明
long handle64位长整型高度优化的数据结构,通过位运算编码了内存块的所有元信息(如是否是 subpage、在 PoolChunk 内的偏移量、大小等),是定位和释放内存的关键
int normCapacity整型已标准化的容量。虽然在此方法内部没有直接使用,但通常由调用方(如 PoolArena)持有,用于决策(例如选择哪个 PoolSubpage 池)
ByteBuffer nioBufferJava NIO Buffer当内存是直接内存(Direct Memory)时,这代表关联的 Java NIO ByteBuffer 对象。释放后可以被缓存以备复用,避免重复创建 ByteBuffer 对象的开销

代码逻辑分步详解

子页(Subpage)内存释放

void free(long handle, int normCapacity, ByteBuffer nioBuffer) {if (isSubpage(handle)) {int sIdx = runOffset(handle);PoolSubpage<T> subpage = subpages[sIdx];assert subpage != null;PoolSubpage<T> head = subpage.chunk.arena.smallSubpagePools[subpage.headIndex];// Obtain the head of the PoolSubPage pool that is owned by the PoolArena and synchronize on it.// This is need as we may add it back and so alter the linked-list structure.head.lock();try {assert subpage.doNotDestroy;if (subpage.free(head, bitmapIdx(handle))) {//the subpage is still used, do not free itreturn;}assert !subpage.doNotDestroy;// Null out slot in the array as it was freed and we should not use it anymore.subpages[sIdx] = null;} finally {head.unlock();}}
//剩余释放run

​关键步骤:​

  1. ​判断类型​
    调用 isSubpage(handle) 检查是否属于 subpage(用于分配小块内存的页)

  2. ​获取 Subpage​

    • 通过 runOffset(handle) 计算 subpage 在 subpages 数组中的索引 sIdx
    • 获取对应的 PoolSubpage 实例
  3. ​同步锁定​

    • 找到该 subpage 所在链表的头节点(head
    • 加锁保证线程安全
  4. ​释放子块​

    • bitmapIdx(handle) 从句柄解码出内存块在 subpage 内部位图(bitmap)中的位置
    • subpage.free() 更新位图,将该位置标记为可用
  5. ​处理结果​

    • 如果 subpage.free 返回 true:说明 subpage 仍有其他内存块在使用,直接返回
    • 如果返回 false:说明 subpage 完全空闲,将 subpages 数组对应槽位置为 null

运行块(Run)内存释放与合并

    int runSize = runSize(pageShifts, handle);//start free runrunsAvailLock.lock();try {// collapse continuous runs, successfully collapsed runs// will be removed from runsAvail and runsAvailMaplong finalRun = collapseRuns(handle);//set run as not usedfinalRun &= ~(1L << IS_USED_SHIFT);//if it is a subpage, set it to runfinalRun &= ~(1L << IS_SUBPAGE_SHIFT);insertAvailRun(runOffset(finalRun), runPages(finalRun), finalRun);freeBytes += runSize;} finally {runsAvailLock.unlock();}

​关键步骤:​

  1. ​适用场景​

    • 当释放的内存是普通 run
    • 或 subpage 被完全清空后
  2. ​同步锁定​
    使用 runsAvailLock 保护对 run 可用性数据结构的并发访问

  3. ​内存合并(Coalescing)​

    • collapseRuns(handle) 为核心方法
    • 检查被释放的 run 前后是否有空闲 run,合并成更大的连续 run
  4. ​更新句柄状态​

    • finalRun &= ~(1L << IS_USED_SHIFT):清除"使用中"标志位
    • finalRun &= ~(1L << IS_SUBPAGE_SHIFT):清除"是子页"标志位
  5. ​重新加入可用列表​

    • insertAvailRun() 将空闲 run 添加回可用数据结构(如 runsAvailMap
  6. ​更新空闲字节数​
    freeBytes += runSize 更新 PoolChunk 的空闲字节总数


缓存 NIO ByteBuffer

    if (nioBuffer != null && cachedNioBuffers != null &&cachedNioBuffers.size() < PooledByteBufAllocator.DEFAULT_MAX_CACHED_BYTEBUFFERS_PER_CHUNK) {cachedNioBuffers.offer(nioBuffer);}
}

​优化点:​

  • 针对直接内存(Direct Buffer)的优化
  • 如果传入 nioBuffer 且缓存未满,则存入 cachedNioBuffers 队列
  • 下次分配时可直接复用,避免调用 ByteBuffer.allocateDirect(),降低系统调用和 GC 压力

总结

PoolChunk.free 方法的设计亮点:

  1. ​高效回收机制​:区分 subpage 和 run,适配不同大小内存块的回收需求

  2. ​内存合并机制​:通过 collapseRuns 有效对抗内存碎片化

  3. ​对象复用优化​ 缓存 ByteBuffer 提升直接内存分配性能

  4. ​线程安全​:精细的锁策略保证多线程环境下的安全性

这是 Netty 高性能内存管理体系的基石,体现了空间与时间效率的完美平衡。

Run合并算法 collapseRuns

collapseRuns是Netty内存池PoolChunk中实现内存碎片合并的核心函数,其主要作用是在释放内存块时将相邻的空闲run(连续内存页)合并为更大的块,从而减少内存碎片并提高后续分配效率。以下是基于源代码的详细分析:


函数核心逻辑

​PoolChunk.java​

private long collapseRuns(long handle) {return collapseNext(collapsePast(handle));
}

该函数通过先合并前驱空闲块(collapsePast)再合并后继空闲块(collapseNext)的策略,实现连续内存的整合。合并过程会修改runsAvailMaprunsAvail两个核心数据结构:

  • ​runsAvailMap​​:存储可用run的偏移量与句柄映射
  • ​runsAvail​​:按页大小分类的可用run优先级队列

ollapsePast:合并前驱空闲块​

private long collapsePast(long handle) {for (;;) {int runOffset = runOffset(handle);int runPages = runPages(handle);long pastRun = getAvailRunByOffset(runOffset - 1);if (pastRun == -1) return handle;int pastOffset = runOffset(pastRun);int pastPages = runPages(pastRun);if (pastOffset + pastPages == runOffset) {removeAvailRun(pastRun);handle = toRunHandle(pastOffset, pastPages + runPages, 0);} else {return handle;}}
}
  • ​逻辑​​:从当前run向前查找相邻空闲块,若存在连续空间则合并
  • ​核心调用​​:
    • getAvailRunByOffset:通过runsAvailMap查找前驱run
    • removeAvailRun:从可用队列移除被合并的run
    • toRunHandle:生成合并后的新run句柄

​collapseNext:合并后继空闲块​

private long collapseNext(long handle) {for (;;) {int runOffset = runOffset(handle);int runPages = runPages(handle);long nextRun = getAvailRunByOffset(runOffset + runPages);if (nextRun == -1) return handle;int nextOffset = runOffset(nextRun);int nextPages = runPages(nextRun);if (runOffset + runPages == nextOffset) {removeAvailRun(nextRun);handle = toRunHandle(runOffset, runPages + nextPages, 0);} else {return handle;}}
}private long getAvailRunByOffset(int runOffset) {return runsAvailMap.get(runOffset);}
  • ​逻辑​​:与collapsePast对称,向后查找并合并连续空闲块
  • ​终止条件​​:找不到后继空闲块或非连续空间时返回

内存合并数据结构操作

  1. ​removeAvailRun0实现​​(实际执行从映射表删除):​

    private void removeAvailRun0(long handle) {int runOffset = runOffset(handle);int pages = runPages(handle);runsAvailMap.remove(runOffset);if (pages > 1) {runsAvailMap.remove(lastPage(runOffset, pages));}
    }
    • 同时删除run的起始页和结束页映射(仅当页数>1时)
  2. ​insertAvailRun实现​​(合并后新run的插入):
    ​PoolChunk.java​

    private void insertAvailRun(int runOffset, int pages, long handle) {int pageIdxFloor = arena.sizeClass.pages2pageIdxFloor(pages);runsAvail[pageIdxFloor].offer((int) (handle >> BITMAP_IDX_BIT_LENGTH));insertAvailRun0(runOffset, handle);if (pages > 1) {insertAvailRun0(lastPage(runOffset, pages), handle);}
    }
    • 将新run按页大小分类插入runsAvail队列
    • runsAvailMap中注册起始页和结束页

内存管理中的作用

  1. ​碎片抑制​​:通过合并相邻空闲块,维持大尺寸连续内存区域,提高大内存分配成功率
  2. ​分配效率​​:配合runFirstBestFit分配策略,实现最佳适配
  3. ​线程安全​​:通过runsAvailLock保证并发环境下的数据一致性

调用链路总结

free() → collapseRuns() → collapsePast() → [getAvailRunByOffset/removeAvailRun]↓collapseNext() → [getAvailRunByOffset/removeAvailRun]↓insertAvailRun() → [更新runsAvail/runsAvailMap]

通过这套机制,Netty实现了媲美jemalloc的高效内存管理,在高并发场景下显著降低GC压力并提升内存利用率。

特性

线程安全

  • 使用ReentrantLock runsAvailLock保护run分配/释放

  • PoolSubpage有独立的锁机制

内存碎片管理

  • 最佳适配: 优先使用最小满足需求的run

  • 连续合并: 释放时自动合并相邻的空闲run

  • 偏移排序: 相同大小的run按偏移量排序,减少碎片

性能优化

  • 位操作: 使用位操作快速编码/解码handle信息
  • 缓存机制cachedNioBuffers缓存ByteBuffer减少GC【当调用 ByteBuf 的 internalNioBuffer(...) 或 nioBuffer(...) 等方法,需要将 ByteBuf 的数据以 ByteBuffer 的形式访问时, PoolChunk 就会利用 cachedNioBuffers 来提供 ByteBuffer 对象。】
  • 分层索引: 多级索引结构提高查找效率

PoolChunk通过这种精巧的设计,实现了高效的内存分配和回收,最大化内存利用率并最小化碎片产生。

相关文章:

  • 给同一个wordpress网站绑定多个域名的实现方法
  • C#Halcon从零开发_Day11_圆拟合
  • vim学习流程,以及快捷键总结
  • Docker 运行RAGFlow 搭建RAG知识库
  • Linux下QGIS二次开发环境搭建
  • 【投稿与写作】overleaf 文章转投arxiv流程经验分享
  • LeetCode 每日一题 2025/6/16-2025/6/22
  • 【DDD】——带你领略领域驱动设计的独特魅力
  • winform mvvm
  • 案例练习二
  • Unity3D 屏幕点击特效
  • 【前后前】导入Excel文件闭环模型:Vue3前端上传Excel文件,【Java后端接收、解析、返回数据】,Vue3前端接收展示数据
  • 「Linux文件及目录管理」vi、vim编辑器
  • Azure Devops
  • 【递归,搜索与回溯算法】记忆化搜索(二)
  • 深度实战|星环OS三大创新场景解密:如何用确定性技术重构智能汽车安全与体验?
  • 【旧题新解】第 20 集 输出保留 3 位小数的浮点数
  • 解决qt.qpa.plugin: Could not find the Qt platform plugin “windows“ in ““ ...
  • MySQL安装与配置【windowsMac】
  • 15.3 LLaMA 3+LangChain实战:智能点餐Agent多轮对话设计落地,订单准确率提升90%!
  • 英文购物网站建设/网络推广的优势有哪些
  • 备案后网站打不开/怎样下载优化大师
  • 网站设计建设公司/深圳网站优化软件
  • 帮客户做网站内容/人员优化方案怎么写
  • 网站建设销售怎么样/高端网站制作
  • 潍坊尚呈网站建设公司/网络营销的含义是什么