How HashMap Works Internally in Java
Buckets, hashing, collisions and resizing. Understanding the mechanism explains the performance and every rule about keys.
-
Java Basics
- Introduction to Java
- Setting Up Java and Writing Your First Program
- Variables, Data Types and Literals in Java
- Type Casting and Type Conversion in Java
- Operators and Expressions in Java
- Input and Output in Java
- Comments, Keywords and Naming Conventions in Java
- Control Flow in Java: if, else and switch
- Loops in Java: for, while and do-while
- Methods
- Arrays and Strings
-
OOP
- Classes and Objects in Java
- Constructors in Java
- The this Keyword in Java
- The static Keyword in Java
- Encapsulation in Java
- Access Modifiers in Java
- Inheritance in Java
- Method Overriding and super in Java
- Polymorphism in Java
- Abstraction, Abstract Classes and Interfaces in Java
- Composition, Aggregation and Association in Java
- The Object Lifecycle in Java
- Core Java
- Exception Handling
-
Collections
- The Java Collections Framework
- List in Java: ArrayList, LinkedList, Vector and Stack
- Set in Java: HashSet, LinkedHashSet and TreeSet
- Map in Java: HashMap, LinkedHashMap and TreeMap
- How HashMap Works Internally in Java
- Queue and Deque in Java: ArrayDeque and PriorityQueue
- Iterators in Java
- Comparable and Comparator in Java
- Collections Utilities and Choosing the Right Collection
- Generics
- Java 8+
- Stream API
- Date and Time
- File and I/O
-
Multithreading
- Threads in Java: Processes, Runnable and Thread
- Thread Lifecycle in Java
- Synchronization in Java: synchronized and volatile
- Locks and Atomic Classes in Java
- Race Conditions and Deadlocks in Java
- The Executor Framework and Thread Pools in Java
- Future and CompletableFuture in Java
- Concurrent Collections in Java
- The Java Memory Model
- JVM and Memory
- Advanced Java
- Networking
- JDBC
- Testing
The structure
A HashMap holds an array of buckets. A key is converted to a number, that number selects a bucket, and the entry is stored there. Because the bucket is found by arithmetic rather than by searching, lookup is constant time on average.
table (an array of buckets)
0 -> null
1 -> [ "sql"=5 ]
2 -> null
3 -> [ "java"=15 ] -> [ "html"=7 ] two keys collided in bucket 3
...Step by step: put
- Call
hashCode()on the key. - Spread the bits, so that keys differing only in the high bits still land in different buckets.
- Compute the index as
hash & (capacity - 1), which works because the capacity is always a power of two. - If the bucket is empty, store the entry there.
- If not, compare with each entry in the bucket using
hashCodeand thenequals. A match replaces the value; otherwise the entry is appended. - If the table is too full, resize.
// A simplified view of the index calculation
int hash = key.hashCode();
hash = hash ^ (hash >>> 16); // spread the high bits downwards
int index = hash & (table.length - 1); // cheaper than a moduloStep by step: get
- Compute the same index from the key.
- Look in that bucket.
- Compare each entry: first the stored hash, which is a fast integer test, and only then
equals. - Return the value, or
nullif nothing matches.
This is exactly whyequalsandhashCodemust agree. If two equal keys produce different hashes, they are looked for in different buckets, and the entry is stored but unreachable.
Collisions
Two different keys can produce the same index. That is normal and unavoidable, and the map keeps both entries in the same bucket.
| Bucket contents | Structure | Lookup cost |
|---|---|---|
| Up to 8 entries | A linked list | O(k), k small |
| More than 8, table at least 64 | A balanced tree | O(log k) |
| Shrinks below 6 | Back to a linked list | O(k) |
Since Java 8, a heavily collided bucket converts to a red black tree. This turned the worst case for a map under a deliberate hash collision attack from O(n) into O(log n). Tree conversion requires the keys to be Comparable, or it falls back to a stable ordering.
Capacity, load factor and resizing
Map<String, Integer> map = new HashMap<>(); // capacity 16, load factor 0.75
Map<String, Integer> sized = new HashMap<>(1000); // fewer resizes later- Capacity is the number of buckets, always a power of two.
- Load factor is how full the table may get before it grows. The default of 0.75 balances space against collisions.
- When
size > capacity * loadFactor, the capacity doubles and every entry is rehashed into the new table.
With the default settings, a map grows at 12, 24, 48 entries and so on. Each resize is O(n), so giving the expected size up front matters when inserting many entries.
Why keys must be immutable
List<String> key = new ArrayList<>(List.of("a"));
Map<List<String>, String> map = new HashMap<>();
map.put(key, "value"); // stored in the bucket for hash of ["a"]
key.add("b"); // hash is now that of ["a","b"]
map.get(key); // looks in a different bucket -> null
map.containsKey(key); // false, although the entry is presentThe entry has not moved; the map simply looks in the wrong place. Nothing detects this, which is why it is such a persistent bug.
A poor hashCode destroys performance
class BadKey {
private final int id;
BadKey(int id) { this.id = id; }
@Override public int hashCode() { return 1; } // legal, and terrible
@Override public boolean equals(Object o) {
return o instanceof BadKey k && k.id == id;
}
}Every key lands in one bucket, and the map degenerates into a single list or tree. Lookup becomes O(n) or O(log n) instead of O(1). A hash code must be legal and spread values well; Objects.hash(...) over the identifying fields does both.
Null keys
HashMap allows one null key, stored in bucket zero by special case, since null.hashCode() is impossible. TreeMap and ConcurrentHashMap reject null keys entirely.
Iteration order
Map<String, Integer> map = new HashMap<>();
map.put("banana", 1);
map.put("apple", 2);
map.put("cherry", 3);
System.out.println(map); // order follows bucket layout, not insertionThe order is a consequence of the hashes and the current capacity. It can change when the map resizes, and it is not part of the specification. Never depend on it; use LinkedHashMap or TreeMap when order matters.
HashMap and threads
Map<String, Integer> shared = new ConcurrentHashMap<>(); // correct
// Map<String, Integer> unsafe = new HashMap<>(); // corrupt under concurrent writesConcurrent writes during a resize can leave the internal structure inconsistent. ConcurrentHashMap locks per bucket and is the right choice for shared access.
Common mistakes
- Overriding
equalswithouthashCode. - Returning a constant from
hashCode. - Mutating a key after insertion.
- Depending on iteration order.
- Sharing a
HashMapacross threads without synchronisation. - Leaving the default capacity when inserting hundreds of thousands of entries.
Best practices
- Use immutable keys:
String, wrappers, enums, records,LocalDate. - Build
hashCodewithObjects.hashover the same fields asequals. - Size the map when the entry count is known and large.
- Use
ConcurrentHashMapfor shared maps. - Use
EnumMapwhen the keys are enum constants.
Practice
- Explain, bucket by bucket, why a lookup fails when
hashCodeis not overridden. - Why is the capacity always a power of two, and how does that make the index calculation cheap?
- At which sizes does a default
HashMapresize, and what is the cost each time? - What changes when a bucket exceeds eight entries, and why was that introduced?
- Demonstrate the mutable key problem with a two element list, then fix it with a record.
Conclusion
A HashMap is an array of buckets indexed by a spread hash code, with collisions kept in a list or a tree and the table doubling as it fills. Every rule about keys, immutability, equals with hashCode, and unpredictable ordering follows directly from that design.