Alokasi heap adalah sumber tekanan utama bagi garbage collector: setiap alokasi baru harus ditandai, dilacak, dan akhirnya dibersihkan. Program yang mengalokasikan lebih sedikit memberi GC lebih sedikit pekerjaan, dan hasilnya sering terasa langsung pada latensi. Chapter ini membedah pola-pola umum yang memicu alokasi berlebih — penyambungan string, filter slice, konversi []byte, dan objek sekali pakai — lalu menunjukkan pengganti yang lebih hemat serta cara membuktikan semuanya lewat pengukuran.

Stack Murah, Heap Mahal #

Setiap variabel di Go tinggal di salah satu dari dua tempat. Stack milik fungsi dibersihkan begitu fungsi kembali tanpa kerja apa pun, sedangkan heap dikelola garbage collector dan menuntut biaya pelacakan. Compiler yang memutuskan alamatnya melalui analisis bernama escape analysis: nilai yang tidak lolos dari fungsi penciptanya tetap di stack, nilai yang lolos dipindahkan ke heap. Aturan mainnya telah kita bahas pada chapter Escape Analysis ; di sini kita fokus pada konsekuensinya terhadap performa.

Tiga pemicu lolos yang paling sering muncul di kode sehari-hari:

  • nilai dikembalikan lewat pointer atau disimpan ke struct yang sudah di heap;
  • string digabung dengan + di dalam loop — hasil tiap penggabungan adalah string baru;
  • nilai dikonversi ke interface, termasuk argumen variadic fmt.

Tur Lima Pola Alokasi Umum #

Berkas alloc-demo.go mengukur lima pola dengan runtime.ReadMemStats. Variabel sink* di level package mencegah compiler menghapus alokasi yang hasilnya tidak dipakai, dan GC sengaja dimatikan sementara agar sync.Pool tidak dibersihkan di tengah pengukuran.

go
package main

import (
	"bytes"
	"fmt"
	"runtime"
	"runtime/debug"
	"sync"
)

// sink* mencegah compiler menghapus alokasi yang hasilnya disimpan.
var (
	sinkString string
	sinkInts   []int
	sinkBuffer *bytes.Buffer
)

// ukurAlokasi menjalankan f di antara dua pembacaan runtime.ReadMemStats,
// sehingga hanya alokasi yang terjadi di dalam f yang ikut dihitung.
func ukurAlokasi(f func()) (alokasi int64, ukuran int64) {
	var sebelum, sesudah runtime.MemStats
	runtime.ReadMemStats(&sebelum)
	f()
	runtime.ReadMemStats(&sesudah)
	return int64(sesudah.Mallocs - sebelum.Mallocs),
		int64(sesudah.TotalAlloc - sebelum.TotalAlloc)
}

// buatAngka menyiapkan data uji tetap di luar jendela pengukuran.
func buatAngka(n int) []int {
	xs := make([]int, 0, n)
	for i := 0; i < n; i++ {
		xs = append(xs, i)
	}
	return xs
}

func main() {
	// GC dimatikan sementara agar pengukuran murni:
	// sync.Pool dibersihkan saat siklus GC, dan siklus tak terduga
	// akan membuat hasil berubah antar eksekusi.
	gogcLama := debug.SetGCPercent(-1)
	defer debug.SetGCPercent(gogcLama)

	// Pola 1: penyambungan string di dalam loop.
	alokasi, ukuran := ukurAlokasi(func() {
		s := ""
		for i := 0; i < 100; i++ {
			s += "belajar go "
		}
		sinkString = s
	})
	fmt.Printf("Penyambungan += 100 kali       : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 2a: filter yang menumbuhkan slice hasil dengan append.
	asal := buatAngka(500)
	alokasi, ukuran = ukurAlokasi(func() {
		hasil := []int{}
		for _, v := range asal {
			if v%2 == 0 {
				hasil = append(hasil, v)
			}
		}
		sinkInts = hasil
	})
	fmt.Printf("Filter tanpa kapasitas         : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 2b: filter yang sama dengan prealokasi kapasitas.
	alokasi, ukuran = ukurAlokasi(func() {
		hasil := make([]int, 0, len(asal))
		for _, v := range asal {
			if v%2 == 0 {
				hasil = append(hasil, v)
			}
		}
		sinkInts = hasil
	})
	fmt.Printf("Filter dengan make(cap)        : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 3: konversi []byte menjadi string berulang kali.
	potongan := []byte("belajar-pemrograman")
	alokasi, ukuran = ukurAlokasi(func() {
		for i := 0; i < 1000; i++ {
			sinkString = string(potongan)
		}
	})
	fmt.Printf("Konversi []byte->string 1000x  : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 4: sync.Pool memakai ulang buffer yang sudah dikembalikan.
	pool := &sync.Pool{
		New: func() any { return new(bytes.Buffer) },
	}
	pertama := pool.Get().(*bytes.Buffer) // alokasi pertama melalui New
	pool.Put(pertama)                     // objek dikembalikan ke pool
	alokasi, ukuran = ukurAlokasi(func() {
		sinkBuffer = pool.Get().(*bytes.Buffer) // dipakai ulang, bukan baru
	})
	fmt.Printf("Pool Get setelah Put           : %d alokasi, %d byte\n", alokasi, ukuran)
	sinkBuffer.WriteString("contoh")
}

Jalankan dari folder chapter:

bash
go run alloc-demo.go

Output yang Dihasilkan:

text
Penyambungan += 100 kali       : 99 alokasi, 58872 byte
Filter tanpa kapasitas         : 6 alokasi, 4032 byte
Filter dengan make(cap)        : 1 alokasi, 4096 byte
Konversi []byte->string 1000x  : 1000 alokasi, 24000 byte
Pool Get setelah Put           : 0 alokasi, 0 byte

Mari kita telaah baris demi baris program di atas:

  1. Baris 20-27 (func ukurAlokasi): membuka dan menutup jendela ukur dengan dua ReadMemStats; hanya alokasi di antaranya yang dihitung.
  2. Baris 42-43 (debug.SetGCPercent(-1)): mematikan GC sementara. Mallocs tidak terpengaruh GC, tetapi isi sync.Pool dibersihkan pada tiap siklus GC sehingga tanpa ini hasil pola keempat bisa berubah antar eksekusi.
  3. Baris 46-52 (pola 1): 100 penyambungan += menghasilkan 99 alokasi karena string tidak dapat diubah — setiap s += membuat salinan baru yang semakin panjang.
  4. Baris 57-77 (pola 2a dan 2b): filter tanpa kapasitas menumbuhkan backing array 6 kali untuk 250 elemen; make([]int, 0, len(asal)) cukup sekali.
  5. Baris 81-87 (pola 3): konversi string(potongan) menyalin byte ke string baru — 1000 konversi berarti 1000 alokasi.
  6. Baris 90-97 (pola 4): pool.Put mengembalikan buffer, pool.Get berikutnya memakai ulang objek yang sama tanpa alokasi baru.

Membaca Laporan Escape Analysis #

Compiler bersedia menjelaskan keputusannya lewat flag -gcflags=-m. Jalankan perintah berikut dari folder chapter (diagnostik muncul di stderr, jadi arahkan 2>&1):

bash
go build -gcflags=-m alloc-demo.go 2>&1 | grep "^./alloc-demo.go"

Output yang Dihasilkan (cuplikan, baris lain dipangkas):

text
./alloc-demo.go:20:18: f does not escape
./alloc-demo.go:31:12: make([]int, 0, n) escapes to heap
./alloc-demo.go:91:31: new(bytes.Buffer) escapes to heap
./alloc-demo.go:58:17: []int{} escapes to heap
./alloc-demo.go:70:16: make([]int, 0, len(asal)) escapes to heap
./alloc-demo.go:81:21: ([]byte)("belajar-pemrograman") does not escape
./alloc-demo.go:81:21: zero-copy string->[]byte conversion
./alloc-demo.go:84:24: string(potongan) escapes to heap
./alloc-demo.go:91:8: func literal escapes to heap

Baris make([]int, 0, n) escapes to heap menandakan slice buatan buatAngka lolos ke heap karena ukurannya berupa variabel; sebaliknya zero-copy string->[]byte conversion menunjukkan compiler menghindari salinan pada konversi []byte("...") dari konstanta karena hasilnya terbukti hanya dibaca dan tidak lolos dari fungsi. Baris string(potongan) escapes to heap mengonfirmasi konversi di pola 3 memang mengalokasikan.

sync.Pool untuk Objek Sekali Pakai #

sync.Pool menjawab pola “buat objek besar, pakai sebentar, buang, ulangi” — buffer sementara, encoder JSON, atau objek besar lain. Method Get mengambil dari pool (memanggil New bila kosong), Put mengembalikan. Pola 4 pada program di atas membuktikan Get setelah Put tidak mengalokasikan apa pun.

Field Struct: Pointer atau Nilai #

Field pointer (type Node struct { data *Besar }) memaksa satu alokasi terpisah untuk setiap Besar dan menambah satu lompatan pointer saat diakses; field nilai menanamkan data langsung di dalam struct sehingga memorinya berdampingan. Gunakan field nilai saat datanya kecil atau dimiliki penuh oleh struct, dan pointer hanya saat memang perlu berbagi atau mengubah data yang sama dari beberapa tempat. Kombinasi field pointer dalam jumlah besar juga merusak cache locality — CPU melompat-lompat mengikuti alamat yang tersebar.

Setelah pola-pola ini dikenali, langkah berikutnya adalah struktur data yang paling sering dipakai: slice dan map, dengan kebiasaan prealokasi yang murah tetapi berdampak besar — topik chapter berikutnya.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

alloc-demo.go

Unduh berkas
go
package main

import (
	"bytes"
	"fmt"
	"runtime"
	"runtime/debug"
	"sync"
)

// sink* mencegah compiler menghapus alokasi yang hasilnya disimpan.
var (
	sinkString string
	sinkInts   []int
	sinkBuffer *bytes.Buffer
)

// ukurAlokasi menjalankan f di antara dua pembacaan runtime.ReadMemStats,
// sehingga hanya alokasi yang terjadi di dalam f yang ikut dihitung.
func ukurAlokasi(f func()) (alokasi int64, ukuran int64) {
	var sebelum, sesudah runtime.MemStats
	runtime.ReadMemStats(&sebelum)
	f()
	runtime.ReadMemStats(&sesudah)
	return int64(sesudah.Mallocs - sebelum.Mallocs),
		int64(sesudah.TotalAlloc - sebelum.TotalAlloc)
}

// buatAngka menyiapkan data uji tetap di luar jendela pengukuran.
func buatAngka(n int) []int {
	xs := make([]int, 0, n)
	for i := 0; i < n; i++ {
		xs = append(xs, i)
	}
	return xs
}

func main() {
	// GC dimatikan sementara agar pengukuran murni:
	// sync.Pool dibersihkan saat siklus GC, dan siklus tak terduga
	// akan membuat hasil berubah antar eksekusi.
	gogcLama := debug.SetGCPercent(-1)
	defer debug.SetGCPercent(gogcLama)

	// Pola 1: penyambungan string di dalam loop.
	alokasi, ukuran := ukurAlokasi(func() {
		s := ""
		for i := 0; i < 100; i++ {
			s += "belajar go "
		}
		sinkString = s
	})
	fmt.Printf("Penyambungan += 100 kali       : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 2a: filter yang menumbuhkan slice hasil dengan append.
	asal := buatAngka(500)
	alokasi, ukuran = ukurAlokasi(func() {
		hasil := []int{}
		for _, v := range asal {
			if v%2 == 0 {
				hasil = append(hasil, v)
			}
		}
		sinkInts = hasil
	})
	fmt.Printf("Filter tanpa kapasitas         : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 2b: filter yang sama dengan prealokasi kapasitas.
	alokasi, ukuran = ukurAlokasi(func() {
		hasil := make([]int, 0, len(asal))
		for _, v := range asal {
			if v%2 == 0 {
				hasil = append(hasil, v)
			}
		}
		sinkInts = hasil
	})
	fmt.Printf("Filter dengan make(cap)        : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 3: konversi []byte menjadi string berulang kali.
	potongan := []byte("belajar-pemrograman")
	alokasi, ukuran = ukurAlokasi(func() {
		for i := 0; i < 1000; i++ {
			sinkString = string(potongan)
		}
	})
	fmt.Printf("Konversi []byte->string 1000x  : %d alokasi, %d byte\n", alokasi, ukuran)

	// Pola 4: sync.Pool memakai ulang buffer yang sudah dikembalikan.
	pool := &sync.Pool{
		New: func() any { return new(bytes.Buffer) },
	}
	pertama := pool.Get().(*bytes.Buffer) // alokasi pertama melalui New
	pool.Put(pertama)                     // objek dikembalikan ke pool
	alokasi, ukuran = ukurAlokasi(func() {
		sinkBuffer = pool.Get().(*bytes.Buffer) // dipakai ulang, bukan baru
	})
	fmt.Printf("Pool Get setelah Put           : %d alokasi, %d byte\n", alokasi, ukuran)
	sinkBuffer.WriteString("contoh")
}