backend

10 min read

HashMap in Java: Complete Guide With Examples

A senior dev's guide to HashMap in Java - performance characteristics, internal buckets and hashing, the equals/hashCode contract, and the gotchas that bite you in production.

HashMap in Java: Complete Guide With Examples thumbnail

Published By: Nelson Djalo | Date: April 29, 2026

If you write Java for a living, HashMap is probably the data structure you reach for more than any other. Caching user lookups by ID, counting word frequencies, indexing products by SKU, holding request headers, deduplicating records - the moment you need fast key-based access, you type Map<K, V> and move on. That ubiquity is exactly why misunderstanding it is so expensive. A subtle bug in a HashMap is rarely loud. It just quietly returns null when it shouldn't, or leaks memory, or makes your service mysteriously slower under load.

This post walks through what HashMap actually is, how to use it day to day, what's happening under the hood, and the handful of mistakes that catch even experienced developers off guard.

Table of Contents

What HashMap Really Is

HashMap<K, V> is Java's general-purpose implementation of the Map interface. It stores key-value pairs and gives you average O(1) lookup, insertion, and removal. It sits inside java.util alongside the rest of the Collections API, implementing Map<K, V> and extending AbstractMap.

Compared to its siblings, HashMap has a specific personality. It does not maintain insertion order (use LinkedHashMap for that). It does not sort keys (use TreeMap). It is not thread-safe (use ConcurrentHashMap). It allows one null key and any number of null values, which makes it more permissive than its concurrent cousin.

If you need fast, unordered key-based lookup in a single-threaded context - or one where you control synchronisation yourself - HashMap is the default answer.

Creating and Populating a HashMap

The straightforward way to build one looks like this:

Map<String, Integer> userAges = new HashMap<>();
userAges.put("alice", 32);
userAges.put("bob", 28);
userAges.put("carol", 41);

Always declare the variable as Map, not HashMap. You almost never need the concrete type, and using the interface makes it trivial to swap implementations later.

For small, fixed maps where you know the contents at compile time, use Map.of():

Map<String, Integer> defaults = Map.of(
    "timeoutSeconds", 30,
    "maxRetries", 3,
    "poolSize", 10
);

Map.of() returns an immutable map. Trying to mutate it throws UnsupportedOperationException. That's usually what you want for configuration constants. If you need a mutable copy, wrap it: new HashMap<>(Map.of(...)).

For larger preset data, Map.ofEntries() is cleaner:

var statusCodes = Map.ofEntries(
    Map.entry(200, "OK"),
    Map.entry(404, "Not Found"),
    Map.entry(500, "Internal Server Error")
);

If you know roughly how many entries you'll insert, pre-size the map. It avoids unnecessary resizing under the hood:

var cache = new HashMap<UUID, User>(1024);

Core Operations

The core API is small but worth knowing well.

Map<String, Product> productsBySku = new HashMap<>();

productsBySku.put("SKU-001", new Product("Keyboard", 79.99));
productsBySku.put("SKU-002", new Product("Mouse", 29.99));

Product keyboard = productsBySku.get("SKU-001");        // returns Product or null
boolean exists = productsBySku.containsKey("SKU-003");  // false
Product removed = productsBySku.remove("SKU-002");      // returns and removes
int total = productsBySku.size();                       // 1

get() returning null is famously ambiguous - it could mean "key not present" or "key present, value is null." Use containsKey() if that distinction matters, or avoid storing nulls in the first place.

A few methods are quietly very useful:

// Default value if key absent - no NPE, no ternary
int retries = config.getOrDefault("maxRetries", 3);

// Insert only if absent - useful for caches
cache.putIfAbsent(userId, loadUser(userId));

// Compute or update atomically (single-threaded sense)
wordCount.merge(word, 1, Integer::sum);

// Lazy initialisation of grouped values
Map<String, List<Order>> byCustomer = new HashMap<>();
byCustomer.computeIfAbsent(customerId, k -> new ArrayList<>()).add(order);

computeIfAbsent and merge are the two methods that separate clean HashMap code from nested-if spaghetti. Learn them.

Iterating a HashMap

Three idiomatic ways, in roughly increasing order of usefulness.

// Keys only
for (String sku : productsBySku.keySet()) {
    System.out.println(sku);
}

// Entry iteration - the most common pattern
for (Map.Entry<String, Product> entry : productsBySku.entrySet()) {
    System.out.println(entry.getKey() + " -> " + entry.getValue());
}

// forEach with a BiConsumer
productsBySku.forEach((sku, product) ->
    System.out.println(sku + " costs " + product.price())
);

For transformations, streams over entrySet() work cleanly:

Map<String, Double> discounted = productsBySku.entrySet().stream()
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        e -> e.getValue().price() * 0.9
    ));

One thing to remember: HashMap iteration order is not specified and can change between JVM versions. Never rely on it.

How HashMap Works Internally

You don't need to know the source by heart, but a working mental model saves you hours of debugging.

A HashMap is essentially an array of buckets. When you call put(key, value), the map computes key.hashCode(), mixes the bits to spread them out, and uses the result modulo the array size to pick a bucket. The entry is stored there.

When two keys land in the same bucket - a collision - the entries form a linked list inside that bucket. Lookups walk the list, comparing keys with equals() until one matches. As of Java 8, when a single bucket grows past eight entries and the map is large enough, the list is upgraded to a balanced tree, capping worst-case lookup at O(log n) instead of O(n).

The map tracks two numbers: capacity (current array size, always a power of two) and load factor (default 0.75). When size / capacity exceeds the load factor, the map doubles its capacity and rehashes every entry into the new array. That resize is O(n) - cheap amortised, but worth avoiding in hot paths by pre-sizing.

The takeaway: HashMap is fast because hashing distributes keys roughly evenly across buckets. When that distribution breaks - usually because of a bad hashCode() - performance collapses toward O(n).

The equals and hashCode Contract

This is the single biggest source of HashMap bugs. The rule is simple but easy to violate:

If two objects are equal according to equals(), they must return the same hashCode().

Break this and the map will store duplicate keys, fail to find entries you just put in, or quietly lose data. Here's what goes wrong:

public class UserId {
    private final String value;

    public UserId(String value) {
        this.value = value;
    }

    @Override
    public boolean equals(Object o) {
        if (!(o instanceof UserId other)) return false;
        return value.equals(other.value);
    }

    // hashCode NOT overridden - inherits Object's identity-based hash
}
var map = new HashMap<UserId, String>();
map.put(new UserId("u-1"), "alice");
String found = map.get(new UserId("u-1"));  // returns null!

Two UserId objects with the same value are equal but have different hash codes, so they land in different buckets. The map can't find the entry. Always override equals() and hashCode() together. Better yet, use a record:

public record UserId(String value) {}

Records generate both methods correctly based on their components. For most key types, that's all you need. If you'd like to build this kind of foundation step by step, the Java for Beginners course covers equals, hashCode, and the rest of the OOP plumbing in proper depth before you ever touch a HashMap in anger.

Null Keys and Null Values

HashMap allows exactly one null key and any number of null values:

Map<String, String> map = new HashMap<>();
map.put(null, "default");
map.put("greeting", null);

This is permissive but rarely a good idea. null keys make logs ambiguous, and null values turn get() into a guessing game. Prefer Optional<V> for values that may legitimately be missing, or use getOrDefault() to avoid storing nulls at all.

ConcurrentHashMap, by contrast, forbids nulls entirely. Treating that as the stricter default in your own code will save you headaches later.

HashMap vs LinkedHashMap vs TreeMap vs ConcurrentHashMap

Pick the right map for the job:

HashMap - Default choice. Fastest. No order guarantees. Not thread-safe. Allows one null key.

LinkedHashMap - Maintains insertion order (or access order, if configured). Slightly slower than HashMap because of the linked list bookkeeping. Useful for LRU caches via removeEldestEntry().

TreeMap - Keeps keys sorted by natural order or a Comparator. Operations are O(log n) instead of O(1). Use when you need range queries (subMap, headMap, tailMap) or sorted iteration.

ConcurrentHashMap - Thread-safe by design, with fine-grained locking. No nulls allowed. Slightly more overhead in single-threaded code but the only sane choice when multiple threads write to the same map.

Rule of thumb: HashMap unless you have a specific reason. Reach for the others when ordering, sorting, or thread safety becomes a hard requirement.

A Real Example: Word Frequency Counter

This example pulls everything together.

public class WordCounter {

    public static Map<String, Integer> countWords(String text) {
        var counts = new HashMap<String, Integer>();
        var words = text.toLowerCase()
            .replaceAll("[^a-z\\s]", "")
            .split("\\s+");

        for (String word : words) {
            if (word.isBlank()) continue;
            counts.merge(word, 1, Integer::sum);
        }
        return counts;
    }

    public static List<Map.Entry<String, Integer>> topN(Map<String, Integer> counts, int n) {
        return counts.entrySet().stream()
            .sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
            .limit(n)
            .toList();
    }
}

merge() does the heavy lifting - if the word isn't in the map, insert it with value 1; otherwise, add 1 to the existing value. No null check, no containsKey, no boilerplate. The streaming top-N at the end shows how naturally HashMap entries flow into the rest of the Collections API.

A Real Example: User Cache

A typical service-layer cache:

public class UserCache {

    private final Map<UUID, User> cache = new HashMap<>();
    private final UserRepository repository;

    public UserCache(UserRepository repository) {
        this.repository = repository;
    }

    public User findById(UUID id) {
        return cache.computeIfAbsent(id, repository::findById);
    }

    public void invalidate(UUID id) {
        cache.remove(id);
    }

    public int size() {
        return cache.size();
    }
}

computeIfAbsent does the lookup-or-load dance in a single line. Note that this is a deliberately simple, single-threaded cache. The moment more than one thread touches it, you swap HashMap for ConcurrentHashMap and the rest of the code stays identical - that's the value of programming to the Map interface.

Common Mistakes

  • Using mutable objects as keys. If a key's hashCode() changes after insertion, the map can no longer find it. Treat keys as immutable. Records, strings, and wrapper types are safe by default.
  • Forgetting to override hashCode() when you override equals(). The classic mistake. Use records or let your IDE generate both at once.
  • Iterating while modifying. map.put() or map.remove() during a for-each loop throws ConcurrentModificationException. Use the iterator's remove() or collect changes and apply them after.
  • Sharing a HashMap across threads. It is not thread-safe. Concurrent writes can corrupt the internal structure - in older JVMs they could even cause infinite loops. Use ConcurrentHashMap.
  • Storing nulls without thinking. get() returning null is ambiguous. Prefer getOrDefault() or Optional.
  • Not pre-sizing large maps. If you know you'll insert a million entries, pass that capacity to the constructor. Repeated resizes are not free.
  • Relying on iteration order. It's not specified. If order matters, use LinkedHashMap or TreeMap.

Best Practices

  • Declare variables as Map<K, V>, not HashMap<K, V>. Code to the interface.
  • Use Map.of() and Map.ofEntries() for immutable preset data.
  • Pre-size when you know the rough capacity.
  • Use computeIfAbsent, merge, and getOrDefault instead of nested if-else.
  • Make keys immutable. Records are the easiest way.
  • Always override equals() and hashCode() together for custom key types.
  • Reach for ConcurrentHashMap the moment threads enter the picture.
  • Don't store null values - use Optional or sensible defaults.
  • Profile before assuming HashMap is your bottleneck. Usually it isn't.

Wrapping Up

HashMap is one of those classes you can use for years without ever fully understanding, and that's exactly why it pays to learn properly. The API is compact - put, get, merge, computeIfAbsent cover most of what you'll ever write. The internals are simple enough to hold in your head: buckets, hashing, load factor, the equals/hashCode contract.

Get those fundamentals right and HashMap quietly handles billions of operations in your services without ever showing up in a profiler. Get them wrong - mutable keys, broken hash codes, unsynchronised access - and you end up debugging the kind of bug that only appears under production load on a Friday afternoon.

If you want to lock in the foundations that make all of this click, the Java for Beginners course works through collections, equals/hashCode, and modern Java idioms with the same practical lens you've seen here.

Your Career Transformation Starts Now

Join thousands of developers mastering in-demand skills with Amigoscode. Try it free today.