Every BiblioTech search walks a whole list. Catalog.remove(reference) compares against every material until it finds its own. LoanManager.returnItem(reference, day) does the same. Grouping loans by employee needs nested loops: for each employee, walk every loan. With five materials you do not notice; with fifty thousand, every search is fifty thousand comparisons, and the grouped report is two thousand five hundred million operations.

HashMap puts an end to that. It is the structure that finds a value from its key in constant time, no matter whether the map has ten elements or ten million. It is not an incremental improvement over walking a list: it is a change of category, from O(n) to O(1), and that is why it is —together with ArrayList— the most used collection in Java and one of the most important ideas in all of computing.

This lesson has two halves. The first is practical: the Map interface, its complete API and how to apply it to BiblioTech. The second is the internal machinery: the hash function, the buckets, the collisions, the load factor and the rehash. And there the promise made explicitly in 03-09 is kept: you will see, with runnable code, why equals and hashCode always had to go together, and what happens to an object that enters a map with one of the two badly implemented. If you have ever heard "always override both" without understanding the reason, this is the lesson.

Contents

  1. What a map is and why it changes everything
  2. The Map interface: basic operations
  3. Traversing a map correctly
  4. The methods that save you half your code
  5. How a HashMap works inside
  6. Collisions, collision lists and trees
  7. Load factor and rehash
  8. The promise kept: why equals and hashCode go together
  9. The danger of mutable keys
  10. Requirements for a good key
  11. HashMap, LinkedHashMap and TreeMap
  12. An LRU cache in five lines
  13. Hashtable: the legacy
  14. Applying it to BiblioTech
  15. Common Mistakes and Tips
  16. Exercises

  1. What a map is and why it changes everything

A map (also called a dictionary or an associative table) stores key → value pairs. The key identifies; the value is what is identified. Keys are unique: each one leads to exactly one value.

Map<String, Material> index = new HashMap<>();
index.put("978-0000000001", effectiveJava);
index.put("978-0000000002", designPatterns);

Material found = index.get("978-0000000001");     // instant

Compare that get with what you used to do:

// Before: O(n). With 50,000 materials, up to 50,000 string comparisons.
public Material findByReference(String reference) {
    for (Material m : materials) {
        if (m.getReference().equals(reference)) { return m; }
    }
    return null;
}

// Now: O(1). One operation, always, whether the map has 10 or 10,000,000 entries.
public Material findByReference(String reference) {
    return index.get(reference);
}

The practical difference is brutal:

Elements Search in a List (O(n)) Search in a HashMap (O(1))
10 5 comparisons on average 1 operation
1,000 500 1
1,000,000 500,000 1
100,000,000 50,000,000 1

That the cost does not grow at all with the size is what makes the map a structurally different tool. And it is not magic: the price is extra memory and the requirement that keys have a correct hashCode. Section 5 explains how it is achieved.

Remember from 05-02 that Map is not a Collection: it stores pairs, not loose elements, and add(x), contains(x) or iterator() would be ambiguous on it. It connects with the rest of the Framework through its three views: keySet(), values() and entrySet().

  1. The Map interface: basic operations

import java.util.HashMap;
import java.util.Map;

Map<String, Material> index = new HashMap<>();

The two type parameters are, in this order, the key type and the value type. Map<String, Material> is "a map whose keys are strings and whose values are materials". As in 05-02, read it that way and leave the theory for 10-01.

put: insert or replace

Material previous = index.put("978-0000000001", effectiveJava);
System.out.println(previous);      // null: there was nothing with that key

Material former = index.put("978-0000000001", otherEdition);
System.out.println(former);        // the earlier effectiveJava: it has been REPLACED

put returns the previous value associated with that key, or null if there was none. It is a useful detail: it lets you know whether you were inserting or replacing without a prior lookup.

And the fundamental property: if the key already exists, the value is replaced. A map never has two entries with the same key.

get and getOrDefault

Material m = index.get("978-0000000001");      // the value, or null if absent
Material x = index.get("does-not-exist");      // null

// getOrDefault avoids the null and a whole family of checks
int term = termsByType.getOrDefault("Audiobook", 15);       // 15 if it is not registered

getOrDefault(key, fallback) is one of the most useful methods in the Framework. It replaces this:

Integer value = map.get(key);
int result = (value == null) ? 0 : value;

with this:

int result = map.getOrDefault(key, 0);

containsKey, containsValue, remove

boolean isCatalogued = index.containsKey("978-0000000001");      // O(1)
boolean bookAppears  = index.containsValue(effectiveJava);       // O(n): walks EVERYTHING

Material removed = index.remove("978-0000000001");    // returns the removed value, or null
boolean wasRemoved = index.remove("978-0000000002", designPatterns);   // only if the value matches

Notice the asymmetry: containsKey is O(1) and containsValue is O(n). The map is indexed by key, not by value; looking for a value forces a walk over every entry. If you often need to search in both directions, keep two maps or rethink the design.

size, isEmpty, clear

System.out.println(index.size());         // number of pairs
System.out.println(index.isEmpty());
index.clear();

  1. Traversing a map correctly

A map offers three views, and choosing the right one matters for performance.

Map<String, Material> index = new HashMap<>();
// ... filled ...

// 1. Keys only
for (String reference : index.keySet()) {
    System.out.println(reference);
}

// 2. Values only
for (Material m : index.values()) {
    System.out.println(m.getTitle());
}

// 3. Keys AND values: ALWAYS with entrySet
for (Map.Entry<String, Material> entry : index.entrySet()) {
    System.out.printf("%-16s -> %s%n", entry.getKey(), entry.getValue().getTitle());
}

The third one is the important one. The frequent mistake is this:

// INEFFICIENT: two operations per entry
for (String reference : index.keySet()) {
    Material m = index.get(reference);          // an extra, unnecessary lookup
    System.out.println(reference + " -> " + m.getTitle());
}

Every get is a complete lookup (compute the hash, locate the bucket, compare). With entrySet, the key and the value arrive together in the same Map.Entry object, with no additional lookup. When you need key and value, always use entrySet.

Map.Entry is a nested interface (04-03) that represents a pair:

Method of Map.Entry What it does
getKey() The key
getValue() The value
setValue(v) Changes the value in the map: it is a view, not a copy

That setValue lets you modify safely during a traversal:

for (Map.Entry<String, Integer> e : counters.entrySet()) {
    e.setValue(e.getValue() + 1);      // legal and efficient
}

And forEach on a map takes a BiConsumer (04-06), which accepts both arguments:

index.forEach((reference, material) ->
    System.out.printf("%-16s -> %s%n", reference, material.getTitle()));

The three views are live views, not copies: removing from keySet() removes the entry from the map.

index.keySet().remove("978-0000000001");      // removes the WHOLE entry
index.values().removeIf(m -> !m.isAvailable());     // removes the entries whose values match

And, like any collection, modifying them during a for-each causes ConcurrentModificationException, with the same solutions as in 05-02.

A warning that will come up again: the traversal order of a HashMap is not guaranteed and can change when elements are inserted. Never write code —or tests— that depends on it. If you need order, use LinkedHashMap or TreeMap (section 11).

  1. The methods that save you half your code

Java 8 added a group of methods to Map that solve everyday patterns and that many people still do not use, writing five lines where one would do.

putIfAbsent

// Instead of:
if (!index.containsKey(ref)) { index.put(ref, material); }

// Write:
index.putIfAbsent(ref, material);

It inserts only if the key was absent (or its value was null). It returns the existing value, or null if it inserted.

computeIfAbsent: the king of maps of lists

This is probably the most useful method in the whole interface. It solves the "group elements by a key" pattern:

// WITHOUT computeIfAbsent: the classic pattern, verbose and easy to break
Map<Employee, List<Loan>> byEmployee = new HashMap<>();
for (Loan l : loans) {
    List<Loan> list = byEmployee.get(l.getEmployee());
    if (list == null) {
        list = new ArrayList<>();
        byEmployee.put(l.getEmployee(), list);
    }
    list.add(l);
}

// WITH computeIfAbsent: one line
for (Loan l : loans) {
    byEmployee.computeIfAbsent(l.getEmployee(), k -> new ArrayList<>()).add(l);
}

It reads like this: "give me the list associated with this employee; if it does not exist, create it with this function, store it and give it back to me". The result is always a valid list, so you can chain the .add(l) in complete safety.

The function receives the key as an argument, which is sometimes useful:

Map<String, List<Material>> byType = new HashMap<>();
for (Material m : catalog) {
    byType.computeIfAbsent(m.getType(), type -> new ArrayList<>()).add(m);
}

An important warning: the function only runs if the key is absent. That makes it efficient (it does not create useless lists) but it also means it must not have side effects you depend on.

merge: the king of counters

It solves the "accumulate by key" pattern:

// WITHOUT merge
Map<String, Integer> countByType = new HashMap<>();
for (Material m : catalog) {
    Integer current = countByType.get(m.getType());
    countByType.put(m.getType(), (current == null) ? 1 : current + 1);
}

// WITH merge
for (Material m : catalog) {
    countByType.merge(m.getType(), 1, Integer::sum);
}

merge(key, initialValue, combiningFunction) works like this: if the key is absent, it stores initialValue; if it is present, it applies the function to the existing value and the new one, and stores the result. Integer::sum is the method reference (04-06) that adds two integers.

It works for any accumulation, not just counting:

Map<Employee, Double> fineByEmployee = new HashMap<>();
for (Loan l : loans) {
    fineByEmployee.merge(l.getEmployee(), l.calculateFine(currentDay), Double::sum);
}

Map<String, String> titlesByType = new HashMap<>();
for (Material m : catalog) {
    titlesByType.merge(m.getType(), m.getTitle(), (a, b) -> a + ", " + b);
}

An equally readable alternative for counting, with getOrDefault:

countByType.put(type, countByType.getOrDefault(type, 0) + 1);

compute and computeIfPresent

// compute: ALWAYS recomputes, with the current value (which may be null)
counters.compute("Book", (k, v) -> (v == null) ? 1 : v + 1);

// computeIfPresent: only acts if the key ALREADY exists
inventory.computeIfPresent("978-0000000001", (k, v) -> v - 1);

An important detail about all three: if the function returns null, the entry is removed from the map. It is a useful idiom for cleaning up as you go:

// Deducts one unit and removes the entry when it reaches zero
inventory.computeIfPresent(isbn, (k, v) -> (v <= 1) ? null : v - 1);

replaceAll

rates.replaceAll((type, rate) -> rate * 1.10);         // a 10% rise across the board

Summary table

Method When to use it
getOrDefault(k, def) Reading with a default value, without checking for null
putIfAbsent(k, v) Inserting only if it was absent
computeIfAbsent(k, f) Maps of lists or sets: grouping by key
computeIfPresent(k, f) Updating only what already exists
compute(k, f) Recomputing always, present or not
merge(k, v, f) Counters and accumulators
replaceAll(f) Transforming every value
forEach(bc) Traversing key and value with a BiConsumer

These seven methods eliminate an enormous amount of repetitive code. In module 10, Streams will add Collectors.groupingBy and counting(), which express the same thing even more declaratively; until then, computeIfAbsent and merge are your tools.

  1. How a HashMap works inside

Now the machinery. Understanding it is what will let you use maps without surprises.

A HashMap is, inside, an array of buckets. Each bucket can hold zero, one or several entries.

transient Node<K,V>[] table;          // the array of buckets
transient int size;                   // number of pairs stored
int threshold;                        // capacity * load factor
final float loadFactor;               // 0.75 by default

The idea, in three steps:

Step 1: compute the key's hash. key.hashCode() is called, returning an int (about 4,300 million possible values).

"978-0000000001".hashCode();     // for example, -1234567890

Step 2: turn that hash into an index into the array. Since the array has, say, 16 buckets, the hash has to be reduced to a number between 0 and 15. HashMap uses a bit operation equivalent to hash % 16, but vastly faster:

int index = (table.length - 1) & hash;     // equals hash % 16 when length is a power of 2

That is why a HashMap's capacity is always a power of two: it lets the modulo (a division, expensive) be replaced by a bitwise AND (one instruction). In addition, HashMap first applies a mixing function that combines the high bits with the low ones, so that keys whose hashes differ only in the high bits do not all end up in the same bucket:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

Step 3: store into or search in that bucket.

flowchart TB
    K["key: '978-0000000001'"] --> H["hashCode() → -1234567890"]
    H --> M["bit mixing: h XOR (h >>> 16)"]
    M --> I["index = (16-1) AND hash → 7"]
    I --> T["table[7]"]

    subgraph table["Object[] table (capacity 16)"]
        B0["[0] null"]
        B1["[1] → (ISBN-2, Design Patterns)"]
        B2["[2] null"]
        B7["[7] → (ISBN-1, Effective Java)"]
        B9["[9] → (REV-2024-03, Java Magazine)"]
        B15["[15] null"]
    end
    T --> B7

And there is the trick: finding the right bucket requires no searching at all. It is computed. It makes no difference whether the map has 10 entries or 10 million: computing the hash and the index costs the same. That is O(1).

Searching is exactly the same process:

Material m = index.get("978-0000000001");
// 1. hash of the key -> 2. bucket index -> 3. look in that bucket

  1. Collisions, collision lists and trees

An int has about 4,300 million values, but the array only has 16 buckets. It is inevitable that two different keys end up in the same one: that is a collision.

HashMap resolves them with chaining: each bucket holds a linked list of entries.

flowchart LR
    subgraph table["table"]
        B0["[0] null"]
        B3["[3] →"]
        B7["[7] →"]
    end
    B3 --> E1["(key A, value 1)"]
    E1 --> E2["(key B, value 2)"]
    E2 --> E3["(key C, value 3)"]
    B7 --> E4["(key D, value 4)"]

Keys A, B and C have different hashes but land in bucket 3. When looking up A:

  1. Its hash and index are computed: bucket 3.
  2. That bucket's list is walked, comparing: first by hash (fast, it is an int) and, if that matches, with equals (the real comparison).

There is the role of each method, and it is the key to everything: hashCode decides which bucket to look in; equals decides which of that bucket's entries is the right one. Both are indispensable, and that is why they must be consistent.

The real cost

Situation Cost of get
No collisions (the normal case) O(1)
Few collisions O(1) with a somewhat larger constant
Every key in the same bucket O(n): it degenerates into a list

The worst case happens if hashCode is badly implemented. The extreme example:

@Override public int hashCode() { return 42; }    // legal, but catastrophic

It is technically correct —the contract only requires equal objects to have the same hash— but every key would land in the same bucket and the map would behave like a linked list: O(n) on every operation.

The conversion to a tree (Java 8+)

To limit the damage, since Java 8 HashMap watches the length of each bucket. If a bucket accumulates 8 or more entries (and the table has at least 64 buckets), that bucket's list is converted into a red-black tree, a balanced binary tree:

flowchart TB
    subgraph before["Bucket with 8+ entries: LIST → O(n)"]
        L1["e1"] --> L2["e2"] --> L3["e3"] --> L4["e4"] --> L5["..."] --> L8["e8"]
    end
    subgraph after["Converted into a TREE → O(log n)"]
        R["e4"] --> A1["e2"]
        R --> A2["e6"]
        A1 --> B1["e1"]
        A1 --> B2["e3"]
        A2 --> B3["e5"]
        A2 --> B4["e7"]
    end

With this, the worst case goes from O(n) to O(log n). It is a safety net, not an excuse: if your buckets turn into trees, your hashCode is badly distributed. (If the bucket drops below 6 entries, it becomes a list again.)

This change was introduced for security reasons: there was a denial-of-service attack that sent a server thousands of keys with the same hash in order to degrade its maps to O(n).

  1. Load factor and rehash

The more entries there are for the same number of buckets, the more collisions. The load factor (loadFactor) is the occupancy threshold beyond which HashMap decides to grow. Its default value is 0.75.

threshold = capacity × loadFactor

With the initial capacity of 16: threshold = 16 × 0.75 = 12. When entry number 13 is inserted, a rehash is triggered:

  1. A new array with double the buckets (32) is created.
  2. Every entry is rehomed, recomputing its index with the new capacity.
  3. The old array is discarded.
flowchart LR
    A["16 buckets<br/>12 entries<br/>threshold reached"] --> B["the 13th is inserted"]
    B --> C["new Node[32]"]
    C --> D["rehome the 13 entries<br/>index = (32-1) AND hash"]
    D --> E["32 buckets<br/>new threshold: 24"]

The rehash is O(n) and it is the reason put is amortised O(1) and not pure O(1), exactly like ArrayList's resizing (05-03). Since the capacity is doubled, the average cost per insertion stays constant.

Why 0.75

It is a measured compromise:

Load factor Collisions Wasted memory Rehashes
0.5 Very few A lot (half the array empty) Frequent
0.75 Few Reasonable Reasonable
1.0 Quite a few None Rare
2.0 Many: it degrades into lists None Very rare

With 0.75 and a decent hashCode, the probability of a bucket having more than one element is low, and the memory waste is 25%. Change it only if you have a measurement that justifies it.

Sizing a large map properly

If you know how many entries you are going to put in, size the map when constructing it and save all the rehashes:

// Bad: 50,000 entries cause about 12 rehashes, with O(n) rehoming every time
Map<String, Material> index = new HashMap<>();

// Good: enough capacity from the start
Map<String, Material> index = new HashMap<>((int) (50_000 / 0.75f) + 1);

The formula expectedEntries / 0.75 + 1 guarantees there will be no rehash at all. Beware the classic mistake of writing new HashMap<>(50_000): that states the capacity, not the number of entries, and with a factor of 0.75 the rehash would fire on reaching 37,500.

As always: this matters with tens of thousands of entries. With two hundred, new HashMap<>() is perfect.

  1. The promise kept: why equals and hashCode go together

The moment has come. In 03-09 you learned the contract:

If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true.

(The converse is not required: two different objects may share a hash.)

And it was promised that in module 5 you would see why. Here it is, with runnable code.

Experiment 1: equals yes, hashCode no

A class that overrides equals correctly but forgets hashCode:

package com.nexussoftware.bibliotech.domain;

import java.util.Objects;

/** Composite key of a copy. equals correct, hashCode MISSING. */
public class BrokenCopyKey {

    private final String isbn;
    private final int    copyNumber;

    public BrokenCopyKey(String isbn, int copyNumber) {
        this.isbn = isbn;
        this.copyNumber = copyNumber;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (!(o instanceof BrokenCopyKey other)) { return false; }
        return copyNumber == other.copyNumber && Objects.equals(isbn, other.isbn);
    }

    // hashCode IS MISSING: Object's is inherited, based on the memory address
}

And the result:

BrokenCopyKey c1 = new BrokenCopyKey("978-0000000001", 1);
BrokenCopyKey c2 = new BrokenCopyKey("978-0000000001", 1);

System.out.println("c1.equals(c2): " + c1.equals(c2));            // true: they are "equal"
System.out.println("hash c1: " + c1.hashCode());                  // 1735600054  (for example)
System.out.println("hash c2: " + c2.hashCode());                  // 21685669    DIFFERENT

Map<BrokenCopyKey, String> locations = new HashMap<>();
locations.put(c1, "Shelf A-3");

System.out.println("get(c1): " + locations.get(c1));              // Shelf A-3
System.out.println("get(c2): " + locations.get(c2));              // null   <-- THE PROBLEM
System.out.println("containsKey(c2): " + locations.containsKey(c2));   // false
System.out.println("size: " + locations.size());                  // 1

locations.put(c2, "Shelf B-1");
System.out.println("size after putting c2: " + locations.size()); // 2  <-- DUPLICATE KEY

The object has vanished from the map. And worse: there are now two equal keys in a map that guarantees unique keys.

Follow the trail step by step:

flowchart TB
    P["put(c1, 'A-3')"] --> P1["c1.hashCode() = 1735600054"]
    P1 --> P2["index = 6 → stored in table[6]"]

    G["get(c2)"] --> G1["c2.hashCode() = 21685669"]
    G1 --> G2["index = 5 → looks in table[5]"]
    G2 --> G3["table[5] is EMPTY"]
    G3 --> G4["returns null: equals is NEVER even called"]

There is the complete explanation: get searches in the bucket that hashCode points at. If the hash is different, it looks in another bucket, and equals is never executed. That c1.equals(c2) is true is irrelevant if they never get compared.

And put(c2, ...) adds a second entry because, from the map's point of view, c2 goes to another bucket and there is nothing to compare it against.

Experiment 2: the correct version

package com.nexussoftware.bibliotech.domain;

import java.util.Objects;

/** The same key, with CONSISTENT equals and hashCode. */
public class CopyKey {

    private final String isbn;
    private final int    copyNumber;

    public CopyKey(String isbn, int copyNumber) {
        this.isbn = Objects.requireNonNullElse(isbn, "");
        this.copyNumber = copyNumber;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) { return true; }
        if (!(o instanceof CopyKey other)) { return false; }
        return copyNumber == other.copyNumber && isbn.equals(other.isbn);
    }

    /** The SAME fields that equals uses, not one more, not one less. */
    @Override
    public int hashCode() {
        return Objects.hash(isbn, copyNumber);
    }

    @Override
    public String toString() {
        return isbn + "#" + copyNumber;
    }
}
CopyKey c1 = new CopyKey("978-0000000001", 1);
CopyKey c2 = new CopyKey("978-0000000001", 1);

System.out.println("hash c1: " + c1.hashCode());     // 1635958654
System.out.println("hash c2: " + c2.hashCode());     // 1635958654   THE SAME

Map<CopyKey, String> locations = new HashMap<>();
locations.put(c1, "Shelf A-3");

System.out.println("get(c2): " + locations.get(c2));     // Shelf A-3   <-- IT WORKS
locations.put(c2, "Shelf B-1");
System.out.println("size: " + locations.size());         // 1: c2 REPLACED c1

Now it works. Same hash → same bucket → equals runs → the map recognises they are the same key.

And that is why the records of 04-07 are perfect keys: they automatically generate equals and hashCode from all their components, always consistent with each other.

public record CopyKeyRecord(String isbn, int copyNumber) { }   // already a valid key

The rule, in three lines

Situation Consequence
equals yes, hashCode no The object vanishes from maps and sets; duplicates appear
hashCode yes, equals no They go to the same bucket, but equals compares references: they are not found either
Both, inconsistent with each other Unpredictable behaviour depending on the fields each one uses
Both, over the same fields Correct

And the definitive operational rule: if you override one, override the other, and do it over exactly the same fields. Every IDE generates both at once precisely because of this.

  1. The danger of mutable keys

There is a second way of breaking a map, subtler and harder to debug: mutating a key after inserting it.

package com.nexussoftware.bibliotech.domain;

import java.util.Objects;

/** MUTABLE key: the isbn field can change. A bad idea. */
public class MutableKey {
    private String isbn;                       // NOT final

    public MutableKey(String isbn) { this.isbn = isbn; }
    public void setIsbn(String isbn) { this.isbn = isbn; }    // the poison

    @Override public boolean equals(Object o) {
        return (o instanceof MutableKey k) && Objects.equals(isbn, k.isbn);
    }
    @Override public int hashCode() { return Objects.hash(isbn); }
    @Override public String toString() { return "Key[" + isbn + "]"; }
}
MutableKey key = new MutableKey("978-0000000001");
Map<MutableKey, String> map = new HashMap<>();
map.put(key, "Effective Java");

System.out.println(map.get(key));          // Effective Java   correct

key.setIsbn("978-0000000002");             // we MUTATE the key ALREADY INSERTED

System.out.println(map.get(key));          // null   <-- the object has been lost
System.out.println(map.size());            // 1      <-- but it is still in there
System.out.println(map.containsKey(key));  // false
System.out.println(map);                   // {Key[978-0000000002]=Effective Java}

The entry still exists, you can see it when printing the map, it counts towards size... and it is unreachable.

flowchart TB
    A["put(key, value)<br/>hashCode = 1234 → bucket 2"] --> B["the entry ends up in table[2]"]
    B --> C["key.setIsbn(...)<br/>now hashCode = 9876"]
    C --> D["get(key): index = 9876 mod 16 → bucket 4"]
    D --> E["table[4] does not have that entry"]
    E --> F["null: the entry in table[2] is UNREACHABLE"]

The map placed the entry in the bucket corresponding to the hash the key had at the moment of insertion. When the hash changes, nobody rehomes anything: the map does not watch its keys. The entry is left orphaned, taking up memory and unrecoverable even with remove.

This problem is especially treacherous with collections as keys:

List<String> list = new ArrayList<>(List.of("a", "b"));
Map<List<String>, String> map = new HashMap<>();
map.put(list, "value");

list.add("c");                          // a list's hashCode DEPENDS on its content (05-03)
System.out.println(map.get(list));      // null

And with mutable domain objects:

// If Employee had equals/hashCode based on the name and the name could change:
Map<Employee, List<Loan>> byEmployee = new HashMap<>();
byEmployee.put(marta, martasLoans);
marta.setName("Marta Ruiz Garcia");     // a silent catastrophe

The solution is always the same: keys must be immutable, or at least the fields taking part in equals and hashCode must be.

  1. Requirements for a good key

Requirement Why How to achieve it
Immutable If its hash changes, the entry is lost final fields, no setters (03-07)
Correct equals Distinguishes entries within the bucket Over the fields that define identity
Correct hashCode Locates the bucket Over the same fields as equals
Well distributed Stops everything landing in one bucket Objects.hash(...) does it well
Cheap to compute It is called on every operation Simple fields, or cache it if it is expensive
Not null in general HashMap allows it, other maps do not Prefer real keys

The best key candidates, in order of preference:

  1. String: immutable, with correct equals/hashCode and an internally cached hash. It is by far the most common key.
  2. Numeric wrappers (Integer, Long): immutable and with a trivial hash.
  3. enum: immutable by construction, with a unique identity. And if your key is an enum, consider EnumMap, an extremely efficient specialised implementation.
  4. record (04-07): generated equals and hashCode, consistent and over immutable fields. The ideal candidate for composite keys.
  5. Your own immutable classes with both methods well written.

The worst candidates:

  • Mutable domain objects (Employee, Loan) if their identity fields can change.
  • Mutable collections (ArrayList, HashSet): their hash depends on the content.
  • Arrays: their hashCode is the one inherited from Object, based on identity. map.get(new int[]{1,2}) will never find the entry stored with new int[]{1,2}. Use List.of(1, 2) instead.
  • Objects with an expensive hashCode that is not cached.

An example of a well-made composite key in BiblioTech:

/** Identifies a specific copy: same ISBN, different copy number. */
public record CopyKey(String isbn, int copyNumber) {
    public CopyKey {
        if (isbn == null || isbn.isBlank()) { isbn = "NO-ISBN"; }
        if (copyNumber < 1) { copyNumber = 1; }
    }
}

Map<CopyKey, String> locations = new HashMap<>();
locations.put(new CopyKey("978-0000000001", 1), "Shelf A-3");
locations.put(new CopyKey("978-0000000001", 2), "Shelf A-4");

System.out.println(locations.get(new CopyKey("978-0000000001", 2)));     // Shelf A-4

One declaration line and it is already a perfect key.

  1. HashMap, LinkedHashMap and TreeMap

The three main implementations of Map, compared:

Aspect HashMap LinkedHashMap TreeMap
Internal structure Array of buckets Buckets + doubly linked list Red-black tree
Traversal order None guaranteed Insertion (or access) Sorted keys
get / put / remove O(1) O(1) O(log n)
containsKey O(1) O(1) O(log n)
Memory per entry Lower +2 references per entry Higher (tree nodes)
null key One allowed One allowed NOT allowed
null values Yes Yes Yes
Key requirement equals + hashCode equals + hashCode Comparable or Comparator
When to use it By default Reproducible order; LRU cache Ranges, firstKey, permanent ordering

LinkedHashMap

It maintains a linked list running through every entry in the order they were inserted. The cost is small (two references per entry) and in exchange the traversal is deterministic:

Map<String, Integer> terms = new LinkedHashMap<>();
terms.put("Book", 15);
terms.put("Magazine", 7);
terms.put("DVD", 3);

terms.forEach((type, days) -> System.out.println(type + ": " + days));
// ALWAYS in this order: Book, Magazine, DVD

Use it when order matters for presentation, for tests or for reproducibility. Curiously, traversing it is even somewhat faster than traversing a HashMap, because it follows the linked list instead of examining every bucket (including the empty ones).

TreeMap

It keeps the keys sorted in a balanced tree. Everything costs O(log n) instead of O(1), but in exchange it offers operations no hash map can provide:

TreeMap<String, Material> byReference = new TreeMap<>();
byReference.put("978-0000000002", designPatterns);
byReference.put("978-0000000001", effectiveJava);
byReference.put("978-0000000003", refactoring);

// Always sorted, with no manual sorting
byReference.forEach((k, v) -> System.out.println(k + " -> " + v.getTitle()));

System.out.println(byReference.firstKey());          // 978-0000000001
System.out.println(byReference.lastKey());           // 978-0000000003
System.out.println(byReference.firstEntry());        // the complete pair

// Navigation: NavigableMap
System.out.println(byReference.floorKey("978-0000000002x"));     // greatest key <= the given one
System.out.println(byReference.ceilingKey("978-0000000000"));    // smallest key >= the given one
System.out.println(byReference.higherKey("978-0000000001"));     // strictly greater
System.out.println(byReference.lowerKey("978-0000000003"));      // strictly smaller

// Range views, which are LIVE VIEWS of the original map
SortedMap<String, Material> firstOnes = byReference.headMap("978-0000000003");  // < the given one
SortedMap<String, Material> lastOnes  = byReference.tailMap("978-0000000002");  // >= the given one
NavigableMap<String, Material> stretch = byReference.subMap("978-0000000001", true,
                                                            "978-0000000002", true);

System.out.println(byReference.descendingMap().firstKey());      // reverse traversal

And it accepts a Comparator of its own, connecting with everything from 04-06:

// Sorted by key length and, on a tie, alphabetically
Map<String, Integer> custom = new TreeMap<>(
    Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()));

When to choose TreeMap: when you need to traverse in order frequently, query ranges (headMap, subMap), or ask "which is the key immediately before this one". If you only need order once at the end, it is cheaper to use a HashMap and sort the keys at that point.

Nulls, summarised

null key null values
HashMap Yes, one (it goes to bucket 0) Yes, as many as you like
LinkedHashMap Yes, one Yes
TreeMap No: NullPointerException Yes
Hashtable No No
Map.of(...) No No

That HashMap accepts a null key creates a well-known ambiguity: map.get(key) returns null both when the key is absent and when it is associated with null. To tell them apart, containsKey. In general, avoid null keys and values: they complicate the code without contributing anything. And Optional (10-04) is the modern answer to "there may be no value".

  1. An LRU cache in five lines

LinkedHashMap has a little-known and very elegant capability. Its third constructor accepts an ordering mode:

new LinkedHashMap<>(initialCapacity, loadFactor, accessOrder);

With accessOrder = true, the internal list is reordered on every access: every time you look up an entry with get, that entry moves to the end. That is, the front of the list is always the least recently used entry.

Combined with the removeEldestEntry method, which LinkedHashMap invokes after every insertion to ask whether it should evict the oldest entry, you have a complete LRU cache (Least Recently Used):

package com.nexussoftware.bibliotech.service;

import java.util.LinkedHashMap;
import java.util.Map;

/** LRU cache: keeps the N most recently consulted cards. */
public class CardCache<K, V> extends LinkedHashMap<K, V> {

    private final int max;

    public CardCache(int max) {
        super(16, 0.75f, true);      // accessOrder = true: reorders on every get
        this.max = max;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > max;         // if it returns true, LinkedHashMap evicts it
    }
}

Five useful lines. In action:

CardCache<String, String> cache = new CardCache<>(3);

cache.put("978-0000000001", "Effective Java");
cache.put("978-0000000002", "Design Patterns");
cache.put("978-0000000003", "Refactoring");
System.out.println(cache.keySet());      // [...001, ...002, ...003]

cache.get("978-0000000001");             // we consult the FIRST one: it moves to the end
System.out.println(cache.keySet());      // [...002, ...003, ...001]

cache.put("978-0000000004", "Clean Code");      // exceeds the maximum
System.out.println(cache.keySet());      // [...003, ...001, ...004]
// ...002 has gone: it was the least recently used

Notice the key detail: ...001 survived because we consulted it, even though it was the oldest by insertion. That is exactly the LRU policy, and it is the one used by database, browser and operating-system caches.

In BiblioTech it would serve to cache the cards of the most consulted materials without memory growing without limit.

  1. Hashtable: the legacy

Hashtable is the original class from Java 1.0, predating the Collections Framework. Do not use it in new code.

Hashtable HashMap
Synchronised Yes, every method No
Performance Worse (a lock on every operation) Better
null keys/values Forbidden Allowed
Age Java 1.0 Java 1.2
Iteration Enumeration (obsolete) Iterator

Its only apparent advantage —being synchronised— turns out to be insufficient for real concurrent use, because it locks the whole table on every operation and even so does not make compound sequences of the "check and then insert" kind atomic.

The correct answer for concurrency is ConcurrentHashMap, which allows concurrent access without locking the entire map and offers atomic operations. It is the subject of module 8.

The same goes for Vector (the Hashtable of lists) and for Stack, which extends Vector and which you will see discouraged in 05-08. They all share the same history: global synchronisation inherited from Java 1.0.

  1. Applying it to BiblioTech

Now, the big refactoring. Two applications that change the project at its root.

An index by reference in the Catalog

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
import com.nexussoftware.bibliotech.domain.Material;

/** Catalogue with an index by reference: O(1) lookups. */
public class Catalog {

    private final List<Material>          materials   = new ArrayList<>();
    private final Map<String, Material>   byReference = new HashMap<>();
    private final Map<String, List<Material>> byType  = new HashMap<>();

    /** Registration: keeps the three containers in sync. */
    public boolean add(Material m) {
        if (m == null) { return false; }
        // putIfAbsent returns the existing value, or null if it inserted
        if (byReference.putIfAbsent(m.getReference(), m) != null) {
            return false;                       // duplicate reference: registration rejected
        }
        materials.add(m);
        byType.computeIfAbsent(m.getType(), type -> new ArrayList<>()).add(m);
        return true;
    }

    /** Lookup by reference: O(1). It used to be O(n). */
    public Material findByReference(String reference) {
        return byReference.get(reference);
    }

    public boolean isCatalogued(String reference) {
        return byReference.containsKey(reference);
    }

    /** Removal: O(1) to locate it, O(n) to take it out of the list. */
    public boolean remove(String reference) {
        Material m = byReference.remove(reference);
        if (m == null) { return false; }
        materials.remove(m);
        // If the group is left empty, the entry is removed from the map (function returning null)
        byType.computeIfPresent(m.getType(),
                (type, list) -> { list.remove(m); return list.isEmpty() ? null : list; });
        return true;
    }

    /** All the materials of a type: O(1). It used to be a complete walk. */
    public List<Material> ofType(String type) {
        return List.copyOf(byType.getOrDefault(type, List.of()));
    }

    /** Report by type: it used to be 20 lines with nested O(n^2) loops. */
    public Map<String, Integer> countByType() {
        Map<String, Integer> summary = new HashMap<>();
        for (Material m : materials) {
            summary.merge(m.getType(), 1, Integer::sum);
        }
        return summary;
    }

    /** Sum of daily rates grouped by type, also with merge. */
    public Map<String, Double> totalRateByType() {
        Map<String, Double> summary = new HashMap<>();
        for (Material m : materials) {
            summary.merge(m.getType(), m.getDailyRate(), Double::sum);
        }
        return summary;
    }

    public List<Material> find(Predicate<Material> criteria) {
        List<Material> result = new ArrayList<>();
        for (Material m : materials) {
            if (criteria.test(m)) { result.add(m); }
        }
        return result;
    }

    public void sort(Comparator<Material> criteria) { materials.sort(criteria); }
    public List<Material> list() { return List.copyOf(materials); }
    public int size() { return materials.size(); }
}

Compare the reportByType promised at the end of module 4:

// BEFORE: nested loops, O(n * types), 20 lines
public void reportByType(Material[] catalog) {
    String[] types = { "Book", "Magazine", "DVD", "Audiobook" };
    for (int t = 0; t < types.length; t++) {
        int howMany = 0;
        double rate = 0.0;
        for (int i = 0; i < catalog.length; i++) {
            if (catalog[i] != null && catalog[i].getType().equals(types[t])) {
                howMany++;
                rate += catalog[i].getDailyRate();
            }
        }
        if (howMany > 0) {
            System.out.printf("%-12s %3d materials, average rate %.2f%n",
                              types[t], howMany, rate / howMany);
        }
    }
}

// NOW: one pass, O(n), and with no hand-coded list of types
public void reportByType() {
    Map<String, Integer> howMany = countByType();
    Map<String, Double>  rates   = totalRateByType();
    howMany.forEach((type, n) ->
        System.out.printf("%-12s %3d materials, average rate %.2f%n",
                          type, n, rates.get(type) / n));
}

Three lines of body, a single pass over the catalogue, and the types are no longer hard-coded: if the audiobook shows up tomorrow, the report includes it on its own.

Loans by employee

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.domain.Employee;
import com.nexussoftware.bibliotech.domain.Loan;

public class LoanRegistry {

    private final Map<String, Loan>            byReference = new HashMap<>();
    private final Map<Employee, List<Loan>>    byEmployee  = new HashMap<>();

    /**
     * Employee as a KEY: it requires correct equals/hashCode based on an
     * IMMUTABLE field. In BiblioTech they are based on 'identifier', which is final.
     */
    public void register(Loan l) {
        if (l == null) { return; }
        byReference.put(l.getReference(), l);
        byEmployee.computeIfAbsent(l.getEmployee(), e -> new ArrayList<>()).add(l);
    }

    /** Return in O(1): it used to walk the whole list of loans. */
    public boolean returnItem(String loanReference, int day) {
        Loan l = byReference.get(loanReference);
        if (l == null || l.isReturned()) { return false; }
        l.registerReturn(day);
        return true;
    }

    /** An employee's loans in O(1): it used to be a complete walk. */
    public List<Loan> of(Employee employee) {
        return List.copyOf(byEmployee.getOrDefault(employee, List.of()));
    }

    /** Accumulated fine per employee, with merge. */
    public Map<Employee, Double> finesByEmployee(int currentDay) {
        Map<Employee, Double> fines = new HashMap<>();
        for (List<Loan> list : byEmployee.values()) {
            for (Loan l : list) {
                if (!l.isReturned() && l.isOverdue(currentDay)) {
                    fines.merge(l.getEmployee(), l.calculateFine(currentDay), Double::sum);
                }
            }
        }
        return fines;
    }

    /** How many active loans each employee has. */
    public Map<Employee, Integer> activeByEmployee() {
        Map<Employee, Integer> active = new HashMap<>();
        for (Map.Entry<Employee, List<Loan>> e : byEmployee.entrySet()) {
            int n = 0;
            for (Loan l : e.getValue()) {
                if (!l.isReturned()) { n++; }
            }
            if (n > 0) { active.put(e.getKey(), n); }
        }
        return active;
    }
}

Usage:

Employee marta = new Employee("Marta Ruiz",   "EMP-001");
Employee diego = new Employee("Diego Alonso", "EMP-002");

Catalog catalog = new Catalog();
catalog.add(new Book("Effective Java",  "Joshua Bloch",  "978-0000000001", 2018));
catalog.add(new Book("Design Patterns", "Erich Gamma",   "978-0000000002", 1994));
catalog.add(new Book("Refactoring",     "Martin Fowler", "978-0000000003", 1999));
catalog.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
catalog.add(new Dvd("Refactoring Live", "DVD-0007",      95));

// Instant lookup, walking nothing
Material m = catalog.findByReference("978-0000000002");
System.out.println("Found: " + m.getTitle());

System.out.println("--- Report by type ---");
catalog.countByType().forEach((type, n) -> System.out.printf("  %-8s %d%n", type, n));

LoanRegistry registry = new LoanRegistry();
registry.register(new Loan(catalog.findByReference("978-0000000001"), marta, 100));
registry.register(new Loan(catalog.findByReference("978-0000000003"), marta, 102));
registry.register(new Loan(catalog.findByReference("DVD-0007"),       diego, 105));

System.out.println("Loans for Marta: " + registry.of(marta).size());
System.out.println("--- Fines on day 130 ---");
registry.finesByEmployee(130).forEach((e, fine) ->
    System.out.printf("  %-14s %6.2f EUR%n", e.getName(), fine));
Found: Design Patterns
--- Report by type ---
  Book     3
  Magazine 1
  DVD      1
Loans for Marta: 2
--- Fines on day 130 ---
  Marta Ruiz      20.00 EUR
  Diego Alonso    12.50 EUR

Common Mistakes and Tips

Overriding equals without hashCode. Mistake number one. The object is stored but never found, and duplicate keys can be created. If you override one, override the other, over the same fields. Let the IDE generate both, or use a record.

Using mutable keys. If the key's hash changes after inserting it, the entry is unreachable forever. Immutable keys: String, Integer, enum, record.

Using a mutable collection as a key. A list's hashCode depends on its content: adding an element loses the entry. If you need a list as a key, List.copyOf(...).

Using an array as a key. Its hashCode is Object's, based on identity: you will never find the entry. Use List.of(...).

Depending on a HashMap's traversal order. It is not guaranteed and it can change on insertion or between Java versions. If you need order, LinkedHashMap or TreeMap. A test that depends on a HashMap's order will fail one day.

Traversing with keySet() and doing a get inside. It doubles the work. Use entrySet() when you need key and value.

Confusing containsKey with containsValue. The first is O(1); the second, O(n). Do not put a containsValue inside a loop.

Modifying the map during traversal. ConcurrentModificationException, just as with any collection. Use entrySet()'s Iterator, values().removeIf(...) or keySet().removeIf(...).

Confusing a get that returns null with "the key does not exist". It may exist with a null value. Tell them apart with containsKey, or better, do not store null values.

new HashMap<>(n) thinking n is the expected number of entries. It is the capacity: with a factor of 0.75, the rehash fires at 0.75 × n. The correct formula is new HashMap<>((int)(expected / 0.75f) + 1).

Using Hashtable or Vector in new code. They are legacy from Java 1.0. For normal use, HashMap; for concurrency, ConcurrentHashMap (module 8).

Tip: computeIfAbsent and merge are your best friends. The map of lists and the counter are the two most frequent patterns in the real world, and each is solved in one line. Internalise them.

Tip: records are perfect keys. When you need a composite key, declare a one-line record (04-07) and you get correct equals and hashCode for free.

Exercises

Exercise 1: an inverted search index

Write SearchIndex to let you find materials by words in their title:

  • void index(Material m): breaks the title into words (lower case, separated by spaces) and stores each word in a Map<String, List<Material>> with computeIfAbsent.
  • List<Material> find(String word): the materials containing that word, or an empty list.
  • Map<String, Integer> frequencies(): how many times each word appears, with merge.
  • List<String> mostCommonWords(int howMany): the N most frequent words.
  • void remove(Material m): takes the material out of all its words, deleting the entry when the list is left empty.

Exercise 2: the equals and hashCode demonstration

Write a runnable class HashCodeDemo with a main that demonstrates, printing results and explaining them:

  1. A class BrokenKey with equals but no hashCode: put an object into a HashMap and show that an equal object does not find it, and that duplicate keys can be created.
  2. A class CorrectKey with both: show that it works.
  3. A class MutableKey: put an object in, mutate the key and show that the entry becomes unreachable but still counts towards size().
  4. A record RecordKey: show that it works without writing a single method.
  5. A class SillyKey whose hashCode() always returns 42: measure the time of 50,000 insertions and lookups, and compare it with CorrectKey.

Exercise 3: library statistics

Write LibraryStatistics to take a List<Loan> and compute, using Map methods exclusively (no Streams):

  • Map<Employee, Integer> loansByEmployee().
  • Map<String, Double> totalFineByType(int currentDay).
  • Map<Severity, List<Loan>> groupedBySeverity(int currentDay).
  • Employee busiestEmployee(): the one with the most loans, null if there are none.
  • Map<String, Integer> topMaterials(int howMany): the N most borrowed materials, in a LinkedHashMap that preserves the order from highest to lowest.

Solutions

Solution 1

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.domain.Material;

/** Inverted index: from each word to the materials containing it in the title. */
public class SearchIndex {

    private final Map<String, List<Material>> byWord    = new HashMap<>();
    private final Map<String, Integer>        frequency = new HashMap<>();

    private static List<String> wordsOf(Material m) {
        if (m == null || m.getTitle() == null) { return List.of(); }
        List<String> words = new ArrayList<>();
        for (String w : m.getTitle().toLowerCase().split("\\s+")) {
            if (w.length() > 2) { words.add(w); }      // we discard "of", "the", "in"...
        }
        return words;
    }

    public void index(Material m) {
        for (String word : wordsOf(m)) {
            // computeIfAbsent: creates the list ONLY if the word is new,
            // and always returns a valid list to chain add onto
            byWord.computeIfAbsent(word, w -> new ArrayList<>()).add(m);
            // merge: if the word is new it stores 1; if it exists, it adds
            frequency.merge(word, 1, Integer::sum);
        }
    }

    /** getOrDefault avoids returning null and the caller's checks. */
    public List<Material> find(String word) {
        if (word == null) { return List.of(); }
        return List.copyOf(byWord.getOrDefault(word.toLowerCase(), List.of()));
    }

    public Map<String, Integer> frequencies() {
        return Map.copyOf(frequency);         // immutable copy
    }

    public List<String> mostCommonWords(int howMany) {
        // We dump the keys into a list and sort it by descending frequency.
        // A TreeMap would not help: it sorts by KEY, not by value.
        List<String> words = new ArrayList<>(frequency.keySet());
        words.sort(Comparator.comparingInt((String w) -> frequency.get(w)).reversed()
                             .thenComparing(Comparator.naturalOrder()));
        return words.subList(0, Math.min(howMany, words.size()));
    }

    public void remove(Material m) {
        for (String word : wordsOf(m)) {
            // If the function returns null, computeIfPresent REMOVES the entry from the map.
            byWord.computeIfPresent(word, (w, list) -> {
                list.remove(m);
                return list.isEmpty() ? null : list;
            });
            frequency.computeIfPresent(word, (w, n) -> (n <= 1) ? null : n - 1);
        }
    }

    public int indexedWords() { return byWord.size(); }
}

A test:

SearchIndex index = new SearchIndex();
index.index(new Book("Effective Java",     "Joshua Bloch",  "978-0000000001", 2018));
index.index(new Book("Design Patterns",    "Erich Gamma",   "978-0000000002", 1994));
index.index(new Book("Refactoring",        "Martin Fowler", "978-0000000003", 1999));
index.index(new Magazine("Java Magazine",  "REV-2024-03",   42, "Monthly"));
index.index(new Dvd("Refactoring Live",    "DVD-0007",      95));

System.out.println("With 'java': " + index.find("java").size());                 // 2
System.out.println("With 'refactoring': " + index.find("refactoring").size());   // 2
System.out.println("Most common: " + index.mostCommonWords(3));

The key to the exercise is the trio computeIfAbsent / merge / computeIfPresent returning null. The three together let you maintain a map of lists and a counter without a single explicit null check. Written with get and put by hand, the same code would take three times the space and would have at least one NullPointerException waiting for its moment.

An important observation: mostCommonWords has to dump and sort, because a map sorts by key, never by value. It is a structural limitation of maps worth keeping in mind.

Solution 2

package com.nexussoftware.bibliotech.presentation;

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class HashCodeDemo {

    // --- 1. equals WITHOUT hashCode ---
    static class BrokenKey {
        final String isbn;
        BrokenKey(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof BrokenKey k) && Objects.equals(isbn, k.isbn);
        }
        // no hashCode: Object's is inherited (based on identity)
        @Override public String toString() { return "Broken[" + isbn + "]"; }
    }

    // --- 2. consistent equals AND hashCode ---
    static class CorrectKey {
        final String isbn;
        CorrectKey(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof CorrectKey k) && Objects.equals(isbn, k.isbn);
        }
        @Override public int hashCode() { return Objects.hash(isbn); }
        @Override public String toString() { return "Correct[" + isbn + "]"; }
    }

    // --- 3. a MUTABLE key ---
    static class MutableKey {
        String isbn;                        // not final: the problem
        MutableKey(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof MutableKey k) && Objects.equals(isbn, k.isbn);
        }
        @Override public int hashCode() { return Objects.hash(isbn); }
        @Override public String toString() { return "Mutable[" + isbn + "]"; }
    }

    // --- 4. record: equals and hashCode FOR FREE ---
    record RecordKey(String isbn, int copy) { }

    // --- 5. constant hashCode: legal but catastrophic ---
    static class SillyKey {
        final String isbn;
        SillyKey(String isbn) { this.isbn = isbn; }
        @Override public boolean equals(Object o) {
            return (o instanceof SillyKey k) && Objects.equals(isbn, k.isbn);
        }
        @Override public int hashCode() { return 42; }   // ALL in the same bucket
    }

    public static void main(String[] args) {
        demoBroken();
        demoCorrect();
        demoMutable();
        demoRecord();
        demoSilly();
    }

    static void demoBroken() {
        System.out.println("=== 1. equals WITHOUT hashCode ===");
        BrokenKey a = new BrokenKey("978-0000000001");
        BrokenKey b = new BrokenKey("978-0000000001");

        System.out.println("a.equals(b): " + a.equals(b));            // true
        System.out.println("hash a: " + a.hashCode());
        System.out.println("hash b: " + b.hashCode() + "   <- DIFFERENT");

        Map<BrokenKey, String> map = new HashMap<>();
        map.put(a, "Shelf A-3");
        System.out.println("get(a): " + map.get(a));                  // Shelf A-3
        System.out.println("get(b): " + map.get(b) + "   <- it has been LOST");

        map.put(b, "Shelf B-1");
        System.out.println("size after putting b: " + map.size() + "   <- DUPLICATE KEY");
        System.out.println("Cause: get(b) computes b's hash, goes to ANOTHER bucket,");
        System.out.println("       finds it empty and returns null. equals is NEVER called.\n");
    }

    static void demoCorrect() {
        System.out.println("=== 2. equals AND hashCode ===");
        CorrectKey a = new CorrectKey("978-0000000001");
        CorrectKey b = new CorrectKey("978-0000000001");

        System.out.println("hash a == hash b: " + (a.hashCode() == b.hashCode()));  // true

        Map<CorrectKey, String> map = new HashMap<>();
        map.put(a, "Shelf A-3");
        System.out.println("get(b): " + map.get(b) + "   <- IT WORKS");
        map.put(b, "Shelf B-1");
        System.out.println("size: " + map.size() + "   <- b REPLACED a\n");
    }

    static void demoMutable() {
        System.out.println("=== 3. a MUTABLE key ===");
        MutableKey key = new MutableKey("978-0000000001");
        Map<MutableKey, String> map = new HashMap<>();
        map.put(key, "Effective Java");
        System.out.println("get before mutating: " + map.get(key));

        key.isbn = "978-0000000002";          // we mutate the key ALREADY inserted

        System.out.println("get after mutating:  " + map.get(key) + "   <- LOST");
        System.out.println("containsKey:         " + map.containsKey(key));
        System.out.println("size:                " + map.size() + "   <- still in there");
        System.out.println("contents:            " + map);
        System.out.println("The entry is in the bucket of the OLD hash. Nobody rehomes it:");
        System.out.println("it is memory occupied and unrecoverable, not even with remove.\n");
    }

    static void demoRecord() {
        System.out.println("=== 4. record ===");
        Map<RecordKey, String> map = new HashMap<>();
        map.put(new RecordKey("978-0000000001", 1), "Shelf A-3");
        map.put(new RecordKey("978-0000000001", 2), "Shelf A-4");

        System.out.println("get(a new equal instance): "
                           + map.get(new RecordKey("978-0000000001", 2)));
        System.out.println("size: " + map.size());
        System.out.println("Zero methods written: the record generates them consistently.\n");
    }

    static void demoSilly() {
        System.out.println("=== 5. constant hashCode ===");
        final int N = 50_000;

        Map<CorrectKey, Integer> good = new HashMap<>();
        long t1 = System.nanoTime();
        for (int i = 0; i < N; i++) { good.put(new CorrectKey("ref-" + i), i); }
        for (int i = 0; i < N; i++) { good.get(new CorrectKey("ref-" + i)); }
        long msGood = (System.nanoTime() - t1) / 1_000_000;

        Map<SillyKey, Integer> silly = new HashMap<>();
        long t2 = System.nanoTime();
        for (int i = 0; i < N; i++) { silly.put(new SillyKey("ref-" + i), i); }
        for (int i = 0; i < N; i++) { silly.get(new SillyKey("ref-" + i)); }
        long msSilly = (System.nanoTime() - t2) / 1_000_000;

        System.out.printf("hashCode well distributed: %5d ms%n", msGood);
        System.out.printf("hashCode constant (42):    %5d ms%n", msSilly);
        System.out.println("Every key lands in the SAME bucket. Since Java 8 that bucket");
        System.out.println("becomes a tree, so it degrades to O(log n) instead of O(n);");
        System.out.println("without that safety net it would be catastrophic.");
    }
}

Typical output:

=== 1. equals WITHOUT hashCode ===
a.equals(b): true
hash a: 1735600054
hash b: 21685669   <- DIFFERENT
get(a): Shelf A-3
get(b): null   <- it has been LOST
size after putting b: 2   <- DUPLICATE KEY
...
=== 5. constant hashCode ===
hashCode well distributed:    38 ms
hashCode constant (42):     1420 ms

This is the demonstration 03-09 promised. The five cases together tell the whole story: hashCode picks the bucket and equals picks the entry inside it. If the first fails, the second is never even executed; if the second fails, the right entry is never recognised; if the key mutates, the entry stays in a bucket nobody is going to visit again; and if the hash is badly distributed, the map stops being O(1) even though it keeps working.

Solution 3

package com.nexussoftware.bibliotech.service;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.nexussoftware.bibliotech.domain.Employee;
import com.nexussoftware.bibliotech.domain.Severity;
import com.nexussoftware.bibliotech.domain.Loan;

public class LibraryStatistics {

    private final List<Loan> loans;

    public LibraryStatistics(List<Loan> loans) {
        this.loans = (loans == null) ? List.of() : List.copyOf(loans);
    }

    /** merge: the counter pattern in one line. */
    public Map<Employee, Integer> loansByEmployee() {
        Map<Employee, Integer> result = new HashMap<>();
        for (Loan l : loans) {
            result.merge(l.getEmployee(), 1, Integer::sum);
        }
        return result;
    }

    /** merge with Double::sum: an accumulator, the same pattern. */
    public Map<String, Double> totalFineByType(int currentDay) {
        Map<String, Double> result = new HashMap<>();
        for (Loan l : loans) {
            double fine = l.calculateFine(currentDay);
            if (fine > 0) {
                result.merge(l.getMaterial().getType(), fine, Double::sum);
            }
        }
        return result;
    }

    /** computeIfAbsent: the map-of-lists pattern. */
    public Map<Severity, List<Loan>> groupedBySeverity(int currentDay) {
        Map<Severity, List<Loan>> result = new HashMap<>();
        for (Loan l : loans) {
            Severity s = l.getMaterial().classifySeverity(currentDay);
            result.computeIfAbsent(s, key -> new ArrayList<>()).add(l);
        }
        return result;
        // Note: with an enum as the key, EnumMap would be even more efficient.
        // In 10-04 this will be Collectors.groupingBy in a single expression.
    }

    /** A walk over entrySet to keep the maximum. */
    public Employee busiestEmployee() {
        Employee best = null;
        int max = 0;
        for (Map.Entry<Employee, Integer> e : loansByEmployee().entrySet()) {
            if (e.getValue() > max) {
                max  = e.getValue();
                best = e.getKey();
            }
        }
        return best;       // null if there are no loans
    }

    /**
     * The N most borrowed materials, IN ORDER.
     * A HashMap does not preserve order, so we have to dump, sort and
     * rebuild into a LinkedHashMap, which does preserve it.
     */
    public Map<String, Integer> topMaterials(int howMany) {
        Map<String, Integer> count = new HashMap<>();
        for (Loan l : loans) {
            count.merge(l.getMaterial().getTitle(), 1, Integer::sum);
        }

        List<Map.Entry<String, Integer>> entries = new ArrayList<>(count.entrySet());
        entries.sort(Map.Entry.<String, Integer>comparingByValue().reversed()
                              .thenComparing(Map.Entry.comparingByKey()));

        Map<String, Integer> top = new LinkedHashMap<>();      // preserves insertion order
        int n = 0;
        for (Map.Entry<String, Integer> e : entries) {
            if (n++ >= howMany) { break; }
            top.put(e.getKey(), e.getValue());
        }
        return top;
    }

    public void report(int currentDay) {
        System.out.println("=== BiblioTech statistics (day " + currentDay + ") ===");

        System.out.println("Loans per employee:");
        loansByEmployee().forEach((e, n) ->
            System.out.printf("  %-16s %d%n", e.getName(), n));

        System.out.println("Fines by material type:");
        totalFineByType(currentDay).forEach((type, fine) ->
            System.out.printf("  %-10s %6.2f EUR%n", type, fine));

        System.out.println("Loans by severity:");
        groupedBySeverity(currentDay).forEach((s, list) ->
            System.out.printf("  %-12s %d loans%n", s, list.size()));

        Employee busiest = busiestEmployee();
        System.out.println("Busiest employee: "
                           + (busiest == null ? "(none)" : busiest.getName()));

        System.out.println("Most borrowed materials:");
        topMaterials(3).forEach((title, n) ->
            System.out.printf("  %-26s %d times%n", title, n));
    }
}

Three patterns repeat throughout the class, and they are the three that solve most of the reports you will write in your professional life:

  1. Counter: map.merge(key, 1, Integer::sum).
  2. Accumulator: map.merge(key, value, Double::sum).
  3. Grouper: map.computeIfAbsent(key, k -> new ArrayList<>()).add(element).

And one important limitation the exercise reveals: a map cannot be sorted by value. TreeMap sorts by key. For a "top N" you have to dump entrySet() into a list, sort it with Map.Entry.comparingByValue() and rebuild into a LinkedHashMap if you want to keep the order. It is an idiom worth memorising.

Conclusion

You have crossed the most important frontier of this module. You know that a map associates unique keys with values and finds a value by its key in O(1): a cost that does not grow with the size, against the O(n) of walking a list. With a million elements, the difference is between one operation and five hundred thousand.

You have mastered the complete Map interface: put with its returned previous value, get and getOrDefault, containsKey in O(1) versus containsValue in O(n), remove in its two forms, and the three views keySet/values/entrySet —live, not copies—, knowing that when you need key and value the answer is always entrySet. And you handle the seven Java 8 methods that eliminate half the repetitive code, with the three patterns that solve most real reports: computeIfAbsent for maps of lists, merge for counters and accumulators, and a function that returns null to remove entries on the fly.

You understand the machinery: hashCode reduces the key to a bucket index through a bit mix and an AND with the capacity —always a power of two— and equals distinguishes the entries inside that bucket. You know what a collision is, how entries are chained, when a bucket becomes a red-black tree (8 elements, a table of at least 64) and why that safety net was introduced for security reasons. You know the load factor of 0.75, the rehash that doubles the buckets and rehomes everything in O(n), and therefore you know that put is amortised O(1), just like ArrayList's add. And you know how to size a large map with expected / 0.75 + 1.

And above all, module 3's promise has been kept. You have seen with runnable code what happens when you override equals without hashCode: the object vanishes, because get looks in another bucket and equals is never even executed; and on top of that duplicate keys can be created in a map that guarantees unique keys. You have seen what happens when you mutate an already inserted key: the entry is orphaned in the old hash's bucket, counts towards size(), is visible when printing the map and is unrecoverable. And you have seen what happens with a constant hashCode: legal, and forty times slower. From that come the requirements of a good key —immutable, with equals and hashCode over the same fields, well distributed and cheap— and the best candidates: String, wrappers, enum and, for composite keys, a one-line record.

You know the three implementations and when to use each one: HashMap by default, LinkedHashMap when the order must be reproducible —and with accessOrder plus removeEldestEntry, a complete LRU cache in five lines—, and TreeMap when you need sorted keys, ranges with headMap/subMap or navigation with floorKey/ceilingKey. And you know that Hashtable is Java 1.0 legacy and that the answer to concurrency is ConcurrentHashMap, in module 8.

BiblioTech has changed category. The Catalog maintains a Map<String, Material> by reference and a Map<String, List<Material>> by type, both updated with putIfAbsent and computeIfAbsent: finding a material is instant and that twenty-line reportByType with nested loops has been reduced to, literally, three —and it no longer has the types hard-coded—. The LoanRegistry indexes by loan reference and by employee, with the fines accumulated using merge.

In the next lesson, HashSet, you will see the other side of the same coin. A HashSet is literally a HashMap with dummy values, so it inherits all the machinery you have just understood —and all its requirements about equals and hashCode—. You will learn the Set interface, the set operations (union, intersection, difference, subset) with addAll, retainAll, removeAll and containsAll, how the boolean returned by add solves duplicate detection in one line, the comparison between HashSet, LinkedHashSet and TreeSet with its range navigation, the danger of mutating an element already stored, and why contains on a Set is incomparably faster than on a List. In BiblioTech, the Set<String> of catalogued ISBNs will stop being a stopgap and become a piece of design.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved