Why this matters
- Maps are the most used data structure in application code, and the convenience methods added in Java 8 remove most of the boilerplate people still write by hand.
- The check-then-act pattern on a map is both verbose and unsafe under concurrency, and there is a single-call replacement for every instance of it.
- Load factor and initial capacity are the two tuning knobs that matter, and they are easy to get right once.
Bucket index comes from the key's hash, not its insertion order — which is why HashMap iteration has no predictable sequence.
The core operations
Map<String, Integer> stock = new HashMap<>();
stock.put("apple", 10);
stock.put("apple", 15); // replaces — returns the previous value, 10
stock.get("apple"); // 15
stock.get("banana"); // null — absent, not an error
stock.containsKey("apple"); // true
stock.containsValue(15); // true, but O(n) — scans every entry
stock.remove("apple"); // returns 15
stock.size();
stock.isEmpty();
The three views
A map exposes its contents as three views — live windows, not copies.
Map<String, Integer> stock = new HashMap<>(Map.of("apple", 10, "pear", 4));
Set<String> keys = stock.keySet(); // view of the keys
Collection<Integer> values = stock.values(); // view of the values
Set<Map.Entry<String, Integer>> entries = stock.entrySet(); // view of the pairs
keys.remove("apple"); // removes the entry from the MAP
System.out.println(stock); // {pear=4}
Because they are views, removing from keySet() removes from the map. Adding is not supported, since a
key with no value is meaningless.
Iterating by entry is both clearer and faster than looking each value up again:
// Preferred: one pass, no repeated lookups
for (Map.Entry<String, Integer> entry : stock.entrySet()) {
System.out.println(entry.getKey() + " = " + entry.getValue());
}
// Wasteful: a hash lookup per iteration
for (String key : stock.keySet()) {
System.out.println(key + " = " + stock.get(key));
}
// Cleanest for a simple pass
stock.forEach((key, value) -> System.out.println(key + " = " + value));
The methods that replace check-then-act
Each of these collapses a pattern that is otherwise three or four lines, and each is atomic on a
ConcurrentHashMap.
Map<String, Integer> counts = new HashMap<>();
Map<String, List<String>> groups = new HashMap<>();
// Counting — merge
counts.merge("java", 1, Integer::sum); // insert 1, or add 1 to what is there
// Grouping — computeIfAbsent
groups.computeIfAbsent("languages", key -> new ArrayList<>()).add("java");
// Reading with a fallback — getOrDefault
int seen = counts.getOrDefault("kotlin", 0);
// Insert only if missing — putIfAbsent
counts.putIfAbsent("go", 0);
// Update only if present — computeIfPresent
counts.computeIfPresent("java", (key, value) -> value * 2);
// Update either way — compute
counts.compute("rust", (key, value) -> value == null ? 1 : value + 1);
// Replace every value in place
counts.replaceAll((key, value) -> value * 10);
// Conditional removal
counts.remove("go", 0); // only if the value is currently 0
Compare the before and after on the two most common cases:
// Counting, the old way
Integer current = counts.get("java");
if (current == null) {
counts.put("java", 1);
} else {
counts.put("java", current + 1);
}
// Counting, with merge
counts.merge("java", 1, Integer::sum);
// Grouping, the old way
List<String> list = groups.get("languages");
if (list == null) {
list = new ArrayList<>();
groups.put("languages", list);
}
list.add("java");
// Grouping, with computeIfAbsent
groups.computeIfAbsent("languages", key -> new ArrayList<>()).add("java");
Which to use when
merge— combining a new value with an existing one. Counting and summing.computeIfAbsent— the value is a container you need to create once. Grouping, caching.getOrDefault— reading, where absence has a sensible default.putIfAbsent— initialising without overwriting.compute— the new value depends on the old one, which may be absent.
Capacity and load factor
A HashMap holds an array of buckets. Capacity is the array length, always a power of two.
Load factor is how full it is allowed to get before resizing, defaulting to 0.75.
Map<String, String> map = new HashMap<>(); // capacity 16, threshold 12
Map<String, String> sized = new HashMap<>(64); // capacity 64, threshold 48
Map<String, String> tuned = new HashMap<>(64, 0.5f); // threshold 32 — fewer collisions, more memory
When size exceeds capacity × load factor, the map doubles its capacity and redistributes every entry.
That is an O(n) operation.
Sizing it properly
- Expecting n entries, pass
n / 0.75 + 1as the initial capacity to avoid any resize. - Java 19 added
HashMap.newHashMap(n), which does that arithmetic for you. - A lower load factor means fewer collisions and more wasted space.
- A higher load factor means denser buckets, longer chains and slower lookups.
- 0.75 is a well-chosen default; change it only with a measurement in hand.
// Expecting 1000 entries, with no resizing
Map<String, String> precise = new HashMap<>(1334);
Map<String, String> modern = HashMap.newHashMap(1000); // Java 19+
Choosing a map
| Aspect | Implementation | Use it when |
|---|---|---|
| HashMap | The default | O(1) average, no ordering, one null key allowed |
| LinkedHashMap | Order matters | Insertion or access order; the basis of an LRU cache |
| TreeMap | Sorted keys needed | O(log n), with navigation and range queries |
| ConcurrentHashMap | Multiple threads | Concurrent reads, fine-grained write locking, no nulls |
| EnumMap | Keys are enum constants | Array-backed, very fast and compact |
| Hashtable | Never, in new code | Legacy; superseded by ConcurrentHashMap |
HashMap
ImplementationThe defaultUse it whenO(1) average, no ordering, one null key allowedLinkedHashMap
ImplementationOrder mattersUse it whenInsertion or access order; the basis of an LRU cacheTreeMap
ImplementationSorted keys neededUse it whenO(log n), with navigation and range queriesConcurrentHashMap
ImplementationMultiple threadsUse it whenConcurrent reads, fine-grained write locking, no nullsEnumMap
ImplementationKeys are enum constantsUse it whenArray-backed, very fast and compactHashtable
ImplementationNever, in new codeUse it whenLegacy; superseded by ConcurrentHashMap
EnumMap is as overlooked as EnumSet and just as worthwhile.
Keys must be stable
The key's hash code is computed once, at insertion, to choose a bucket. If the key changes afterwards, the entry becomes unreachable.
List<String> key = new ArrayList<>(List.of("a"));
Map<List<String>, String> map = new HashMap<>();
map.put(key, "stored");
key.add("b"); // the list's hashCode just changed
System.out.println(map.get(key)); // null
System.out.println(map.size()); // 1 — still present, but unreachable
Common misreadings
- "
getreturning null means the key is absent." It may be present with a null value. UsecontainsKeyorgetOrDefault. - "
keySet()returns a copy." It is a live view, and removing from it removes from the map. - "
containsValueis as fast ascontainsKey." It scans every entry — O(n). - "Load factor is a size limit." It is the fullness threshold that triggers doubling.
- "
computeIfAbsentreturns the map." It returns the value, existing or newly computed, which is what makes the chained.add(...)work. - "
HashMapis thread-safe for reads." Only if nothing writes. A concurrent resize can corrupt the structure.
Quick recall
Everything you need if you only revisit this box.
getreturningnullis ambiguous; usecontainsKeyorgetOrDefault.keySet,valuesandentrySetare live views — removing from them changes the map.- Iterate with
entrySetorforEach, never withkeySetplusget. - Replace check-then-act with
merge(counting),computeIfAbsent(grouping),getOrDefault,putIfAbsentandcompute. - Returning
nullfromcomputeormergeremoves the entry. - Capacity is a power of two; the default load factor 0.75 triggers doubling and a full rehash. Pre-size with
n / 0.75 + 1, orHashMap.newHashMap(n)on Java 19+. - Keys must be immutable. A key whose hash changes strands its entry permanently.
Test yourself
Answer these before moving on — recall is what makes it stick.