Go version
go version go1.25.5 darwin/arm64
Output of go env in your module/workspace:
What did you do?
Reproduction
Two functionally identical programs differ only in where the package-level pointer is initialized — inline vs. in init().
leak.go — leaks (inline initialization):
package main
type List struct {
next *List
}
var l = &List{
next: &List{},
}
func next() {
next := l.next
next.next = &List{}
l = next
}
noleak.go — no leak (init() initialization):
package main
type List struct {
next *List
}
var l *List
func init() {
l = &List{
next: &List{},
}
}
func next() {
next := l.next
next.next = &List{}
l = next
}
main.go (test driver):
package main
import (
"fmt"
"runtime"
"runtime/debug"
)
func formatMB(b uint64) string {
return fmt.Sprintf("%.2f MB", float64(b)/1024/1024)
}
func printMemStats(label string) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("[%s] HeapAlloc=%s HeapSys=%s HeapObjects=%d NumGC=%d\n",
label,
formatMB(m.HeapAlloc),
formatMB(m.HeapSys),
m.HeapObjects,
m.NumGC,
)
}
func main() {
printMemStats("init")
for i := 0; i < 1_000_000; i++ {
next()
}
runtime.GC()
runtime.GC()
debug.FreeOSMemory()
printMemStats("after 1M next")
}
What did you see happen?
Leaking version (inline var initialization):
[init] HeapAlloc=0.13 MB HeapSys=3.75 MB HeapObjects=140 NumGC=0
[after 1M next] HeapAlloc=7.77 MB HeapSys=11.69 MB HeapObjects=1000147 NumGC=4
Non-leaking version (init() initialization):
[init] HeapAlloc=0.13 MB HeapSys=3.75 MB HeapObjects=142 NumGC=0
[after 1M next] HeapAlloc=0.14 MB HeapSys=7.66 MB HeapObjects=151 NumGC=5
After 1,000,000 next() calls + explicit runtime.GC() + debug.FreeOSMemory():
- Inline initialization: 1,000,147 objects remain on the heap — all 1M discarded nodes are retained, HeapAlloc grows to 7.77 MB.
init() initialization: only 151 objects remain — old nodes are collected as expected, HeapAlloc stays at 0.14 MB.
Objects created during static package-level initialization appear to bypass normal escape analysis reporting. This suggests the runtime may not register them as regular GC-managed heap objects, causing them to act as pinned roots even after the pointer is reassigned.
What did you expect to see?
Both versions should produce the same heap after GC. Old StorageEvent nodes that are no longer reachable (the global pointer has been reassigned away from them) should be collected.
Each next() call:
- advances
l to its .next
- allocates a new node as the new tail
The previous head node loses all references and should be eligible for collection.
Go version
go version go1.25.5 darwin/arm64
Output of
go envin your module/workspace:What did you do?
Reproduction
Two functionally identical programs differ only in where the package-level pointer is initialized — inline vs. in
init().leak.go — leaks (inline initialization):
noleak.go — no leak (
init()initialization):main.go (test driver):
What did you see happen?
Leaking version (inline var initialization):
Non-leaking version (
init()initialization):After 1,000,000
next()calls + explicitruntime.GC()+debug.FreeOSMemory():init()initialization: only 151 objects remain — old nodes are collected as expected, HeapAlloc stays at 0.14 MB.Objects created during static package-level initialization appear to bypass normal escape analysis reporting. This suggests the runtime may not register them as regular GC-managed heap objects, causing them to act as pinned roots even after the pointer is reassigned.
What did you expect to see?
Both versions should produce the same heap after GC. Old
StorageEventnodes that are no longer reachable (the global pointer has been reassigned away from them) should be collected.Each
next()call:lto its.nextThe previous head node loses all references and should be eligible for collection.