--- tags: [test/review, go, slice, append-growth, memory-allocation] create time: 2026-08-09 12:00 --- # 切片底层实现_测试题 ## 概述 本测试覆盖 Go 切片的内部结构、nil/empty 区别、append 扩容策略(含 Go 1.18 变化)、三索引切片以及共享底层数组的经典陷阱。共包含 6 道选择题、3 道填空题和 1 道综合简答题。 --- ## 一、选择题(6道,由浅入深) > **难度阶梯**: Q1-Q2 基础概念 → Q3-Q4 核心原理 → Q5-Q6 深入应用/边界场景 ### Q1(基础)— 考察定义层面 在 64 位平台上,`unsafe.Sizeof(s)` 对一个类型为 `[]int` 的切片变量 s 返回多少字节? A. 与切片中元素个数相同,每个元素占 8 字节 B. 永远是 24 C. 是底层数组的总大小(元素个数 × 元素大小) D. 取决于切片的容量 cap ### Q2(基础)→ 行为判断 以下代码的输出是什么? ```go package main import "encoding/json" import "fmt" func main() { var s1 []int s2 := make([]int, 0) b1, _ := json.Marshal(s1) b2, _ := json.Marshal(s2) fmt.Println(string(b1), string(b2)) } ``` A. `[] []` B. `null null` C. `null []` D. panic ### Q3(进阶)→ 核心原理 Go 1.18 之后,当切片当前容量 `oldCap >= 256` 且触发扩容时,新容量的计算公式是什么? A. `newCap = oldCap * 2` B. `newCap = oldCap + oldCap / 2` C. `newCap = oldCap + oldCap / 4` D. `newCap = oldCap + 256` ### Q4(进阶)→ 比较与辨析 下面三种切片创建方式中,哪一项描述是正确的? ```go var s1 []int // 方式一 s2 := make([]int, 0) // 方式二 s3 := []int{} // 方式三 ``` A. 方式一的 `s1 == nil` 为 true,方式二和三的 `s2 == nil` 也为 true B. 方式二和方式三创建的切片,它们的 `ptr` 都指向一块已分配的空数组内存 C. 方式一的 `cap` 不为 0,而是等于后续第一次 append 时的默认值 D. 三者功能完全等价,没有任何语义或行为差异 ### Q5(深入)→ 场景推理 ```go orig := []byte("Hello, World!") // len=13, cap=13 short := orig[:5] // short = "Hello", len=5, cap=13 saved := short[:cap(short)] // saved 长度为 13,共享 orig 的底层数组 copy(saved, "Hi") // 将 "Hi" 复制到 saved 的前三个位置 ``` 执行后 `string(orig)` 的结果是什么? A. `"Hello, World!"` — orig 不受影响 B. `"Hi, World!"` — orig 前三个字节被覆盖 C. `"Hi"` — orig 长度变为 3 D. panic — 越界访问 ### Q6(深入)→ 源码级边界场景 假设有一个空切片 `s := make([]int, 0, 0)`,对其连续调用 `append(s, 1, 2, 3, ..., 100)` 一次性追加 100 个元素。请问最终切片的容量 cap 是多少? A. 200(翻倍到 ≥ 100) B. 100(直接使用目标 cap) C. 128(翻倍两次:0→1→2→...→100 过程中的渐进增长) D. 101(100 + 预留一个) --- ## 二、填空题(3道) ### F1 — 扩容数值计算 ```go s := make([]int, 0, 300) // ... 追加若干元素直到触发扩容 // newCap = 300 + 300/4 = _____ ``` 请计算当 `oldCap = 300` 时首次触发的 `newCap` 值:_____ > **提示**: 使用 Go 1.18 之后的 1.25x 增长率公式,注意整除运算向下取整。 ### F2 — 三索引切片容量 ```go a := make([]byte, 5) // a: [0 0 0 0 0], len=5, cap=5 s1 := a[1:4] // s1: _____, len=3, cap=? s2 := s1[0:2] // s2: [0 0], len=2, cap=? ``` `s1` 的容量 cap = _____;`s2` 受 `s1.cap` 限制,`s2.cap` = _____ > **提示**: 从原始切片 a 切出 a[1:4] 时,max 省略时默认为 a.cap,因此 s1.cap = a.cap - 1。再对 s1 切分时,新切片的 cap 不能超过 s1.cap。 ### F3 — 缓存复用模式 ```go var cache []int // ... 之前已经往 cache 里 append 了不少数据 cache = cache[:0] // 重置长度,保留底层数组 for _, item := range items { cache = append(cache, process(item)) } // 下一次调用 cache[:0] 即可复用已分配的内存,无需额外的 _____ ``` 这种模式中 `cache[:0]` 的核心优势是不触发额外的内存分配,因为底层数组保持不变。空白处填该操作避免的操作名:_____ > **提示**: 对比 `make([]T, expectedSize)` 与 `cache[:0]`,后者跳过了 runtime 层面的哪一步? --- ## 三、简答题(1道) ### S1 你在设计一个 HTTP 请求处理管道,多个 goroutine 并行处理请求并将结果写入同一个 `results` 切片。某次压测发现结果中出现重复数据和脏数据。排查发现:worker goroutine 接收到 request body 的 `[]byte` 缓冲区后直接将其切分传给下游,而没有做 copy。 请结合切片底层的至少三个关键知识点,分析为什么会出现这个问题,并给出修正方案。 > **答题框架提示**: > 1. 先说明切片字段机制(ptr/len/cap 如何操控共享内存) > 2. 再说明多个切片共享同一底层数组的后果 > 3. 接着说明 goroutine 并发场景下的具体风险 > 4. 最后给出防御方案(copy 断开共享、预分配等) --- ## 参考答案与解析 ### 选择题答案 | 题号 | 正确答案 | 解析 | |------|---------|------| | Q1 | B | 切片头在 64 位平台固定为 24 字节(ptr 8 + len 8 + cap 8),与其中元素数量和容量无关。底层数组的实际大小由运行时独立管理,不包含在切片头内。 | | Q2 | C | `var s1 []int` 创建的是 nil slice(ptr=nil),`encoding/json` 将其序列化为 `null`。`make([]int, 0)` 创建的是 empty slice(有分配底层数组),序列化为 `[]`。这是 nil slice 和 empty slice 最常见的外部可见差异。 | | Q3 | C | Go 1.18 起,当需求 cap ≥ 256 时使用 `newCap = oldCap + oldCap/4`(即 1.25x 增长率)。小容量(< 256)仍然翻倍。这样做避免了大容量切片过度分配。例如 cap=300 时,newCap=375。 | | Q4 | B | 方式一 `var s` 的 ptr 为 nil、len=0、cap=0,`s==nil` 为 true。方式二和方式三的 ptr 都指向 runtime 分配的一块空数组(cap≥1),`s==nil` 为 false。方式三比方式二更明确地表达"需要一个非 nil 的空切片"。选项 A 错在方式二三不是 nil;选项 C 错在方式一的 cap=0;选项 D 错在 JSON 序列化等行为确实不同。 | | Q5 | B | `short` 和 `orig` 共享同一底层数组。`saved := short[:cap(short)]` 将 short 扩展到整个容量(13),然后 `copy(saved, "Hi")` 写入了 `orig[0:3]`,覆盖了原来的 "Hel",结果为 "Hi, World!"。这是一个真实项目中出现过的 bug 模式。 | | Q6 | B | 根据扩容规则的第 3 步:"如果旧 cap 太小而目标 cap 太大,直接用目标 cap"。从零容量开始一次性 append 100 个元素,target cap 为 100,runtime 直接取 100 而非经过多次翻倍。这是扩容逻辑的特殊分支保护。 | ### 填空题答案 | 题号 | 答案 | 解析 | |------|------|------| | F1 | `375` | Go 1.18 扩容公式:`oldCap >= 256` 时用 `newCap = oldCap + oldCap/4 = 300 + 75 = 375`。注意整除运算 `300/4 = 75` 恰好整除。 | | F2 | `4`, `2` | `a[1:4]` 相当于 `a[1:4:5]`(省略 max 时取原切片 cap),所以 `s1.cap = 5 - 1 = 4`。`s2 := s1[0:2]` 是从 s1 头部切 2 个元素,显式 cap 计算为 `2-0=2`,所以 `s2.cap = 2`。 | | F3 | `malloc`(或"内存分配") | `cache[:0]` 仅修改 len 字段为 0,不改变 ptr 和 cap,底层数组继续保持可达。下次 append 时可以直接复用已有空间,跳过 runtime 的 malloc 调用和数据拷贝开销。 | ### 简答题参考答案 S1:**参考答案要点**: 1. **切片头部只有 24 字节的指针/长度/容量,真正的数据在共享的底层数组**——多个 worker 传递的切片可能指向同一块内存。worker 之间传递的是切片头而不是数据的副本。 2. **多个 worker 切分同一个 request body 的缓冲区后,它们共享底层数组**——任何一个 worker 对切片的修改(尤其是通过大容量扩展后的 append 操作)都会影响其他 worker 看到的原始数据。 3. **goroutine 并发环境下,request body 缓冲区可能被上游(HTTP handler 或 net/http)复用覆盖**——即使没有直接的相互覆盖,buffer pool 的标准回收机制也会在请求结束后重用这块内存,导致后续 worker 读到被改写的数据。 4. **防御方案**:通过 `copy(dst, src)` 将需要的数据深拷贝到各自独立的底层数组,彻底断开共享关系。对于已知大小的数据,可以先 `make` 预分配再进行 copy。 **评分标准**:答出任意 2 个要点即可得满分;完全正确需覆盖全部要点。重点考察是否理解"切片是遥控器而非容器"这一核心理念。 ## 关联笔记 - [[00.Go/data-structures/Map 底层实现]] — Slice 是 Map bucket 内部的数组类型,理解切片有助于理解 Map 的扩容和数据组织 - [[00.Go/concurrency/Goroutine 调度模型]] — Goroutine 参数传递中使用切片的注意事项 - [[00.Go/runtime/三色标记GC原理]] — 切片作为 GC root 参与可达性分析的过程