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.

MethodJob
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 / mergeUpdate a mapping in one call
forEach(BiConsumer)Visit every mapping
replace / replaceAllConditional or bulk value change
Map.of / Map.copyOfUnmodifiable 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.

TypeNull keyNull values
HashMap, LinkedHashMaponeyes
Hashtable, ConcurrentHashMapnono
TreeMap (natural order)noyes, with care
Map.of / Map.copyOfnono

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:

QuestionHonest 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 (or forEach) when you need key and value.
  • Use computeIfAbsent / merge instead of get-then-put for “create bucket” and “running total.”

Don’t:

  • Treat keySet() as a snapshot.
  • Call containsValue on the hot path.
  • Assume the Map type allows nulls; read the class you new.

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.

Next optional step in the series The default List is the first everyday implementation — growth, capacity, and when ensureCapacity pays off. ArrayList: The Default List and When Growth Bites