Checkout needs the order for "ORD-1001" without scanning a list. That is a mapping: one key, one value, lookup by identity of the key. A List<Order> can fake it with a loop. A Map<String, Order> is that job.
Map is not a Collection. The Collections roadmap already drew the split: a collection is a group of elements; a map is a group of key-value mappings. This post is the Map contract — get / put, the compute family, and the three views that write through to the same storage. Internals of any one class stay in later posts.
The scan you used to fake a map
Five open orders in a list looks harmless:
Order findById(List<Order> open, String id) {
for (Order order : open) {
if (order.id().equals(id)) {
return order;
}
}
return null;
}
Same verb against a map is one call, and the type already forbids two values for one id:
Map<String, Order> byId = new HashMap<>();
byId.put(order.id(), order);
Order found = byId.get("ORD-1001");
Order.id is the key. The lab records stay the same as the rest of this series. If records are new, Java Records covers why id is an honest key. The contract those methods must keep is equals and hashCode.
You still new HashMap<>() most days. Program to Map on fields and parameters. This page does not pick the class.
The contract
Trimmed to the methods this post uses. Implementations fill them; some throw UnsupportedOperationException (factory maps, unmodifiable wrappers). That optional-ops rule lives on the hub.
| Method | Job |
|---|---|
get(k) | Value, or null if absent or mapped to null |
getOrDefault(k, d) | Value, or the default you passed |
put(k, v) | Insert or replace; returns the previous value |
putIfAbsent(k, v) | Put only if the key is absent (or mapped to null) |
remove(k) | Drop the mapping; return the previous value |
remove(k, v) | Drop only if currently mapped to v |
containsKey(k) | Whether that key is in the map |
containsValue(v) | Whether any key maps to v — a scan |
keySet() / values() / entrySet() | Live views, not copies |
compute / computeIfAbsent / computeIfPresent / merge | Update a mapping in one call |
forEach(BiConsumer) | Visit every mapping |
replace / replaceAll | Conditional or bulk value change |
Map.of / Map.copyOf | Unmodifiable maps — factories |
You cannot for-each a Map. Iterable sits under Collection. Iterate a view.
get, put, putIfAbsent, remove
put always writes. The return value is whatever used to live at that key — null if the key was new, or if it was mapped to null.
Map<String, Order> byId = new HashMap<>();
Order first = new Order("ORD-1001", "ada@ex.com", List.of(), BigDecimal.TEN, true);
Order again = new Order("ORD-1001", "ada@ex.com", List.of(), new BigDecimal("12.00"), true);
Order previous = byId.put(first.id(), first); // null — new key
Order replaced = byId.put(again.id(), again); // first — same key, new value
Order current = byId.get("ORD-1001"); // again
putIfAbsent is the “insert only if missing” form. On HashMap a key mapped to null counts as absent, so this call will replace that null. Use containsKey when “mapped to null” must stay.
byId.putIfAbsent("ORD-1001", first); // keeps again; key already present
byId.putIfAbsent("ORD-1002", first); // inserts
Order gone = byId.remove("ORD-1002"); // first
boolean dropped = byId.remove("ORD-1001", again); // true only if value still again
Note: get returning null is ambiguous when the implementation allows null values. containsKey answers “is this key here?” getOrDefault answers “give me a stand-in.” Do not write if (map.get(id) == null) to mean missing unless you have forbidden null values.
containsKey and containsValue
containsKey is the membership check the map is good at. containsValue walks every mapping and calls equals on values.
boolean known = byId.containsKey("ORD-1001"); // expected cheap on HashMap
boolean stock = byId.containsValue(first); // scan — equals on every Order
containsValue is not the inverse of containsKey. If you need “do we already have this Order?”, that is a Set<Order> or a second map, not a value scan on the hot path.
The three views write through
keySet, values, and entrySet are windows on the same storage. The hub named views vs copies. Here is what that means on a map:
Map<String, Order> byId = new HashMap<>();
byId.put("ORD-1001", first);
byId.put("ORD-1002", again);
Set<String> ids = byId.keySet();
ids.remove("ORD-1001");
System.out.println(byId.containsKey("ORD-1001")); // false — the map lost it too
Collection<Order> orders = byId.values();
orders.remove(again);
System.out.println(byId.size()); // 0
keySet().remove(id) removes the mapping. That is the point of a view. It is also the footgun if you thought you had a snapshot.
Set<String> snapshot = new HashSet<>(byId.keySet()); // copy — mutate freely
add on keySet is unsupported: a key without a value is not a mapping. entrySet.add is similarly not how you insert. put on the map (or setValue on a live entry) is.
values() may contain duplicates. keySet() may not. Removing from values drops one mapping whose value equals the argument — if two ids hold equal orders, only one mapping leaves.
A structural change on the map while you iterate a view is fail-fast on the usual java.util implementations — same iterator rule as the hub. Iterator.remove is the supported way to drop during a loop.
Map.Entry is the pair
Each element of entrySet is a Map.Entry<K, V>: getKey, getValue, and on a live iterator entry, setValue.
for (Map.Entry<String, Order> e : byId.entrySet()) {
if (!e.getValue().active()) {
e.setValue(archive(e.getValue())); // writes through; key stays
}
}
Map.entry(k, v) (Java 9) builds an unmodifiable pair for Map.ofEntries. That entry’s setValue throws. Do not hold a live HashMap entry after the iterator has moved on and expect a stable snapshot — copy the key and value if you need them later.
record IdAndOrder(String id, Order order) {}
List<IdAndOrder> copy = byId.entrySet().stream()
.map(e -> new IdAndOrder(e.getKey(), e.getValue()))
.toList();
compute, computeIfAbsent, computeIfPresent, merge
get then put is two lookups and a gap where you can forget the write. In single-threaded code that “race” is not two threads. It is a stale local, a missing put, or a load that runs even though the key arrived a line later. The compute family is one map operation that sees the current value.
computeIfAbsent runs the function only when the key is missing (or mapped to null). A null return means “do not insert.”
Map<String, List<Order>> byEmail = new HashMap<>();
for (Order order : orders) {
byEmail.computeIfAbsent(order.customerEmail(), email -> new ArrayList<>())
.add(order);
}
The get-then-put form of the same idea is longer and easy to get wrong:
List<Order> bucket = byEmail.get(order.customerEmail());
if (bucket == null) {
bucket = new ArrayList<>();
byEmail.put(order.customerEmail(), bucket);
}
bucket.add(order);
Same result on one thread if you always put. computeIfAbsent is the slot the JDK already named. Nested mutation of the same map from inside the function is not supported — HashMap (Java 9+) throws ConcurrentModificationException if the mapping function modifies the map.
computeIfPresent runs only when the key is present and the value is non-null. A null return deletes the entry.
byId.computeIfPresent("ORD-1001", (id, order) ->
order.active() ? order : null); // null → remove
compute always calls the function, with null as the current value when the key is missing. Use it when both “insert” and “replace” need the same formula. Prefer computeIfAbsent when you only care about the missing case — do not fake it with compute.
merge is the combiner. You always pass a non-null value. If the key is absent (or mapped to null), that value is stored and the function does not run. If the key is present, the function sees (old, incoming). A null function result deletes.
Map<String, Integer> qtyBySku = new HashMap<>();
for (LineItem item : order.items()) {
qtyBySku.merge(item.sku(), item.quantity(), Integer::sum);
}
Map<String, BigDecimal> totals = new HashMap<>();
for (Order o : orders) {
totals.merge(o.customerEmail(), o.total(), BigDecimal::add);
}
merge is the counter and the running total. The Bi-arity post owns the BiFunction slot; this post owns why the map method beats get-then-put.
Note: merge’s incoming value must be non-null or you get NullPointerException before the function runs. A null result from the remapper is a delete, not “store null.”
forEach, replace, replaceAll
forEach is a BiConsumer<K, V> over the mappings. Prefer it when you do not need to remove during the visit. Removal still belongs on an iterator (or entrySet.removeIf).
byId.forEach((id, order) ->
log.info("{} {}", id, order.total()));
replace(k, v) writes only if the key is already present. replace(k, old, neu) writes only if the current value equals old. put does not offer that check.
byId.replace("ORD-1001", shipped); // no-op if key missing
boolean ok = byId.replace("ORD-1001", shipped, packed); // compare-and-set on one thread
replaceAll remaps every value. Same null-means-remove rule as compute.
byId.replaceAll((id, order) ->
order.active() ? order : archive(order));
Map.of and Map.copyOf
Java 9 factories build unmodifiable maps. They are not views of a live HashMap. Details and the rest of List.of / Set.of live in Collection Factories. Two rules show up in interviews on Map itself:
Map<String, String> skuToAisle = Map.of(
"SKU-1", "A1",
"SKU-2", "B3");
Map<String, Order> frozen = Map.copyOf(byId);
Map.of forbids null keys, null values, and duplicate keys. Null is NullPointerException. A repeated key is IllegalArgumentException. Map.of tops out at ten pairs; Map.ofEntries(Map.entry(k, v), …) scales past that. copyOf also rejects nulls and does not track later puts on the source.
A factory map’s put throws UnsupportedOperationException. That is an optional operation, not a broken JDK.
Iterate entrySet, not keySet plus get
Walking keys and calling get does two probes per mapping. entrySet already has the pair.
// extra get per key — skip this
for (String id : byId.keySet()) {
Order order = byId.get(id);
bill(order);
}
for (Map.Entry<String, Order> e : byId.entrySet()) {
bill(e.getValue());
}
byId.forEach((id, order) -> bill(order));
Streams follow the same habit: byId.entrySet().stream(), not keySet().stream().map(byId::get).
Null policy is per implementation
The interface does not ban null. The class does.
| Type | Null key | Null values |
|---|---|---|
HashMap, LinkedHashMap | one | yes |
Hashtable, ConcurrentHashMap | no | no |
TreeMap (natural order) | no | yes, with care |
Map.of / Map.copyOf | no | no |
Write Map<String, Order> byId and you have not decided nulls. The new does. Application code that must run against ConcurrentHashMap later should not put nulls “because HashMap allows it.”
Interview lens
Interviewers want the sibling-of-Collection picture, the view contract, and which method replaces get-then-put. They do not want HashMap treeify numbers here — that is the next implementation post.
Complexity they expect without a table. containsKey / get / put on the default map are expected O(1) (hash). containsValue is O(n). Iteration is O(n). A TreeMap would be O(log n) per key op — different class, different post. The interface does not promise any of those costs; the implementation does.
What to draw. Collection on the left (List / Set / Queue). Map off to the side. Three arrows out of the map labeled keySet, values, entrySet, all pointing at the same box of pairs. Caption: views, not copies. One Order.id → Order entry inside the box.
Iterable
Collection Map ← sibling, not a subtype
List ┌─ keySet() ──┐
Set ├─ values() ──┼─→ same storage
Queue └─ entrySet() ──┘
(you for-each a view, not the map)
Typical questions:
| Question | Honest answer |
|---|---|
Why is Map not a Collection? | A map is mappings, not elements. contains would be ambiguous (key or value?). Views are collections; the map is not. You cannot for-each a map. |
entrySet vs keySet + get? | entrySet already has the pair. keySet plus get hashes twice per entry and hides the value the table already found. |
computeIfAbsent vs get + put? | One operation; the function runs only when absent. Get-then-put is two lookups and a place to forget put. On one thread it is clarity and a missed write, not a memory-model race. |
| Do views write through? | Yes. keySet().remove(k) removes from the map. new HashSet<>(map.keySet()) is the copy. |
Map.of and null / duplicates? | No null keys, no null values (NPE). Duplicate keys → IllegalArgumentException. Unmodifiable. |
get returned null — missing? | Only if that implementation forbids null values. Otherwise containsKey. |
containsValue cost? | A scan of the mappings. Do not use it as a set. |
Can entry.setValue insert a key? | No. It replaces the value for that live entry. Insert is put on the map. |
Wrong answer: “map.keySet() returns a copy of the keys.” It returns a view. Remove from the set and the mapping is gone. If you needed a copy, you allocate one.
Cheat sheet
Map mappings, not a Collection; iterate a view
get / put lookup and write; get(null) is ambiguous if null values exist
putIfAbsent insert when missing (null mapping counts as missing on HashMap)
containsKey membership of the key
containsValue scan — not a Set
Views keySet / values / entrySet — same storage, write-through
Copy new HashSet<>(map.keySet()), Map.copyOf(map)
Entry getKey / getValue; live setValue writes through
computeIfAbsent Function runs only if absent; null return → no insert
computeIfPresent / compute null return → remove
merge absent → put; present → combine; null remapper → remove
Iterate entrySet or forEach; not keySet + get
Map.of no nulls, no duplicate keys, unmodifiable
Nulls per implementation, not per the interface
Do:
- Key by
Order.id. Put uniqueness of ids in the map, not in a scan. - Iterate
entrySet(orforEach) when you need key and value. - Use
computeIfAbsent/mergeinstead of get-then-put for “create bucket” and “running total.”
Don’t:
- Treat
keySet()as a snapshot. - Call
containsValueon the hot path. - Assume the
Maptype allows nulls; read the class younew.
Wrap-up
Map is keys to values, sitting beside Collection, not under it. get / put / putIfAbsent / remove are the writes; containsKey is membership; containsValue is a scan. keySet, values, and entrySet are live views — remove on the view and the mapping is gone. computeIfAbsent and merge close the get-then-put gap in ordinary single-threaded code. Map.of is a small unmodifiable map that rejects nulls and duplicate keys.
The default delivery of this contract is HashMap. The default list — growth, capacity, and when ensureCapacity pays off — is the next everyday type in this series.