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
- What a map is and why it changes everything
- The
Mapinterface: basic operations - Traversing a map correctly
- The methods that save you half your code
- How a
HashMapworks inside - Collisions, collision lists and trees
- Load factor and rehash
- The promise kept: why
equalsandhashCodego together - The danger of mutable keys
- Requirements for a good key
HashMap,LinkedHashMapandTreeMap- An LRU cache in five lines
Hashtable: the legacy- Applying it to BiblioTech
- Common Mistakes and Tips
- Exercises
- 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"); // instantCompare 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().
- The
Map interface: basic operations
Map interface: basic operationsThe 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 REPLACEDput 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 registeredgetOrDefault(key, fallback) is one of the most useful methods in the Framework. It replaces this:
with this:
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 matchesNotice 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();
- 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 matchAnd, 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).
- 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:
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
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.
- How a
HashMap works inside
HashMap works insideNow 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 defaultThe idea, in three steps:
Step 1: compute the key's hash. key.hashCode() is called, returning an int (about 4,300 million possible values).
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:
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
- 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:
- Its hash and index are computed: bucket 3.
- That bucket's list is walked, comparing: first by
hash(fast, it is anint) and, if that matches, withequals(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:
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).
- 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.
With the initial capacity of 16: threshold = 16 × 0.75 = 12. When entry number 13 is inserted, a rehash is triggered:
- A new array with double the buckets (32) is created.
- Every entry is rehomed, recomputing its index with the new capacity.
- 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.
- The promise kept: why
equals and hashCode go together
equals and hashCode go togetherThe moment has come. In 03-09 you learned the contract:
If
a.equals(b)istrue, thena.hashCode() == b.hashCode()must betrue.(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 KEYThe 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 c1Now 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.
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.
- 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)); // nullAnd 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 catastropheThe solution is always the same: keys must be immutable, or at least the fields taking part in equals and hashCode must be.
- 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:
String: immutable, with correctequals/hashCodeand an internally cached hash. It is by far the most common key.- Numeric wrappers (
Integer,Long): immutable and with a trivial hash. enum: immutable by construction, with a unique identity. And if your key is an enum, considerEnumMap, an extremely efficient specialised implementation.record(04-07): generatedequalsandhashCode, consistent and over immutable fields. The ideal candidate for composite keys.- 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
hashCodeis the one inherited fromObject, based on identity.map.get(new int[]{1,2})will never find the entry stored withnew int[]{1,2}. UseList.of(1, 2)instead. - Objects with an expensive
hashCodethat 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-4One declaration line and it is already a perfect key.
HashMap, LinkedHashMap and TreeMap
HashMap, LinkedHashMap and TreeMapThe 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, DVDUse 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 traversalAnd 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".
- An LRU cache in five lines
LinkedHashMap has a little-known and very elegant capability. Its third constructor accepts an ordering mode:
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 usedNotice 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.
Hashtable: the legacy
Hashtable: the legacyHashtable 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.
- 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 aMap<String, List<Material>>withcomputeIfAbsent.List<Material> find(String word): the materials containing that word, or an empty list.Map<String, Integer> frequencies(): how many times each word appears, withmerge.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:
- A class
BrokenKeywithequalsbut nohashCode: put an object into aHashMapand show that an equal object does not find it, and that duplicate keys can be created. - A class
CorrectKeywith both: show that it works. - A class
MutableKey: put an object in, mutate the key and show that the entry becomes unreachable but still counts towardssize(). - A
record RecordKey: show that it works without writing a single method. - A class
SillyKeywhosehashCode()always returns 42: measure the time of 50,000 insertions and lookups, and compare it withCorrectKey.
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,nullif there are none.Map<String, Integer> topMaterials(int howMany): the N most borrowed materials, in aLinkedHashMapthat 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:
- Counter:
map.merge(key, 1, Integer::sum). - Accumulator:
map.merge(key, value, Double::sum). - 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
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
