📋 Section 20 · Question #10

Can you use Record as a key in HashMap

Using a Java record as a key in hash-based collections (HashMap, HashSet, ConcurrentHashMap) is one of its primary architectural use cases. Records provide concise syntax, shall...

Using a Java record as a key in hash-based collections (HashMap, HashSet, ConcurrentHashMap) is one of its primary architectural use cases. Records provide concise syntax, shallow immutability, and compiler-generated equals() and hashCode() implementations based on state description components. However, beneath this surface lie critical pitfalls regarding mutable components, array fields, garbage collection allocation pressure, and caching limitations.


🟢 Junior Level

Why Records are Ideal HashMap Keys

To function reliably as a key in HashMap, an object must satisfy two primary constraints:

  1. The equals() and hashCode() Contract: Two objects that are equal according to equals(Object) must produce the exact same integer hashCode().
  2. Key Invariance (Immutability): The hash code and equality characteristics of the key should not change while the object resides inside the map. If a key’s hash code mutates after insertion, HashMap.get(key) calculates a different bucket index or fails key equality checks, rendering the stored entry unreachable (causing memory leaks and data loss).

Traditional Java classes require significant boilerplate to implement correct keys:

// Traditional boilerplate key
public final class OrderKey {
    private final String tenantId;
    private final long orderId;

    public OrderKey(String tenantId, long orderId) {
        this.tenantId = Objects.requireNonNull(tenantId);
        this.orderId = orderId;
    }

    public String getTenantId() { return tenantId; }
    public long getOrderId() { return orderId; }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof OrderKey that)) return false;
        return orderId == that.orderId && Objects.equals(tenantId, that.tenantId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(tenantId, orderId);
    }
}

With records, this collapses into a single declarative line:

public record OrderKey(String tenantId, long orderId) {
    public OrderKey {
        Objects.requireNonNull(tenantId, "tenantId cannot be null");
    }
}

Compiler-Generated Contract

The Java compiler automatically generates bytecode for equals() and hashCode() derived directly from all record components:

  • hashCode() hashes every declared component in declaration order.
  • equals() performs component-wise equality (== for primitives, Objects.equals() for reference types).
  • All record fields are marked private final, ensuring reference immutability by default.

🟡 Middle Level

Pitfall 1: Shallow Immutability and Mutable Components

While a record’s component fields are implicitly final, final only ensures reference immutability, not deep immutability. If a record component references a mutable collection or object, mutating that referenced object modifies the record’s hashCode().

// DANGEROUS: Mutable component in a record key
public record FilterKey(String userId, List<String> permissions) {}

Map<FilterKey, UserProfile> cache = new HashMap<>();
List<String> perms = new ArrayList<>(List.of("READ", "WRITE"));
FilterKey key = new FilterKey("user_42", perms);

cache.put(key, new UserProfile("Alice"));

// Mutating the component list changes the record's hash code!
perms.add("ADMIN");

// Fails: returns null because the bucket index changed
UserProfile profile = cache.get(key); // null!

Production Defense: Defensive Copying via Compact Constructor

To guarantee deep immutability, make defensive copies using unmodifiable collections in the compact constructor:

public record FilterKey(String userId, List<String> permissions) {
    public FilterKey {
        Objects.requireNonNull(userId, "userId must not be null");
        // List.copyOf returns an unmodifiable list and handles null checks
        permissions = List.copyOf(permissions);
    }
}

Pitfall 2: Arrays as Record Components

In Java, arrays do not override equals() or hashCode(). They inherit java.lang.Object identity-based semantics (==).

When a record includes an array component, the compiler generates:

  • equals(): checks this.data == other.data (pointer equality, not content equality).
  • hashCode(): uses System.identityHashCode(this.data).
public record SignatureKey(String algorithm, byte[] checksum) {}

byte[] hash1 = new byte[]{1, 2, 3};
byte[] hash2 = new byte[]{1, 2, 3};

SignatureKey key1 = new SignatureKey("SHA-256", hash1);
SignatureKey key2 = new SignatureKey("SHA-256", hash2);

// key1.equals(key2) evaluates to FALSE!
Map<SignatureKey, String> map = new HashMap<>();
map.put(key1, "SignaturePayload");

System.out.println(map.get(key2)); // null!

The Solution: Explicit Override or Defensive Conversion

If an array must be used, you must explicitly override equals() and hashCode() using Arrays:

public record SignatureKey(String algorithm, byte[] checksum) {
    public SignatureKey(String algorithm, byte[] checksum) {
        this.algorithm = algorithm;
        this.checksum = checksum != null ? checksum.clone() : null;
    }

    @Override
    public byte[] checksum() {
        return checksum != null ? checksum.clone() : null;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SignatureKey that)) return false;
        return Objects.equals(algorithm, that.algorithm) 
            && Arrays.equals(checksum, that.checksum);
    }

    @Override
    public int hashCode() {
        return 31 * Objects.hashCode(algorithm) + Arrays.hashCode(checksum);
    }
}

Better architecture: Replace byte[] with an immutable wrapper or java.nio.ByteBuffer or List<Byte>.


🔴 Senior Level

Garbage Collection Pressure and Escape Analysis in High-Throughput Lookups

In high-throughput services (e.g., 500,000+ cache lookups/second), querying a Map<CompositeKey, V> with newly allocated record keys creates high allocation rates:

// Hot path: millions of queries per second
public OrderStatus getStatus(String tenant, long orderId) {
    // Allocates new OrderKey on heap or stack
    return cache.get(new OrderKey(tenant, orderId));
}

JVM Optimization Mechanics

  1. Escape Analysis (EA): The HotSpot C2 compiler attempts Scalar Replacement to decompose new OrderKey(tenant, orderId) into primitive registers and stack allocations.
  2. Escape Barrier in HashMap.getNode: In standard HashMap.get(Object key), the key is passed as an Object reference across method boundaries (hash(key) and getNode(hash, key)). Under JIT compilation, if getNode is inlined, Scalar Replacement can succeed. However, if method inlining thresholds are exceeded (e.g., deep call chains or polymorphic call sites in mega-morphic maps), the temporary record key escapes to the Young Generation (Eden space).
  3. Allocation Rate Impact: At 1,000,000 lookups/sec, allocating an 24-byte record key generates ~24 MB/s of Young Gen churn, triggering frequent Minor GCs and pause times.
  4. Future Solution (Project Valhalla): Project Valhalla introduces value objects (value class / value record), eliminating object identity and object headers. Lookups will pass keys in CPU registers or on the stack without heap allocation.

The Myth of Caching hashCode in Records

In classes with expensive hash code computations (e.g., java.lang.String), the hash code is cached in a private field:

// java.lang.String caching pattern
private int hash; // 0 if not computed

Engineers often attempt to replicate this in records:

public record HeavyKey(String partA, String partB, List<UUID> ids) {
    // COMPILATION ERROR: Cannot declare instance fields in record body
    private int cachedHash = 0; 
}

Why the JLS prohibits instance fields in records: Section 8.10.1 of the Java Language Specification mandates that the state of a record is strictly and exclusively defined by its component list in the record header. Introducing hidden or additional instance fields would violate record serialization protocols, reflection invariants, and destructuring guarantees.

Workaround Options for Expensive Keys

  1. Compute and store the hash as a record component:
    public record PrecomputedKey(String payload, int precomputedHash) {
        public static PrecomputedKey of(String payload) {
            return new PrecomputedKey(payload, payload.hashCode());
        }
    
        @Override
        public int hashCode() {
            return precomputedHash;
        }
    }
    
  2. Revert to a standard final class with a lazily initialized private int hash; field if component-level storage is undesirable.

4 Tricky Questions

1. Can you declare a static cache inside a record to memoize its hashCode()?

Answer: While you can declare a static ConcurrentMap<RecordInstance, Integer> or thread-local storage, doing so is an anti-pattern. Maintaining a static cache introduces memory leaks, lock contention, and cache lookup overhead that exceeds the cost of recomputing the hash code. If a hash calculation is genuinely CPU-intensive, either pass the precomputed hash as a record component or use a regular class with a cached int hash field.

2. What happens if two different records have identical component names and types? Can one be used to retrieve values put by the other?

public record UserKey(String tenant, long id) {}
public record CustomerKey(String tenant, long id) {}

Map<Object, String> map = new HashMap<>();
map.put(new UserKey("corp", 100L), "Data");

System.out.println(map.get(new CustomerKey("corp", 100L)));

Answer: It returns null. The compiler-generated equals() method in java.lang.Record checks:

if (!(other instanceof UserKey that)) return false;

Even though both records have identical memory layouts and identical component values, equals checks class equality. They are distinct types in the JVM type system.

3. Does deserializing a record key bypass its compact constructor validation?

Answer: No. This is one of the greatest security advantages of records over regular classes. Java Object Serialization for normal classes bypasses constructors, writing field values directly into memory via native unsafe reflection. For records, deserialization must invoke the canonical constructor, passing the deserialized stream components as arguments. Any defensive copy, immutability check, or validation logic placed inside the compact/canonical constructor is guaranteed to execute during deserialization.

4. What subtle bug occurs if a record used as a key overrides equals() but forgets to override hashCode()?

Answer: It is a classic violation of the equals/hashCode contract. If an engineer overrides equals() to ignore a transient timestamp component:

public record SessionKey(String sessionId, Instant timestamp) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof SessionKey that)) return false;
        return Objects.equals(sessionId, that.sessionId); // ignores timestamp
    }
    // Forgot to override hashCode()!
}

The compiler-generated hashCode() still calculates the hash using both sessionId and timestamp. Two keys with the same sessionId but different timestamps evaluate to true via equals(), but hash to different buckets in HashMap. map.get(new SessionKey("s1", now)) will fail to find an entry inserted with new SessionKey("s1", earlier).


🎯 Interview Cheat Sheet

  • Always Safe for Primitives and Immutable Objects: Records whose components are primitives, String, UUID, BigInteger, or other records are intrinsically safe, optimal HashMap keys.
  • Enforce Deep Immutability: Always defensively copy mutable components (e.g. List.copyOf, Set.copyOf) in the compact constructor.
  • Array Danger: Record equals() and hashCode() treat arrays with identity semantics (==). Either override equals/hashCode with Arrays.equals and Arrays.hashCode, or convert arrays to immutable collections.
  • No Extra Instance Fields: You cannot cache hashCode in a private int hash; field because records prohibit instance fields outside the header components.
  • High-Throughput Hot Paths: In extreme throughput scenarios (>500k lookups/sec), watch Young Gen allocation pressure if JIT escape analysis fails to scalar-replace temporary key allocations.
  • Serialization Safety: Records guarantee that the canonical constructor is invoked during deserialization, preventing invalid or mutated keys from bypassing invariants.