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:

go
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:

bash
go run builder-demo.go

Output yang Dihasilkan:

text
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 byte

Mari kita telaah baris demi baris program di atas:

  1. Baris 25-31 (func gabungPlus): satu alokasi per iterasi — 499 alokasi dan hampir 1,5 MB untuk teks 5.890 karakter.
  2. Baris 35-41 (func gabungBuilder): WriteString menampung data di buffer internal yang tumbuh berganda; hanya 13 alokasi untuk data yang sama.
  3. Baris 45-56 (func gabungBuilderGrow): total panjang dihitung dulu, b.Grow(total) mengalokasikan buffer sekali — cukup 1 alokasi.
  4. 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:

bash
go test -bench=. -benchtime=100x

Output yang Dihasilkan:

text
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.039s

Builder atau bytes.Buffer #

strings.Builder dan bytes.Buffer sama-sama menampung data di buffer yang tumbuh, tetapi keduanya dirancang untuk kebutuhan berbeda:

Aspekstrings.Builderbytes.Buffer
TujuanKhusus membangun stringBuffer byte serba bisa
String()Nol salinan (konversi aman internal)Membuat salinan string
Method bacaTidak adaRead, ReadString, WriteTo
Cocok untukMenyusun string di loopStream 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 #

Berkas Sumber

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

builder-demo_test.go

Unduh berkas
go
package 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 berkas
go
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)
}