Layers of Logic

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.

Core18 min read4 exercises
01

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.

02

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.

03

The idea

The Map methods worth knowing

What you wantMethod
Store a pairthe basic oneput(k, v)
Read a valuenull if missingget(k), getOrDefault(k, fallback)
Is it therecheckscontainsKey(k), containsValue(v)
Store only if absentno overwriteputIfAbsent(k, v), computeIfAbsent(k, fn)
Update what is thereread and write in onemerge(k, v, fn), compute(k, fn)
Walk itthree viewskeySet(), values(), entrySet()
Removeremove(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 alone

The 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.

04

Under the hood

Going deeper

EnumMap: 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

  1. No hashCode callThe ordinal is already there, sitting in the constant.
  2. No bucket calculationNo mixing, no masking. The ordinal is the index.
  3. No collisionsEvery constant has its own slot. Two keys can never share one.
  4. 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 order

The 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.

HashMapHashtable
Thread safenoyes, every method locks
null keyone allowedthrows NullPointerException
null valuesallowednot allowed
Use ityesno

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.

05

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.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. 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. An EnumMap is a plain array, and the ordinal is the index.

    No hashCode call, no bucket calculation, no collisions, and no Node object 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.

  2. `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 HashMap and you get one entry, because equals merges them. Put them into an IdentityHashMap and 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.

  3. Both `Hashtable` and `HashMap` store key-value pairs. Why should you never use the first one?

    Show the answer

    Same reason as Vector and StringBuffer: 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 null keys and values, while HashMap allows 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.

07

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

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

One Line Instead of Five

Warm up·20 min·15 points

checkedex-10-6-a

Write 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
MapMethods.java
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
The long version throws without the check because 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 this is really testing

Whether you know the four Map methods that replace whole blocks of code. Word counting written by hand is five lines and one null bug.

B

What EnumMap Actually Buys You

Real work·25 min·20 points

checkedex-10-6-b

Two 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
Enums.java
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
An EnumMap must be told its enum class when you build it: new EnumMap<>(Status.class). That is how it knows how big to make its array.
Hint 2
Run the timing more than once. With four constants and a warm JVM the two are within a few milliseconds, and which one wins changes between runs. That is a real result, not a broken measurement.
Hint 3almost the answer
What it buys you: an array of four slots instead of a table of Node objects, declaration order for free on every walk, and a refusal when you hand it a key that cannot exist. Speed on a tiny enum is not on that list, whatever you have read.
What this is really testing

Whether you check a claim before repeating it. EnumMap is described everywhere as the fast one, and on a four constant enum it is not measurably faster. What it is instead is worth knowing.

C

When == Is the Right Answer

Real work·25 min·25 points

checkedex-10-6-c

Everything 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
Identity.java
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
For an identity based Set there is no IdentityHashSet. Use Collections.newSetFromMap(new IdentityHashMap<>()).
Hint 3almost the answer
Identity is the right question when you are tracking objects rather than values: which objects have I already visited, already serialised, already cleaned up. Two equal objects are still two things to visit.
What this is really testing

Whether you can find the one job where IdentityHashMap is correct. Everything in Phase VIII said use equals, and this is the exception that proves the rule.

D

TreeMap Answers Questions HashMap Cannot

Real work·25 min·25 points·The Registry

checkedex-10-6-d

Four 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
Navigate.java
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
A TreeMap pays on every single insert: it compares its way down a tree to find where the key belongs, so put is O(log n) instead of O(1). A HashMap has no order at all, so the only way it can answer a nearest question is to look at every entry.
What this is really testing

Whether you know what sorted order buys you. Nearest-below and range queries are impossible on a hash map, and they are exactly what some problems need.

08

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