case
最近在看内存泄漏的例子。
下面的代码,是一个博客说s0 = strings.Clone(s1[:50])替代s0 = s1[:50]可以避免内存泄漏。
var s0 string // 包级变量
// 模拟内存泄漏的函数
func f(s1 string) {
//s0 = s1[:50]
s0 = strings.Clone(s1[:50]) // NOTE 创建一个新的独立内存块。函数内部用make分配的
// 现在,s0 和 s1 共享相同的底层内存块
// 即使 s1 不再使用,s0 仍然引用这个内存块
// 因此,整个内存块无法被垃圾回收
}
用runtime.ReadMemStats获取的Alloc可以简单的验证确实没有了内存泄漏。 但是我想看下之前内存分配的情况,以及修改后内存分配的情况。
下面的代码是单独验证strings.Clone的内存分配的。
在这个过程中,我遇到了很多问题,涉及几个知识点,我这里将其记录下来。
func main() {
// 设置堆内存采样频率为每次分配都采样
runtime.MemProfileRate = 1
// 创建内存 profile 文件
f, err := os.Create("mem.prof")
if err != nil {
fmt.Println("无法创建内存 profile 文件:", err)
return
}
defer f.Close()
// 打印初始内存使用情况
printMemUsage("Initial")
// 创建一个 1 MiB 的字符串
s1 := string(make([]byte, 1<<20)) // 1 MiB
// 打印分配 1 MiB 字符串后的内存使用情况
printMemUsage("After s1 allocation")
// 调用 strings.Clone(s1[:50])
for i := 0; i < 2000; i++ {
s0 := strings.Clone(s1[:500]) // NOTE go build -gcflags="-m -N -l" -o main clone分配了多少内存.go
// 防止 s0 被优化掉
_ = s0
// 防止 s0 被优化掉
// runtime.KeepAlive(s0)
}
// 打印调用 strings.Clone 后的内存使用情况
printMemUsage("After strings.Clone")
// 强制进行垃圾回收
fmt.Println("第1次强制GC")
runtime.GC()
time.Sleep(100 * time.Millisecond) // 给GC时间执行
// 打印垃圾回收后的内存使用情况
printMemUsage("After GC")
// 写入内存 profile
err = pprof.WriteHeapProfile(f)
if err != nil {
return
}
}
func printMemUsage(phase string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("[%s] Alloc = %v MiB", phase, bToMb(m.Alloc))
fmt.Printf("\tTotalAlloc = %v MiB", bToMb(m.TotalAlloc))
fmt.Printf("\tSys = %v MiB", bToMb(m.Sys))
fmt.Printf("\tHeapReleased = %v MiB", bToMb(m.HeapReleased))
fmt.Printf("\tNumGC = %v\n", m.NumGC)
}
func bToMb(b uint64) uint64 {
return b / 1024 / 1024
}
问题1:内存调用图看不到strings.Clone
几个原因
-
pprof的堆内存分析只采集堆上的内存分配,不会采集栈上的内存分配。所以用逃逸分析看下关注的变量(
strings.Clone内部b := make([]byte, len(s))`)有没有逃逸到堆。没在堆上则采样不到,也就不会在pprof调用图中。 -
内联函数导致采样内存分配被算入父函数:
strings.Clone被内联到main函数,采样的时候都算main函数的。
解决: 编译时禁用内联-l和优化-N,同时-m打印逃逸分析信息:
go build -gcflags="-m -N -l" -o main xx.go -
开关-N,-l可能会影响逃逸分析。反正我发现strings.Clone内部
b := make([]byte, len(s))会在启用内联和优化时逃逸,禁用时不逃逸。 -
采集频率和触发采样的内存阈值大于你观察变量的内存分配。
- 内存分析的采样频率由
runtime.MemProfileRate变量控制。 - 默认情况下,内存采样频率是每分配 512 KB 内存采样一次。
- 设置内存采样频率为每 1 KB 分配采样一次 runtime.MemProfileRate = 1024
- 设置内存采样频率为每次分配都采样: runtime.MemProfileRate = 1
- 基于上述知识点,所以我把
strings.Clone(s1[:50])增大为strings.Clone(s1[:500]),这样每次分配(512KB)都可以被采样到方便观察。
- 内存分析的采样频率由
-
内存调用链图。经过上面的调整。保证关注的变量会逃逸到heap,同时保证每次内存分配都会被采样。 理论上肯定可以看到在调用图中看到了吧。结果还是看不到,因为
- 知识点: 即使所有内存分配都被记录在
pprof文件中,图形显示时仍然会省略一些较小的分配。 pprof工具提供了一个-nodefraction参数,用于控制节点(框)过滤的阈值。默认情况下,-nodefraction的值为0.005(即 0.5%),这意味着小于总内存 0.5% 的分配会被过滤掉。pprof工具还提供了一个-edgefraction参数,用于控制边的过滤阈值。默认情况下,-edgefraction的值为0.001(即 0.1%),这意味着小于总内存 0.1% 的边会被过滤掉。- 同时使用
-nodefraction和-edgefraction参数,以完全禁用过滤。go tool pprof -nodefraction=0 -edgefraction=0 heap.pprof使得所有内存分配和边都会被显示在图形中。
- 知识点: 即使所有内存分配都被记录在
-
本地用
web命令得到下图,图中链接提示你如何阅读生成的图形,以及若干命令。- 实线和虚线:表示直接调用和间接调用。
- list过滤和weblist过滤
- github.com/google/ppro… 这个有需要还可以继续研读,现在了解的基本够用了。
- 这几个命令
最终的分析命令
-
打开heap.prof的命令是
go tool pprof -nodefraction=0 -edgefraction=0 -alloc_space mem.prof注意是alloc_space是自程序启动以来分配的总内存大小(字节),如果是usage_space会漏掉一些调用链,因为后者只表示当前堆上存活的内存大小(字节)。参考我上篇博客 -
[可选操作] pprof命令行执行
sample_index=inuse_space可以切换采样类型 -
执行web命令
-
main分配了2MB+1000KB(2000500=1000,000,估计有内存对齐是2000512=1024,000)正好1000KB。
-
验证内存对齐,把循环改为1次或者去掉,可以看到单次就是分配了
512B。 -
按照上篇博客的分析,每个根节点表示一个携程调用链,因为pprof采样是分携程采样的。图中有3个根节点。
-
3048 kb of 3049.82,of之前数字表示当前main的分配,其余是子函数的内存分配。 -
-
-
执行list main或者weblist main命令在浏览器显示。 *
其他
s1 := string(make([]byte, 1<<20))为什么会分配2MB
图中 s1 := string(make([]byte, 1<<20)) 这一行代码显示分配了 2MB,而不是预期的 1MB,这是因为在 Go 语言中,字符串和字节切片之间的转换会涉及内存的复制。
详细解释:
make([]byte, 1<<20): 这部分代码创建了一个 1MB (1 << 20 = 1048576 字节) 的字节切片。这块内存是实际存储字节数据的。string(...): 这部分代码将字节切片转换为字符串。在 Go 1.0 版本之后,string([]byte)会创建一个新的字符串,并将字节切片的内容复制到新的字符串的底层数组中。这意味着,除了原始的字节切片占用的 1MB 内存外,还需要额外的 1MB 内存来存储新的字符串。
因此,总共分配了 2MB 的内存:1MB 用于字节切片,1MB 用于字符串。
内存布局示意图:
+-----------------+ +-----------------+
| 字节切片 (1MB) |---->| 字符串 (1MB) |
+-----------------+ +-----------------+
| 数据 (1MB) | | 数据 (1MB,复制) |
+-----------------+ +-----------------+
为什么需要复制?
Go 语言中的字符串是不可变的(immutable)。这意味着一旦创建,就不能修改字符串的内容。为了保证字符串的不可变性,string([]byte) 必须创建一个新的字符串,而不是直接使用原始的字节切片。如果直接使用原始的字节切片,那么修改字节切片的内容就会影响到字符串,这违反了字符串的不可变性原则。
如何避免复制?
在某些特定的场景下,如果你非常注重性能,并且可以接受一些限制,可以采用 unsafe 包来进行零拷贝转换。但是,这种方法需要非常小心,因为它绕过了 Go 语言的类型安全机制,可能会导致一些难以调试的问题。通常情况下,不建议使用这种方法,除非你对 Go 的内存模型和 unsafe 包非常熟悉。
以下是使用 unsafe 包进行零拷贝转换的示例(仅供参考,不建议在生产环境中使用):
Go
import (
"reflect"
"unsafe"
)
func BytesToString(b []byte) string {
bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))
sh := reflect.StringHeader{
Data: bh.Data,
Len: bh.Len,
}
return *(*string)(unsafe.Pointer(&sh))
}
func StringToBytes(s string) []byte {
sh := (*reflect.StringHeader)(unsafe.Pointer(&s))
bh := reflect.SliceHeader{
Data: sh.Data,
Len: sh.Len,
Cap: sh.Len,
}
return *(*[]byte)(unsafe.Pointer(&bh))
}
func main() {
b := make([]byte, 1<<20)
s := BytesToString(b)
// ...
b2 := StringToBytes(s)
// ...
}
总结:
s1 := string(make([]byte, 1<<20)) 分配了 2MB 内存的原因是字符串和字节切片之间的转换会进行内存复制,以保证字符串的不可变性。在大多数情况下,这种复制是必要的,也是值得的。如果确实需要避免复制,可以使用 unsafe 包,但需要谨慎使用。在你的代码中,显示分配 2MB 是符合预期的行为。