Files
autumn-recruitment/00.Go/data-structures/切片底层实现_test.md
T

200 lines
9.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 参与可达性分析的过程