10.5
Set and Map: Inside HashMap
Finding a value in a million without searching. This is the section every earlier phase has been feeding, and your own hashCode is what makes it work.
Previously on
This is the section the course has been pointing at since Phase II.
You wrote hashCode() and equals() in Section 8.3, and made a HashMap lose a value on purpose. You learned hash & (n - 1) in Section 3.1, direct indexed access in Section 4.2, >>> in Section 2.2, and nested classes in Section 7.4.
Every one of those turns up in the source of HashMap. Nothing here is new.
The problem
Ask a List whether it contains something:
list.contains("Atlas");It walks every element, calling equals on each, until it finds a match or runs out. On a million elements that is up to a million comparisons.
Now ask a HashSet the same question. It answers immediately, on a million elements, every time.
It is not searching faster. It is not searching at all.
The idea
Build it yourself, in four steps
Suppose you want to store numbers and answer “is this one here?” instantly.
Working out how a hash set has to work
- First idea: use the value as the indexStore 5 at position 5. Asking about 5 means looking at position 5. Instant. But storing the number 1,000,000 needs an array of a million slots, almost all empty.
- Second idea: shrink it with moduloStore the value at position
value % n, where n is the array size. Any number, however big, lands somewhere inside the array. - Problem: two values can land in the same slotWith n = 4, both 4 and 8 give 0. You cannot overwrite one with the other. This is a collision.
- Fix: make each slot a small listA slot holding several values. Collisions go in the same slot, and you compare within it. Each slot is called a bucket.
You have just designed a hash set. Java’s is exactly this, with the modulo replaced by something faster and a few refinements.
Where hashCode comes in
Modulo works on numbers. Your keys are usually not numbers.
So the map asks each key for one:
int hash = key.hashCode();That is the method you wrote in Section 8.3. HashMap calls your code, from code written years before your class existed, using polymorphism from Section 7.2.
What put(key, value) actually does
- Call your hashCode()One int. Might be negative, since every int in Java is signed (Section 2.2).
- Mix the high bits down
h ^ (h >>> 16). Explained below. - Turn it into a bucket number
hash & (n - 1), the power-of-two trick from Section 3.1. Faster than%. - Look in that one bucketUsually empty or holding one thing.
- Call your equals() on what is thereBecause two different keys can share a bucket. If it matches, replace the value. If not, add to the bucket.
Steps 1 and 5 are your methods. The map supplies the rest.
Under the hood
Going deeperWhy hash & (n - 1) and not hash % n
The bucket count is always a power of two. Not a coincidence. It is what makes this work:
53 % 16 // 5
53 & 15 // 5Identical when n is a power of two, and & is far cheaper than division. This is exactly the trick you met in Section 3.1, sitting in the most used class in the standard library.
Why the hash gets mixed first
hash & (n - 1) keeps only the low bits. With 16 buckets it keeps four and discards 28.
Which is a problem when hash codes differ only in the high bits:
So HashMap mixes them first:
static int hash(Object key) {
int h = key.hashCode();
return h ^ (h >>> 16);
}>>> 16 shifts the top 16 bits down. XOR mixes them into the bottom. Now the high bits affect the bucket choice.
You can see it work:
| Hash code | Bucket in a 16 slot table | |
|---|---|---|
| 65536, no mixing | 65536 & 15 | 0 |
| 131072, no mixing | 131072 & 15 | 0 <- collision |
| 65536, mixed | (65536 ^ 65536>>>16) & 15 | 1 |
| 131072, mixed | (131072 ^ 131072>>>16) & 15 | 2 <- no collision |
One shift and one XOR, using nothing but Section 2.2 and Section 3.1.
What is inside a bucket
Each entry is stored in a small object:
static class Node<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next; // the next entry in this same bucket
}A static nested class, from Section 7.4. Static, because a Node does not need to reach back into the map.
next makes each bucket a small linked list, which is step four of the design above.
Growing
A HashMap starts with 16 buckets and a load factor of 0.75. When the number of entries passes 75% of the bucket count, it doubles the table and redistributes everything.
Same idea as ArrayList growing, and as StringBuilder in Section 9.2. Copy rarely, double each time.
If you know roughly how many entries you will have, say so and skip the rehashing:
Map<String, Integer> m = new HashMap<>(1000);What a bad hashCode costs
Return a constant and everything still works. Every result is correct.
public int hashCode() { return 1; } // legal. Contract satisfied.Every key now lands in one bucket. That bucket becomes a list, and every lookup walks it.
| 40,000 keys inserted | Time | |
|---|---|---|
| hashCode returns a constant | one bucket, one long list | 4268 ms |
| hashCode uses the id | spread across buckets | 3 ms |
This is why hashCode is not boilerplate you generate and forget. A correct but poor one turns your map into the list you were avoiding.
HashSet is a HashMap wearing a hat
A HashSet holds keys and no values. So Java did not write a second implementation:
public class HashSet<E> {
private transient HashMap<E, Object> map;
private static final Object PRESENT = new Object();
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
}Every element becomes a key, and every value is the same dummy object. add returns true when put returned null, meaning the key was not already there. This is the boolean from Section 10.3.
Which means everything in this section applies to HashSet unchanged.
The three Sets and the three Maps
| Implementation | Order, and what it costs | |
|---|---|---|
| HashSet / HashMap | no order at all | fastest. Use unless you need order. |
| LinkedHashSet / LinkedHashMap | insertion order | a little slower, extra references per entry |
| TreeSet / TreeMap | sorted order | noticeably slower, needs Comparable |
List.of("pear", "apple", "fig", "date")
HashSet -> [date, apple, pear, fig] no order you can rely on
LinkedHashSet -> [pear, apple, fig, date] the order you added them
TreeSet -> [apple, date, fig, pear] sortedHashSet’s order is not random, it is bucket order, and it can change when the map grows. Never write code that depends on it.
TreeSet and TreeMap are not hash based at all. They use a balanced tree, keep everything sorted, and need to compare keys, which is Section 10.8.
The disaster, in its natural habitat
You made this happen deliberately in the Section 8.3 exercises. Here it is where it actually bites.
Map<Mut, String> map = new HashMap<>();
Mut key = new Mut(1);
map.put(key, "the value");
// get = the value, containsKey = true, size = 1
key.id = 99; // change a field used by hashCode
// get = null, containsKey = false, size = 1The entry sits in the bucket the key hashed to when you put it in. The key now hashes elsewhere, so every lookup checks the wrong bucket.
It gets worse:
map.remove(key); // returns null. You cannot even delete it.
for (Map.Entry<Mut, String> e : map.entrySet()) {
System.out.println(e); // Mut(99)=the value. It is right there.
}Iterating walks the buckets directly without hashing, so it finds the entry that no lookup can reach. Counted by size(), visible when you iterate, unreachable by key, and impossible to remove.
Map methods worth knowing
Map<String, Integer> counts = new HashMap<>();
counts.merge(word, 1, Integer::sum); // add 1, or start at 1
counts.getOrDefault("missing", 0); // no null check needed
counts.computeIfAbsent(key, k -> 0); // insert only if absent
counts.putIfAbsent("a", 99); // returns the existing value if there is onemerge counting words is a whole loop in one line:
for (String w : "a b a c b a".split(" ")) counts.merge(w, 1, Integer::sum);
// {a=3, b=2, c=1}getOrDefault also removes the NullPointerException from Section 7.3, where int x = map.get(missing) unboxes a null.
What it costs
A hash based collection gives you no order, and the order it does have looks stable enough to tempt you into relying on it. Then the map grows, everything redistributes, and code that worked breaks.
It uses more memory than a list. An array of buckets that is kept about a quarter empty on purpose, plus a Node object per entry.
It depends entirely on you. hashCode and equals have to agree, and neither the compiler nor the runtime checks. Get it wrong and entries vanish silently.
A poor hashCode also costs you everything the structure was for, with no error to point at it.
And keys must not change. Nothing enforces that either.
What you get is a lookup that costs the same on ten entries and ten million. That single property is why HashMap is the most used class in Java after String.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
A `List` has to check every element to answer `contains`. A `HashMap` answers instantly on a million entries. How?
Show the answer
It does not search. It calculates where the answer would be.
Underneath is an array of buckets. Given a key, the map calls your
hashCode(), turns that number into a bucket index, and looks in that one bucket. Everything else is ignored.Turning a hash into an index is the same arithmetic as reaching an array position in Section 4.2. One calculation, then one jump, whatever the size.
Then it calls your
equals()on whatever is in that bucket, because two different keys can land in the same one.You return a constant from `hashCode()`. Everything still works. What did you break?
Show the answer
Speed, completely, and nothing tells you.
Every key now hashes to the same number, so every entry lands in the same bucket. That bucket becomes a list, and every lookup walks it. Constant time has become linear time.
Measured with 40,000 keys: about 4268 ms with a constant hashCode, against about 3 ms with a proper one. Around 1400 times slower, with correct results and no error anywhere.
This is why
hashCodeis not decoration. A correct but poor one turns your map into the list you were trying to avoid.Why does `HashMap` mix the hash with `h ^ (h >>> 16)` before choosing a bucket?
Show the answer
Because choosing a bucket only looks at the low bits, and some hash codes differ only in the high ones.
The bucket index is
hash & (n - 1). With 16 buckets that is& 15, which keeps the bottom four bits and throws the other 28 away.So two keys hashing to 65536 and 131072 both give 0 in the low bits and collide, even though the numbers are far apart.
h ^ (h >>> 16)shifts the top 16 bits down and XORs them in, so the high bits get a say. It is one shift and one XOR, using exactly the operators from Section 3.1, and it costs almost nothing.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
5 exercises130 pointsabout 145 minutes
Build a Hash Set From Scratch
ex-10-5-aBuild a hash set before you read Java’s.
Forty lines, and every piece is something you already have: an array from Phase IV, hashCode from Phase VIII, and the mask and shift from Phase III.
The bucket report is the part that proves it works. If your entries spread out evenly across the buckets, your hashing is doing its job. If one bucket holds everything, you have accidentally written the constant hashCode from the next exercise.
Once this runs, HashMap’s source is a longer version of what you just wrote.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class MiniSet {
int bucketFor(Object v)
public boolean add(Object v)
public boolean contains(Object v)
public int size()
public int[] bucketSizes()
}bucketFor has no modifier so the tests can reach it. add returns false when the value is already there. bucketSizes returns how many values sit in each of the 16 buckets, which is how you check your hashing is spreading them.
What your program must do
- Implement bucketFor with spreading and the power-of-two mask
- Implement add so duplicates return false
- Implement contains so it only looks in one bucket
- Implement bucketSizes and show the values are spread out, not piled up
import java.util.*;
public class MiniSet {
private static class Node {
Object value;
Node next;
Node(Object v) { value = v; }
}
private Node[] buckets = new Node[16];
private int size;
// hashCode, spread with ^ (h >>> 16), then mask with & (buckets.length - 1).
int bucketFor(Object v) {
return 0; // TODO
}
// false when the value is already in the set.
public boolean add(Object v) {
return false; // TODO
}
// Look in ONE bucket only. That is the whole point.
public boolean contains(Object v) {
return false; // TODO
}
public int size() {
return size;
}
// How many values sit in each of the 16 buckets.
public int[] bucketSizes() {
return new int[16]; // TODO
}
public static void main(String[] args) {
// TODO: add 1000 strings, print bucketSizes, show they are spread out
// TODO: add a duplicate and show add returns false
}
}
Hint 1
bucketFor is three steps: int h = v.hashCode(); h = h ^ (h >>> 16); return h & (buckets.length - 1);Hint 2
add walks only its own bucket looking for an equal value. If it finds one, return false. If not, put a new Node at the front of that bucket and increase size.Hint 3almost the answer
bucketSizes shows one bucket holding nearly everything, bucketFor is returning close to a constant. With 1000 values in 16 buckets you should see roughly 60 in each.Measure a Bad hashCode
ex-10-5-bWrite a hashCode that is completely legal and completely useless, then measure it.
The contract says equal objects must have equal hash codes. Returning a constant satisfies that perfectly. Every lookup returns the right answer, no exception is thrown, and nothing warns you.
Time it against a proper one. The gap is large enough to be unmistakable.
Then try the middle case, id % 4. Better than a constant and still bad, and working out why tells you what “a good hashCode” actually means: not correctness, but spread.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class BadHash {
static long timeConstant(int n)
static long timeProper(int n)
static long timeModFour(int n)
static int sizeWithConstantHash(int n)
static boolean lookupsStillCorrect(int n)
}Each timing method builds a HashMap of n keys of one class and returns milliseconds. The last two prove the constant version is still correct, which is the uncomfortable part.
What your program must do
- Time inserting 40,000 keys with each of the three hashCodes
- Confirm the constant version still has the right size and the right lookups
- Rank the three and say how many times slower the worst one is
- Explain what correct but useless means here
import java.util.*;
public class BadHash {
static class Constant {
final int id;
Constant(int i) { id = i; }
@Override public boolean equals(Object o) { return o instanceof Constant c && c.id == id; }
@Override public int hashCode() { return 1; } // legal. Contract satisfied.
}
static class Proper {
final int id;
Proper(int i) { id = i; }
@Override public boolean equals(Object o) { return o instanceof Proper p && p.id == id; }
@Override public int hashCode() { return Integer.hashCode(id); }
}
static class ModFour {
final int id;
ModFour(int i) { id = i; }
@Override public boolean equals(Object o) { return o instanceof ModFour m && m.id == id; }
@Override public int hashCode() { return id % 4; }
}
// Build a HashMap of n keys of each class. Return milliseconds.
static long timeConstant(int n) { return 0; } // TODO
static long timeProper(int n) { return 0; } // TODO
static long timeModFour(int n) { return 0; } // TODO
// The uncomfortable part: prove the slow one is still RIGHT.
static int sizeWithConstantHash(int n) {
return 0; // TODO
}
static boolean lookupsStillCorrect(int n) {
return false; // TODO
}
public static void main(String[] args) {
// TODO: time all three at 40_000 and print them
// TODO: print the size and the correctness check too
}
}
Hint 1
equals, so all 40,000 belong in the map whatever the hashCode says.Hint 2
Hint 3almost the answer
Watch the Spreading Work
ex-10-5-cFour hash codes, all landing in the same bucket, and one shift plus one XOR fixes it.
Run the unmixed version first and watch all four collide. Then mix them and watch them separate.
Print the bits for one of them before and after. Seeing the top half fold down into the bottom is what makes the trick obvious.
Finish with the negative hash. hashCode returns a signed int, so it can be negative, and % would give you a negative index. & cannot. That is two earlier phases quietly solving a problem together.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Spread {
static int bucket(int hash, int n)
static int spread(int h)
static int[] bucketsWithoutSpread(int[] hashes, int n)
static int[] bucketsWithSpread(int[] hashes, int n)
static String bits(int h)
}bits returns all 32 binary digits, padded with leading zeros, so two numbers line up when you print them one above the other.
What your program must do
- Show all four hashes landing in the same bucket without spreading
- Show them spreading out once mixed
- Print the bits of one hash before and after mixing
- Show that a negative hash still gives a valid index, and say why
import java.util.*;
public class Spread {
// Mask into the array. n is always a power of two.
static int bucket(int hash, int n) {
return 0; // TODO
}
// Mix the high bits down into the low ones.
static int spread(int h) {
return h; // TODO
}
static int[] bucketsWithoutSpread(int[] hashes, int n) {
return new int[hashes.length]; // TODO
}
static int[] bucketsWithSpread(int[] hashes, int n) {
return new int[hashes.length]; // TODO
}
// All 32 bits, padded with leading zeros.
static String bits(int h) {
return ""; // TODO
}
public static void main(String[] args) {
int[] hashes = {65536, 131072, 196608, 262144};
// TODO: print the buckets without spreading. All four land in the same one
// TODO: print them with spreading, and compare
// TODO: print the bits of 65536 before and after mixing, one above the other
// TODO: check that a negative hash still gives a valid index
}
}
Hint 1
h & (n - 1) keeps only the low bits. With n of 16, that is the low four bits and nothing else.Hint 2
h ^ (h >>> 16) folds the high half down so the mask can see it.Hint 3almost the answer
& throws away every bit above the low four, including the sign bit. The % operator would not: -17 % 16 is -1, and there is no bucket -1.The Vanishing Entry, For Real
ex-10-5-dThe bug from Section 8.3, now in the place it actually happens to people.
Show all four symptoms: get returns null, containsKey says false, remove cannot delete it, and size still counts it. Then iterate and see the entry sitting right there.
That combination is genuinely unsettling the first time, and it is worth being unsettled by it once.
Then redesign Unit so it cannot happen. Not “remember not to change ids”, but a design where changing one is not possible. That is the difference between a rule you have to follow and a rule the compiler follows for you.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class Vanish {
static String lookupBeforeChange()
static boolean getFailsAfterChange()
static boolean containsKeyFailsAfterChange()
static int sizeAfterChange()
static boolean entryStillThere()
static boolean fixedVersionCannotBreak()
}Every method builds its own map with Atlas#101 mapped to "north patrol". The middle four change atlas.id to 999 afterwards and report what the map does. The last one uses a redesigned class where the bug cannot be written.
What your program must do
- Show the lookup working before the change
- Show get, containsKey and remove all failing after it, while size still says 1
- Iterate the map and show the entry sitting there
- Redesign Unit so the bug is impossible, and explain what you changed
import java.util.*;
public class Vanish {
static class Unit {
String name;
int id; // not final, and that is the bug
Unit(String n, int i) { name = n; id = i; }
@Override public boolean equals(Object o) { return o instanceof Unit u && u.id == id; }
@Override public int hashCode() { return Integer.hashCode(id); }
@Override public String toString() { return name + "#" + id; }
}
// TODO: a second class, the same but with the bug made impossible
// Put Atlas#101 -> "north patrol", then get it straight back.
static String lookupBeforeChange() {
return null; // TODO
}
// Put it in, change atlas.id to 999, then report whether get returns null.
static boolean getFailsAfterChange() {
return false; // TODO
}
static boolean containsKeyFailsAfterChange() {
return false; // TODO
}
// The map still says it holds how many?
static int sizeAfterChange() {
return 0; // TODO
}
// Walk the entries. Is the entry still physically in there?
static boolean entryStillThere() {
return false; // TODO
}
// The same scenario with your redesigned class. Nothing can go wrong.
static boolean fixedVersionCannotBreak() {
return false; // TODO
}
public static void main(String[] args) {
// TODO: print all six and read them in order
}
}
Hint 1
get now looks in a completely different bucket.Hint 2
size still says 1 and iterating still finds it. Iterating walks the buckets one by one and never asks for a hash, so it sees what get cannot reach.Hint 3almost the answer
final on every field that hashCode reads. If the value can never change, the hash can never move, and this whole class of bug stops being writable.HashSet, LinkedHashSet, TreeSet
ex-10-5-eThree sets, same input, three different answers.
The important experiment is the second one. Print a HashSet as it grows and watch the order of the early items change when the table doubles. Any code that relied on that order is now broken, and nothing threw.
Then compare their timings and connect what you observe to the work each structure does on insertion. Do not
treat one short timing run as a universal speed ranking: the durable distinction is that TreeSet maintains
sorted order by comparing during adds.
Finish by putting a class with no natural order into a TreeSet. The exception arrives on the very first add, before there is anything to compare against, and working out why is a good clue about what a tree actually does.
What to write
These exact signatures. The checks call them by name, so a different name or a different parameter type will not build.
class ThreeSets {
static List<String> hashSetOrder(List<String> input)
static List<String> linkedHashSetOrder(List<String> input)
static List<String> treeSetOrder(List<String> input)
static String treeSetWithNoNaturalOrder()
static long timeInserts(Set<Integer> target, int n)
}The three order methods put the input into that kind of set and return what walking it gives back. treeSetWithNoNaturalOrder adds a plain Object to a TreeSet and returns "ok" or the name of the exception.
What your program must do
- Show all three orderings for the same input
- Time 200,000 insertions into each, compare the results, and explain the extra work sorted order requires
- Put a class with no natural order into a TreeSet and record the exception
- Say which of the three you would reach for by default, and why
import java.util.*;
public class ThreeSets {
static List<String> hashSetOrder(List<String> input) {
return List.of(); // TODO
}
static List<String> linkedHashSetOrder(List<String> input) {
return List.of(); // TODO
}
static List<String> treeSetOrder(List<String> input) {
return List.of(); // TODO
}
// Add a plain Object to a TreeSet. Return "ok" or the exception's name.
static String treeSetWithNoNaturalOrder() {
return "ok"; // TODO
}
// Add 0 to n-1 to the set given. Return milliseconds.
static long timeInserts(Set<Integer> target, int n) {
return 0; // TODO
}
public static void main(String[] args) {
List<String> input = List.of("pear", "apple", "fig", "date", "cherry");
// TODO: print all three orderings for the same input
// TODO: time 200_000 insertions into each of the three and rank them
// TODO: print what a TreeSet does with something that has no natural order
}
}
Hint 1
LinkedHashSet keeps a linked list running through the entries in insertion order. TreeSet keeps them in a sorted tree instead.Hint 2
Hint 3almost the answer
Comparable. A plain Object is not one, and the ClassCastException arrives when the program runs, not when it compiles.After the credits
You have now seen the payoff for six phases of groundwork, and every piece of it was something you already had.
Section 10.6 covers the rest of the family, including two that skip hashing altogether. EnumMap is an array indexed by ordinal(), from Section 8.4, which is why it beats HashMap for enum keys. IdentityHashMap deliberately uses == instead of equals, and there is one job it is right for.
Then TreeMap needs to know which key comes first, and Collections.sort needs the same thing for lists. Neither can work it out for themselves.
All of that is Comparable, in Section 10.8. It has a contract like the one you learned here, and it fails just as quietly: break it and sort can throw, or silently produce an order that is wrong.
Threads you opened in this section
- HashMapA long bucket turns into a tree, and a tree needs an order.10.8 - Comparable, Comparator, and Sorting
- HashMapHashMap breaks under threads. ConcurrentHashMap is the answer.Phase XIV. Concurrency
- SetEnumSet is a set stored as bits, one per constant.10.6 - Map Methods, EnumMap, and the Rest of the Family
HashMap will return in 10.6 - Map Methods, EnumMap, and the Rest of the Family