Java HashMap Tutorial with Examples
Java 13 min read
Put, get, iterate and merge — plus why a mutable key makes an entry unreachable, what happens when equals and hashCode disagree, and why ConcurrentHashMap is not just a synchronized HashMap.
HashMap stores key-value pairs and finds one in constant time on average. That average depends
entirely on hashCode and equals behaving properly, which is where nearly every real HashMap bug
comes from, not from the API.
Written against Java 17.
Creating and populating
Map<String, Integer> ages = new HashMap<>();
Map<String, Integer> sized = new HashMap<>(64); // initial capacity
Map<String, Integer> copy = new HashMap<>(existingMap);
Map<String, Integer> fixed = Map.of("ann", 34, "bob", 41); // immutable, no nulls
Declare the variable as Map. HashMap is the implementation choice, not the contract.
The capacity argument avoids rehashing while the map grows. A HashMap resizes when it passes 75% of
capacity (the default load factor), and each resize rehashes every entry, so if you know you are
inserting ten thousand keys, new HashMap<>(16384) skips several of those.
Map.of is immutable and rejects null keys and values. Useful for constants; not a HashMap
substitute.
put, get, and the null question
ages.put("ann", 34);
Integer previous = ages.put("ann", 35); // returns 34, the replaced value
ages.putIfAbsent("bob", 41); // only if absent
Integer a = ages.get("ann"); // 35
Integer missing = ages.get("zoe"); // null
int safe = ages.getOrDefault("zoe", 0); // 0
get returns null for a missing key, and also for a key mapped to null, because HashMap
permits both a null key and null values. So map.get(k) == null does not mean “absent”:
ages.put("carol", null);
ages.get("carol"); // null
ages.containsKey("carol"); // true
containsKey is the question you usually mean. Better still, do not store nulls: getOrDefault
becomes unreliable the moment you do, since it returns the default only when the key is absent, not
when the mapped value is null.
Note the unboxing trap:
int n = ages.get("zoe"); // NullPointerException — null cannot unbox to int
The methods that replace boilerplate
These four remove most of the “check, then act” code people still write by hand:
// count occurrences
counts.merge(word, 1, Integer::sum);
// group into lists
index.computeIfAbsent(letter, k -> new ArrayList<>()).add(word);
// update only when present
cache.computeIfPresent(key, (k, v) -> v.refreshed());
// general compute, and returning null removes the entry
map.compute(key, (k, v) -> v == null ? 1 : v + 1);
merge and computeIfAbsent are worth memorising. The first replaces:
Integer current = counts.get(word);
counts.put(word, current == null ? 1 : current + 1);
and the second replaces the equivalent three lines for a map of collections. Both are also atomic on
ConcurrentHashMap, which the hand-written versions are not.
One caveat: the mapping function passed to computeIfAbsent must not modify the same map. Doing so
can corrupt it or throw ConcurrentModificationException, and the failure is not always immediate.
Removing and iterating
ages.remove("bob");
ages.remove("bob", 41); // only if currently mapped to 41
ages.clear();
for (Map.Entry<String, Integer> e : ages.entrySet()) {
System.out.println(e.getKey() + " = " + e.getValue());
}
ages.forEach((k, v) -> System.out.println(k + " = " + v));
for (String k : ages.keySet()) { ... }
for (Integer v : ages.values()) { ... }
Iterate entrySet() when you need both. Iterating keySet() and calling get inside the loop does
two lookups per entry for no reason.
keySet(), values() and entrySet() are views, not copies. Removing from the key set removes
from the map:
ages.keySet().removeIf(k -> k.startsWith("temp-")); // modifies the map
Modifying the map during a for-each throws ConcurrentModificationException. Use removeIf on a
view, or an explicit iterator:
Iterator<Map.Entry<String, Integer>> it = ages.entrySet().iterator();
while (it.hasNext()) {
if (it.next().getValue() < 18) {
it.remove();
}
}
entrySet().removeIf(e -> e.getValue() < 18) does the same in one line.
Iteration order is unspecified and not insertion order. It is stable for an unchanged map within a
run, and you must not depend on it. LinkedHashMap preserves insertion order; TreeMap sorts by key.
Custom keys: the part that matters
A key type must implement hashCode and equals, and they must agree.
public final class CacheKey {
private final String tenant;
private final long id;
public CacheKey(String tenant, long id) {
this.tenant = tenant;
this.id = id;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof CacheKey other)) return false;
return id == other.id && Objects.equals(tenant, other.tenant);
}
@Override
public int hashCode() {
return Objects.hash(tenant, id);
}
}
Or let the compiler do it, a record generates both from its components:
public record CacheKey(String tenant, long id) { }
Without hashCode, two equal keys land in different buckets and the map holds duplicates that
get cannot find. Without equals, two identical keys are different keys. With one but not the
other, you get whichever half of the failure the mismatch produces. This is why records are the
right default for compound keys.
A mutable key makes an entry unreachable
List<String> key = new ArrayList<>(List.of("a"));
Map<List<String>, String> map = new HashMap<>();
map.put(key, "value");
key.add("b"); // the key's hashCode just changed
map.get(key); // null — wrong bucket
map.containsKey(key); // false
map.size(); // 1 — the entry is still there
The entry was filed under the old hash. Nothing rehashes on mutation, so it is now stored in a bucket the lookup will never search, present, counted, and unreachable. Even iterating and re-putting it does not reliably fix things.
Keys must be effectively immutable. String, boxed primitives, records of immutable components,
enums. Never a collection, and never an entity whose hashCode derives from a field that changes.
Collisions and what the JDK does about them
Two keys with the same bucket index form a chain. Lookup then compares with equals along that
chain, so a map where every key collides degrades from O(1) to O(n).
Since Java 8, a bucket that exceeds eight entries converts from a linked list to a red-black tree,
provided the keys are Comparable, which bounds worst-case lookup at O(log n) instead of O(n). That
change was a response to hash-collision denial-of-service attacks, where an attacker submits keys
chosen to collide.
The practical takeaway is not to tune this but to write a hashCode that spreads. Objects.hash(...)
over the fields that define equality is sufficient; returning a constant is legal, correct, and turns
your map into a list.
Thread safety
HashMap is not synchronised, and concurrent modification can do more than lose an update, before
Java 8, a concurrent resize could produce a circular chain and an infinite loop in get. The
treeification changes made that specific failure unlikely, and the map is still unsafe.
Map<String, Integer> sync = Collections.synchronizedMap(new HashMap<>());
Map<String, Integer> conc = new ConcurrentHashMap<>();
These are not equivalent. synchronizedMap wraps every method in one lock, so all access serialises,
and iteration needs external synchronisation:
synchronized (sync) {
for (var e : sync.entrySet()) { ... }
}
ConcurrentHashMap locks per bin, so unrelated keys proceed in parallel, and its iterators are
weakly consistent: they never throw ConcurrentModificationException and may or may not reflect
concurrent updates. It also makes merge, compute and putIfAbsent atomic, which is the real
reason to choose it: those are the operations that a read-then-write pair gets wrong.
// broken under concurrency even on a synchronized map
Integer n = map.get(k);
map.put(k, n == null ? 1 : n + 1);
// atomic on ConcurrentHashMap
map.merge(k, 1, Integer::sum);
ConcurrentHashMap does not permit null keys or values, deliberately: get returning null
would be ambiguous in a map where another thread may be removing the key.
Choosing an implementation
| Order | Null key | Notes | |
|---|---|---|---|
HashMap | none | yes | the default |
LinkedHashMap | insertion (or access) | yes | LRU cache via removeEldestEntry |
TreeMap | sorted by key | no | O(log n), needs Comparable or a comparator |
ConcurrentHashMap | none | no | concurrent, atomic compute methods |
EnumMap | enum ordinal | no | array-backed, very fast for enum keys |
EnumMap is the one people miss. For an enum key it is an array indexed by ordinal — no hashing, no
collisions, less memory.
Frequently asked questions
Does get() returning null mean the key is absent?
No. HashMap allows null values, so null can
mean “absent” or “mapped to null”. Use containsKey, or avoid storing nulls.
Why can my custom key not be found?
Missing or inconsistent hashCode/equals. Implement both
over the same fields, or use a record.
What happens if I mutate a key after inserting it?
The entry stays filed under the old hash and
becomes unreachable — present in size(), invisible to get. Keys must be effectively immutable.
Why is iteration order different from insertion order?
HashMap makes no order guarantee. Use
LinkedHashMap for insertion order or TreeMap for sorted keys.
How do I count occurrences?
counts.merge(word, 1, Integer::sum). It replaces the get-check-put
pattern and is atomic on ConcurrentHashMap.
How do I build a map of lists?
map.computeIfAbsent(key, k -> new ArrayList<>()).add(value). The
mapping function must not modify the same map.
What is the initial capacity for?
It sizes the bucket array so growth does not rehash. A map resizes past 75% of capacity, so pre-size when the final count is known.
How do I remove entries safely while iterating?
entrySet().removeIf(...), or an explicit
Iterator and it.remove(). Modifying the map inside a for-each throws
ConcurrentModificationException.
Is synchronizedMap the same as ConcurrentHashMap?
No. synchronizedMap serialises all access
behind one lock; ConcurrentHashMap locks per bin and provides atomic merge/compute. Only the
latter makes read-modify-write safe.
Why does ConcurrentHashMap reject nulls?
Because get returning null would be ambiguous when
another thread may be removing the key concurrently.
Where should I go next?
ArrayList covers the other collection with sharp API
edges, and Comparable and Comparator covers the ordering that
TreeMap depends on.