Chapter lalu menghilangkan salinan yang percuma; sekarang kita menyerang sumber biayanya: alokasi heap itu sendiri. Setiap panggilan operator new harus menemukan blok memori yang cocok di pembebasannya, mencatat ukurannya, dan menjaga agar pembebasan berikutnya tetap rapi — pekerjaan yang jauh lebih mahal daripada menaikkan satu penunjuk di stack. Program yang membuat string baru di dalam loop yang berjalan sejuta kali membayar biaya itu sejuta kali, sering kali tanpa disadari. Dengan penghitung alokasi yang sudah kita bangun, kita bisa melihat setiap alokasi tersembunyi, lalu menghapusnya lewat tiga lapis: pemanfaatan Small String Optimization, pemakaian ulang buffer, dan pengelompokan alokasi lewat arena.
Mengapa Alokasi Heap Mahal #
Allocator harus berurusan dengan permintaan yang beragam ukurannya sepanjang umur program, sehingga malloc melalui beberapa lapisan: cari bin/list ukuran yang cocok, pecah blok besar bila perlu, perbarui metadata, dan kadang minta halaman baru ke sistem operasi — operasi terakhir ini bermakna page fault dan jauh lebih lambat lagi. Selain itu, alokasi kecil yang bertebaran memecah memori (fragmentasi) dan memecah locality: dua objek yang dibuat berturut-turut belum tentu bertetangga di memori, sehingga cache line yang ditarik belum tentu terpakai. Karena itu aturan praktisnya: alokasi sekali di awal dan dipakai ulang, bukan alokasi kecil-kecil berulang di jalur yang panas.
Menghitung Alokasi di Dalam Loop #
Program berikut memproses 500 baris log dengan tiga gaya: membuat string baru setiap iterasi, menulis ke satu buffer tetap, dan menulis ke buffer yang kapasitasnya sudah disiapkan. Angka alokasi dan total karakter keduanya deterministik:
// Menghitung biaya alokasi heap yang tersembunyi di dalam loop: membuat
// string baru setiap iterasi versus memakai ulang satu buffer, ditutup
// perbandingan array statis versus vector. Semua angka deterministik.
#include <cstdlib>
#include <iostream>
#include <new>
#include <string>
#include <vector>
long alokasiBaru = 0; // penghitung global pemanggilan operator new
void* operator new(std::size_t n) {
++alokasiBaru;
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
const int N = 500;
// Versi boros: setiap pemanggilan membangun string sendiri dari nol.
std::string buatBaris(int i) {
std::string s = "pengukuran-suhu-ruang-server-"; // 29 karakter: 1 alokasi
s += std::to_string(i); // melebihi kapasitas: 1 lagi
s += ";ok";
return s; // return by value: pindahan
}
// Versi hemat: menulis ke buffer yang diberikan pemanggil, kapasitas terjaga.
void isiBaris(std::string& keluar, int i) {
keluar = "pengukuran-suhu-ruang-server-";
keluar += std::to_string(i);
keluar += ";ok";
}
int main() {
std::cout << "memproses " << N << " baris log\n";
long total1 = 0;
alokasiBaru = 0;
for (int i = 0; i < N; ++i) {
std::string baris = buatBaris(i); // string baru lalu dibuang tiap iterasi
total1 += static_cast<long>(baris.size());
}
std::cout << "string baru tiap iterasi : alokasi = " << alokasiBaru << '\n';
long total2 = 0;
std::string kerja;
alokasiBaru = 0;
for (int i = 0; i < N; ++i) {
isiBaris(kerja, i); // buffer lama terus dipakai ulang
total2 += static_cast<long>(kerja.size());
}
std::cout << "assign ke buffer tetap : alokasi = " << alokasiBaru << '\n';
long total3 = 0;
std::string kerjaRapi;
kerjaRapi.reserve(64); // kapasitas cukup sejak awal
alokasiBaru = 0;
for (int i = 0; i < N; ++i) {
isiBaris(kerjaRapi, i);
total3 += static_cast<long>(kerjaRapi.size());
}
std::cout << "dengan reserve di awal : alokasi = " << alokasiBaru << '\n';
std::cout << "total karakter (3x sama) : " << total1 << '/'
<< total2 << '/' << total3 << '\n';
// Array statis vs dinamis: kebutuhan ukuran yang sudah pasti sejak awal
// tidak wajib lewat heap.
static int dataStatis[4096]; // hidup di memori statis: 0 alokasi
alokasiBaru = 0;
for (int i = 0; i < 4096; ++i) dataStatis[i] = i;
long a0 = alokasiBaru;
std::vector<int> dataDinamis(4096); // satu alokasi heap
long a1 = alokasiBaru;
for (int i = 0; i < 4096; ++i) dataDinamis[i] = i;
long cek1 = 0, cek2 = 0;
for (int i = 0; i < 4096; ++i) { cek1 += dataStatis[i]; cek2 += dataDinamis[i]; }
std::cout << "array static 4096 int : alokasi = " << a0 << '\n';
std::cout << "vector 4096 int : alokasi = " << a1 << '\n';
std::cout << "checksum statis/vector : " << cek1 << '/' << cek2 << '\n';
return 0;
}Output yang Dihasilkan:
memproses 500 baris log
string baru tiap iterasi : alokasi = 1000
assign ke buffer tetap : alokasi = 2
dengan reserve di awal : alokasi = 0
total karakter (3x sama) : 17390/17390/17390
array static 4096 int : alokasi = 0
vector 4096 int : alokasi = 1
checksum statis/vector : 8386560/8386560Bedah angkanya:
- 1000 alokasi versi boros: setiap
buatBarismelakukan dua alokasi — satu untuk 29 karakter pembuka yang melebihi SSO, satu lagi saat+=angka melebihi kapasitas. Kelima ratus iterasi mengulang semuanya, padahal hasilnya dibuang setiap kali. - 2 alokasi versi buffer tetap: iterasi pertama menyewa kapasitas (dua tahap), dan karena
kerjatidak pernah dihancurkan, kapasitas 58 karakter itu dipertahankan untuk 499 iterasi berikutnya — nol alokasi tambahan. - 0 alokasi dengan
reserve(64): kapasitas sudah cukup sejak sebelum loop, sehingga seluruh penulisan hanya mengubah isi buffer. - Statis vs dinamis: array
static4096 int hidup di memori statis — nol alokasi, ukurannya pasti saat kompilasi;std::vector<int> dataDinamis(4096)membayar satu alokasi heap untuk kebebasan ukuran saat runtime. Ketika ukuran memang sudah pasti dan seumur program, memori statis menghilangkan biayanya.
Small String Optimization #
Allocator termahal adalah alokasi yang tidak terjadi. std::string modern memanfaatkan itu lewat Small String Optimization (SSO): objek string menyediakan ruang internal untuk string pendek, sehingga isi seperti "budi" tidak menyentuh heap sama sekali. Kita buktikan dengan sizeof, capacity, alamat buffer, dan penghitung alokasi:
// Small String Optimization (SSO): string pendek hidup DI DALAM objek
// std::string tanpa menyentuh heap. Angka sizeof/capacity adalah nilai
// libstdc++ pada platform uji ini — implementasi lain boleh berbeda.
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <new>
#include <string>
long alokasiBaru = 0; // penghitung global pemanggilan operator new
void* operator new(std::size_t n) {
++alokasiBaru;
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
// Apakah buffer data berada di dalam objek string itu sendiri?
bool bufferDiDalam(const std::string& s) {
auto alamatObjek = reinterpret_cast<std::uintptr_t>(&s);
auto alamatData = reinterpret_cast<std::uintptr_t>(s.data());
return alamatData >= alamatObjek && alamatData < alamatObjek + sizeof(std::string);
}
int main() {
std::cout << "sizeof(std::string) = " << sizeof(std::string) << " byte\n";
std::string pendek = "budi";
std::string kosong;
std::string batas(15, 'a'); // tepat 15 karakter
std::string lewat(16, 'a'); // 16 karakter: keluar dari SSO
std::cout << "capacity string kosong : " << kosong.capacity() << '\n';
std::cout << "capacity 15 karakter : " << batas.capacity() << '\n';
std::cout << "capacity 16 karakter : " << lewat.capacity() << '\n';
std::cout << "buffer 'budi' : "
<< (bufferDiDalam(pendek) ? "di dalam objek (SSO)" : "di heap") << '\n';
std::cout << "buffer 16 karakter : "
<< (bufferDiDalam(lewat) ? "di dalam objek (SSO)" : "di heap") << '\n';
// Salinan: string pendek tidak mengalokasikan apa pun, panjang satu kali.
alokasiBaru = 0;
std::string salinPendek = pendek;
long a1 = alokasiBaru;
alokasiBaru = 0;
std::string salinLewat = lewat;
long a2 = alokasiBaru;
std::cout << "alokasi salin pendek/panjang: " << a1 << '/' << a2 << '\n';
return 0;
}Output yang Dihasilkan:
sizeof(std::string) = 32 byte
capacity string kosong : 15
capacity 15 karakter : 15
capacity 16 karakter : 16
buffer 'budi' : di dalam objek (SSO)
buffer 16 karakter : di heap
alokasi salin pendek/panjang: 0/1Pada platform uji ini (libstdc++ GCC 14), sizeof(std::string) adalah 32 byte dan SSO menampung hingga 15 karakter: fungsi bufferDiDalam menunjukkan buffer "budi" memang berada di dalam objek itu sendiri, dan menyalin string pendek tidak memunculkan satu pun alokasi. Di ambang 16 karakter, string keluar dari SSO: buffer pindah ke heap (capacity 16) dan salinannya membayar satu alokasi.
Konsekuensi praktisnya: untuk field dan variabel yang isinya pendek (nama, ID, satuan), std::string sudah hemat dari sana — jangan buru-buru menggantinya dengan const char* yang rawan kesalahan kepemilikan. Justru string panjang yang dibangun bertahap di loop adalah target optimasi kita.
Arena: Satu Blok untuk Banyak Objek #
Bila sebuah fase program membuat banyak objek lalu membuang semuanya sekaligus (satu frame render, satu request, satu batch), arena allocator mengelompokkan biayanya: satu blok besar disewa sekali, lalu setiap permintaan hanya menggeser penunjuk — sering disebut bump allocator:
// Arena allocator paling sederhana (bump allocator): satu blok besar
// disewa sekali lewat heap, lalu setiap permintaan hanya menggeser
// penunjuk. Tidak ada pembebasan per objek; reset() atau destructor
// yang membebaskan semuanya sekaligus.
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <new>
#include <vector>
long alokasiBaru = 0; // penghitung global pemanggilan operator new
void* operator new(std::size_t n) {
++alokasiBaru;
if (void* p = std::malloc(n)) return p;
throw std::bad_alloc{};
}
void operator delete(void* p) noexcept { std::free(p); }
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
class Arena {
public:
explicit Arena(std::size_t total) : blok_(total) {} // satu alokasi heap
void* alloc(std::size_t n, std::size_t align = alignof(std::max_align_t)) {
auto posisi = reinterpret_cast<std::uintptr_t>(blok_.data() + terpakai_);
std::size_t sisa = posisi % align; // rapikan ke kelipatan alignment
if (sisa != 0) terpakai_ += align - sisa;
if (terpakai_ + n > blok_.size()) return nullptr; // penuh
void* hasil = blok_.data() + terpakai_;
terpakai_ += n;
return hasil;
}
void reset() { terpakai_ = 0; } // seluruh isi hangus sekaligus
std::size_t terpakai() const { return terpakai_; }
std::size_t kapasitas() const { return blok_.size(); }
private:
std::vector<std::byte> blok_; // pemilik blok: RAII, satu alokasi
std::size_t terpakai_ = 0;
};
struct Titik { float x, y, z; };
int main() {
std::cout << "arena bump allocator\n";
const int N = 1000;
alokasiBaru = 0;
Arena arena(N * sizeof(Titik) + 64);
long aBlok = alokasiBaru;
alokasiBaru = 0;
for (int i = 0; i < N; ++i) {
void* ruang = arena.alloc(sizeof(Titik), alignof(Titik));
new (ruang) Titik{i * 1.0f, i * 2.0f, i * 3.0f}; // placement new
}
long aIsi = alokasiBaru;
std::cout << "blok arena : " << aBlok << " alokasi\n";
std::cout << N << " objek via arena.alloc(): " << aIsi << " alokasi tambahan\n";
std::cout << "terpakai : " << arena.terpakai()
<< " dari " << arena.kapasitas() << " byte\n";
arena.reset();
std::cout << "setelah reset, terpakai : " << arena.terpakai() << '\n';
// Pembanding: 1000 objek yang masing-masing menyewa heap sendiri.
// Penunjuk disimpan di array statis agar alokasinya tidak bisa dihapus
// compiler (elisi) dan tetap terhitung.
alokasiBaru = 0;
static Titik* simpanan[N];
for (int i = 0; i < N; ++i) simpanan[i] = new Titik{i * 1.0f, i * 2.0f, i * 3.0f};
long cek = 0;
for (int i = 0; i < N; ++i) cek += static_cast<long>(simpanan[i]->x);
for (int i = 0; i < N; ++i) delete simpanan[i];
std::cout << N << " objek dengan new terpisah: " << alokasiBaru
<< " alokasi (checksum " << cek << ")\n";
return 0;
}Output yang Dihasilkan:
arena bump allocator
blok arena : 1 alokasi
1000 objek via arena.alloc(): 0 alokasi tambahan
terpakai : 12000 dari 12064 byte
setelah reset, terpakai : 0
1000 objek dengan new terpisah: 1000 alokasi (checksum 499500)Bedah mekanismenya:
allochanya berhitung: merapikan posisi ke kelipatan alignment, memeriksa sisa ruang, lalu menggeserterpakai_. Tidak ada pencarian blok, tidak ada metadata per objek — 1000 objek menghasilkan 0 alokasi tambahan.- Placement new:
new (ruang) Titik{...}membangun objek pada alamat yang sudah disewa.ruanghanyalah memori mentah; placement new-lah yang menaruh objek di sana. reset()dan destructor: arena tidak membebaskan objek satu per satu —reset()menganggap seluruh isi hangus (untuk objek dengan destructor, kita panggil manual sebelum reset), danstd::vector<std::byte>pemilik blok membebaskan memori lewat RAII saatArenamati.- Bonus locality: 1000
Titikberdempetan dalam satu blok 12.000 byte — berbeda dengan 1000newterpisah yang penempatannya ditentukan allocator.
Statis, Dinamis, dan Pilihannya #
Tiga rumah untuk data kini lengkap, dan pilihannya bisa dirangkum. Memori statis/global (array static, variabel global) tanpa alokasi, ukuran pasti saat kompilasi, hidup sepanjang program — cocok untuk tabel tetap dan buffer kerja yang memang seumur program. Stack (variabel lokal, std::array) tanpa alokasi, hidup sebatas scope — pilihan default untuk ukuran kecil. Heap (std::vector, std::string panjang, arena) fleksibel ukurannya dan bisa dibagikan, tetapi setiap sewa membayar. Strategi yang kita latih di chapter ini bukan meninggalkan heap, melainkan menekan frekuensinya: satu alokasi yang dipakai ulang mengalahkan seribu alokasi kecil.
Sampai di sini semua klaim kita bertumpu pada penghitung alokasi — bukti bahwa pekerjaan heap berkurang. Pertanyaan yang tersisa: apakah pengurangan itu terasa di durasi, dan bagaimana mengukurnya secara jujur? Alat untuk menjawabnya adalah benchmark yang benar, topik chapter berikutnya.
Rujukan resmi: std::basic_string , operator new , dan new expression .