10.6
Map Methods, EnumMap, and the Rest of the Family
The methods you will actually call, plus four specialised maps. Two are worth knowing, one has a single use, and one Java would delete if it could.
Previously on
Section 10.5 opened up HashMap and showed that your hashCode and equals do the real work.
That is the general purpose one. Java also ships four specialised maps, and two of them are genuinely worth reaching for.
The problem
Map sits outside Collection, so it inherits nothing. Every method it has, it had to declare itself.
Which raises a practical question: what are they, and which ones matter?
There is a second question underneath. HashMap is general purpose, and general purpose means compromises. If you know something specific about your keys, can you do better?
Sometimes yes, by a lot.
The idea
The Map methods worth knowing
| What you want | Method | |
|---|---|---|
| Store a pair | the basic one | put(k, v) |
| Read a value | null if missing | get(k), getOrDefault(k, fallback) |
| Is it there | checks | containsKey(k), containsValue(v) |
| Store only if absent | no overwrite | putIfAbsent(k, v), computeIfAbsent(k, fn) |
| Update what is there | read and write in one | merge(k, v, fn), compute(k, fn) |
| Walk it | three views | keySet(), values(), entrySet() |
| Remove | remove(k), remove(k, v) |
Java reused the Collection names where it could. size, isEmpty, clear, remove all mean what you expect, even though Map inherits none of them.
The four that replace loops
These are the ones that turn five lines into one, and they are worth learning properly.
Map<String, Integer> counts = new HashMap<>();
// Count words. One line.
for (String w : "a b a c b a".split(" ")) {
counts.merge(w, 1, Integer::sum);
}
// {a=3, b=2, c=1}merge(key, valueIfAbsent, combineFunction). If the key is missing, store the second argument. If it is present, combine the old and new values with the function.
counts.getOrDefault("missing", 0); // 0, no null check needed
counts.computeIfAbsent("d", k -> 0); // insert only if not there
counts.putIfAbsent("a", 99); // returns 3, leaves the map aloneThe three views
A Map is not Iterable, so you ask it for a collection first:
for (String key : map.keySet()) { } // Set<K>
for (Integer v : map.values()) { } // Collection<V>
for (Map.Entry<String, Integer> e : map.entrySet()) {
e.getKey();
e.getValue();
}entrySet() is the one to use when you need both. Looping over keySet() and calling get(key) inside does the lookup twice.
Map.Entry is a nested interface inside Map, from Section 7.4.
Under the hood
Going deeperEnumMap: an array pretending to be a map
When every key is an enum constant, hashing is wasted work. Each constant already has a position: ordinal(), from Section 8.4.
So EnumMap is a plain array, and the ordinal is the index.
Map<Status, Integer> counts = new EnumMap<>(Status.class);What EnumMap skips
- No hashCode callThe ordinal is already there, sitting in the constant.
- No bucket calculationNo mixing, no masking. The ordinal is the index.
- No collisionsEvery constant has its own slot. Two keys can never share one.
- No Node objectsOne array of values, not an object per entry. Far less memory.
It is direct indexed access from Section 4.2, with none of the hashing work in between. It also comes out in declaration order because walking the internal slots follows the constants’ ordinal order.
new EnumMap<>(Map.of(Status.RETIRED, 1, Status.ACTIVE, 2, Status.RESERVE, 3))
// {ACTIVE=2, RESERVE=3, RETIRED=1} <- declaration order, not insertion orderThe rule: if your key is an enum, use EnumMap. Not because it is dramatically faster.
Time five million puts on a four constant enum and the two come out within a few milliseconds of
each other, with the winner changing between runs. Four keys is too small a job for hashing to
cost anything you can see.
What you get instead is an array of four slots, not a table of Node objects. You also get
declaration order for free on every walk. And you get a refusal when you hand it a key that
cannot exist. A HashMap will quietly file a null key in a bucket of its own. An EnumMap
has no slot for one, and says so.
EnumSet: a set stored as bits
The same idea for sets, and it goes further. With a small number of constants, EnumSet stores the whole set in a single long, one bit per constant.
EnumSet<Status> active = EnumSet.of(Status.ACTIVE, Status.RETIRED);
EnumSet.complementOf(active); // [RESERVE]
EnumSet.allOf(Status.class); // [ACTIVE, RESERVE, RETIRED]
EnumSet.noneOf(Status.class); // []Membership is one bitwise AND. Union is an OR. Complement is a NOT. Exactly the flag operations you built by hand in the Section 3.1 exercises, now in the standard library with names.
An EnumSet of ten constants uses less memory than a single HashSet entry.
IdentityHashMap: the one that ignores equals
Everything in Phase VIII told you to use equals and not ==. This map does the opposite, deliberately.
String k1 = new String("key");
String k2 = new String("key"); // equal, different objects
Map<String,Integer> hm = new HashMap<>();
hm.put(k1, 1); hm.put(k2, 2);
hm.size(); // 1. equals merged them.
Map<String,Integer> im = new IdentityHashMap<>();
im.put(k1, 1); im.put(k2, 2);
im.size(); // 2. Two objects, two entries.That is wrong for almost everything. It is exactly right for one job: tracking objects you have already visited.
Walking a graph, detecting cycles, serializing an object tree. In all of those, two equal objects are still two separate things you must handle separately, and merging them would be a bug.
It is also immune to the mutable key problem, since identity never changes. That is a side benefit, not a reason to use it.
Hashtable and Properties: the legacy pair
Hashtable is HashMap from Java 1.0, with every method synchronized.
| HashMap | Hashtable | |
|---|---|---|
| Thread safe | no | yes, every method locks |
| null key | one allowed | throws NullPointerException |
| null values | allowed | not allowed |
| Use it | yes | no |
The third time you have met this pattern: ArrayList and Vector, StringBuilder and StringBuffer, HashMap and Hashtable. Java 1.0 locked everything, later designs did not, and the old classes stayed for compatibility.
If you genuinely need a thread safe map, use ConcurrentHashMap. It locks small parts rather than the whole thing, so several threads can work at once. That is Phase XIV.
Properties extends Hashtable and is used for configuration files. It has the same Stack extends Vector problem from Section 10.4: it is meant to hold String to String, and because it extends Hashtable<Object,Object> you can put anything in it.
TreeMap navigation
TreeMap keeps keys sorted, which lets it answer questions no hash map can.
TreeMap<Integer,String> tm = new TreeMap<>(Map.of(10,"a", 20,"b", 30,"c", 40,"d"));
tm.firstKey(); // 10
tm.lastKey(); // 40
tm.floorKey(25); // 20 largest key <= 25
tm.ceilingKey(25); // 30 smallest key >= 25
tm.headMap(30); // {10=a, 20=b}
tm.tailMap(30); // {30=c, 40=d}
tm.subMap(20, 40); // {20=b, 30=c}“Find me the nearest key below this one” is impossible on a HashMap, because a hash map has no idea what order its keys are in. Sorted order is what you are paying for.
TreeSet offers the same: first, last, floor, ceiling, headSet, tailSet, subSet.
None of it works unless the keys can be compared, which is Section 10.8.
What it costs
There are a lot of maps, and most of them are wrong for you. Reaching past HashMap should be a deliberate decision with a reason attached.
EnumMap and EnumSet only work with enums, and cannot be used for anything else.
IdentityHashMap breaks the rule Phase VIII spent a whole section teaching. Used by accident, it produces duplicate entries you cannot explain.
Hashtable and Properties are still in the standard library, still appear in tutorials, and still turn up in code you will have to maintain.
The map views also surprise people. keySet() looks like a copy and is a window, so removing from it removes from the map.
What you get is a map that fits the job. If your keys are enums, EnumMap is smaller and ordered for free, though not noticeably faster. If you need order, TreeMap answers questions no hash can. HashMap is the right default and is not always the right answer.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
What does `EnumMap` actually give you over `HashMap` when the key is an enum?
Show the answer
Not speed, mostly. It skips hashing entirely, and on a small enum that turns out to save almost nothing measurable.
Every enum constant already has a position:
ordinal(), from Section 8.4. AnEnumMapis a plain array, and the ordinal is the index.No
hashCodecall, no bucket calculation, no collisions, and noNodeobject per entry. It uses the direct indexed access from Section 4.2, with none of the hashing work in between.It also arrives sorted by declaration order for free, since walking the array walks the constants in order.
`IdentityHashMap` uses `==` instead of `equals`. Everything in Phase VIII said not to do that. When is it right?
Show the answer
When you genuinely mean "the same object", not "an equal object".
Put two equal but separate Strings into a
HashMapand you get one entry, becauseequalsmerges them. Put them into anIdentityHashMapand you get two, because they are two objects.The real use is tracking objects you have already visited: graph traversal, cycle detection, serialization. There, two equal objects are still two things you must handle separately, and merging them would be a bug.
It is also immune to the mutable key problem from Section 10.5, since identity never changes. That is a narrow benefit and not a reason to reach for it.
Both `Hashtable` and `HashMap` store key-value pairs. Why should you never use the first one?
Show the answer
Same reason as
VectorandStringBuffer: it is from Java 1.0 and locks every method.You pay for locking whether or not you have threads. And it is not actually safe for a sequence of calls, only for one at a time, which is rarely what you need.
It also rejects
nullkeys and values, whileHashMapallows one null key and any number of null values.If you genuinely need a thread safe map, use
ConcurrentHashMap. It locks small parts rather than the whole thing, so several threads can work at once. That is Phase XIV.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises85 pointsabout 95 minutes
One Line Instead of Five
ex-10-6-aWrite the long version first, including the null check, so you can see what you are replacing.
Then replace it with merge and count the lines you deleted. Also count the lookups: the naive version looks each key up twice, and merge does it once.
computeIfAbsent is the one worth practising, because grouping is something you will do constantly. Note that the lambda only runs when the key is missing, so no wasted list is ever created.
All three of these use lambdas, which Phase XI explains. You are using them before they are taught, which is exactly how they arrived in real Java code.
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 MapMethods {
static Map<String, Integer> countTheLongWay(String[] words)
static Map<String, Integer> countWithMerge(String[] words)
static int scoreOrZero(Map<String, Integer> scores, String name)
static Map<Character, List<String>> groupByFirstLetter(String[] words)
}The first two must produce the same map. countTheLongWay uses get and put with the null check written out. countWithMerge is one line inside the loop.
What your program must do
- Write the long version, including the null check it needs
- Replace it with merge in a single line and confirm both give the same map
- Show getOrDefault removing an unboxing NullPointerException
- Use computeIfAbsent to group words by first letter
import java.util.*;
public class MapMethods {
// The long way. Write the null check the naive version needs.
static Map<String, Integer> countTheLongWay(String[] words) {
return Map.of(); // TODO
}
// The same answer, one line inside the loop.
static Map<String, Integer> countWithMerge(String[] words) {
return Map.of(); // TODO
}
// 0 when the name is not in the map. No null check of your own.
static int scoreOrZero(Map<String, Integer> scores, String name) {
return -1; // TODO
}
// "atlas" and "ant" both go under 'a'.
static Map<Character, List<String>> groupByFirstLetter(String[] words) {
return Map.of(); // TODO
}
public static void main(String[] args) {
String[] words = "atlas beacon atlas cipher beacon atlas".split(" ");
// TODO: print all four results
// TODO: remove the null check from the long version and see what it throws
}
}
Hint 1
merge(w, 1, Integer::sum) means: put 1 if the key is new, otherwise add 1 to what is there.Hint 2
get returns null for a new word, and unboxing null into an int is a NullPointerException.Hint 3almost the answer
computeIfAbsent(letter, k -> new ArrayList<>()) makes the list only when the letter is new, then returns whichever list is there so you can add to it. Making a new list every time would throw away everything already grouped.What EnumMap Actually Buys You
ex-10-6-bTwo maps holding the same enum keys, and one of them does far less work.
Measure both, then explain the gap. The explanation matters more than the number, and it is short: an enum constant already knows its position, so there is nothing to hash.
Then use EnumSet, including complementOf. A set stored as bits, where union is an OR and membership is an AND, is the flag exercise from Section 3.1 with a proper name.
Write the final paragraph carefully. If it mentions ordinal and array index, you have it.
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 Enums {
static long timePuts(Map<Status, Integer> target, int rounds)
static List<Status> orderOf(Map<Status, Integer> filled)
static Map<Status, Integer> filledEnumMap()
static Set<Status> notActive()
static Set<Status> everything()
static Set<Status> nothing()
static String nullKey(Map<Status, Integer> m)
}filledEnumMap must be filled in a scrambled order, TRAINING then ACTIVE then RETIRED then RESERVE, so orderOf proves the map sorts them back by itself. nullKey tries to put a null key and returns "ok" or "NullPointerException".
What your program must do
- Time five million puts into both, three times, and say whether either reliably wins
- Fill an EnumMap out of order and show it still walks in declaration order
- Try a null key on both and record the difference
- Use EnumSet.of, complementOf, allOf and noneOf
- Say what EnumMap actually buys you, now that you have the numbers
import java.util.*;
enum Status { ACTIVE, RESERVE, RETIRED, TRAINING }
public class Enums {
// Put rounds entries into whichever map you are handed. Return milliseconds.
static long timePuts(Map<Status, Integer> target, int rounds) {
return 0; // TODO
}
// The order a walk over the keys gives you.
static List<Status> orderOf(Map<Status, Integer> filled) {
return List.of(); // TODO
}
// Fill an EnumMap in a SCRAMBLED order: TRAINING, ACTIVE, RETIRED, RESERVE.
static Map<Status, Integer> filledEnumMap() {
return Map.of(); // TODO
}
static Set<Status> notActive() { return Set.of(); } // TODO: complementOf
static Set<Status> everything() { return Set.of(); } // TODO: allOf
static Set<Status> nothing() { return Set.of(); } // TODO: noneOf
// Try to put a null KEY. Return "ok" or the name of what came out.
static String nullKey(Map<Status, Integer> m) {
return "ok"; // TODO
}
public static void main(String[] args) {
// TODO: time 5_000_000 puts into a HashMap and an EnumMap. Run it three times.
// Write down whether one of them reliably wins.
// TODO: print the EnumMap's order and compare it with how you filled it
// TODO: try a null key on both
}
}
Hint 1
new EnumMap<>(Status.class). That is how it knows how big to make its array.Hint 2
Hint 3almost the answer
When == Is the Right Answer
ex-10-6-cEverything in Phase VIII told you to use equals rather than ==. Find the job where that advice is wrong.
Start with the simple demonstration: two equal Strings, one map keeping one entry and the other keeping two.
Then the real case. Two different graph nodes that happen to share a name, in a cycle. Walk it with a normal HashSet of visited nodes and watch it skip a node it never actually visited.
Then use identity and watch it work. Write the one sentence rule at the end, and note that it is about what question you are asking, not about which method is better.
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 Identity {
static int hashMapSizeForEqualKeys()
static int identityMapSizeForEqualKeys()
static Node[] cyclicPair()
static int countWithEqualsSet(Node start)
static int countWithIdentitySet(Node start)
}cyclicPair returns two DIFFERENT Node objects that share the name "hub" and link to each other. The two count methods walk from the node given and return how many nodes they reached.
What your program must do
- Show HashMap merging two equal keys and IdentityHashMap keeping them apart
- Walk the cyclic graph using a HashSet of visited nodes and show the bug
- Walk it again using identity and show it works
- Say in one sentence when identity is the right question
import java.util.*;
public class Identity {
static class Node {
final String name;
final List<Node> links = new ArrayList<>();
Node(String n) { name = n; }
@Override public boolean equals(Object o) { return o instanceof Node n && n.name.equals(name); }
@Override public int hashCode() { return name.hashCode(); }
@Override public String toString() { return name; }
}
// Put new String("key") and another new String("key") into a HashMap. Size?
static int hashMapSizeForEqualKeys() {
return 0; // TODO
}
// The same two keys into an IdentityHashMap. Size?
static int identityMapSizeForEqualKeys() {
return 0; // TODO
}
// Two DIFFERENT nodes that happen to share a name, linked to each other.
static Node[] cyclicPair() {
return new Node[0]; // TODO
}
// Walk from start, counting nodes, using a normal HashSet of visited nodes.
static int countWithEqualsSet(Node start) {
return 0; // TODO
}
// Walk again, but let the visited set compare by identity.
static int countWithIdentitySet(Node start) {
return 0; // TODO
}
public static void main(String[] args) {
// TODO: print both map sizes and both walk counts
// TODO: say in one sentence when identity is the right question
}
}
Hint 1
new String("key") makes a fresh object every time, so the two keys are equal but not the same object.Hint 2
Collections.newSetFromMap(new IdentityHashMap<>()).Hint 3almost the answer
TreeMap Answers Questions HashMap Cannot
ex-10-6-dFour questions a HashMap cannot answer at all.
Answer them with TreeMap, then try each one on a HashMap and note that there is not even a method to call. That absence is the point: a hash map does not know what order its keys are in, so “nearest below” is not a question it can be asked.
Then say what the ordering costs. It is not free, and knowing the price is how you decide.
Finish with the last question. A sorted List plus binary search could answer some of these too, and working out which ones tells you what a tree adds over an array.
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 Navigate {
static TreeMap<Integer, String> byScore()
static String highest(TreeMap<Integer, String> m)
static String lowest(TreeMap<Integer, String> m)
static String bestAtOrBelow(TreeMap<Integer, String> m, int score)
static String worstAtOrAbove(TreeMap<Integer, String> m, int score)
static List<String> between(TreeMap<Integer, String> m, int from, int to)
}byScore holds 45 Beacon, 60 Drift, 88 Atlas, 95 Cipher. The two nearest methods return null when nothing qualifies. between includes both ends and comes back in score order.
What your program must do
- Answer all five questions using TreeMap navigation methods
- Show that a HashMap cannot answer the nearest-below question at all
- Explain what a TreeMap pays for the ability
- Say which of the five questions a sorted List could also answer
import java.util.*;
public class Navigate {
// Readiness score -> unit name.
static TreeMap<Integer, String> byScore() {
return new TreeMap<>(); // TODO: 45 Beacon, 60 Drift, 88 Atlas, 95 Cipher
}
static String highest(TreeMap<Integer, String> m) { return null; } // TODO
static String lowest(TreeMap<Integer, String> m) { return null; } // TODO
// The nearest score that does not go over. null if there is none.
static String bestAtOrBelow(TreeMap<Integer, String> m, int score) {
return null; // TODO
}
// The nearest score going the other way. null if there is none.
static String worstAtOrAbove(TreeMap<Integer, String> m, int score) {
return null; // TODO
}
// Everyone from `from` to `to`, both ends included, in score order.
static List<String> between(TreeMap<Integer, String> m, int from, int to) {
return List.of(); // TODO
}
public static void main(String[] args) {
// TODO: answer all five questions
// TODO: try each one on a HashMap and record why it cannot answer
}
}
Hint 1
firstEntry and lastEntry are the two ends. floorEntry means at or below. ceilingEntry means at or above.Hint 2
subMap(from, true, to, true) gives the range with both ends included. The two booleans are what makes it inclusive.Hint 3almost the answer
After the credits
List cares about position. Set cares about uniqueness. Map cares about pairs.
None of them answers a question real systems ask constantly: who is next?
A print queue serves jobs in the order they arrived. A hospital serves the most urgent patient first, whatever time they arrived. Both are queues, and they disagree about what “next” means.
Section 10.7 covers Queue, Deque and PriorityQueue. The last one is built on a heap, stored inside an ordinary array. The arithmetic that finds a node’s children is the index arithmetic from Section 4.2.
It also has to decide which element is most urgent, which brings back the question that has now been deferred three times: how does Java know which of two objects comes first?
EnumMap and EnumSet will return in Phase XIV. Concurrency