📋 Розділ 20 · Питання #10

Чи можна використовувати Record як ключ в HashMap

Для коректної роботи ключа в HashMap потрібні дві умови:


🟢 Junior Level

Так, Record — один із найкращих і найбільш рекомендованих типів для ключів у HashMap та HashSet.

Чому Record ідеальний у ролі ключа

Для коректної роботи ключа в HashMap потрібні дві умови:

  1. Незмінність (Immutability): стан ключа не повинен змінюватися після поміщення в карту.
  2. Узгодженість equals() та hashCode(): однакові за значенням ключі зобов’язані повертати однаковий хеш-код і бути рівними за .equals().

Record задовольняє обидві вимоги «з коробки»:

  • Усі поля неявно є private final.
  • Методи equals() та hashCode() компілятор генерує автоматично за всіма компонентами.
// Ідеальний складений ключ для кешу або таблиці:
public record CacheKey(String tenantId, Long userId) {}

Map<CacheKey, UserProfile> cache = new HashMap<>();

CacheKey key1 = new CacheKey("tenant-A", 42L);
cache.put(key1, new UserProfile("Alice"));

// Пошук за новим об'єктом з ідентичними значеннями:
CacheKey key2 = new CacheKey("tenant-A", 42L);
UserProfile profile = cache.get(key2); // ✅ Успішно знаходить Alice!

🟡 Middle Level

1. Критична пастка: Мутабельні компоненти в ключі

Якщо компонент Record посилається на змінний об’єкт (наприклад, ArrayList або Date), незмінність порушується, що веде до катастрофічного збою HashMap:

public record BrokenKey(String tenant, List<String> tags) {}

List<String> tags = new ArrayList<>(List.of("vip"));
BrokenKey key = new BrokenKey("org1", tags);

Map<BrokenKey, String> map = new HashMap<>();
map.put(key, "VIP Account");

// 💣 Небезпечна мутація: змінюємо список після додавання в HashMap!
tags.add("blocked");

// Хеш-код ключа змінився!
// map.get(key) поверне null, а значення залишиться в пам'яті назавжди (Memory Leak)!
System.out.println(map.get(key)); // null

Рішення: Глибока незмінність у компактному конструкторі

public record SafeKey(String tenant, List<String> tags) {
    public SafeKey {
        // List.copyOf створює по-справжньому незмінну колекцію
        tags = List.copyOf(tags);
    }
}

2. Масиви як компоненти ключа

[!WARNING] Якщо компонент Record — масив (byte[], int[]), автогенерований equals() порівнюватиме посилання, а hashCode() поверне адресу в пам’яті! Пошук у HashMap за новим екземпляром завжди повертатиме null.

Рішення: Перевизначте equals і hashCode через методи класу Arrays:

public record CompositeHashKey(String prefix, byte[] hash) {
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof CompositeHashKey other)) return false;
        return Objects.equals(prefix, other.prefix) && Arrays.equals(hash, other.hash);
    }

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

🔴 Senior Level

Проблема Garbage Collector (GC Pressure) при пошуку в Highload

Хоча складені ключі на Records пишуться лаконічно, у високонавантажених системах (Highload / Low Latency) виникає проблема тиску на збирач сміття:

public UserProfile getProfile(String tenant, Long userId) {
    // При КОЖНОМУ виклику get() у купі створюється тимчасовий об'єкт CacheKey:
    return cache.get(new CacheKey(tenant, userId));
}

Чому Escape Analysis не рятує:

  1. Метод map.get(key) передає посилання на об’єкт усередину методу HashMap.getNode(hash, key).
  2. У складних реалізаціях колекцій C2-компілятор часто не може довести, що об’єкт не витікає за межі локального фрейму (No Escape).
  3. У результаті на мільйони запитів на секунду створюються мільйони дрібних об’єктів в Eden Space, провокуючи часті Minor GC.

Рішення в production:

  • Використання спеціалізованих структур без алокацій (FastUtil, Agrona primitive maps, Eclipse Collections).
  • Перспектива Project Valhalla: перетворення Record на Value Object (Identity-free) усуне виділення пам’яті в купі, оскільки об’єкт буде скаляризований або переданий за значенням без заголовка.

Міф про кешування hashCode всередині Record

Часто на співбесідах кандидати пропонують: «Якщо розрахунок хеш-коду для великого Record дорогий, давайте закешуємо його в приватному полі, як у String»:

// ❌ ТАК НАПИСАТИ НЕ МОЖНА! НЕ СКОМПІЛЮЄТЬСЯ!
public record HeavyKey(String a, String b, String c) {
    // private int hash; // ❌ Помилка компіляції: non-static field not allowed in record
}

Специфікація мови Java категорично забороняє оголошення нестатичних полів усередині тіла record. Якщо вам дійсно необхідно кешувати хеш-код, ви зобов’язані використовувати звичайний клас, а не Record.


4 Tricky Questions

1. Чому Record вважається ідеальним складеним ключем для HashMap, і якої частої помилки з кешуванням хеш-коду припускаються на співбесідах?

Відповідь: Record вирішує головну проблему складених ключів у Java: позбавляє від необхідності писати громіздкі класи з ручним equals/hashCode або з’єднувати рядки через роздільники (tenant + ":" + id), що повільно і небезпечно.

Типова помилка кандидатів: пропозиція оголосити поле-кеш private int hash; для оптимізації повторних викликів hashCode(). У Record заборонені будь-які нестатичні поля екземпляра, тому кешувати хеш усередині самого об’єкта фізично неможливо. Для кешування хешу доведеться використовувати класичний final class.

2. Що станеться, якщо вставити в HashMap об’єкт record Key(String prefix, byte[] salt) і спробувати отримати значення за новим рекордом із тими самими рядком і байтами?

Відповідь: Виклик map.get(new Key(prefix, saltCopy)) поверне null!

Автогенерований компілятором equals() викликає Objects.equals(), який для масивів перевіряє рівність посилань (==). У двох різних екземплярів Key масиви salt лежатимуть за різними адресами в купі. Їхній equals() поверне false, а автозгенерований hashCode() видасть різні значення. Для використання запису з масивами в якості ключа необхідно явно перевизначити equals() та hashCode() за допомогою Arrays.equals() та Arrays.hashCode().

3. Що станеться, якщо компонент Record є мутабельним об’єктом (наприклад, ArrayList), і його вміст зміниться після додавання в HashMap?

Відповідь: Відбудеться класичний ефект «втрати ключа» (Lost Key):

  1. При додаванні map.put(key, value) елемент розміщується в кошику (bucket), обчисленому за вихідним hashCode().
  2. Після мутації списку всередині рекорду значення key.hashCode() змінюється.
  3. При спробі знайти об’єкт через map.get(key) хеш-мапа обчислює новий індекс кошика і шукає елемент там, де його немає. Метод поверне null.
  4. Старий запис назавжди залишиться заблокованим у вихідному кошику, призводячи до прихованого витоку пам’яті. Для запобігання цьому в компактному конструкторі завжди слід викликати List.copyOf().

4. Чи створює використання конструкції cache.get(new MyRecordKey(a, b)) навантаження на Garbage Collector у highload-системах?

Відповідь: Так, створює відчутний тиск на пам’ять (GC Pressure).

При кожному пошуку в карті створюється новий екземпляр MyRecordKey. Хоча об’єкт живе недовго, JVM Escape Analysis не завжди здатний скаляризувати його (розкласти на стеку), оскільки посилання на ключ передається в глибокий ланцюжок викликів HashMap.getNode(). При темпі в сотні тисяч запитів на секунду це викликає стрімке засмічення області Young Generation (Eden) і часті паузи на збирання сміття.

У високопродуктивних сервісах до приходу Project Valhalla такі складені ключі замінюють пулінгом, пласкими примітивними мапами (LongLongMap) або складеними примітивними бітовими ключами.


🎯 Шпаргалка для інтерв’ю

Головне про Record як ключ у HashMap

  • Плюси: Поля незмінні (final), equals та hashCode згенеровані автоматично і суворо узгоджені.
  • Головна загроза: Мутабельні колекції та масиви. Завжди застосовуйте List.copyOf() / Set.copyOf() у компактному конструкторі.
  • Масиви: Автогенерація не працює! Обов’язково перевизначати equals та hashCode через Arrays.equals / Arrays.hashCode.
  • Кешування хешу: Неможливо додати поле hash у Record. Для кешування хешу використовуйте звичайний клас.
  • Highload: Постійний new Key(...) у циклі створює навантаження на GC (чекає на Project Valhalla для скаляризації).

Пов’язані теми