Checkout statuses are not an open set of strings. They are four constants you already named. Putting OrderStatus in a HashSet or HashMap compiles. It also hashes a value whose universe is a handful of ordinals.
When the key universe is an enum, skip the hash table. EnumSet is a bit vector. EnumMap is an array indexed by ordinal(). Both iterate in declaration order. Neither accepts a null key or element. The collections hub owns fail-fast and optional operations; HashSet and HashMap own the hash deliveries this post is not.
The lab domain is the hub’s Order / LineItem plus one closed type:
record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
enum OrderStatus { OPEN, PAID, SHIPPED, CANCELLED }
record Fulfillment(Order order, OrderStatus status) {}
OrderStatus is the universe. Fulfillment hangs a status on an order so the set and the map have something to group.
EnumSet: a bit vector of one enum type
Warehouse work cares about “which statuses still need a pick,” not “a general set of objects.” That is a subset of OrderStatus, and the JDK already has a type for subsets of one enum:
LineItem wb = new LineItem("WB-40", 2, new BigDecimal("8.50"));
LineItem nut = new LineItem("NUT-M8", 12, new BigDecimal("0.20"));
Order paid = new Order("o-1", "ada@ex.com", List.of(wb),
new BigDecimal("17.00"), true);
Order open = new Order("o-2", "grace@ex.com", List.of(nut),
new BigDecimal("2.40"), true);
Order shipped = new Order("o-3", "linus@ex.com", List.of(wb),
new BigDecimal("8.50"), true);
List<Fulfillment> fulfillments = List.of(
new Fulfillment(paid, OrderStatus.PAID),
new Fulfillment(open, OrderStatus.OPEN),
new Fulfillment(shipped, OrderStatus.SHIPPED));
EnumSet<OrderStatus> pickable = EnumSet.of(OrderStatus.PAID);
List<String> pickSkus = fulfillments.stream()
.filter(f -> pickable.contains(f.status()))
.flatMap(f -> f.order().items().stream())
.map(LineItem::sku)
.toList();
pickable.contains is a bit test. It is still the Set verb. The layout is not HashSet.
You never new EnumSet<>(). EnumSet is abstract. Factories pick RegularEnumSet (one long, up to 64 constants) or JumboEnumSet (long[], more than 64). OrderStatus has four constants. That is one long.
Factories: allOf, noneOf, of, range, complementOf
| Factory | Job |
|---|---|
noneOf(OrderStatus.class) | Empty set, typed to that universe |
allOf(OrderStatus.class) | Every constant |
of(e) / of(e1, e2, …) | One or more constants (overloads, then varargs) |
range(from, to) | Inclusive span in declaration order |
complementOf(s) | Universe minus s (same enum type) |
copyOf(c) | From a collection, or cheaper from another EnumSet |
EnumSet<OrderStatus> none = EnumSet.noneOf(OrderStatus.class);
EnumSet<OrderStatus> all = EnumSet.allOf(OrderStatus.class);
EnumSet<OrderStatus> terminal = EnumSet.of(OrderStatus.SHIPPED, OrderStatus.CANCELLED);
EnumSet<OrderStatus> live = EnumSet.complementOf(terminal);
EnumSet<OrderStatus> paidThroughShipped =
EnumSet.range(OrderStatus.PAID, OrderStatus.SHIPPED);
live is {OPEN, PAID}. paidThroughShipped is {PAID, SHIPPED} — endpoints included, in enum order, not the order you thought of them. range throws IllegalArgumentException if from is after to in declaration order.
none.add(OrderStatus.OPEN);
none.contains(OrderStatus.OPEN); // true
all.remove(OrderStatus.CANCELLED);
EnumSet<OrderStatus> stillOpen = EnumSet.complementOf(terminal);
add / contains / remove are the Set methods. Iteration is declaration order, not insertion order. Insert CANCELLED then OPEN and the iterator still yields OPEN before CANCELLED if both are present. That is the enum’s ordinal, not a LinkedHashSet.
EnumSet<OrderStatus> mixed = EnumSet.of(OrderStatus.CANCELLED, OrderStatus.OPEN);
System.out.println(mixed);
[OPEN, CANCELLED]
EnumSet iteration follows the enum source, not add order.
Not a HashSet, and not a bag of anything else
A sketch of the small-universe implementation (the idea, not a class to ship):
class RegularEnumSet<E extends Enum<E>> {
private long elements; // bit i is constant ordinal i
boolean add(E e) {
long old = elements;
elements |= (1L << e.ordinal());
return elements != old;
}
boolean contains(E e) {
return (elements & (1L << e.ordinal())) != 0;
}
}
No hashCode. No buckets. No load factor. Membership is a shift and a mask. That is why EnumSet beats HashSet for enums on both time and space: a HashSet stores references and hash codes for a universe you already numbered 0..n-1.
It cannot hold another type. E extends Enum<E> is the bound. EnumSet<String> does not compile. Mixing OrderStatus and some other enum is a ClassCastException at the factory or on add — the set is typed to one class.
No null elements. add(null) throws NullPointerException. contains(null) is false (it is not a member; it is also not a legal member). HashSet’s one-null story does not apply here.
Iterators are fail-fast; the type is not thread-safe. Same hub contract as HashSet, different storage.
EnumSet implements Set, Cloneable, and Serializable. Do not pack the bits into a long yourself and call that persistence unless a wire format already is a bit field. Use the EnumSet. It already chose Regular vs Jumbo, already serializes, already rejects null. A hand-rolled mask is how you forget the 65th constant.
EnumMap: an array indexed by ordinal
The other half of the same idea: a map whose keys are OrderStatus. Group today’s orders by status without hashing the status.
EnumMap<OrderStatus, List<Order>> byStatus = new EnumMap<>(OrderStatus.class);
for (Fulfillment f : fulfillments) {
byStatus.computeIfAbsent(f.status(), s -> new ArrayList<>())
.add(f.order());
}
byStatus.get(OrderStatus.PAID); // [Order o-1]
byStatus.containsKey(OrderStatus.CANCELLED); // false
You do new EnumMap<>(OrderStatus.class). The Class is how the map sizes the array: keyType.getEnumConstants().length. There is no no-arg constructor — the universe has to be named.
A sketch of the layout:
class EnumMap<K extends Enum<K>, V> {
private final K[] keyUniverse; // OrderStatus.values()
private Object[] vals; // index = key.ordinal()
private int size;
V put(K key, V value) {
int i = key.ordinal();
Object old = vals[i];
vals[i] = value; // null values are masked internally
if (old == null) size++;
return unmask(old);
}
V get(K key) {
return unmask(vals[key.ordinal()]);
}
}
put / get / remove are array slots. Expected O(1) without a hash bet. Denser than a HashMap of four keys: no nodes, no dummy, no load factor, no resize. The array length is the enum size, not the number of mappings. size() is how many ordinals currently hold a value.
The API, nulls, and views
| Method / constructor | Job |
|---|---|
new EnumMap<>(OrderStatus.class) | Empty map, array sized to that enum |
new EnumMap<>(otherEnumMap) | Copy another EnumMap |
new EnumMap<>(map) | Copy a map; empty non-EnumMap throws |
put / get / remove | Slot at key.ordinal() |
containsKey | false for null; no hash |
containsValue | Scan the array (universe is small) |
keySet / values / entrySet | Declaration order of present keys |
putAll / computeIfAbsent / merge | Usual Map verbs on this storage |
No null keys. put(null, orders) throws NullPointerException. get(null) and containsKey(null) do not throw — they behave like an unknown key (null / false). That matches HashMap’s get-unknown-key shape, not HashMap’s one-null-key permission.
Null values are allowed. EnumMap masks them internally so “mapped to null” is not “absent”:
EnumMap<OrderStatus, String> labels = new EnumMap<>(OrderStatus.class);
labels.put(OrderStatus.OPEN, null);
labels.containsKey(OrderStatus.OPEN); // true — present, value null
labels.get(OrderStatus.OPEN); // null
labels.containsKey(OrderStatus.PAID); // false — never put
labels.get(OrderStatus.PAID); // null — absent also reads as null
Use containsKey when absence matters. Same discipline as any map that allows null values; the array just makes the slot obvious.
Iteration of keys, values, and entries follows enum declaration order among keys that have a mapping — OPEN, then PAID, then SHIPPED, then CANCELLED, skipping holes. It is not insertion order. Putting CANCELLED first does not make it iterate first.
EnumMap<OrderStatus, Integer> counts = new EnumMap<>(OrderStatus.class);
counts.put(OrderStatus.CANCELLED, 1);
counts.put(OrderStatus.OPEN, 4);
System.out.println(counts.keySet());
[OPEN, CANCELLED]
Views are live, fail-fast, same hub story as HashMap’s views. The map is not thread-safe.
Copying from a plain map: new EnumMap<>(existing) needs at least one key so it can see the enum type, unless existing is already an EnumMap. An empty HashMap<OrderStatus, List<Order>> throws IllegalArgumentException. Prefer new EnumMap<>(OrderStatus.class) and putAll, or copy another EnumMap.
When not vs HashSet / HashMap (and everyone else)
These types are specialized. The specialization is the point.
The universe is not an enum. SKUs, order ids, emails, request tokens — open sets. That is HashSet / HashMap. You cannot fake an EnumSet with a handful of strings. EnumSet.noneOf needs Class<? extends Enum>.
You needed a HashSet of enums anyway. You almost never did. Set<OrderStatus> s = new HashSet<>() hashes ordinal-bearing constants and iterates in bucket order. EnumSet is denser, faster, and iterates in the order the enum is declared. Reach for HashSet of enums only if you are mixing types you should not mix — which you should not.
You needed a HashMap with enum keys anyway. Same story. Map<OrderStatus, List<Order>> byStatus = new HashMap<>() works and wastes a hash table on four keys. EnumMap is the default for enum keys in application code.
You need a null key or element. Neither type allows it. A nullable “no status yet” is an Optional, a dedicated UNKNOWN constant, or a different map. Do not pick HashMap only to store a null status key if the honest model is “status is missing.”
You need insertion order of first-seen statuses. EnumSet / EnumMap iterate in declaration order. First-seen is LinkedHashSet / LinkedHashMap. With four statuses that distinction is rarely the product.
You need sorted order other than declaration order. Declaration order is ordinal order. A different ranking is a TreeMap with a Comparator, not an EnumMap you sorted in your head. Navigable Collections own that comparator contract.
The set is a frozen literal. Set.of(OrderStatus.PAID, OrderStatus.SHIPPED) is still the factory type — unmodifiable, not an EnumSet. If you will add later, start with EnumSet.of. Factories: Collection Factories.
Closed enum universe → EnumSet / EnumMap. Open keys → HashSet / HashMap.
Interview lens
Interviewers want “why not a HashSet of enums,” iteration order, nulls, and whether you should serialize bits by hand. Draw storage, not a class hierarchy dump.
EnumSet
Complexity. add / contains / remove are O(1) bit operations on a universe-sized vector (one long if ≤ 64 constants). Iteration is O(universe) of bit tests, declaration order. No hash, no resize, no load factor.
What to draw. enum OrderStatus { OPEN, PAID, SHIPPED, CANCELLED } with ordinals 0..3. A long under it: bit 0 = OPEN, bit 1 = PAID, … EnumSet.of(OPEN, SHIPPED) is 0b0101. Label: not a HashSet. Label: add(null) → NPE.
| Question | Honest answer |
|---|---|
Why does EnumSet beat HashSet for enums? | Bit vector indexed by ordinal. No hash, no nodes, denser, declaration-order iteration. |
| What is iteration order? | Enum declaration order among members present. Not insertion order. |
| Nulls? | No null elements. add(null) is NPE. contains(null) is false. |
| Can it hold other types? | No. One enum class. EnumSet is abstract; use factories (noneOf / of / allOf / range / complementOf). |
| Serialize as bits yourself? | Usually no. The type already is a bit vector (Regular vs Jumbo) and is Serializable. Use EnumSet unless the wire already is a mask. |
EnumMap
Complexity. get / put / remove are O(1) array access at ordinal(). Iteration of views is declaration order of present keys. Universe-sized array, size = populated slots. Faster and denser than HashMap for enum keys.
What to draw. Same four ordinals. An Object[] of length 4. put(PAID, list) writes index 1. Empty slots are absent keys. A slot holding a masked null is a present mapping to null. Label: no null keys. Label: constructor needs OrderStatus.class.
| Question | Honest answer |
|---|---|
EnumMap vs HashMap for enum keys? | Array by ordinal vs hash table. No null keys; null values OK. Declaration-order views. Prefer EnumMap. |
Why pass OrderStatus.class? | EnumMap is not abstract, but it must size the array. Empty new EnumMap<>(emptyHashMap) cannot see the key type and throws. |
get(null) vs put(null, v)? | put NPE. get / containsKey treat null as unknown key (null / false), not as HashMap’s null-key slot. |
Wrong answer: “EnumSet is just a HashSet of enums.” It is not a HashSet. It does not hash. It does not allow null. Iteration is not bucket order. Naming new HashSet<OrderStatus>() in an interview when the universe is an enum is the tell they are waiting for.
Cheat sheet
Universe one enum type — OrderStatus, not SKU strings
EnumSet abstract; factories; bit vector (long / long[])
allOf noneOf of range complementOf copyOf
no null; declaration-order iteration; not a HashSet
EnumMap new EnumMap<>(OrderStatus.class); array[ordinal]
no null keys; null values OK (masked)
views in declaration order; denser than HashMap
vs HashSet HashSet hashes; EnumSet does not
vs HashMap HashMap hashes; EnumMap indexes
vs Linked* first-seen order is not declaration order
Don't pack a long and serialize it as the set
new EnumSet<>() — it is abstract
Do:
- Reach for
EnumSet/EnumMapas soon as the key or element type is an enum. - Build sets with factories (
of,range,complementOf); build maps withnew EnumMap<>(TheEnum.class). - Treat declaration order as the iteration contract.
Don’t:
- Put enum constants in a
HashSetorHashMapby default. - Expect insertion order, a
nullkey, or a universe of strings. - Hand-serialize an
EnumSetas a bit mask because you peeked atRegularEnumSet.
Wrap-up
EnumSet and EnumMap exist because an enum already numbered its universe. The set is bits; the map is an array; both walk in declaration order; neither hashes; neither accepts a null key or element. HashSet and HashMap remain the defaults for open keys — SKUs, ids, emails. Use those when the universe is not closed. Use these two when it is.
The next maps in this series drop equals or let the collector drop a key. They are still maps. They are not enum tables.