Definition
- No equals/hashCode override – StringBuilder inherits
Object.equals()(reference comparison) andObject.hashCode()(identity hash), so two builders with the same text are different keys - Mutable state – even if StringBuilder overrode hashCode based on content, mutating the builder after insertion would change the hash, making the entry unreachable
- Double failure – a StringBuilder key fails at lookup time (identity equals) and fails after mutation (stale bucket position)
- Rule – HashMap keys must be immutable with value-based equals and hashCode
- Safe alternatives –
String,Integer,Long,BigDecimal, and custom immutable classes with proper equals/hashCode
Imagine Rajesh at National Bank of India labels a filing drawer with a pencil tag that says "SAV2001". The filing clerk memorises which shelf it is on based on the original tag. Later Rajesh erases the tag and writes "CUR3001". When someone searches for the drawer labelled CUR3001, the clerk looks on the wrong shelf because the drawer was filed under the original label. With an immutable printed label (a String), the label never changes and the drawer is always found.
Understanding the Concepts
What happens with a mutable StringBuilder key in HashMap?
StringBuilder inherits equals() and hashCode() from Object. This means equals() checks reference identity (==), and hashCode() returns the identity hash code. Two StringBuilder objects with the same text are considered different keys.
package com.bank.demo;
import java.util.HashMap;
import java.util.Map;
public class StringBuilderKeyDemo {
public static void main(String[] args) {
Map<StringBuilder, String> map = new HashMap<>();
StringBuilder key = new StringBuilder("SAV2001");
map.put(key, "Rajesh");
// Lookup with the same object works
System.out.println("Same object: " + map.get(key));
// Lookup with a new StringBuilder with same text fails
StringBuilder lookup = new StringBuilder("SAV2001");
System.out.println("New object same text: " + map.get(lookup));
// Mutation: change the key after insertion
key.append("-CLOSED");
System.out.println("After mutation, same ref: " + map.get(key));
System.out.println("Map size: " + map.size());
}
}
Same object: Rajesh
New object same text: null
After mutation, same ref: Rajesh
Map size: 1
The lookup with a new StringBuilder returns null because key.equals(lookup) is false (identity comparison). The mutated key still works with the same reference only because Object.hashCode() is identity-based and does not change with content. However, this behaviour is fragile and misleading.
Why do mutable keys break HashMap?
HashMap relies on a contract: hashCode() must be consistent with equals(), and both must remain stable for the lifetime of the key inside the map. If a key's hashCode changes after insertion, the entry is stranded in the wrong bucket. A get() call computes the new hashCode, looks in a different bucket, and finds nothing.
With StringBuilder, the problem is twofold. First, equals() uses identity, so content-based lookup is impossible. Second, if StringBuilder did override hashCode based on content, mutating it would strand the entry.
package com.bank.demo;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class MutableKeyStrandedDemo {
// A mutable key class that DOES override hashCode based on content
static class MutableAccount {
String id;
MutableAccount(String id) { this.id = id; }
@Override
public int hashCode() { return Objects.hashCode(id); }
@Override
public boolean equals(Object o) {
return o instanceof MutableAccount m && Objects.equals(id, m.id);
}
}
public static void main(String[] args) {
Map<MutableAccount, String> map = new HashMap<>();
MutableAccount key = new MutableAccount("SAV2001");
map.put(key, "Rajesh");
System.out.println("Before mutation: " + map.get(key));
key.id = "CUR3001"; // mutate the key after insertion
System.out.println("After mutation (same ref): " + map.get(key));
System.out.println("Lookup with old id: " + map.get(new MutableAccount("SAV2001")));
System.out.println("Map size: " + map.size());
}
}
Before mutation: Rajesh
After mutation (same ref): null
Lookup with old id: null
Map size: 1
The entry exists in the map (size is 1), but no lookup can find it. It is stranded in the bucket computed from the original hashCode "SAV2001", while all lookups now compute the hashCode for "CUR3001".
How do you fix this?
The fix is simple: always use immutable objects as HashMap keys. Convert mutable objects to their immutable representation before using them as keys.
package com.bank.demo;
import java.util.HashMap;
import java.util.Map;
public class ImmutableKeyFixDemo {
public static void main(String[] args) {
// WRONG: StringBuilder as key
Map<StringBuilder, String> bad = new HashMap<>();
bad.put(new StringBuilder("SAV2001"), "Rajesh");
System.out.println("StringBuilder lookup: " + bad.get(new StringBuilder("SAV2001")));
// CORRECT: String as key
Map<String, String> good = new HashMap<>();
good.put("SAV2001", "Rajesh");
System.out.println("String lookup: " + good.get("SAV2001"));
// CORRECT: Convert StringBuilder to String first
StringBuilder builder = new StringBuilder("CUR3001");
good.put(builder.toString(), "Priya");
System.out.println("Converted lookup: " + good.get("CUR3001"));
}
}
StringBuilder lookup: null
String lookup: Rajesh
Converted lookup: Priya
What are the rules for good HashMap keys?
A well-behaved HashMap key must satisfy these rules.
- Immutable. The fields used in
equals()andhashCode()must never change after the object is used as a key. - Consistent equals and hashCode. If
a.equals(b)is true, thena.hashCode() == b.hashCode()must also be true. - Value-based equality. Two objects with the same logical content should be equal.
Good key types in banking code:
| Type | Immutable | Value-based equals/hashCode | Safe as key |
|---|---|---|---|
| String | Yes | Yes | Yes |
| Integer, Long | Yes | Yes | Yes |
| BigDecimal | Yes | Yes (with caveats on scale) | Yes |
| StringBuilder | No | No (identity-based) | No |
| Date (java.util) | No (mutable) | Yes | Dangerous |
| LocalDate | Yes | Yes | Yes |
What are common mistakes?
Mistake 1: Using StringBuilder directly as a key
// WRONG
Map<StringBuilder, String> map = new HashMap<>();
map.put(new StringBuilder("SAV2001"), "Rajesh");
System.out.println(map.get(new StringBuilder("SAV2001"))); // null
// CORRECT
Map<String, String> map = new HashMap<>();
map.put("SAV2001", "Rajesh");
System.out.println(map.get("SAV2001")); // Rajesh
Mistake 2: Using java.util.Date as a key and mutating it
import java.util.*;
// WRONG
Map<Date, String> map = new HashMap<>();
Date key = new Date();
map.put(key, "Transaction");
key.setTime(0L); // mutate after insertion
System.out.println(map.get(key)); // null! Stranded entry.
// CORRECT: Use immutable LocalDate
import java.time.LocalDate;
import java.util.*;
Map<LocalDate, String> map = new HashMap<>();
map.put(LocalDate.now(), "Transaction");
System.out.println(map.get(LocalDate.now())); // Transaction
What should a senior engineer know?
- Effective Java Item 11: Always override hashCode when you override equals. Conversely, if a class does not override both, it should not be used as a HashMap key.
- BigDecimal caveat.
new BigDecimal("1.0").equals(new BigDecimal("1.00"))returns false because scale differs. If you use BigDecimal as a map key, usestripTrailingZeros()or consider using thecompareTo-based TreeMap. - Records (Java 16+) automatically generate equals and hashCode based on all components, making them excellent HashMap keys as long as their components are immutable.
- Defensive copies. When receiving a mutable object to use as a key, always make a defensive copy:
map.put(new String(mutableBuilder.toString()), value).
What are common interview follow-up questions?
- "Why can't you use StringBuilder as a HashMap key?" : It does not override equals/hashCode, so it uses identity comparison. Two builders with the same text are different keys.
- "What happens if you mutate a key after inserting it?" : The entry is stranded in the old bucket. Lookups fail even with the same reference.
- "What makes a good HashMap key?" : Immutability, consistent equals/hashCode, value-based equality. String, Integer, and BigDecimal are ideal.
- "Can you use a custom class as a key?" : Yes, if it properly overrides equals and hashCode and is effectively immutable.
Visual Overview
Why mutable keys break HashMap
==============================
put(key="SAV2001", value="Rajesh"):
┌───────────────────────────────────────┐
│ hashCode("SAV2001") = 1234 │
│ bucket index = 1234 & (capacity - 1) │
│ Entry stored in bucket 4 │
└───────────────────────────────────────┘
Mutate key: key.id = "CUR3001"
┌───────────────────────────────────────┐
│ Entry is STILL in bucket 4 │
│ (HashMap does not know key changed) │
└───────────────────────────────────────┘
get(key) after mutation:
┌───────────────────────────────────────┐
│ hashCode("CUR3001") = 5678 │
│ bucket index = 5678 & (capacity - 1) │
│ Looks in bucket 10 -> EMPTY -> null! │
└───────────────────────────────────────┘
Entry is stranded in bucket 4.
No lookup can reach it.
Map size = 1, but effectively lost.
Key Takeaway
Using a mutable object like StringBuilder as a HashMap key is a double failure. First, StringBuilder does not override equals or hashCode, so it uses identity comparison and two builders with the same text are treated as different keys. Second, mutating any key after insertion changes its logical state while the entry remains in the bucket computed from the original hash, making it unreachable by any lookup.
The fix is straightforward: always use immutable types as HashMap keys. String, Integer, Long, BigDecimal, and LocalDate all have proper value-based equals and hashCode and cannot be mutated after creation. If you must work with a StringBuilder, convert it to a String with toString() before using it as a key.
At National Bank of India, Pankaj established a coding rule that every map key in the banking codebase must be an immutable type with value-based equality. Account identifiers are always String (SAV2001, CUR3001), amounts are always BigDecimal, and dates are always LocalDate. This eliminated an entire class of silent lookup failures that could cause a balance query to return null in a system that handles real money.
Remember one sentence: HashMap keys must be immutable with value-based equals and hashCode, so use String instead of StringBuilder and LocalDate instead of Date.
---