HashMap adalah implementasi Map yang paling banyak dipakai di seluruh ekosistem Java — dan untuk alasan yang kuat: operasi get, put, containsKey, dan remove rata-rata selesai dalam waktu konstan O(1), berapa pun jumlah pasangan yang disimpan. Kecepatan itu bukan hasil kebetulan melainkan konsekuensi teknik hashing: key diubah menjadi angka, angka itu menentukan laci penyimpanan, dan pencarian langsung membuka laci yang tepat. Pada Chapter ini kita bedah mesin di baliknya — bucket, tabrakan (collision), load factor, dan rehash — lalu kita kupas satu kontrak yang wajib dipenuhi agar mesin itu bekerja: equals dan hashCode pada key.
Bagaimana HashMap Menyimpan Data #
Di dalam HashMap terdapat array bernama table yang berisi sejumlah bucket. Saat put(k, v) dipanggil, HashMap menghitung k.hashCode(), meratakannya dengan operasi bit agar sebarannya lebih merata, lalu memilih indeks bucket dengan hash & (kapasitas - 1). Operasi bitwise ini setara dengan hash % kapasitas ketika kapasitas berupa pangkat dua — murah bagi prosesor dan menentukan satu-satunya bucket tempat pasangan itu boleh hidup.
Pencarian mengikuti jalur yang sama persis: get(k) menghitung hash dari k, melompat ke bucket yang ditunjuk, lalu membandingkan isi bucket dengan equals. Konsekuensi logisnya penting untuk dipahami: dua objek yang setara menurut equals wajib memiliki hashCode yang sama — kalau tidak, satu objek dicari di bucket A sedangkan kembaranya disimpan di bucket B, dan pasangan itu tidak akan pernah ditemukan.
Simulasi Bucket, Tabrakan, dan Rehash #
Karena bucket HashMap yang asli tersembunyi di internal JVM, kita bangun simulasi yang disederhanakan: array empat bucket, key bertipe Integer, dan indeks dihitung dengan n % kapasitas. Simulasi ini meniru tiga perilaku inti HashMap: tabrakan dua key di bucket yang sama, ambang load factor 0.75, dan rehash yang menggandakan kapasitas lalu menebarkan ulang seluruh isi:
import java.util.*;
public class TabelHashSederhana {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
List<Integer>[] bucket = new List[4];
for (int i = 0; i < bucket.length; i++) {
bucket[i] = new ArrayList<>();
}
int ukuran = 0;
int[] data = {10, 22, 31, 4, 17, 28};
for (int n : data) {
if (ukuran + 1 > bucket.length * 3 / 4) {
bucket = rehash(bucket);
}
int indeks = n % bucket.length;
String keterangan = bucket[indeks].isEmpty()
? "" : " (tabrakan dengan " + bucket[indeks].get(0) + ")";
bucket[indeks].add(n);
ukuran++;
System.out.println("Masukkan " + n + " -> bucket " + indeks + keterangan
+ " [kapasitas " + bucket.length + "]");
}
System.out.println();
for (int i = 0; i < bucket.length; i++) {
System.out.println("Bucket " + i + " : " + bucket[i]);
}
System.out.println();
System.out.print("Urutan iterasi: ");
for (List<Integer> daftar : bucket) {
for (int n : daftar) {
System.out.print(n + " ");
}
}
System.out.println();
}
@SuppressWarnings("unchecked")
private static List<Integer>[] rehash(List<Integer>[] lama) {
List<Integer>[] baru = new List[lama.length * 2];
for (int i = 0; i < baru.length; i++) {
baru[i] = new ArrayList<>();
}
for (List<Integer> daftar : lama) {
for (int n : daftar) {
baru[n % baru.length].add(n);
}
}
System.out.println(" >> load factor 0.75 terlampaui: kapasitas "
+ lama.length + " -> " + baru.length);
return baru;
}
}Output yang Dihasilkan:
Masukkan 10 -> bucket 2 [kapasitas 4]
Masukkan 22 -> bucket 2 (tabrakan dengan 10) [kapasitas 4]
Masukkan 31 -> bucket 3 [kapasitas 4]
>> load factor 0.75 terlampaui: kapasitas 4 -> 8
Masukkan 4 -> bucket 4 [kapasitas 8]
Masukkan 17 -> bucket 1 [kapasitas 8]
Masukkan 28 -> bucket 4 (tabrakan dengan 4) [kapasitas 8]
Bucket 0 : []
Bucket 1 : [17]
Bucket 2 : [10]
Bucket 3 : []
Bucket 4 : [4, 28]
Bucket 5 : []
Bucket 6 : [22]
Bucket 7 : [31]
Urutan iterasi: 17 10 4 28 22 31 Mari kita bedah peristiwanya satu per satu:
- Baris 14-16 (
10dan22): keduanya menghasilkan sisa2saat dibagi 4, sehingga menempati bucket yang sama — inilah collision.HashMapyang asli menangani tabrakan dengan menyimpan node berantai di dalam bucket (dan mengubah rantai menjadi red-black tree bila tabrakan di satu bucket sangat panjang). - Baris 18-19 (rehash): sebelum memasukkan anggota keempat, beban
4 > 4 × 0.75sudah terlampaui, sehingga kapasitas digandakan menjadi 8 dan seluruh isi ditebarkan ulang — perhatikan bahwa22pindah dari bucket 2 ke bucket 6. Operasiputyang memicu rehash menjadi lebih lambat dari biasanya; inilah makna istilah amortized: biaya penyebaran ulang ditanggung sedikit demi sedikit oleh seluruh operasi sebelumnya. - Baris 21-23 (
28): tabrakan tetap mungkin setelah rehash —4dan28sama-sama jatuh ke bucket 4 pada kapasitas 8. Tabrakan tidak salah; yang diperhatikanHashMaphanyalah agar tidak menggerombol di satu bucket. - Baris 32-38 (urutan iterasi): iterasi menyusuri bucket demi bucket dari indeks kecil ke besar, menghasilkan
17 10 4 28 22 31— bukan urutan penyisipan10 22 31 4 17 28. Inilah penjelasan mesin di balik urutan aneh pada contoh-contohHashMapsebelumnya.
Kontrak equals dan hashCode #
HashMap memakai hashCode untuk menentukan bucket dan equals untuk memastikan kesetaraan di dalam bucket. Kontrak yang mengikat keduanya: jika a.equals(b) mengembalikan true, maka a.hashCode() == b.hashCode() wajib benar. Arah sebaliknya tidak wajib — dua objek dengan hash sama boleh saja tidak setara; keduanya hanya bertetangga di bucket yang sama, dan equals yang memutuskan.
Jika kontrak dilanggar, gejalanya senyap: put berhasil, size bertambah, entry terlihat saat iterasi, tetapi get mengembalikan null untuk key yang seharusnya ditemukan. Jenis pelanggaran paling umum bukanlah hash yang salah, melainkan key yang berubah setelah dimasukkan — dibuktikan pada bagian terakhir chapter ini.
Record sebagai Key Ideal #
Key yang baik bersifat immutable (tidak bisa berubah), memiliki equals dan hashCode yang konsisten, dan murah dihitung. Sebelum Java 16, tiga syarat itu harus ditulis manual lewat method yang dioverride; sejak Java 16, record menyediakan semuanya otomatis karena seluruh komponen record bersifat final dan kedua method itu dihasilkan dari komponen-komponennya:
import java.util.*;
public class KunciRecord {
// Record otomatis membuat equals dan hashCode dari seluruh komponennya.
record KodeBarang(String kategori, int nomor) {}
public static void main(String[] args) {
Map<KodeBarang, String> gudang = new HashMap<>();
gudang.put(new KodeBarang("ATK", 101), "Pensil 2B");
gudang.put(new KodeBarang("ATK", 102), "Buku Tulis");
gudang.put(new KodeBarang("ELE", 201), "Kalkulator");
System.out.println("cari (ATK, 101) : " + gudang.get(new KodeBarang("ATK", 101)));
System.out.println("cari (ATK, 999) : " + gudang.get(new KodeBarang("ATK", 999)));
System.out.println("ukuran awal : " + gudang.size());
gudang.put(new KodeBarang("ATK", 101), "Pensil 2B isi 12");
System.out.println("setelah put ulang: " + gudang.get(new KodeBarang("ATK", 101)));
System.out.println("ukuran akhir : " + gudang.size());
System.out.println("Daftar entry:");
gudang.forEach((kode, nama) -> System.out.println(" " + kode + " -> " + nama));
}
}Output yang Dihasilkan:
cari (ATK, 101) : Pensil 2B
cari (ATK, 999) : null
ukuran awal : 3
setelah put ulang: Pensil 2B isi 12
ukuran akhir : 3
Daftar entry:
KodeBarang[kategori=ATK, nomor=102] -> Buku Tulis
KodeBarang[kategori=ATK, nomor=101] -> Pensil 2B isi 12
KodeBarang[kategori=ELE, nomor=201] -> KalkulatorPoin bedahnya:
- Baris 6 (
record KodeBarang): satu baris deklarasi menghasilkan constructor, accessor,equals,hashCode, dantoString— dua method tengah itulah yang dibacaHashMap. - Baris 14 (
get(new KodeBarang("ATK", 101))): objek pencarian dibuat baru, berbeda dari objek yang dipakai saatput— namun ditemukan, karenaequalsmembandingkan isi (ATK,101), bukan alamat objek. Inilah perilaku yang diharapkan dari kontrak yang benar. - Baris 21-23 (
putulang): key setara menimpa value lama;ukurantetap3karenaHashMapmenghitung keunikan berdasarkanequals, bukan jumlah objek.
Jebakan Key yang Berubah #
Sekarang kita lihat akibat key yang mutable. Class Pelanggan menghitung hashCode dari field nama — field yang masih terbuka untuk diubah. Entry dimasukkan dengan nama Budi, lalu nama objek key diganti menjadi Siti setelah entry tersimpan:
import java.util.*;
public class KunciBerubah {
// equals dan hashCode mengikuti field nama, yang masih bisa diubah.
static class Pelanggan {
String nama;
Pelanggan(String nama) {
this.nama = nama;
}
@Override
public boolean equals(Object o) {
return o instanceof Pelanggan p && p.nama.equals(nama);
}
@Override
public int hashCode() {
return nama.hashCode();
}
@Override
public String toString() {
return nama;
}
}
public static void main(String[] args) {
Map<Pelanggan, Integer> poin = new HashMap<>();
Pelanggan kunci = new Pelanggan("Budi");
poin.put(kunci, 120);
System.out.println("get(Budi) sebelum ubah : " + poin.get(new Pelanggan("Budi")));
kunci.nama = "Siti"; // field milik key diubah setelah key dimasukkan
System.out.println("get(Budi) setelah ubah : " + poin.get(new Pelanggan("Budi")));
System.out.println("get(Siti) : " + poin.get(new Pelanggan("Siti")));
System.out.println("ukuran map : " + poin.size());
System.out.println("isi map : " + poin);
}
}Output yang Dihasilkan:
get(Budi) sebelum ubah : 120
get(Budi) setelah ubah : null
get(Siti) : null
ukuran map : 1
isi map : {Siti=120}Mari kita urai kejadiannya:
- Baris 30 (sebelum ubah):
getmenemukan120— kontrak bekerja normal selama field key tidak disentuh. - Baris 33-34 (setelah
kunci.nama = "Siti"): entry tersimpan di bucket milik hashBudi, tetapi objek key di dalam entry kini bernamaSiti. Pencarian denganBudisampai ke bucket yang benar lalu gagal diequals; pencarian denganSitidiarahkan ke bucket milik hashSitiyang kosong. Kedua jalur menghasilkannull. - Baris 35-37:
size()masih1dan isi map masih tercetak — entry-nya tidak hilang, hanya tidak lagi bisa dijumpai lewatget. Hanya iterasi penuh yang masih menemukannya, dan penghapusan lewatremove(Budi)juga gagal, sehingga entry seperti ini berpotensi membocorkan memori secara perlahan.
HashMap memberi kecepatan, tetapi kontraknya menuntut kedisiplinan key, dan urutan iterasinya tidak bisa dijanjikan. Untuk kasus yang butuh kecepatan hashing sekaligus urutan yang terprediksi, Java menyediakan LinkedHashMap — implementasi yang menambah satu daftar tautan untuk mengingat urutan penyisipan. Chapter berikutnya membedahnya sampai ke penerapannya sebagai cache LRU.
Referensi #
- Java API Specification: java.util.HashMap
- Java API Specification: java.lang.Record
- Dev.java: The Java Collections Framework
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
KunciBerubah.java
Unduh berkasimport java.util.*;
public class KunciBerubah {
// equals dan hashCode mengikuti field nama, yang masih bisa diubah.
static class Pelanggan {
String nama;
Pelanggan(String nama) {
this.nama = nama;
}
@Override
public boolean equals(Object o) {
return o instanceof Pelanggan p && p.nama.equals(nama);
}
@Override
public int hashCode() {
return nama.hashCode();
}
@Override
public String toString() {
return nama;
}
}
public static void main(String[] args) {
Map<Pelanggan, Integer> poin = new HashMap<>();
Pelanggan kunci = new Pelanggan("Budi");
poin.put(kunci, 120);
System.out.println("get(Budi) sebelum ubah : " + poin.get(new Pelanggan("Budi")));
kunci.nama = "Siti"; // field milik key diubah setelah key dimasukkan
System.out.println("get(Budi) setelah ubah : " + poin.get(new Pelanggan("Budi")));
System.out.println("get(Siti) : " + poin.get(new Pelanggan("Siti")));
System.out.println("ukuran map : " + poin.size());
System.out.println("isi map : " + poin);
}
}
KunciRecord.java
Unduh berkasimport java.util.*;
public class KunciRecord {
// Record otomatis membuat equals dan hashCode dari seluruh komponennya.
record KodeBarang(String kategori, int nomor) {}
public static void main(String[] args) {
Map<KodeBarang, String> gudang = new HashMap<>();
gudang.put(new KodeBarang("ATK", 101), "Pensil 2B");
gudang.put(new KodeBarang("ATK", 102), "Buku Tulis");
gudang.put(new KodeBarang("ELE", 201), "Kalkulator");
System.out.println("cari (ATK, 101) : " + gudang.get(new KodeBarang("ATK", 101)));
System.out.println("cari (ATK, 999) : " + gudang.get(new KodeBarang("ATK", 999)));
System.out.println("ukuran awal : " + gudang.size());
gudang.put(new KodeBarang("ATK", 101), "Pensil 2B isi 12");
System.out.println("setelah put ulang: " + gudang.get(new KodeBarang("ATK", 101)));
System.out.println("ukuran akhir : " + gudang.size());
System.out.println("Daftar entry:");
gudang.forEach((kode, nama) -> System.out.println(" " + kode + " -> " + nama));
}
}
TabelHashSederhana.java
Unduh berkasimport java.util.*;
public class TabelHashSederhana {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
List<Integer>[] bucket = new List[4];
for (int i = 0; i < bucket.length; i++) {
bucket[i] = new ArrayList<>();
}
int ukuran = 0;
int[] data = {10, 22, 31, 4, 17, 28};
for (int n : data) {
if (ukuran + 1 > bucket.length * 3 / 4) {
bucket = rehash(bucket);
}
int indeks = n % bucket.length;
String keterangan = bucket[indeks].isEmpty()
? "" : " (tabrakan dengan " + bucket[indeks].get(0) + ")";
bucket[indeks].add(n);
ukuran++;
System.out.println("Masukkan " + n + " -> bucket " + indeks + keterangan
+ " [kapasitas " + bucket.length + "]");
}
System.out.println();
for (int i = 0; i < bucket.length; i++) {
System.out.println("Bucket " + i + " : " + bucket[i]);
}
System.out.println();
System.out.print("Urutan iterasi: ");
for (List<Integer> daftar : bucket) {
for (int n : daftar) {
System.out.print(n + " ");
}
}
System.out.println();
}
@SuppressWarnings("unchecked")
private static List<Integer>[] rehash(List<Integer>[] lama) {
List<Integer>[] baru = new List[lama.length * 2];
for (int i = 0; i < baru.length; i++) {
baru[i] = new ArrayList<>();
}
for (List<Integer> daftar : lama) {
for (int n : daftar) {
baru[n % baru.length].add(n);
}
}
System.out.println(" >> load factor 0.75 terlampaui: kapasitas "
+ lama.length + " -> " + baru.length);
return baru;
}
}