Complete Go Slice Complete Guide | Internal Structure
이 글의 핵심
Go slice internal structure, memory allocation mechanism, capacity vs length, append operation principles, performance optimization techniques. Complete...
Key Takeaways
Complete deep dive guide covering Go slice internals, memory allocation, and performance optimization. Includes pitfalls and solutions encountered in production.
Real-World Experience: Sharing memory leak and performance degradation experiences from not understanding slice internals, and optimization techniques learned while solving them.
Introduction: “Why Do Slices Behave This Way?”
Real-World Problem Scenarios
Scenario 1: Unexpected Memory Usage
Copied a slice but memory didn’t double. Why?
Scenario 2: Strange Behavior After append
Appended to slice and original was modified. What happened?
Scenario 3: Performance Degradation
Appending in loop makes program slow. How to optimize?
Table of Contents
- Slice Internal Structure
- Memory Allocation Mechanism
- append Operation Principles
- Slice Copy vs Reference
- Performance Optimization
- Practical Patterns
- Pitfalls and Solutions
- Advanced Techniques
1. Slice Internal Structure
What is a Slice?
A slice is Go’s dynamic array. Internally, it’s a struct with 3 fields:
type slice struct {
ptr unsafe.Pointer // Array pointer
len int // Current length
cap int // Capacity
}
Visualization
flowchart TB
subgraph Slice["Slice Struct"]
PTR["ptr: 0x1000"]
LEN["len: 3"]
CAP["cap: 5"]
end
subgraph Array["Actual Array (Memory)"]
A0["[0]: 10"]
A1["[1]: 20"]
A2["[2]: 30"]
A3["[3]: 0"]
A4["[4]: 0"]
end
PTR --> A0
Actual Memory Layout
package main
import (
"fmt"
"unsafe"
)
func main() {
s := []int{10, 20, 30}
// Slice header size
fmt.Printf("Slice header size: %d bytes\n", unsafe.Sizeof(s))
// 24 bytes (pointer 8 + len 8 + cap 8)
// Actual data size
fmt.Printf("Data size: %d bytes\n", len(s)*int(unsafe.Sizeof(s[0])))
// 24 bytes (int 8 * 3)
// Pointer address
fmt.Printf("Pointer: %p\n", &s[0])
fmt.Printf("Length: %d\n", len(s))
fmt.Printf("Capacity: %d\n", cap(s))
}
Output:
Slice header size: 24 bytes
Data size: 24 bytes
Pointer: 0xc000018030
Length: 3
Capacity: 3
2. Memory Allocation Mechanism
Creating Slices with make
// Method 1: Specify len only
s1 := make([]int, 5)
// len: 5, cap: 5
// [0, 0, 0, 0, 0]
// Method 2: Specify len and cap
s2 := make([]int, 3, 10)
// len: 3, cap: 10
// [0, 0, 0] + 7 reserved spaces
// Method 3: Literal
s3 := []int{1, 2, 3}
// len: 3, cap: 3
nil Slice vs Empty Slice
package main
import "fmt"
func main() {
// nil slice
var s1 []int
fmt.Printf("s1: %v, len: %d, cap: %d, nil: %v\n",
s1, len(s1), cap(s1), s1 == nil)
// s1: [], len: 0, cap: 0, nil: true
// Empty slice
s2 := []int{}
fmt.Printf("s2: %v, len: %d, cap: %d, nil: %v\n",
s2, len(s2), cap(s2), s2 == nil)
// s2: [], len: 0, cap: 0, nil: false
s3 := make([]int, 0)
fmt.Printf("s3: %v, len: %d, cap: %d, nil: %v\n",
s3, len(s3), cap(s3), s3 == nil)
// s3: [], len: 0, cap: 0, nil: false
}
Difference:
- nil slice: Pointer is nil, no memory allocation
- Empty slice: Valid pointer, memory allocated (small size)
Production Recommendation: Be careful with JSON encoding -
[]vsnulldifference
3. append Operation Principles
Basic append
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
fmt.Printf("Before: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
s = append(s, 4)
fmt.Printf("After: len=%d, cap=%d, ptr=%p\n", len(s), cap(s), &s[0])
}
Output:
Before: len=3, cap=3, ptr=0xc000018030
After: len=4, cap=6, ptr=0xc000018060
Note: Pointer changed → New array allocated
Capacity Growth Algorithm
Go allocates a new array and copies existing data when capacity is insufficient. Growth Rules (Go 1.18+):
- cap < 256: 2x growth
- cap >= 256: 1.25x growth (gradual)
package main
import "fmt"
func main() {
s := make([]int, 0, 1)
for i := 0; i < 10; i++ {
oldCap := cap(s)
s = append(s, i)
newCap := cap(s)
if oldCap != newCap {
fmt.Printf("len=%d, cap: %d -> %d\n", len(s), oldCap, newCap)
}
}
}
Output:
len=2, cap: 1 -> 2
len=3, cap: 2 -> 4
len=5, cap: 4 -> 8
len=9, cap: 8 -> 16
append Performance Analysis
package main
import (
"fmt"
"time"
)
func benchmarkAppend(n int) time.Duration {
start := time.Now()
s := []int{}
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func benchmarkPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n) // Pre-allocation
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := benchmarkAppend(n)
t2 := benchmarkPrealloc(n)
fmt.Printf("append: %v\n", t1)
fmt.Printf("prealloc: %v\n", t2)
fmt.Printf("improvement: %.2fx\n", float64(t1)/float64(t2))
}
Result:
append: 15.2ms
prealloc: 3.8ms
improvement: 4.00x
Conclusion: 4x performance improvement with pre-allocation
4. Slice Copy vs Reference
Is Slice a Reference Type?
Important: Slices are value types, but share internal pointers.
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1 // Copy slice header
fmt.Printf("s1: %p, s2: %p\n", &s1, &s2)
// Different addresses (separate headers)
fmt.Printf("s1[0]: %p, s2[0]: %p\n", &s1[0], &s2[0])
// Same address (shared array)
s2[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3]
fmt.Println("s2:", s2) // [999, 2, 3]
}
append and References
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3}
s2 := s1
// Case 1: Sufficient capacity (no reallocation)
s1 = append(s1[:2], 999) // len=3, cap=3 → no reallocation
fmt.Println("s1:", s1) // [1, 2, 999]
fmt.Println("s2:", s2) // [1, 2, 999] ← s2 affected!
// Case 2: Insufficient capacity (reallocation)
s3 := []int{1, 2, 3}
s4 := s3
s3 = append(s3, 4) // cap exceeded → new array
s3[0] = 999
fmt.Println("s3:", s3) // [999, 2, 3, 4]
fmt.Println("s4:", s4) // [1, 2, 3] ← s4 unaffected
}
Deep Copy
package main
import "fmt"
func main() {
s1 := []int{1, 2, 3, 4, 5}
// Method 1: Use copy
s2 := make([]int, len(s1))
copy(s2, s1)
// Method 2: Use append
s3 := append([]int(nil), s1...)
// Method 3: Manual copy
s4 := make([]int, len(s1))
for i, v := range s1 {
s4[i] = v
}
s1[0] = 999
fmt.Println("s1:", s1) // [999, 2, 3, 4, 5]
fmt.Println("s2:", s2) // [1, 2, 3, 4, 5]
fmt.Println("s3:", s3) // [1, 2, 3, 4, 5]
fmt.Println("s4:", s4) // [1, 2, 3, 4, 5]
}
5. Performance Optimization
1) Pre-allocation
package main
import (
"fmt"
"time"
)
func withoutPrealloc(n int) time.Duration {
start := time.Now()
var s []int
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func withPrealloc(n int) time.Duration {
start := time.Now()
s := make([]int, 0, n)
for i := 0; i < n; i++ {
s = append(s, i)
}
return time.Since(start)
}
func main() {
n := 1000000
t1 := withoutPrealloc(n)
t2 := withPrealloc(n)
fmt.Printf("Without prealloc: %v\n", t1)
fmt.Printf("With prealloc: %v\n", t2)
fmt.Printf("Improvement: %.2fx\n", float64(t1)/float64(t2))
}
Result:
Without prealloc: 18.5ms
With prealloc: 4.2ms
Improvement: 4.40x
2) Slicing Optimization
package main
import "fmt"
func main() {
data := []byte("Hello, World! This is a long string.")
// ❌ Memory leak risk
// Need small part but keep entire array
small := data[0:5] // "Hello"
fmt.Println(string(small))
// small is small but references entire data
// ✅ Copy to allow memory release
small2 := make([]byte, 5)
copy(small2, data[0:5])
// Now data can be GC'd
// ✅ Go 1.21+: 3-index slice
small3 := data[0:5:5] // [low:high:max]
// Limit cap to 5 → new array on append
}
6. Practical Patterns
Pattern 1: Filtering
package main
import "fmt"
func filter(s []int, predicate func(int) bool) []int {
result := make([]int, 0, len(s)) // Pre-allocate
for _, v := range s {
if predicate(v) {
result = append(result, v)
}
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
evens := filter(numbers, func(n int) bool {
return n%2 == 0
})
fmt.Println("Evens:", evens) // [2, 4, 6, 8, 10]
}
Pattern 2: Map Transformation
package main
import "fmt"
func mapSlice[T, U any](s []T, f func(T) U) []U {
result := make([]U, len(s))
for i, v := range s {
result[i] = f(v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
squared := mapSlice(numbers, func(n int) int {
return n * n
})
fmt.Println("Squared:", squared) // [1, 4, 9, 16, 25]
strings := mapSlice(numbers, func(n int) string {
return fmt.Sprintf("num-%d", n)
})
fmt.Println("Strings:", strings) // [num-1, num-2, ...]
}
Pattern 3: Reduce
package main
import "fmt"
func reduce[T, U any](s []T, initial U, f func(U, T) U) U {
result := initial
for _, v := range s {
result = f(result, v)
}
return result
}
func main() {
numbers := []int{1, 2, 3, 4, 5}
sum := reduce(numbers, 0, func(acc, n int) int {
return acc + n
})
fmt.Println("Sum:", sum) // 15
product := reduce(numbers, 1, func(acc, n int) int {
return acc * n
})
fmt.Println("Product:", product) // 120
}
7. Pitfalls and Solutions
Pitfall 1: Memory Leak After Slicing
package main
import (
"fmt"
"runtime"
)
func printMemory() {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("Alloc: %d MB\n", m.Alloc/1024/1024)
}
func main() {
// Create large slice
data := make([]byte, 100*1024*1024) // 100MB
for i := range data {
data[i] = byte(i)
}
printMemory() // Alloc: 100 MB
// ❌ Use small part but keep entire array
small := data[0:10]
data = nil // data is nil but
runtime.GC()
printMemory() // Alloc: 100 MB (still high)
// small still references entire array
fmt.Println(len(small))
// ✅ Fix with copy
small2 := make([]byte, 10)
copy(small2, data[0:10])
data = nil
small = nil
runtime.GC()
printMemory() // Alloc: ~0 MB
}
Pitfall 2: Slice Pointers in Loops
package main
import "fmt"
func main() {
s := []int{1, 2, 3}
// ❌ Wrong pattern
var ptrs []*int
for _, v := range s {
ptrs = append(ptrs, &v) // v address is always same
}
for _, ptr := range ptrs {
fmt.Print(*ptr, " ") // 3 3 3
}
fmt.Println()
// ✅ Correct pattern
var ptrs2 []*int
for i := range s {
ptrs2 = append(ptrs2, &s[i])
}
for _, ptr := range ptrs2 {
fmt.Print(*ptr, " ") // 1 2 3
}
fmt.Println()
}
Pitfall 3: Function Arguments
package main
import "fmt"
func modifySlice(s []int) {
s[0] = 999 // Modifies original
s = append(s, 100) // New array allocated (separated from original)
}
func main() {
s := []int{1, 2, 3}
modifySlice(s)
fmt.Println(s) // [999, 2, 3] ← only s[0] changed
// append doesn't affect original
}
Solution: Pass as pointer
func modifySlicePtr(s *[]int) {
(*s)[0] = 999
*s = append(*s, 100)
}
func main() {
s := []int{1, 2, 3}
modifySlicePtr(&s)
fmt.Println(s) // [999, 2, 3, 100]
}
8. Advanced Techniques
1) Slice Pooling
package main
import (
"fmt"
"sync"
)
var bufferPool = sync.Pool{
New: func() interface{} {
return make([]byte, 0, 1024)
},
}
func processData(data []byte) {
// Get buffer
buf := bufferPool.Get().([]byte)
buf = buf[:0] // Reset length
// Use
buf = append(buf, data...)
fmt.Printf("Processed: %d bytes\n", len(buf))
// Return
bufferPool.Put(buf)
}
func main() {
for i := 0; i < 5; i++ {
data := []byte(fmt.Sprintf("Data %d", i))
processData(data)
}
}
2) Efficient Insert/Delete
package main
import "fmt"
// Insert at middle
func insert[T any](s []T, index int, value T) []T {
s = append(s[:index], append([]T{value}, s[index:]...)...)
return s
}
// Delete at middle
func remove[T any](s []T, index int) []T {
return append(s[:index], s[index+1:]...)
}
// Efficient delete (order doesn't matter)
func removeFast[T any](s []T, index int) []T {
s[index] = s[len(s)-1] // Overwrite with last element
return s[:len(s)-1]
}
func main() {
s := []int{1, 2, 3, 4, 5}
s = insert(s, 2, 99)
fmt.Println("Insert:", s) // [1, 2, 99, 3, 4, 5]
s = remove(s, 2)
fmt.Println("Remove:", s) // [1, 2, 3, 4, 5]
s = removeFast(s, 2)
fmt.Println("Fast remove:", s) // [1, 2, 5, 4]
}
Summary
Key Takeaways
- Internal Structure
- Slice is (ptr, len, cap) struct
- Actual data stored in separate array
- Slice copy only copies header (shares array)
- append Operation
- Sufficient cap: Use existing array
- Insufficient cap: Allocate new array + copy
- Growth rule: cap < 256 → 2x, cap >= 256 → 1.25x
- Performance Optimization
- Pre-allocation:
make([]T, 0, n) - Index assignment > append (when size is fixed)
- Slice pooling (reuse)
- Pre-allocation:
- Cautions
- Watch for memory leaks after slicing
- Don’t reference loop variable addresses
- Be careful with append when passing to functions
Best Practices
// ✅ Pre-allocate when size is known
s := make([]int, 0, expectedSize)
// ✅ Use index assignment for fixed size
s := make([]int, n)
for i := 0; i < n; i++ {
s[i] = value
}
// ✅ Deep copy when needed
s2 := make([]T, len(s1))
copy(s2, s1)
// ✅ For memory release
s = s[:0] // len=0, keep cap (reuse)
s = nil // GC-able
Checklist
- Pre-allocate when slice size is predictable
- Copy when using small part of large slice
- Use index when referencing element addresses in loops
- Consider pointer passing when modifying slices in functions
- Measure performance with benchmarks
References
- Go Slices: usage and internals
- Go Slice Tricks
- Effective Go - Slices One-line summary: Go slices are (ptr, len, cap) structs that reference arrays, reallocate when capacity is insufficient during append, so optimize performance with pre-allocation and watch for memory leaks when slicing.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. Go slice internal structure, memory allocation mechanism, capacity vs length, append operation principles, performance o… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 또는 관련 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
이 글에서 다루는 키워드 (관련 검색어)
Go, Golang, Slice, Memory, Performance, Data Structure 등으로 검색하시면 이 글이 도움이 됩니다.