DEV Community

TheCodeForge
TheCodeForge

Posted on • Originally published at thecodeforge.io

EnumMap and EnumSet: The Java Collections Most Developers Never Use

Two years ago I was doing a code review and flagged a colleague's
HashMap<OrderStatus, Handler> as "fine, no issues." The tests passed.
The code shipped.

Last year I profiled that same service under load and found the map
lookup on the hot path burning more CPU than it should. The fix was
a one-line change. The only reason I knew about it was because I'd
finally read the java.util.EnumMap source out of curiosity one
afternoon.

This is that story, plus everything I now know about EnumMap and
EnumSet that I wish someone had told me earlier.


The One Fact That Makes Everything Else Click

Every Java enum constant has an ordinal — a zero-based integer
assigned in declaration order:

public enum OrderStatus {
    PLACED,    // ordinal 0
    CONFIRMED, // ordinal 1
    SHIPPED,   // ordinal 2
    DELIVERED, // ordinal 3
    CANCELLED  // ordinal 4
}
Enter fullscreen mode Exit fullscreen mode

This ordinal is stable for a given JVM session. It is the entire
foundation that makes both EnumMap and EnumSet possible.


EnumMap: What's Actually Inside

Open java.util.EnumMap in your JDK source. The core storage field is:

private Object[] vals;
Enter fullscreen mode Exit fullscreen mode

That is the whole map. A plain Java array. The index into that array
is the enum constant's ordinal.

put(OrderStatus.SHIPPED, handler) stores handler at vals[2].

get(OrderStatus.SHIPPED) reads vals[2].

No hashCode(). No bucket array. No Map.Entry objects. No collision
chains. Just a direct array index read.

Compare that to what HashMap has to do:

  1. Call key.hashCode()
  2. Apply a spread function to reduce clustering
  3. Locate the bucket via (n - 1) & hash
  4. Walk the bucket chain comparing keys with .equals()
  5. Potentially trigger a resize and full rehash

For enum keys, all of that work is completely unnecessary.
The ordinal already is a perfect compact hash. EnumMap just
uses it directly.

Memory difference

HashMap stores Map.Entry objects — each one holds a key reference,
a value reference, the hash code, and a next pointer.
That's 4 object fields per entry, plus the bucket array overhead.

EnumMap stores only values. The keys are implicit in the array
position. For a 5-constant enum, an EnumMap uses roughly 40–60%
less heap per entry than the equivalent HashMap.

Creating one

// Always requires the class literal — the array must be pre-sized
Map<OrderStatus, Handler> handlers = new EnumMap<>(OrderStatus.class);

handlers.put(OrderStatus.PLACED,    this::handlePlaced);
handlers.put(OrderStatus.CONFIRMED, this::handleConfirmed);
handlers.put(OrderStatus.SHIPPED,   this::handleShipped);
Enter fullscreen mode Exit fullscreen mode

Copy constructor also works, which is useful for defensive copies:

Map<OrderStatus, Handler> copy = new EnumMap<>(existingMap);
Enter fullscreen mode Exit fullscreen mode

Iteration order

EnumMap always iterates in enum declaration order — not insertion
order, not sorted order, declaration order. This is free: it's
just incrementing an array index. No pointer chasing, no
comparisons.

What it won't do

It does not allow null keys. Attempting put(null, value)
throws NullPointerException immediately. It does allow null values.


Real Pattern: Routing by Status

The cleanest use case I've found in production is replacing a
switch with an EnumMap of handlers:

public enum PaymentMethod {
    CREDIT_CARD, DEBIT_CARD, PAYPAL, CRYPTO, BANK_TRANSFER
}

public class PaymentRouter {

    private final Map<PaymentMethod, PaymentProcessor> processors;

    public PaymentRouter() {
        processors = new EnumMap<>(PaymentMethod.class);
        processors.put(PaymentMethod.CREDIT_CARD,   this::processCreditCard);
        processors.put(PaymentMethod.DEBIT_CARD,    this::processDebitCard);
        processors.put(PaymentMethod.PAYPAL,        this::processPaypal);
        processors.put(PaymentMethod.CRYPTO,        this::processCrypto);
        processors.put(PaymentMethod.BANK_TRANSFER, this::processBankTransfer);
    }

    public Receipt route(Payment payment) {
        PaymentProcessor processor = processors.get(payment.getMethod());
        if (processor == null) {
            throw new IllegalArgumentException(
                "No processor registered for: " + payment.getMethod()
            );
        }
        return processor.process(payment);
    }
}
Enter fullscreen mode Exit fullscreen mode

This is more testable than a switch (inject mock processors),
safer than a switch (compiler won't silently fall through),
and the lookup is an array read instead of a branch table.


EnumSet: A 64-Element Set in 8 Bytes

EnumSet is abstract. The JDK gives you one of two implementations:

  • RegularEnumSet — for enums with ≤ 64 constants. Stores the entire set as a single long using bit manipulation.
  • JumboEnumSet — for enums with > 64 constants. Uses an array of long values.

You never reference these classes. The static factory methods on
EnumSet return the right one automatically.

What's inside RegularEnumSet

private long elements; // that's the entire set
Enter fullscreen mode Exit fullscreen mode

One long. 8 bytes. Each bit position corresponds to one enum
constant's ordinal.

For a Permission enum where READ has ordinal 0 and WRITE
has ordinal 1:

EnumSet containing READ        → elements = 0b...0001 = 1L
EnumSet containing READ, WRITE → elements = 0b...0011 = 3L
Enter fullscreen mode Exit fullscreen mode

contains(Permission.READ) compiles down to:

return (elements & (1L << ordinal)) != 0;
Enter fullscreen mode Exit fullscreen mode

One bitwise AND. Single CPU instruction. The fastest possible
contains() check in Java.

addAll(otherEnumSet) — union of two sets:

elements |= other.elements;
Enter fullscreen mode Exit fullscreen mode

One bitwise OR. The entire operation completes in a single instruction
regardless of how many elements are in the set. retainAll
(intersection) is &=. removeAll (difference) is &= ~other.

For comparison: HashSet.addAll() processes each element
individually through the full hash pipeline. For a set with N elements,
it takes O(N) hash computations. EnumSet.addAll() is always O(1).

Creating one

public enum Permission {
    READ, WRITE, DELETE, ADMIN, AUDIT, EXPORT
}

// Specific elements
Set<Permission> userPerms = EnumSet.of(Permission.READ, Permission.WRITE);

// All elements
Set<Permission> allPerms = EnumSet.allOf(Permission.class);

// Empty, but typed
Set<Permission> noPerms = EnumSet.noneOf(Permission.class);

// Everything NOT in the given set
Set<Permission> nonAdmin = EnumSet.complementOf(EnumSet.of(Permission.ADMIN));

// A range (inclusive both ends, in declaration order)
Set<Permission> basicPerms = EnumSet.range(Permission.READ, Permission.DELETE);
Enter fullscreen mode Exit fullscreen mode

Null handling

EnumSet does not allow null elements. HashSet allows one null.
This is a migration gotcha if you're replacing a HashSet that
has a null somewhere in it.


Real Pattern: Permission System

public class Role {
    public static final Set<Permission> VIEWER =
        Collections.unmodifiableSet(EnumSet.of(Permission.READ));

    public static final Set<Permission> EDITOR =
        Collections.unmodifiableSet(
            EnumSet.of(Permission.READ, Permission.WRITE)
        );

    public static final Set<Permission> MANAGER =
        Collections.unmodifiableSet(
            EnumSet.of(Permission.READ, Permission.WRITE,
                       Permission.DELETE, Permission.EXPORT)
        );
}

public class AuthorizationService {

    public boolean can(Set<Permission> userPerms, Permission action) {
        return userPerms.contains(action); // single bitwise AND
    }

    public boolean canAll(Set<Permission> userPerms,
                          Set<Permission> required) {
        return userPerms.containsAll(required); // single AND + compare to mask
    }

    public Set<Permission> merge(Set<Permission> a, Set<Permission> b) {
        Set<Permission> result = EnumSet.copyOf(a);
        result.addAll(b); // single bitwise OR if b is also an EnumSet
        return result;
    }
}
Enter fullscreen mode Exit fullscreen mode

Note the Collections.unmodifiableSet() wrapper on the constants —
more on why this matters in the mistakes section below.


Real Pattern: Replacing C-Style Bitmask Flags

If you've ever maintained code like this:

int flags = OPTION_COMPRESS | OPTION_ENCRYPT | OPTION_CHECKSUM;
if ((flags & OPTION_ENCRYPT) != 0) { ... }
Enter fullscreen mode Exit fullscreen mode

EnumSet is the type-safe modern replacement:

public enum ExportOption { COMPRESS, ENCRYPT, CHECKSUM, INCLUDE_METADATA }

ExportConfig config = new ExportConfig(
    ExportOption.COMPRESS,
    ExportOption.ENCRYPT
);

if (config.has(ExportOption.ENCRYPT)) { ... }
Enter fullscreen mode Exit fullscreen mode

Same performance (the implementation is the same long bitmask),
type safety, null safety, and readable names instead of integer
literals.


The Benchmark

Here's a runnable JMH benchmark. The results below are from
JDK 21, server VM, i7-class laptop, averaged over 10 measurement
iterations with 5 warmup iterations and 2 forks.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Thread)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class EnumCollectionBenchmark {

    enum Status { A, B, C, D, E, F, G, H } // 8 constants

    Map<Status, Integer> enumMap;
    Map<Status, Integer> hashMap;
    Set<Status> enumSet;
    Set<Status> hashSet;

    @Setup
    public void setup() {
        enumMap = new EnumMap<>(Status.class);
        hashMap = new HashMap<>();
        enumSet = EnumSet.noneOf(Status.class);
        hashSet = new HashSet<>();
        for (Status s : Status.values()) {
            enumMap.put(s, s.ordinal());
            hashMap.put(s, s.ordinal());
            enumSet.add(s);
            hashSet.add(s);
        }
    }

    @Benchmark public Integer enumMapGet()       { return enumMap.get(Status.E); }
    @Benchmark public Integer hashMapGet()       { return hashMap.get(Status.E); }
    @Benchmark public boolean enumSetContains()  { return enumSet.contains(Status.E); }
    @Benchmark public boolean hashSetContains()  { return hashSet.contains(Status.E); }

    @Benchmark
    public void enumMapIterate(Blackhole bh) {
        for (var e : enumMap.entrySet()) bh.consume(e.getValue());
    }

    @Benchmark
    public void hashMapIterate(Blackhole bh) {
        for (var e : hashMap.entrySet()) bh.consume(e.getValue());
    }

    @Benchmark
    public EnumSet<Status> enumSetUnion() {
        EnumSet<Status> copy = EnumSet.copyOf(enumSet);
        copy.addAll(enumSet);
        return copy;
    }

    @Benchmark
    public HashSet<Status> hashSetUnion() {
        HashSet<Status> copy = new HashSet<>(hashSet);
        copy.addAll(hashSet);
        return copy;
    }
}
Enter fullscreen mode Exit fullscreen mode

My results (your numbers will vary by JVM version and hardware):

Operation EnumMap/EnumSet HashMap/HashSet Ratio
Map get 3.8 ns 17.2 ns 4.5×
Set contains 1.9 ns 14.6 ns 7.7×
Map iterate (8 keys) 21 ns 83 ns 4.0×
Set addAll (union) 2.8 ns 91 ns 32×

The union result is the extreme one: EnumSet.addAll() on two
EnumSet instances is a single |= instruction. HashSet.addAll()
processes all 8 elements individually. At larger enum sizes the
ratio stays constant for EnumSet (still O(1)) while HashSet
scales linearly.


Three Mistakes That Will Catch You Off Guard

Mistake 1: Forgetting EnumSet is mutable

// This looks like a constant but isn't
public static final Set<Permission> VIEWER = EnumSet.of(Permission.READ);

// Somewhere else in the codebase:
VIEWER.add(Permission.DELETE); // Silently succeeds. Now "VIEWER" can delete.
Enter fullscreen mode Exit fullscreen mode

Wrap with Collections.unmodifiableSet() for anything declared as a
constant. Java 10+ note: Set.of() gives you an immutable set but
it's not backed by EnumSet, so you lose the bit-vector performance.
For immutable + fast: Collections.unmodifiableSet(EnumSet.of(...)).

Mistake 2: EnumSet.copyOf on an empty collection

List<Permission> perms = List.of(); // empty
Set<Permission> set = EnumSet.copyOf(perms); // IllegalArgumentException
Enter fullscreen mode Exit fullscreen mode

EnumSet.copyOf needs at least one element to infer the enum type.
Guard it:

Set<Permission> set = perms.isEmpty()
    ? EnumSet.noneOf(Permission.class)
    : EnumSet.copyOf(perms);
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Relying on ordinal() across deployments

The whole performance story here depends on ordinals being stable
within a JVM session. They are NOT stable across deployments if
you reorder, add to the middle, or remove enum constants.

This makes ordinal-based serialization dangerous:

// You serialized Status.SHIPPED as 2
// Then someone added PROCESSING between CONFIRMED and SHIPPED
// Now 2 means PROCESSING in the new version
// Your deserialized data is silently wrong
Enter fullscreen mode Exit fullscreen mode

Never use ordinal() directly in application logic or serialization.
Use the enum constant's .name() for any persistence that crosses
a deployment boundary. EnumMap and EnumSet are safe for
in-memory use — they recalculate ordinals from the live enum
class each JVM start. The risk is in your own ordinal() usage.


Decision Guide

Keys/elements all from one enum type?
│
├── Yes → Constants ≤ 64?
│         ├── Yes → Use EnumMap / EnumSet. Done.
│         └── No  → Still use them. JumboEnumSet handles > 64.
│                   Still faster than Hash alternatives.
│
└── No  → HashMap / HashSet as normal.
Enter fullscreen mode Exit fullscreen mode

Additional cases to prefer EnumMap/EnumSet:

  • Hot path: you're doing millions of lookups per second
  • Set operations: union, intersection, difference (the O(1) story)
  • Memory pressure: embedded systems, Android, high instance count
  • Iteration order: you need enum declaration order for free

Cases where HashMap/HashSet is correct:

  • Null keys required (EnumMap forbids them)
  • Mixed key types that happen to include some enums
  • You need ConcurrentHashMap semantics (no ConcurrentEnumMap exists)

Summary Table

EnumMap EnumSet
Purpose Map with enum keys Set of enum values
Internal storage Object[] by ordinal long bitmask
Null keys/elements ❌ NPE ❌ NPE
Null values ✅ allowed
Iteration order Declaration order Declaration order
Thread-safe
Available since JDK 1.5 JDK 1.5
addAll complexity O(n) O(1)
vs. Hash equivalent ~4–5× faster get ~7–32× faster ops

Both have been in the JDK since Java 5. They are not experimental,
not third-party, and not obscure. The reason most codebases underuse
them is habit — HashMap is what people reach for first and it works
well enough that nobody questions it.

The next time you write new HashMap<>() with an enum key, pause
for two seconds. The migration path is a one-word change:
HashMapEnumMap. Same interface. Same contract. Meaningfully
better performance. Code that communicates intent to the next
developer who reads it.


What's your go-to use case for EnumMap or EnumSet? I've found the
permission system pattern comes up constantly — curious what patterns
others have hit.

The full version of this article with additional benchmark configurations, Spring integration patterns, and serialization deep-dive is on TheCodeForge.

Top comments (0)