Lesson 43Phase 4: Collections and algorithms
Inside ArrayList and HashMap: build your own
Writing a growable list and a hash map from scratch, so the library classes stop being magic.
By BasithUpdated 60 min
- dynamic array
- capacity
- resize
- amortized cost
- hash table
- buckets
You have used ArrayList and HashMap for many lessons, and you have looked inside both: the backing array with its spare slots, and the table of buckets. In this lesson you write a small version of each class yourself and watch every array and node it makes in the memory view as it runs. By the end you will know what the two classes do inside, because you will have written the same steps.
- Build a list that grows: an array with spare room, a count of the slots in use, and an
addthat copies everything into a bigger array when the old one is full. - Write a
getthat refuses an index outside the list, and aremovethat shifts the later elements left. - Explain why doubling the array gives
addan amortized cost of O(1), and why growing it one slot at a time makes filling a list O(n²). - Make the list generic, and say why that needs the cast
(T[]) new Object[n]and@SuppressWarnings("unchecked"). - Build a hash map from an array of buckets, each holding a chain of nodes, with a bucket number that is never negative, a
putthat replaces the value of an existing key, and aget. - Double the table once the map passes a load factor of 0.75, and explain what a
hashCodethat always returns the same number does to the map's speed.
new T[10] does not compile, equals, hashCode and toString set out the rules a hashCode must keep, and Big-O gave you names for the costs you will measure here.A list that grows#
An array has a fixed length. You choose it when you create the array, and it never changes. A list has to take as many elements as you add, without being told how many in advance. ArrayList gets round this with a plan you can copy: keep the elements in an array that is longer than you need, count how many of its slots are in use, and when every slot is taken, move everything into a bigger array.
Think of a bookshelf. While there is a gap, a new book goes into the next gap. When the shelf is full, you buy a shelf twice as long, move the books across in the same order, and put the new book after them. The old shelf is thrown away.
An array used this way is called a dynamic array. It needs two fields: the array, here called items, and size, the number of slots in use. The array's length is the list's capacity. Slots 0 to size - 1 hold the elements, and the slots after them are spare. Here is a playlist built that way. Its array starts with two slots.
public class Main { public static void main(String[] args) { Playlist songs = new Playlist(); songs.add("Intro"); songs.add("Waves"); songs.add("Echo"); songs.add("Tides"); songs.add("Rain"); System.out.println(songs.size() + " songs in " + songs.capacity() + " slots"); System.out.println("Song 4 is " + songs.get(4)); }} class Playlist { private String[] items = new String[2]; private int size; void add(String song) { if (size == items.length) { String[] bigger = new String[items.length * 2]; for (int i = 0; i < size; i++) { bigger[i] = items[i]; } items = bigger; } items[size] = song; size++; } String get(int index) { return items[index]; } int size() { return size; } int capacity() { return items.length; }}It prints 5 songs in 8 slots and Song 4 is Rain. Both fields are private, so code outside the class can use the playlist only through its methods, the same as a real ArrayList.
Read add from the bottom up. Usually there is a spare slot: the song goes into items[size], the first free slot, and size++ moves the count on, so the next song lands in the slot after it. The if at the top deals with a full array, when size equals items.length. It makes an array twice as long, copies every song into the same index of the new array, and points items at the new array. Only then does the new song go in.
Step through the third add with the memory view open. The condition is true, and a second array titled bigger appears under Arrays, with four empty slots. The loop copies Intro and Waves into it. Then the step that sets items points the playlist's items field at bigger, and the old two-slot array is left with nothing pointing at it. The fifth add does it all again, with eight slots.
Key ideaA growing list is an array with spare room and a count of the slots in use. When the room runs out, add copies everything into a bigger array and carries on.
Arrays.copyOf does the copying#
Copying an array into a longer one is common enough that java.util.Arrays has a method for it. Arrays.copyOf(items, newLength) makes a new array of the length you ask for, copies the elements of items into the start of it, fills the rest with null (or 0 for an int array), and returns the new array. One line replaces the new array, the loop and the reassignment:
void add(String song) { if (size == items.length) { items = Arrays.copyOf(items, items.length * 2); } items[size] = song; size++;}The file needs import java.util.Arrays; at the top. The programs from here on use this version of add.
3 songs in 4 slots: [Intro, Waves, Echo, null]. Two lines near the end are easy to swap; work out what each of them needs to have happened first.Tap a line, then tap where it should go. Or use the arrows.
get needs a bounds check#
The first get just returns items[index], and trusts the caller to ask only for a song that is in the list. Decide what this program prints before you run it.
import java.util.Arrays; public class Main { public static void main(String[] args) { Playlist songs = new Playlist(); songs.add("Intro"); songs.add("Waves"); songs.add("Echo"); System.out.println(songs.get(2)); System.out.println(songs.get(3)); }} class Playlist { private String[] items = new String[2]; private int size; void add(String song) { if (size == items.length) { items = Arrays.copyOf(items, items.length * 2); } items[size] = song; size++; } String get(int index) { return items[index]; }}The playlist holds three songs. What does the program print? If you think a line throws an exception, pick the output printed before it.
It prints Echo and then null. The playlist holds three songs, but after the third add its array has four slots, so items[3] is a real slot: the spare one, which holds null. The list hands back something nobody added. Ask for get(9) and you do get an exception, but the wrong one: ArrayIndexOutOfBoundsException: Index 9 out of bounds for length 4. The 4 in it is the capacity, a detail of how the list stores its songs. A list of three songs should refuse every index from 3 up.
The fix is one line at the top of get. Objects.checkIndex(index, size), from java.util.Objects, checks that index is at least 0 and less than size. If it is, the method hands the index back and get carries on. If not, it throws an IndexOutOfBoundsException.
import java.util.Arrays;import java.util.Objects; public class Main { public static void main(String[] args) { Playlist songs = new Playlist(); songs.add("Intro"); songs.add("Waves"); songs.add("Echo"); System.out.println(songs.get(2)); System.out.println(songs.get(3)); }} class Playlist { private String[] items = new String[2]; private int size; void add(String song) { if (size == items.length) { items = Arrays.copyOf(items, items.length * 2); } items[size] = song; size++; } String get(int index) { Objects.checkIndex(index, size); return items[index]; }}IndexOutOfBoundsException: Index 3 out of bounds for length 3
A list position must be at least 0 and less than size().
This prints Echo, then stops with IndexOutOfBoundsException: Index 3 out of bounds for length 3. That is word for word the message an ArrayList gave you in the ArrayList lesson when you asked for one past the end, and for a good reason. In OpenJDK, the code the usual JDK downloads are built from, ArrayList.get starts with the same call, Objects.checkIndex(index, size). The method has been in Java since version 9.
remove shifts the rest left#
Taking an element out of the middle leaves a gap, and a list's elements must stay in slots 0 to size - 1 with no gaps. So remove moves every element after the gap one slot to the left. Step through it and watch the array in the memory view.
import java.util.Arrays;import java.util.Objects; public class Main { public static void main(String[] args) { Playlist songs = new Playlist(); songs.add("Intro"); songs.add("Waves"); songs.add("Echo"); songs.add("Tides"); System.out.println(songs); String gone = songs.remove(1); System.out.println("Removed " + gone + ": " + songs); }} class Playlist { private String[] items = new String[2]; private int size; void add(String song) { if (size == items.length) { items = Arrays.copyOf(items, items.length * 2); } items[size] = song; size++; } String remove(int index) { Objects.checkIndex(index, size); String removed = items[index]; for (int i = index; i < size - 1; i++) { items[i] = items[i + 1]; } size--; items[size] = null; return removed; } @Override public String toString() { return Arrays.toString(Arrays.copyOf(items, size)); }}It prints [Intro, Waves, Echo, Tides], then Removed Waves: [Intro, Echo, Tides]. remove(1) checks the index and keeps Waves in removed, to return at the end. The loop then writes Echo into items[1] and Tides into items[2], each pass copying from the slot on its right. size-- shrinks the list to three songs, and the last write puts null into items[3].
That nullmatters, even though slot 3 is outside the list now. Without it, the array would still refer to the object in that slot. Remove the last song and nothing shifts at all, so the array would go on holding the song you removed, and the garbage collector could never free it while the playlist exists. OpenJDK's ArrayList clears the slot in the same way.
toString copies the slots in use into a shorter array and lets Arrays.toString format it, which is why the spare slots never show when you print a playlist.
How long remove takes depends on where you remove from. remove(0) shifts every other element: O(n). Removing the last element shifts nothing: O(1). Those are the costs in the table from Big-O, and now you have written the loop behind them.
add(int index, String song), which puts song at position index and moves the songs from that position on one slot to the right. index can be anything from 0 to size, where size means the end of the list. The calls in main insert at the front, in the middle and at the end, and two of them make the array grow.Why the array doubles#
Doubling is a choice. add could make the new array just one slot longer, which never wastes a slot. To compare the two rules, this program fills an array that starts with one slot, for a few values of n, and counts how many values each rule copies along the way.
import java.util.Arrays; public class Main { public static void main(String[] args) { for (int n = 8; n <= 64; n = n * 2) { System.out.println(n + " adds: doubling copies " + copiesToFill(n, true) + ", growing by one copies " + copiesToFill(n, false)); } } // Adds n values to an array that starts with one slot, and counts the values copied. static int copiesToFill(int n, boolean doubling) { int[] items = new int[1]; int size = 0; int copies = 0; for (int value = 1; value <= n; value++) { if (size == items.length) { int newLength = doubling ? items.length * 2 : items.length + 1; items = Arrays.copyOf(items, newLength); copies = copies + size; } items[size] = value; size++; } return copies; }}With doubling, filling 64 slots copies 63 values. The copies happen when the array holds 1, 2, 4, 8, 16 and 32 values, and 1 + 2 + 4 + 8 + 16 + 32 = 63. Each copy moves one value more than all the copies before it put together (32 is 1 + 2 + 4 + 8 + 16, plus 1), so the total stays below n however large n gets. Adding n values costs n writes plus fewer than n copies: less than 2n steps in all, or less than 2 steps per add.
A single add can still be slow. The one that finds the array full copies the whole list, which is O(n) on its own. But a copy of k values doubles the room, so the next copy is k adds away. Spread over those k adds, the copy costs about one step each, so on average every add costs a constant amount. That is the amortized cost you met in Big-O: add is O(1) amortized. The ArrayList documentation makes exactly this promise: adding n elements takes O(n) time.
Growing by one slot behaves very differently. Once the array is full, every add finds it full again, so every add copies the whole list: 1 + 2 + 3 + ... + 63 = 2,016 copies for 64 values. That sum is n × (n - 1) / 2, about half of n², so filling a list this way is O(n²). The output shows the square: each time n doubles, the copies grow about four times, from 28 to 120 to 496 to 2,016.
| Values added | Copies when the array doubles | Copies when it grows by one slot |
|---|---|---|
| 64 | 63 | 2,016 |
| 1,000 | 1,023 | 499,500 |
| 1,000,000 | 1,048,575 | 499,999,500,000 |
For a million values, doubling copies about a million values, roughly one per add. Growing by one slot copies about half a trillion, roughly half a million per add.
Key ideaGrow an array by multiplying its length, never by adding a fixed number of slots. Multiplying keeps the total copying in proportion to the number of elements, so each add costs O(1) amortized.
A list of any type#
Playlist holds only strings. A real list class works for any element type, so the next step is a generic class, GrowList<T>, as in generics. Most of the change is renaming String to T. One line refuses to change: the one that creates the array. new T[2] does not compile. javac stops with generic array creation, because the running program no longer knows what T is, and an array has to know its element type to check every value stored in it.
The usual way round it is to make an Object[], which can hold any object, and cast it to T[]:
import java.util.Arrays;import java.util.Objects; public class Main { public static void main(String[] args) { GrowList<String> songs = new GrowList<>(); songs.add("Intro"); songs.add("Waves"); songs.add("Echo"); System.out.println(songs + ", song 1 is " + songs.get(1)); GrowList<Integer> scores = new GrowList<>(); scores.add(72); scores.add(90); int total = scores.get(0) + scores.get(1); System.out.println(scores + ", total " + total); }} class GrowList<T> { private T[] items; private int size; @SuppressWarnings("unchecked") GrowList() { items = (T[]) new Object[2]; } void add(T item) { if (size == items.length) { items = Arrays.copyOf(items, items.length * 2); } items[size] = item; size++; } T get(int index) { Objects.checkIndex(index, size); return items[index]; } @Override public String toString() { return Arrays.toString(Arrays.copyOf(items, size)); }}It prints [Intro, Waves, Echo], song 1 is Waves, then [72, 90], total 162: one class holds strings in one list and Integers in another. Step through it and look at the type printed next to each array in the memory view. It says Object[], for the list of strings as well. The cast changed what the compiler believes about the array, and nothing about the array itself.
javac accepts the cast with a warning. Take away the @SuppressWarnings line and compile, and javac prints a note; ask it for the details and you get this:
$ javac Main.javaNote: Main.java uses unchecked or unsafe operations.Note: Recompile with -Xlint:unchecked for details.$ javac -Xlint:unchecked Main.javaMain.java:25: warning: [unchecked] unchecked cast items = (T[]) new Object[2]; ^ required: T[] found: Object[] where T is a type-variable: T extends Object declared in class GrowList1 warning“Unchecked” means javac cannot check the cast for you. Because of erasure, T is gone when the program runs, so (T[]) has nothing to compare the array with, and the cast never fails. In general that is a risk: a variable of type T[] could end up holding an array that is not a T[] at all, and the trouble would show up far away from the cast.
In GrowList it cannot happen, for two reasons. The array is private and no method hands it out, so no code outside the class ever sees it. And the only way in is add(T item), where javac checks that every value really is a T. Every element that get returns went in through add, so it really is a T. @SuppressWarnings("unchecked") tells javac that you have checked this cast yourself, and it silences the warning for the constructor only. Keep the annotation on the smallest piece of code you can, so that a new unchecked problem anywhere else still gets its warning.
A hash map of your own#
Now the second class. A HashMap keeps its entries in an array of buckets. To put a key, it turns the key's hash code into a bucket number and stores the entry in that bucket. To get a key, it works out the same bucket and looks only there. Keys that land in the same bucket form a chain, a small linked list. Keeping each bucket's entries in a linked list like this is called chaining, or separate chaining.
Each entry is a node, like the nodes in linked lists with one more field: it holds a key, a value, and next, the next node in the same bucket. The map has two fields of its own. bucketsis an array of nodes, where each slot holds the first node of one bucket's chain, or null when the bucket is empty, and size counts the keys. The map on this page is a stock list for the fruit shop from the HashMap lesson, with String keys, int values and four buckets to start.
From hash code to bucket#
A bucket number must be between 0 and the number of buckets minus 1. The remainder after dividing by the number of buckets looks like the answer, since h % 4 ought to be 0, 1, 2 or 3. Run this:
public class Main { public static void main(String[] args) { String[] fruits = {"apple", "banana", "cherry"}; for (String fruit : fruits) { int h = fruit.hashCode(); System.out.println(fruit + ": hash code " + h + ", h % 4 is " + (h % 4) + ", (h & 0x7fffffff) % 4 is " + ((h & 0x7fffffff) % 4)); } }}Apple gets bucket 2, but banana and cherry get -3, and buckets[-3] would stop the program with an ArrayIndexOutOfBoundsException. Hash codes can be negative, as you saw when banana's hash code wrapped around in the HashMap lesson, and % keeps the sign of the number on its left, as operators explained. So a negative hash code gives a negative remainder, or 0.
The last column has the fix. 0x7fffffff is a number written in hexadecimal: the largest int, 2,147,483,647. In binary it is a 0 followed by 31 ones. Java stores every negative int with its highest bit set to 1, and every other int with that bit set to 0, so that bit is called the sign bit. h & 0x7fffffff keeps the lower 31 bits of h and sets the sign bit to 0, so the result is never negative. For a negative hthat is the same as adding 2,147,483,648: banana's -1,396,355,227 becomes 751,128,421, and 751128421 % 4 is 1.
So the map works out a bucket in one line, (key.hashCode() & 0x7fffffff) % tableSize, inside a method called indexFor.
Math.floorMod(h, tableSize), added in Java 8, gives a remainder with the sign of the second number, so with a positive table size it is always between 0 and tableSize - 1. Math.floorMod(-7, 4) is 1, where -7 % 4 is -3. Either fix works, as long as put and get use the same one.put and get#
putworks out the bucket, then walks that bucket's chain. If a node there holds an equal key, putchanges that node's value and returns, because a map holds each key once. If the walk reaches the end of the chain without a match, the key is new. put makes a node for it and links the node in at the front of the chain. That is the add-at-the-front from the linked-list lesson, written as one line: buckets[i] = new Node(key, value, buckets[i]); gives the new node the old first node as its next, then makes it the first node. get walks the same chain and returns the value it finds there, or null.
public class Main { public static void main(String[] args) { StockMap stock = new StockMap(); stock.put("apple", 12); stock.put("banana", 30); stock.put("cherry", 7); stock.put("banana", 25); System.out.println("banana: " + stock.get("banana")); System.out.println("kiwi: " + stock.get("kiwi")); System.out.println("kinds of fruit: " + stock.size()); }} class StockMap { private Node[] buckets = new Node[4]; private int size; private int indexFor(String key, int tableSize) { return (key.hashCode() & 0x7fffffff) % tableSize; } void put(String key, int value) { int i = indexFor(key, buckets.length); for (Node node = buckets[i]; node != null; node = node.next) { if (node.key.equals(key)) { node.value = value; return; } } buckets[i] = new Node(key, value, buckets[i]); size++; } Integer get(String key) { int i = indexFor(key, buckets.length); for (Node node = buckets[i]; node != null; node = node.next) { if (node.key.equals(key)) { return node.value; } } return null; } int size() { return size; }} class Node { String key; int value; Node next; Node(String key, int value, Node next) { this.key = key; this.value = value; this.next = next; }}It prints banana: 25, kiwi: null and kinds of fruit: 3. get returns an Integer so that it can answer null for a missing key, like the real get. indexFor takes the number of buckets as a parameter, because the bucket depends on it as well as on the key; that matters in the next section.
Step through it with the memory view open. Under Arrays, the table is a row of four slots, each holding null or the tag of the first node in that bucket's chain, such as Node #1. Under Objects, each node shows its key, value and next, and the tags follow the order the nodes were made, so a slot and an arrow that say Node #3 lead to the same box.
Watch put("cherry", 7). Cherry belongs in bucket 1, where banana already is. The walk compares cherry with banana and finds no match. The constructor points the new node's next at banana's node, and the step that writes the new node into buckets[1] makes cherry the front of the chain. Then watch put("banana", 25). The walk passes cherry, finds banana, and the step that sets node.value to 25 changes the value in place. No node is added, and size stays at 3.
put walk the chain before it adds a node?Key ideaA hash map with chaining is an array of linked lists. The key's hash code picks the list, and equals finds the key inside it.
Growing the table#
Chains stay short only while there are enough buckets. Four buckets suit three fruits, but with a hundred fruits each chain would hold about 25 nodes, and every get would walk one of them. So the map counts its keys and doubles the table before the chains get long.
HashMap makes that decision with its load factor: the most keys per bucket, on average, that the table may hold before it grows. The default is 0.75, and this map uses the same rule: once size is more than three quarters of the number of buckets, the table doubles. With 4 buckets the line is 3, so the fourth key sets it off.
Doubling the array is not enough on its own, because a key's bucket depends on the number of buckets: indexFor ends with % tableSize. Banana sits in bucket 1 of 4, but belongs in bucket 5 of 8. So resize makes the bigger array, then walks every chain of the old one and moves each node into the bucket its key picks in the new table, at the front of the chain there. A new method, show, prints each bucket with its chain, front first.
public class Main { public static void main(String[] args) { StockMap stock = new StockMap(); stock.put("apple", 12); stock.put("banana", 30); stock.put("cherry", 7); stock.show(); stock.put("mango", 4); stock.show(); }} class StockMap { private Node[] buckets = new Node[4]; private int size; private int indexFor(String key, int tableSize) { return (key.hashCode() & 0x7fffffff) % tableSize; } void put(String key, int value) { int i = indexFor(key, buckets.length); for (Node node = buckets[i]; node != null; node = node.next) { if (node.key.equals(key)) { node.value = value; return; } } buckets[i] = new Node(key, value, buckets[i]); size++; if (size > buckets.length * 0.75) { resize(); } } private void resize() { Node[] bigger = new Node[buckets.length * 2]; for (Node head : buckets) { Node node = head; while (node != null) { Node following = node.next; int i = indexFor(node.key, bigger.length); node.next = bigger[i]; bigger[i] = node; node = following; } } buckets = bigger; } void show() { for (int i = 0; i < buckets.length; i++) { System.out.print(i + ":"); for (Node node = buckets[i]; node != null; node = node.next) { System.out.print(" " + node.key + "=" + node.value); } System.out.println(); } }} class Node { String key; int value; Node next; Node(String key, int value, Node next) { this.key = key; this.value = value; this.next = next; }}The first table is the one from before: cherry and banana share bucket 1, and apple is alone in bucket 2. put("mango", 4) links mango in at the front of bucket 2, size becomes 4, and since 4 is more than 3.0, put calls resize. The second table has eight buckets, and the chain in bucket 1 has split up: cherry stayed in bucket 1, and banana moved to bucket 5.
Step through resize. It creates bigger with eight empty slots, then walks the old buckets from 0 up. For each node it saves node.next in following and works out the new bucket. Then two steps link the node in at the front there: one points node.next at whatever that bucket already holds, and the next writes the node into bigger[i]. Mango and apple both belong in bucket 2. Mango moves first, so when apple arrives, the step that sets node.next points apple at mango, and apple becomes the front. That is why the second table lists apple before mango. The order inside a bucket makes no difference to put and get, which compare the key with every node in the chain anyway.
following is there for the reason you met in the linked-list lesson: node.next = bigger[i] overwrites the only reference to the rest of the old chain, so resize has to save it first. Last of all, buckets = bigger switches the map over to the new table, and the old table is left with nothing pointing at it.
remove, which takes a key out of the map and returns its value, or returns null when the key is not in the map. Unlink the node the way you deleted nodes in the linked-list lesson: the first node of a chain by changing buckets[i], any other node by changing the next of the node before it. Keep size right too. To stay short, this StockMap has no resize, so with five keys in four buckets, bucket 2 holds a chain of three.What a bad hashCode does#
All of this speed depends on the hash codes spreading the keys across the buckets. In equals, hashCode and toString you saw that a hashCode which returns the same number for every object keeps the contract, because equal objects still get equal hash codes. It is legal Java. This section measures what it costs.
To use keys of your own class, the map has to accept any key type, so it becomes generic: TinyMap<K, V> with Node<K, V>, and the same kind of cast for the array as the list needed, (Node<K, V>[]) new Node[capacity]. This cut-down version has only put, and counts how many times put compares two keys with equals. BadKey is a record whose hashCode always returns 42. Its equals is the one every record gets, which compares the text. Both maps get 32 buckets, plenty for 20 keys, so neither needs to grow.
public class Main { public static void main(String[] args) { TinyMap<String, Integer> good = new TinyMap<>(32); TinyMap<BadKey, Integer> bad = new TinyMap<>(32); for (int n = 1; n <= 20; n++) { good.put("key" + n, n); bad.put(new BadKey("key" + n), n); } System.out.println("String keys: " + good.comparisons + " comparisons"); System.out.println("BadKey keys: " + bad.comparisons + " comparisons"); }} record BadKey(String text) { @Override public int hashCode() { return 42; }} class TinyMap<K, V> { int comparisons; private Node<K, V>[] buckets; @SuppressWarnings("unchecked") TinyMap(int capacity) { buckets = (Node<K, V>[]) new Node[capacity]; } void put(K key, V value) { int i = (key.hashCode() & 0x7fffffff) % buckets.length; for (Node<K, V> node = buckets[i]; node != null; node = node.next) { comparisons++; if (node.key.equals(key)) { node.value = value; return; } } buckets[i] = new Node<>(key, value, buckets[i]); }} class Node<K, V> { K key; V value; Node<K, V> next; Node(K key, V value, Node<K, V> next) { this.key = key; this.value = value; this.next = next; }}The twenty String keys landed in twenty different buckets, so put never had to compare two keys: 0 comparisons. Every BadKey has the hash code 42, and (42 & 0x7fffffff) % 32 is 10, so all twenty nodes went into bucket 10. Each new key was compared with every key already in that chain before it went in at the front: 0 + 1 + 2 + ... + 19 = 190 comparisons.
That is the same triangle-shaped sum as growing a list one slot at a time. Putting n keys costs n × (n - 1) / 2 comparisons, so filling the map is O(n²), and each put or get is O(n), because it walks one chain that holds every key. The map has turned into a slow linked list. With 10,000 keys, the same count on a map that resizes comes to 6,836 comparisons for String keys and 49,995,000 for BadKey. Resizing cannot rescue it: when every hash code is the same, every key lands in the same bucket however many buckets there are. The HashMap documentation warns about exactly this: lots of keys that share one hash code make any hash table slow.
Try both in the lab. It follows StockMap's rules: 4 buckets to start, the same bucket formula, new nodes at the front of their chain, and a resize past three quarters full. Put a few words, or press Add a fruit until the table doubles, and watch the nodes move. Get counts the comparisons a lookup makes. Then switch the hash code to always 42: the lab puts the same keys again, in the same order, into a new map.
Each key's hashCode()
Letters and digits, up to 10. The lab holds up to 12 keys.
indexFor("apple", 4)
buckets, each chain from front to back
the node the last put added the key the last get found
An empty map with 4 buckets. Put the word in the box, or press Add a fruit.
- Doubles when keys pass
- 3 (4 × 0.75)
- Longest chain
- 0
- Comparisons, last action
- none yet
- Comparisons, all puts
- 0
With all twelve fruits, String's own hash code spreads them over 16 buckets with no chain longer than two, and the puts make 4 comparisons in all. With 42, every fruit sits in one chain in bucket 10, and the puts make 66.
Key ideaA hash map is only as fast as its keys' hashCode. When the hash codes spread the keys out, put and get take O(1) on average. When every key has the same hash code, every key lands in one chain, and each put and get walks it: O(n).
Your classes next to Java's#
Your two classes use the same ideas as ArrayList and HashMap. The JDK versions add speed-ups and safety checks around those ideas. The tables below show where they differ, from the OpenJDK source code and the Java SE 25 API documentation.
| Your Playlist and GrowList | ArrayList in OpenJDK | |
|---|---|---|
| Where the elements live | T[], made with (T[]) new Object[2] | An Object[], with a cast on each element that get hands out |
| The first array | 2 slots, made by the constructor | 10 slots, made by the first add |
| When the array is full | Arrays.copyOf to twice the length | Arrays.copyOf to the old length plus half |
get(i) | Objects.checkIndex(i, size) | The same check |
remove(i) | A loop shifts the later elements left, then the old last slot is set to null | System.arraycopy shifts them in one call, then the old last slot is set to null |
| Your StockMap and TinyMap | HashMap in OpenJDK | |
|---|---|---|
| Buckets to start | 4, made by the constructor | 16, made by the first put |
| Bucket number | (hashCode & 0x7fffffff) % buckets | The hash code with its top half mixed into its bottom half, then & (buckets - 1). The number of buckets is always a power of two. |
| Where a new key goes | A node at the front of its chain | A node at the end of its chain, which put has walked anyway |
| What a node holds | key, value and next | key, value, next and the key's hash code, so a resize never calls hashCode again, and a lookup skips equals when two hash codes differ |
| When the table grows | When size passes buckets × 0.75 | The same, with 0.75 as the default load factor |
| How it grows | Each node moves to the front of its chain in the new table | Each chain splits in two and keeps its order: a node stays in bucket j or moves to bucket j plus the old number of buckets |
| A very long chain | Stays a chain | Becomes a balanced tree past 8 nodes, in a table of 64 buckets or more |
null as a key | A NullPointerException, from key.hashCode() | Allowed once. Its hash counts as 0. |
Your map is closer still to an older class, java.util.Hashtable, which has been in Java since version 1.0. Hashtable picks a bucket with the same (hash & 0x7FFFFFFF) % tab.length, links a new entry in at the front of its chain, and when it grows, moves each entry to the front of its chain in the new table. It starts with 11 buckets and a load factor of 0.75. Its documentation recommends HashMap in its place, unless several threads share the map.
Try it
Answer each one in your head or on paper first, then open the answer.
GrowList holds 5 elements in an array of 8 slots. What are its size and its capacity? Which indexes does get accept, and what happens with get(6)?Copies.java counted 63 copies for 64 adds when the array doubles, and 2,016 when it grows by one slot. Without running it, how many copies does each rule make for 128 adds?indexFor pick in a table of 4? What would plain -7 % 4 give, and what would happen next?put links a new node in at the front of its chain. Java's HashMap adds it at the end. Does the choice change what get returns?TinyMap with 32 buckets, like the ones in BadHash.java, holds 5 keys whose hash codes are all 42. How many comparisons did the 5 puts make, and how many would a get of the first key you put need?Practice
Work through these on your own. Each one builds on this page.
GrowList three more methods: T set(int index, T item), which replaces an element and returns the old one; int indexOf(T item), which returns the first index whose element equals the item, or -1; and boolean contains(T item), built on indexOf. Test each, including set(5, x) on a list of five.removeLast() so that when the list drops to a quarter of its capacity, the array shrinks to half its length. Then explain why shrinking as soon as the list is half full would be a bad idea: try a list that sits exactly on that line and alternates add with removeLast.StockMap a containsKey(String key) method and a keys() method that returns a GrowList<String> of every key, walking every bucket. In what order do the keys come out, and why is that order different from the order of the puts?Aa and BB have the same hash code, 2112, and so do AaAa, AaBB, BBAa and BBBB. Build the eight strings made of three such pairs, put them all into StockMap, and print the table. Which bucket do they share? Does a resize split them up, and why?indexFor to use the rule from the HashMap lesson, (h ^ (h >>> 16)) & (tableSize - 1), and give the map 16 buckets to start. Why does this rule only work when the number of buckets is a power of two? Put the fruits from this page into the map and check their buckets against the hash table lab in HashMap and HashSet.Further reading#
- ArrayList in the Java SE 25 API documentation, with its promise of amortized constant time for add
- HashMap in the Java SE 25 API documentation, on capacity, load factor and rehashing
- Restrictions on Generics, on dev.java, including why you cannot create arrays of parameterized types
- JEP 180: Handle Frequent HashMap Collisions with Balanced Trees
Spotted a mistake, or a part that did not make sense? Report it by email