Cara Kerja Slice Go: Append, Kapasitas, dan Memori
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
Artikel Lainnya di Kategori Struktur Data
Struktur Data 2 Agustus 2026
Belajar Struktur Data dari Nol: Pengenalan dan Persiapan Go
Mulai belajar struktur data dari nol: konsep dasar, analogi rak gudang, install Go 1.24, setup VS Code, error umum pemula, dan peta 26 bagian seri.
Baca Artikel
Struktur Data 20 Agustus 2026
Array dan Slice di Go: Struktur Data Paling Dasar
Pahami perbedaan array dan slice di Go dari layout memorinya: kenapa akses indeks O(1), insert di tengah O(n), plus bug backing array yang sering lolos.
Baca Artikel
Struktur Data 8 Agustus 2026
Kompleksitas Algoritma: Kenapa Struktur Data Menentukan Performa
Eksperimen mencari 1 item di 1 juta data membuktikan kenapa struktur data menentukan performa. Lengkap dengan benchmark testing.B dan latihan prediksi.
Baca ArtikelIngin Membaca Artikel Lainnya?
Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.
Lihat Semua Artikel