DEV Community

Cover image for Java Collections Deep Dive: HashMap Internal Working and Collision Resolution
DEVANSHU PATIL
DEVANSHU PATIL

Posted on AI-assisted

Java Collections Deep Dive: HashMap Internal Working and Collision Resolution

Java Collections Deep Dive: HashMap Internal Working and Collision Resolution

Introduction

The java.util.HashMap is one of the most frequently utilized data structures in enterprise software development. Offering $O(1)$ constant time performance for basic operations like get and put (under optimal conditions), it forms the backbone of caching layers, indexing mechanisms, and lookup tables.

However, treating HashMap as a black box often leads to severe performance degradation, memory leaks, and unpredictable latency spikes. To write high-performance Java applications, engineers must understand its internal mechanics: how it computes memory addresses, manages capacity, handles hash collisions, and reorganizes its internal storage dynamically.

Core Data Structure: Buckets and Array-Based Storage

At its core, a HashMap is an array of singly-linked list nodes, commonly referred to as the bucket array. When an entry is inserted via put(K key, V value), the key's hash value determines the specific array index (bucket) where the corresponding value will reside.

In Java 8 and later, the underlying node class is Node<K,V>, which implements Map.Entry<K,V>. Each node contains:

  • int hash: The pre-calculated hash value of the key.
  • K key: The key reference.
  • V value: The value reference.
  • Node<K,V> next: A pointer to the next node in the case of a collision.
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;
    final K key;
    V value;
    Node<K,V> next;

    Node(int hash, K key, V value, Node<K,V> next) {
        this.hash = hash;
        this.key = key;
        this.value = value;
        this.next = next;
    }
    // hashCode, equals, and toString implementations omitted for brevity
}
Enter fullscreen mode Exit fullscreen mode

Hash Calculation and Index Determination

When map.put(key, value) is invoked, the HashMap does not use the raw hashCode() returned by the key object directly. Instead, it applies a supplemental hash function (often called the "spread" or "perturb" function) to minimize collisions caused by poorly written hashCode() implementations.

The hash() Method

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
Enter fullscreen mode Exit fullscreen mode

By XORing the higher 16 bits of the hash code with the lower 16 bits, the algorithm ensures that higher-order bits participate in the index calculation even when the array capacity is relatively small.

Finding the Bucket Index

To map the resulting hash integer to an index within the bounds of the current array capacity, HashMap uses a bitwise AND operation instead of the standard modulo operator (%):

int index = (n - 1) & hash;
Enter fullscreen mode Exit fullscreen mode

This optimization relies on the structural invariant that HashMap capacity (n) is always a power of two. If n is $16$, then n - 1 is binary 1111. Performing a bitwise AND with hash efficiently extracts the lower bits, yielding an index between $0$ and $15$.

Collision Resolution: From Linked Lists to Red-Black Trees

A hash collision occurs when two distinct keys evaluate to the exact same bucket index. Because HashMap uses separate chaining to resolve collisions, conflicting nodes are appended to the bucket.

Java 7 Behavior

In Java 7 and earlier, colliding nodes formed a pure linked list within the bucket. If an application suffered from high collision rates (e.g., due to a malicious Denial of Service payload targeting weak hash functions), lookup performance degraded from $O(1)$ to $O(n)$, as traversal required walking the linear chain.

Java 8+ Treeification

To mitigate worst-case performance, Java 8 introduced a hybrid approach. If a bucket's chain grows beyond a specific threshold, the linked list is treeified into a self-balancing Red-Black Tree.

  • TREEIFY_THRESHOLD = 8: The minimum number of entries in a bucket required to convert a linked list into a Red-Black tree.
  • UNTREEIFY_THRESHOLD = 6: The threshold during a resize operation where a treeified bin reverts to a linked list.
  • MIN_TREEIFY_CAPACITY = 64: The minimum total capacity of the map required before a bin can be treeified. If the array size is smaller, the map will choose to resize (resize()) instead of treeifying.

With Red-Black trees, worst-case lookups drop from $O(n)$ to $O(\log n)$.

Capacity, Load Factor, and Rehashing

Two crucial configuration parameters govern the memory footprint and operational performance of a HashMap:

  1. Initial Capacity: The number of buckets when the map is created (default: 16).
  2. Load Factor: A measure of how full the map is allowed to get before its capacity is automatically increased (default: 0.75).

The Rehashing Process

When the number of stored elements exceeds capacity * loadFactor, the HashMap triggers a resize() operation. This process:

  1. Allocates a new bucket array with double the previous capacity ($2n$).
  2. Iterates through every existing bucket, recalculating indices for each node, and redistributing them into the new array.

Rehashing is computationally expensive. Choosing an appropriate initial capacity when instantiating a map prevents unnecessary resizing allocations:

// Prevent dynamic resizing by setting initial capacity based on expected size
int expectedSize = 10000;
int initialCapacity = (int) Math.ceil(expectedSize / 0.75);
Map<String, String> optimizedMap = new HashMap<>(initialCapacity);
Enter fullscreen mode Exit fullscreen mode

The hashCode() and equals() Contract Pitfalls

Violating the contract between hashCode() and equals() breaks the fundamental guarantees of hash-based collections.

The Contract Rules

  1. If two objects are equal according to equals(Object), they must return the same integer result from hashCode().
  2. If two objects have the same hashCode(), they are not required to be equal.

Common Pitfall: Mutable Keys

Using a mutable object as a HashMap key introduces silent data loss or retrieval failures.

public class MutableKey {
    private int id;

    public MutableKey(int id) { this.id = id; }
    public void setId(int id) { this.id = id; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof MutableKey)) return false;
        MutableKey that = (MutableKey) o;
        return id == that.id;
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

// Demonstrating the bug:
MutableKey key = new MutableKey(1);
Map<MutableKey, String> map = new HashMap<>();
map.put(key, "Value");

// Mutating the key after insertion changes its hash code and bucket location
key.setId(99);

// Returns null because the map looks in bucket 99 instead of bucket 1!
String val = map.get(key);
Enter fullscreen mode Exit fullscreen mode

Best Practice: Always use immutable objects (such as String, Integer, or custom records) as keys in hash-based collections.

Summary

  • HashMap manages keys via an internal array of buckets, using bitwise indexing for performance.
  • Hash collisions are resolved via linked lists that automatically upgrade to Red-Black trees when bin sizes exceed $8$.
  • The default load factor of $0.75$ represents an optimal statistical trade-off between time and space complexity.
  • Keys must be immutable and strictly adhere to the hashCode() / equals() contract to ensure reliable data retrieval.

Top comments (0)