---
tags: [go, golang, go-principle, string]
create time: 2026-06-07 15:00
---
# String 底层原理
## 概述
本文深入 Go runtime 源码,解析 `string` 类型的底层存储结构、与 `[]byte` 的转换机制、以及字符串拼接的性能差异。理解 string 的原理,能帮你写出更高效的文本处理代码——尤其是高频场景下的字符串拼接策略。
> [!question] ❓ 思考
> 为什么 Go 要设计一个"指针 + 长度"的结构来表示字符串,而不是直接把字符数组塞进去?如果 string 是可变的,会带来哪些安全隐患?
## 正文
### 一、String 的本质:只读的字节序列
在 Go 语言中,字符串是一串由 UTF-8 编码(也可以不是)的字节序列。它的核心特性就三个字:**不可变**。
```go
// src/builtin/builtin.go
// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string
```
注意三个关键点:
- 字符串是 8-bit 字节的集合,**不一定**是 UTF-8 文本
- 可以为空字符串 `""`,但**不能为 nil**
- 值是不可变的(immutable)
> [!tip] 💡 理解要点
> "重新赋值"不等于"修改"。`str = "new"` 是创建新对象并改变引用,而 `str[0] = 'A'` 才是真正的修改,后者被编译器禁止。
```go
str := "Hello"
str = "Golang" // 合法:重新赋值,str 指向了新对象的内存
str[0] = 'A' // 编译错误:cannot assign to str[0]
```
### 二、String 的数据结构:两个字段就够了
在 runtime 层面,string 的内部表示用 `stringStruct` 描述:
```go
// src/runtime/string.go
type stringStruct struct {
str unsafe.Pointer // 指向底层字节数组的首地址
len int // 字节数(不是字符数!)
}
```
两个字段,极简设计。以 `"Hello"` 为例:
```mermaid
graph LR
ss["stringStruct"] --> ptr["str → 'H','e','l','l','o'"]
ss --> ln["len = 5"]
style ss fill:#e1f5fe
style ptr fill:#fff9c4
style ln fill:#fff9c4
```
| 字段 | 含义 | 示例值 |
|------|------|--------|
| `str` | 指向底层字节数据的指针 | `0x1040a124` |
| `len` | 字节数量 | `5` |
> [!warning] ⚠️ 注意
> `len` 存的是**字节数**而非字符数。对于中文等多字节字符,`len("你好")` 返回 6 而非 2。
既然没有容量(cap)字段,说明 string 根本无法扩容——这进一步印证了它的设计定位:轻量传递,不可修改。
### 三、String ↔ []byte 转换:何时拷贝?
#### 3.1 常规转换总是发生内存拷贝
```go
ss := "Hello"
b := []byte(ss) // 分配新切片,拷贝数据
s := string(b) // 再分配新空间,再拷贝回去
```
转换过程可以概括为:
```mermaid
flowchart LR
A["string
ptr + len"] -- 拷贝 --> B["[]byte
新分配"]
C["[]byte
原数据"] -- 拷贝 --> D["string
新分配"]
style A fill:#e3f2fd
style D fill:#e3f2fd
```
- `[]byte` → `string`:申请新空间,将数据从切片拷贝到 string 底层
- `string` → `[]byte`:申请新切片,将 string 数据拷贝到切片
> [!note] 📝 源码要点
> 这是 `runtime.stringtoslicebyte` 和 `runtime.slicetostring` 做的事情。每次调用都涉及一次 `mallocgc` + `memmove`。
#### 3.2 零拷贝优化:临时场景不拷贝
有些场景下,`[]byte` 转 `string` 不会触发内存拷贝——当转换结果仅用于临时比较或查找时:
```go
// 以下场景不会发生内存拷贝:
if string(bytesSlice) == "Hello" // 临时比较
_ = map[string(bytesSlice)]{...} // 用作 map key
fmt.Sprintf("%s", bytesSlice) // 作为函数参数
```
编译器识别出这些字符串不会被后续使用,直接构造一个指向原切片内存的 stringStruct 返回。
### 四、字符串声明方式
Go 提供两种声明方式:
```go
// 双引号:普通字符串,支持转义
s1 := "Hello\nWorld"
// 反引号:原始字符串(raw string),内容即所见
s2 := `{"key": "value"}`
```
反引号内的一切都是原始内容,适合 JSON、SQL 等包含大量特殊字符的场景。
### 五、字符串拼接性能对比
字符串不可变,拼接必然产生新内存分配。以下是 6 种常见方式的性能测试(1000 次拼接):
| 方法 | ns/op | 分配/次 | 推荐度 |
|------|-------|---------|--------|
| `strings.Builder` | ~37K | 1 alloc | ⭐⭐⭐ |
| `strings.Join` | ~42K | 1 alloc | ⭐⭐⭐ |
| `bytes.Buffer` | ~75K | 2 alloc | ⭐⭐ |
| `append` | ~308K | 24 alloc | ⭐ |
| `+` 操作符 | ~18M | 1010 alloc | ⭐ |
| `fmt.Sprintf` | ~63M | 4275 alloc | ❌ |
> [!tip] 💡 实践建议
> - **少量拼接**(2~3 个):直接用 `+`,简洁够用
> - **大量拼接**:首选 `strings.Builder` 或 `strings.Join`
> - **永远不要**用 `Sprintf` 做拼接——它是格式化专用的
### 六、为什么这样设计?
string 用"指针 + 长度"而非直接存储数据,核心原因有两个:
1. **传递开销极小**:无论字符串多长,复制一个 string 只需拷贝 16 字节(指针 + int64)
2. **安全性保障**:不可变性意味着任何 goroutine 都可以安全读取,无需加锁
这也是 Go 并发编程哲学的一个缩影:**通过类型不变性来消除同步需求**。
## 小结
- String 底层是 `stringStruct{str, len}`,无 cap 字段,故不可变不可扩容
- `[]byte` ↔ `string` 常规转换需拷贝;临时场景中编译器可做零拷贝优化
- 大量拼接优先选 `strings.Builder` / `strings.Join`,避免 `+` 和 `Sprintf`
- "指针 + 长度"的设计让 string 成为 Go 中最轻量的引用类型之一
## 关联笔记
- [[hzh/GolangStar/Go语言基础/Go语言变量]] — 变量的声明与初始化
- [[hzh/GolangStar/Go语言基础/Go语言数组与切片]] — Slice 与 Array 的区别
- [[hzh/GolangStar/Go语言原理/slice原理]] — Slice 的扩容机制