Kembali ke Artikel

Cara Kerja Slice Go: Append, Kapasitas, dan Memori

Solusi IT

Cara kerja slice Golang sebenarnya sederhana: slice bukan array, melainkan struct kecil berisi tiga hal, yaitu pointer ke array di belakangnya, length (jumlah elemen terpakai), dan capacity (ukuran array yang dialokasikan). Saat kamu memanggil append, Go menulis elemen baru ke array itu selama kapasitas masih cukup. Begitu penuh, Go mengalokasikan array baru yang lebih besar, menyalin semua elemen lama, lalu mengembalikan slice yang menunjuk ke array baru tersebut.

Di artikel ini kita bongkar mekanisme itu satu per satu dengan kode yang bisa kamu jalankan sendiri: melihat isi slice header, mengamati kapan alokasi baru terjadi, membuktikan lewat benchmark kenapa pre-alokasi penting, sampai membuat dynamic array versi sendiri. Artikel ini bagian kelima dari seri Belajar Struktur Data dari Nol. Kalau kamu belum paham beda array dan slice di level pemakaian, baca dulu bagian sebelumnya: Array dan Slice di Go: Struktur Data Paling Dasar.

Semua kode di artikel ini dites dengan Go 1.23.1 di Linux dan berperilaku sama di Go 1.24. Jalankan go version untuk memastikan versimu minimal Go 1.20, karena kita memakai fungsi unsafe.SliceData yang baru ada sejak versi itu.

Anatomi Slice Header: Pointer, Length, dan Capacity

Di dalam runtime Go, slice direpresentasikan seperti ini:

type sliceHeader struct {
    Data uintptr // alamat array di belakang slice
    Len  int     // jumlah elemen yang sedang dipakai
    Cap  int     // total slot yang tersedia di array
}

Struct ini ringan, hanya 24 byte di mesin 64-bit. Itu sebabnya slice murah untuk dioper antar fungsi: yang disalin cuma header-nya, bukan seluruh data. Kita bisa mengintip ketiga komponen ini dengan len(), cap(), dan unsafe.SliceData yang mengembalikan pointer ke elemen pertama array:

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	s := make([]int, 3, 8)
	fmt.Println("len:", len(s), "cap:", cap(s))
	fmt.Printf("alamat array s: %p\n", unsafe.SliceData(s))

	t := s[1:3]
	fmt.Println("len t:", len(t), "cap t:", cap(t))
	fmt.Printf("alamat array t: %p\n", unsafe.SliceData(t))
}

Output di mesin saya (alamat pasti berbeda di mesinmu):

len: 3 cap: 8
alamat array s: 0xc00001e1c0
len t: 2 cap t: 7
alamat array t: 0xc00001e1c8

Perhatikan dua hal. Pertama, alamat t hanya bergeser 8 byte dari s. Artinya t tidak punya data sendiri, ia menunjuk ke array yang sama, mulai dari elemen index 1. Ukuran int di mesin 64-bit memang 8 byte. Kedua, cap(t) jadi 7, bukan 8, karena capacity dihitung dari posisi awal slice sampai ujung array. Fakta bahwa dua slice bisa berbagi satu array inilah sumber dari hampir semua bug slice yang akan kita bahas nanti.

Kapan append Memicu Alokasi Baru di Go 1.24

Sekarang kita amati langsung kapan Go mengalokasikan array baru. Caranya gampang: append terus menerus, dan cetak setiap kali nilai cap berubah. Perubahan cap menandakan array lama sudah diganti array baru yang lebih besar.

package main

import "fmt"

func main() {
	var s []int
	lastCap := -1
	for i := 0; i < 600; i++ {
		s = append(s, i)
		if cap(s) != lastCap {
			fmt.Printf("len=%-4d cap=%d\n", len(s), cap(s))
			lastCap = cap(s)
		}
	}
}

Outputnya:

len=1    cap=1
len=2    cap=2
len=3    cap=4
len=5    cap=8
len=9    cap=16
len=17   cap=32
len=33   cap=64
len=65   cap=128
len=129  cap=256
len=257  cap=512
len=513  cap=848

Polanya terbaca jelas. Selama kapasitas masih di bawah 256, Go menggandakan kapasitas setiap kali penuh: 1, 2, 4, 8, dan seterusnya. Setelah melewati 256 elemen, pertumbuhan melambat ke kisaran 1,25 sampai 1,6 kali, makanya dari 512 lompat ke 848 dan bukan 1024. Angka 848 juga bukan hasil perkalian bulat karena runtime membulatkan ukuran alokasi ke kelas ukuran (size class) milik allocator Go supaya memori tidak terbuang. Detail angka ini bisa sedikit bergeser antar versi Go, tapi pola besarnya sama di Go 1.23 dan 1.24: gandakan saat kecil, tumbuh lebih hemat saat besar.

Yang penting dipahami: di baris len=513 cap=848, Go baru saja menyalin 512 elemen ke array baru. Kalau slice-mu berisi jutaan elemen, satu kali append yang kebetulan memicu pertumbuhan berarti menyalin jutaan elemen juga.

Dynamic Array dan Amortized O(1): Kenapa append Rata-Rata Murah

Slice Go adalah implementasi dari struktur data klasik bernama dynamic array: array yang bisa tumbuh dengan cara realokasi dan salin. Kalau sesekali append harus menyalin semua elemen, kenapa append tetap disebut O(1)?

Jawabannya ada di analisis amortized. Misalkan kita append 8 elemen mulai dari slice kosong dengan strategi gandakan kapasitas:

Append ke- Kapasitas sebelum Perlu salin? Elemen disalin
1 0 ya 0
2 1 ya 1
3 2 ya 2
4 4 tidak 0
5 4 ya 4
6 sampai 8 8 tidak 0

Total operasi salin untuk 8 append adalah 7. Kalau diteruskan ke n append, total salinnya selalu di bawah 2n. Dibagi rata ke semua operasi, tiap append menanggung biaya konstan, kurang dari 2 salinan per operasi. Itulah maksud amortized O(1): sesekali ada operasi mahal O(n), tapi rata-ratanya tetap konstan karena operasi mahal itu makin jarang terjadi seiring kapasitas membesar. Konsep menghitung biaya seperti ini sudah kita kenal dari bagian ketiga seri tentang notasi Big O.

Benchmark: append Tanpa make vs Dengan make

Amortized O(1) bukan berarti gratis. Realokasi tetap memakan waktu dan menyampah ke garbage collector. Kalau kamu sudah tahu jumlah elemen dari awal, pre-alokasi dengan make([]int, 0, n) menghapus semua realokasi itu. Kita buktikan dengan benchmark resmi Go. Simpan sebagai append_test.go di folder yang sudah punya go.mod:

package main

import "testing"

const n = 10000

func BenchmarkAppendTanpaMake(b *testing.B) {
	for i := 0; i < b.N; i++ {
		var s []int
		for j := 0; j < n; j++ {
			s = append(s, j)
		}
	}
}

func BenchmarkAppendDenganMake(b *testing.B) {
	for i := 0; i < b.N; i++ {
		s := make([]int, 0, n)
		for j := 0; j < n; j++ {
			s = append(s, j)
		}
	}
}

Jalankan dengan:

go test -bench=. -benchmem

Hasil di laptop saya (Intel i5-10310U, Go 1.23.1):

BenchmarkAppendTanpaMake-8     10000    102632 ns/op    357625 B/op    19 allocs/op
BenchmarkAppendDenganMake-8    81643     13500 ns/op     81920 B/op     1 allocs/op

Bacaannya begini. Tanpa make, mengisi 10 ribu elemen butuh 19 kali alokasi dan total 357 ribu byte, karena setiap kali tumbuh Go membuat array baru dan array lama jadi sampah. Dengan pre-alokasi, cuma ada 1 alokasi sebesar 81920 byte (10 ribu kali 8 byte, dibulatkan ke size class) dan waktunya sekitar 7 kali lebih cepat. Angkamu pasti beda, tapi rasionya akan mirip.

Di backend Go untuk sistem aplikasi klien yang tim Arrazy kerjakan, pola ini paling sering muncul saat mengubah hasil query database menjadi slice DTO. Jumlah baris sudah diketahui, jadi pre-alokasi jadi kebiasaan standar, murah ditulis dan efeknya nyata di endpoint yang dipanggil ribuan kali.

Dua Kesalahan Umum Seputar Slice yang Sering Jadi Bug

Memory leak karena slicing potongan kecil dari slice besar

Ingat, slice hasil re-slicing berbagi array dengan slice asalnya. Kode seperti ini terlihat tidak berdosa:

func ambilHeader(fileBesar []byte) []byte {
	return fileBesar[:100] // BUG: menahan seluruh file di memori
}

Slice yang dikembalikan memang cuma 100 byte panjangnya, tapi pointer-nya masih menunjuk ke array asli yang mungkin puluhan megabyte. Selama slice kecil itu hidup, garbage collector tidak bisa membebaskan seluruh array. Kalau fungsi ini dipanggil untuk banyak file, memori server naik terus tanpa kelihatan sebabnya di kode. Solusinya salin datanya supaya array besar bisa dilepas:

func ambilHeader(fileBesar []byte) []byte {
	header := make([]byte, 100)
	copy(header, fileBesar[:100])
	return header
}

Sejak Go 1.21 kamu juga bisa memakai slices.Clone(fileBesar[:100]) dari package slices, hasilnya sama.

append di dalam fungsi tidak mengubah slice pemanggil

Karena yang dioper ke fungsi adalah salinan slice header, append di dalam fungsi hanya mengubah salinan itu:

package main

import "fmt"

func tambahSalah(s []int) {
	s = append(s, 99)
}

func tambahBenar(s []int) []int {
	return append(s, 99)
}

func main() {
	data := make([]int, 0, 4)
	data = append(data, 1, 2)

	tambahSalah(data)
	fmt.Println("setelah tambahSalah:", data)

	data = tambahBenar(data)
	fmt.Println("setelah tambahBenar:", data)
}

Output:

setelah tambahSalah: [1 2]
setelah tambahBenar: [1 2 99]

Di tambahSalah, elemen 99 sebenarnya sempat tertulis ke array (kapasitasnya masih cukup), tapi length milik pemanggil tetap 2, jadi elemen itu tidak pernah terlihat. Aturannya sederhana: fungsi yang melakukan append harus mengembalikan slice hasilnya, persis seperti append bawaan Go sendiri. Alternatifnya terima parameter *[]int, tapi gaya return lebih umum dan lebih enak dibaca.

Latihan: Membuat Dynamic Array Sendiri di Go

Cara terbaik memahami apa yang Go lakukan di balik layar adalah menirunya. Kita buat dynamic array sederhana: struct dengan array mentah, length manual, dan fungsi grow yang menggandakan kapasitas persis seperti perilaku slice kecil.

package main

import "fmt"

type DynamicArray struct {
	data []int // dipakai sebagai array mentah, tidak pernah di-append
	len  int
}

func NewDynamicArray() *DynamicArray {
	return &DynamicArray{data: make([]int, 1)}
}

func (d *DynamicArray) grow() {
	newData := make([]int, len(d.data)*2)
	copy(newData, d.data)
	d.data = newData
}

func (d *DynamicArray) Push(v int) {
	if d.len == len(d.data) {
		d.grow()
	}
	d.data[d.len] = v
	d.len++
}

func (d *DynamicArray) Get(i int) int {
	if i < 0 || i >= d.len {
		panic("index di luar batas")
	}
	return d.data[i]
}

func (d *DynamicArray) Len() int { return d.len }
func (d *DynamicArray) Cap() int { return len(d.data) }

func main() {
	arr := NewDynamicArray()
	for i := 1; i <= 10; i++ {
		arr.Push(i * 10)
		fmt.Printf("push ke-%d, len=%d cap=%d\n", i, arr.Len(), arr.Cap())
	}
	fmt.Println("elemen index 7:", arr.Get(7))
}

Output:

push ke-1, len=1 cap=1
push ke-2, len=2 cap=2
push ke-3, len=3 cap=4
push ke-4, len=4 cap=4
push ke-5, len=5 cap=8
push ke-6, len=6 cap=8
push ke-7, len=7 cap=8
push ke-8, len=8 cap=8
push ke-9, len=9 cap=16
push ke-10, len=10 cap=16
elemen index 7: 80

Bandingkan dengan output eksperimen append di atas: pola pertumbuhannya identik untuk ukuran kecil. Tiga komponen yang kamu kelola manual di sini, yaitu data, len, dan kapasitas, persis tiga field slice header. Sebagai tantangan tambahan, coba tambahkan method Pop(), Set(i, v), dan aturan pertumbuhan 1,25 kali setelah kapasitas 256 supaya makin mirip runtime Go.

Troubleshooting: Error yang Sering Dialami Pemula

panic: runtime error: slice bounds out of range

Muncul saat re-slicing melebihi capacity, misalnya s[:10] padahal cap(s) hanya 8. Ingat batas re-slicing adalah capacity, bukan length, jadi s[:5] pada slice dengan len 3 dan cap 8 itu sah. Cek dulu dengan cap(s) sebelum memperlebar slice, atau tangani panjang data secara eksplisit.

undefined: unsafe.SliceData

Fungsi ini baru ada di Go 1.20. Kalau muncul error ini, versi Go-mu terlalu lama, cek dengan go version lalu upgrade lewat go.dev/doc/install. Pastikan juga baris go di go.mod tidak memaksa versi bahasa lama.

go test -bench tidak menjalankan apa pun atau "no test files"

Dua penyebab paling umum: nama file tidak diakhiri _test.go, atau nama fungsi tidak diawali Benchmark dengan huruf besar setelahnya. Pastikan juga kamu menjalankan perintah di folder yang berisi file test dan sudah ada go.mod (kalau belum, jalankan go mod init namamodul sekali).

Dua variabel slice saling menimpa data

Gejalanya: kamu append ke slice B, tiba-tiba isi slice A ikut berubah. Ini terjadi karena B hasil re-slicing dari A dan keduanya masih berbagi array, lalu append B menulis ke slot yang masih dalam jangkauan A. Solusinya pisahkan kepemilikan data dengan slices.Clone, atau pakai full slice expression a[low:high:max] untuk membatasi capacity sehingga append berikutnya dipaksa mengalokasikan array baru.

Rangkuman dan Lanjutan Seri

Sekarang kamu tahu isi perut slice: header kecil berisi pointer, length, dan capacity yang menunjuk ke satu array bersama. Append murah secara amortized, tapi pre-alokasi dengan make tetap layak dibiasakan saat ukuran data diketahui, dan dua jebakan klasiknya, yaitu array bersama dan salinan header, sekarang bisa kamu kenali sebelum jadi bug di production.

Dynamic array punya satu kelemahan bawaan: sisip atau hapus elemen di tengah tetap O(n) karena semua elemen setelahnya harus digeser. Struktur data berikutnya menjawab masalah itu dengan pendekatan yang sama sekali berbeda. Bagian keenam, Linked List: Konsep dan Implementasi Singly Linked List di Go, terbit menyusul dan bisa kamu pantau di halaman hub Belajar Struktur Data dari Nol.

Referensi

BACA JUGA

Artikel Lainnya di Kategori Struktur Data

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel