String di Go tidak dapat diubah (immutable): setiap penyambungan menghasilkan string baru, dan isi lama ikut tersalin. Di luar loop, sifat ini tidak terasa; di dalam loop yang berjalan ribuan kali, biaya penyalinan menumpuk secara kuadratik. Chapter ini membuktikan masalahnya lewat pengukuran, lalu membandingkan empat cara menggabungkan string — operator +, strings.Builder dengan dan tanpa Grow, serta strings.Join — dan menutup dengan panduan memilih antara Builder dan bytes.Buffer.
Biaya Tersembunyi Operator + di Loop #
Ketika hasil += p berjalan di dalam loop, runtime mengalokasikan string sepanjang len(hasil) + len(p), menyalin kedua isi lama ke sana, lalu membuang string lama menjadi sampah. Iterasi ke-n menyalin seluruh hasil kumulatif — total byte yang tersalin mengikuti 1 + 2 + 3 + … + n, pola kuadratik O(n²). Pembahasan sifat immutable string tersedia di chapter String Immutable
; fokus kita sekarang adalah konsekuensi performanya.
Empat Versi Dibandingkan #
Berkas builder-demo.go menggabungkan 500 potongan string dengan empat cara berbeda dan mengukur alokasi tiap versinya:
package main
import (
"fmt"
"runtime"
"strconv"
"strings"
)
const jumlahPotongan = 500
var sink string
// buatPotongan menyiapkan data uji tetap di luar jendela pengukuran.
func buatPotongan() []string {
kata := make([]string, 0, jumlahPotongan)
for i := 0; i < jumlahPotongan; i++ {
kata = append(kata, "potongan-"+strconv.Itoa(i))
}
return kata
}
// gabungPlus memakai operator + di dalam loop: setiap penyambungan
// mengalokasikan string baru dan menyalin seluruh isi lama.
func gabungPlus(potongan []string) string {
hasil := ""
for _, p := range potongan {
hasil += p
}
return hasil
}
// gabungBuilder memakai strings.Builder: data ditampung di buffer
// internal yang tumbuh, string final dibuat sekali di akhir.
func gabungBuilder(potongan []string) string {
var b strings.Builder
for _, p := range potongan {
b.WriteString(p)
}
return b.String()
}
// gabungBuilderGrow sama seperti di atas, tetapi kapasitas dihitung
// di muka sehingga buffer hanya dialokasikan satu kali.
func gabungBuilderGrow(potongan []string) string {
total := 0
for _, p := range potongan {
total += len(p)
}
var b strings.Builder
b.Grow(total)
for _, p := range potongan {
b.WriteString(p)
}
return b.String()
}
func main() {
potongan := buatPotongan()
var sebelum, sesudah runtime.MemStats
ukur := func(f func() string) (alokasi int64, ukuran int64) {
runtime.ReadMemStats(&sebelum)
hasil := f()
runtime.ReadMemStats(&sesudah)
sink = hasil
return int64(sesudah.Mallocs - sebelum.Mallocs),
int64(sesudah.TotalAlloc - sebelum.TotalAlloc)
}
alokPlus, bytePlus := ukur(func() string { return gabungPlus(potongan) })
alokBuilder, byteBuilder := ukur(func() string { return gabungBuilder(potongan) })
alokGrow, byteGrow := ukur(func() string { return gabungBuilderGrow(potongan) })
alokJoin, byteJoin := ukur(func() string { return strings.Join(potongan, "") })
fmt.Println("Panjang hasil:", len(gabungPlus(potongan)), "karakter")
fmt.Printf("operator + : %d alokasi, %d byte\n", alokPlus, bytePlus)
fmt.Printf("Builder tanpa Grow : %d alokasi, %d byte\n", alokBuilder, byteBuilder)
fmt.Printf("Builder dengan Grow : %d alokasi, %d byte\n", alokGrow, byteGrow)
fmt.Printf("strings.Join : %d alokasi, %d byte\n", alokJoin, byteJoin)
}Jalankan dari folder chapter:
go run builder-demo.goOutput yang Dihasilkan:
Panjang hasil: 5890 karakter
operator + : 499 alokasi, 1556248 byte
Builder tanpa Grow : 13 alokasi, 24816 byte
Builder dengan Grow : 1 alokasi, 6144 byte
strings.Join : 1 alokasi, 6144 byteMari kita telaah baris demi baris program di atas:
- Baris 25-31 (
func gabungPlus): satu alokasi per iterasi — 499 alokasi dan hampir 1,5 MB untuk teks 5.890 karakter. - Baris 35-41 (
func gabungBuilder):WriteStringmenampung data di buffer internal yang tumbuh berganda; hanya 13 alokasi untuk data yang sama. - Baris 45-56 (
func gabungBuilderGrow): total panjang dihitung dulu,b.Grow(total)mengalokasikan buffer sekali — cukup 1 alokasi. - Baris 62-69 (
func ukur): closure jendela ukur yang memastikan hanya versi yang sedang diukur yang dihitung.
Kontras Benchmark #
Pengukuran alokasi di atas stabil, tetapi angka waktu butuh framework benchmark — berkas builder-demo_test.go di bundle chapter memuat dua benchmark dengan b.ReportAllocs(). Jalankan dari folder chapter:
go test -bench=. -benchtime=100xOutput yang Dihasilkan:
goos: linux
goarch: amd64
pkg: tutorial-website/content/go/performance-dan-optimisasi/string-building
cpu: Intel(R) Xeon(R) Processor
BenchmarkGabungPlus-2 100 341910 ns/op 1556253 B/op 499 allocs/op
BenchmarkGabungBuilderGrow-2 100 2611 ns/op 6144 B/op 1 allocs/op
PASS
ok tutorial-website/content/go/performance-dan-optimisasi/string-building 0.039sBuilder atau bytes.Buffer #
strings.Builder dan bytes.Buffer sama-sama menampung data di buffer yang tumbuh, tetapi keduanya dirancang untuk kebutuhan berbeda:
| Aspek | strings.Builder | bytes.Buffer |
|---|---|---|
| Tujuan | Khusus membangun string | Buffer byte serba bisa |
String() | Nol salinan (konversi aman internal) | Membuat salinan string |
| Method baca | Tidak ada | Read, ReadString, WriteTo |
| Cocok untuk | Menyusun string di loop | Stream byte dua arah |
Method String() milik Builder mengembalikan isi buffer tanpa menyalin karena struct-nya menegaskan bahwa buffer tidak lagi diubah — salin Builder secara nilai pun tidak disarankan; gunakan pointer bila perlu dibagikan. Untuk menyusun string, pilih Builder; untuk memproses byte sebagai stream (misalnya membaca bertahap dari koneksi), bytes.Buffer tetap tepat. fmt.Sprintf untuk sekali pakai wajar, tetapi di dalam loop panas ia mem-format dan mengalokasikan setiap panggilan.
Kapan Memakai Apa #
Tiga panduan ringkas menutup perbandingan ini. Penggabungan berpemisah tetap (strings.Join) paling tepat karena menghitung total panjang di muka — angkanya sama dengan Builder + Grow. Loop dengan potongan yang datang bertahap atau panjangnya tak terduga → Builder polos. Menyusun teks dengan angka → Builder plus strconv.AppendInt ke buffer yang dipakai ulang, pola yang kita lihat di chapter pertama section ini.
Pola serupa berlaku juga untuk eksekusi paralel: goroutine murah, tetapi jumlah yang salah malah memperlambat — chapter berikutnya mengatur fan-out worker dengan proporsi yang tepat.
Referensi #
- Package strings
—
Builder,Join, danGrow. - Package bytes
—
bytes.Bufferuntuk stream byte. - Blog Go: Strings — anatomi string, byte, dan rune.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
builder-demo_test.go
Unduh berkaspackage main
import (
"strings"
"testing"
)
// TestGabungKonsisten memastikan keempat versi menghasilkan teks sama.
func TestGabungKonsisten(t *testing.T) {
potongan := buatPotongan()
plus := gabungPlus(potongan)
builder := gabungBuilder(potongan)
grow := gabungBuilderGrow(potongan)
join := strings.Join(potongan, "")
if plus != builder || plus != grow || plus != join {
t.Fatal("hasil keempat versi tidak identik")
}
}
func BenchmarkGabungPlus(b *testing.B) {
potongan := buatPotongan() // setup: tidak ikut diukur oleh for b.Loop()
b.ReportAllocs()
for b.Loop() {
gabungPlus(potongan)
}
}
func BenchmarkGabungBuilderGrow(b *testing.B) {
potongan := buatPotongan() // setup: tidak ikut diukur oleh for b.Loop()
b.ReportAllocs()
for b.Loop() {
gabungBuilderGrow(potongan)
}
}
builder-demo.go
Unduh berkaspackage main
import (
"fmt"
"runtime"
"strconv"
"strings"
)
const jumlahPotongan = 500
var sink string
// buatPotongan menyiapkan data uji tetap di luar jendela pengukuran.
func buatPotongan() []string {
kata := make([]string, 0, jumlahPotongan)
for i := 0; i < jumlahPotongan; i++ {
kata = append(kata, "potongan-"+strconv.Itoa(i))
}
return kata
}
// gabungPlus memakai operator + di dalam loop: setiap penyambungan
// mengalokasikan string baru dan menyalin seluruh isi lama.
func gabungPlus(potongan []string) string {
hasil := ""
for _, p := range potongan {
hasil += p
}
return hasil
}
// gabungBuilder memakai strings.Builder: data ditampung di buffer
// internal yang tumbuh, string final dibuat sekali di akhir.
func gabungBuilder(potongan []string) string {
var b strings.Builder
for _, p := range potongan {
b.WriteString(p)
}
return b.String()
}
// gabungBuilderGrow sama seperti di atas, tetapi kapasitas dihitung
// di muka sehingga buffer hanya dialokasikan satu kali.
func gabungBuilderGrow(potongan []string) string {
total := 0
for _, p := range potongan {
total += len(p)
}
var b strings.Builder
b.Grow(total)
for _, p := range potongan {
b.WriteString(p)
}
return b.String()
}
func main() {
potongan := buatPotongan()
var sebelum, sesudah runtime.MemStats
ukur := func(f func() string) (alokasi int64, ukuran int64) {
runtime.ReadMemStats(&sebelum)
hasil := f()
runtime.ReadMemStats(&sesudah)
sink = hasil
return int64(sesudah.Mallocs - sebelum.Mallocs),
int64(sesudah.TotalAlloc - sebelum.TotalAlloc)
}
alokPlus, bytePlus := ukur(func() string { return gabungPlus(potongan) })
alokBuilder, byteBuilder := ukur(func() string { return gabungBuilder(potongan) })
alokGrow, byteGrow := ukur(func() string { return gabungBuilderGrow(potongan) })
alokJoin, byteJoin := ukur(func() string { return strings.Join(potongan, "") })
fmt.Println("Panjang hasil:", len(gabungPlus(potongan)), "karakter")
fmt.Printf("operator + : %d alokasi, %d byte\n", alokPlus, bytePlus)
fmt.Printf("Builder tanpa Grow : %d alokasi, %d byte\n", alokBuilder, byteBuilder)
fmt.Printf("Builder dengan Grow : %d alokasi, %d byte\n", alokGrow, byteGrow)
fmt.Printf("strings.Join : %d alokasi, %d byte\n", alokJoin, byteJoin)
}