🔑 Розділ 10 · Питання #15

Чому String часто використовують як ключ в HashMap

Клас String — найпопулярніший і рекомендований тип ключа для HashMap у Java. Основні причини такого вибору:


🟢 Junior Level

Короткий опис для співбесіди (30 секунд)

Клас String — найпопулярніший і рекомендований тип ключа для HashMap у Java. Основні причини такого вибору:

  1. Імутабельність (Immutability): Об’єкт String неможливо змінити після створення. Стан ключа стабільний, а отже, його геш-код ніколи не зміниться під час перебування в колекції.
  2. Кешування hashCode(): Геш-код обчислюється один раз при першому зверненні та зберігається у внутрішньому полі. Повторні виклики працюють миттєво зі складністю $O(1)$.
  3. Якісна геш-функція: Вбудований алгоритм гешування рядків забезпечує рівномірний псевдовипадковий розподіл по бакетах, мінімізуючи ймовірність виникнення колізій.
  4. Реалізація equals() та Comparable: Рядки коректно порівнюються за вмістом (equals), а реалізація інтерфейсу Comparable<String> дозволяє HashMap ефективно будувати збалансовані червоно-чорні дерева (TreeNode) при виникненні колізій.

Приклад базового використання

import java.util.HashMap;
import java.util.Map;

public class StringKeyDemo {
    public static void main(String[] args) {
        Map<String, String> cache = new HashMap<>();

        // Рядкові літерали безпечно та ефективно слугують ключами:
        cache.put("user_101", "Slava");
        cache.put("user_102", "Elena");

        System.out.println(cache.get("user_101")); // Slava
    }
}

[!NOTE] Аналогія: Рядковий ключ — це незмивний інвентарний номер, вигравіруваний на камері схову. Він ніколи не зітреться, не деформується, і за ним завжди гарантовано можна знайти залишену річ.


🟡 Middle Level

1. Анатомія імутабельності та безпека в колекціях

Якби ключ був змінюваним, модифікація його полів призвела б до зміни геш-коду. У результаті об’єкт назавжди «загубився» б у HashMap, оскільки метод get() шукав би його в іншому бакеті.

Клас String оголошений як public final class String. Його внутрішній масив символів byte[] value є private final, а сам клас не надає жодного методу-мутатора (будь-які методи на кшталт trim(), toUpperCase(), replace() повертають новий екземпляр рядка). Це гарантує повну потокобезпечність ключа без необхідності синхронізації.


2. Механізм лінивого кешування hashCode()

У класі String геш-код обчислюється не в конструкторі, а відкладено (lazy initialization) під час першого виклику hashCode():

// Спрощена логіка з java.lang.String (Java 13+)
public final class String implements Serializable, Comparable<String>, CharSequence {
    private int hash; // За замовчуванням 0
    private boolean hashIsZero; // Прапорець для рядків, чий реальний геш дорівнює 0 (Java 13+)

    public int hashCode() {
        int h = hash;
        if (h == 0 && !hashIsZero) {
            h = isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value);
            if (h == 0) {
                hashIsZero = true;
            } else {
                hash = h;
            }
        }
        return h;
    }
}
  • Перше додавання в мапу (put): Для рядка довжини $N$ геш обчислюється за $O(N)$.
  • Подальші операції (get, containsKey, remove): Повернення значення з поля hash за $O(1)$.
  • Оптимізація hashIsZero: До Java 13 для рядків із реальним гешем 0 (наприклад, порожній рядок "" або рядок "polygenelubricants") умова h == 0 була істинною завжди, через що геш перераховувався заново при кожному виклику. Введення прапорця hashIsZero усунуло цей регрес продуктивності.

3. Оптимізація equals() та роль String Pool

У HashMap.getNode(...) перевірка збігу ключа відбувається за ланцюжком умов:

// Усередині HotSpot HashMap:
if (first.hash == hash &&
    ((k = first.key) == key || (key != null && key.equals(k))))
  1. Порівняння гешів: Спочатку порівнюються 32-бітні цілі числа first.hash == hash. Якщо вони не рівні, виклик важкого equals() миттєво відсікається за 1 такт CPU.
  2. Порівняння за посиланням (k == key): Завдяки пулу рядків (String Pool) однакові рядкові літерали посилаються на один і той самий екземпляр у купі (Heap). У цьому випадку перевірка k == key повертає true, і посимвольний обхід equals() навіть не починається.
  3. Векторизований equals(): Якщо посилання різні, сучасна JVM застосовує SIMD-інтринсики (ArraysSupport.mismatch), порівнюючи байти цілими машинними словами (64/128/256 бітів за машинний такт).

🔴 Senior Level

1. Вплив Comparable<String> на Treeification (Червоно-чорні дерева)

Коли кількість колізій в одному бакеті досягає 8 і розмір таблиці $\ge 64$, зв’язний список Node реорганізується в червоно-чорне дерево TreeNode.

Побудова та балансування бінарного дерева пошуку вимагають відносного впорядкування вузлів. String реалізує інтерфейс Comparable<String>:

// HashMap визначає, чи реалізує ключ Comparable:
static Class<?> comparableClassFor(Object x) {
    if (x instanceof Comparable) {
        Class<?> c = x.getClass();
        if (c == String.class) // Швидкий шорткат без рефлексії для String!
            return c;
        // ... перевірка generic-параметра через reflection
    }
    return null;
}

Завдяки реалізації Comparable<String>, HashMap порівнює вузли дерева викликом k.compareTo(pk) зі складністю $O(\log k)$. Якби клас не імплементував Comparable, HashMap мусила б сканувати гілки або вдаватися до резервного методу tieBreakOrder(...) на основі System.identityHashCode(a).


2. Compact Strings (JEP 254) та Memory Footprint

Починаючи з Java 9, рядки зберігаються у вигляді byte[] value плюс байт кодувальника byte coder (LATIN1 = 0, UTF16 = 1).

Переважна більшість ключів у корпоративних системах (UUID, SKU, назви колонок, enum-рядки) складаються виключно із символів Latin-1 (ASCII). Це скорочує споживання пам’яті на масив символів рівно вдвічі порівняно з char[] у Java 8:

JVM (64-bit, Compressed OOPs) Java 8 (char[]) Java 9+ Compact (byte[])
Заголовок String + поля 24 байти 24 байти
Масив символів (довжина $N=16$) $16 + (16 \times 2) = 48$ байтів $16 + (16 \times 1) = 32$ байти
Підсумковий розмір одного ключа 72 байти 56 байтів

Зниження витрат пам’яті на 22–30% для кожного рядкового ключа істотно зменшує тиск на збирач сміття (GC pressure) та покращує утилізацію процесорних кешів L1/L2/L3.


3. Вразливість Hash Flooding (DoS) та захист у Java 8+

Алгоритм обчислення гешу рядка зафіксований у специфікації Java: \(s[0] \cdot 31^{n-1} + s[1] \cdot 31^{n-2} + \dots + s[n-1]\)

Оскільки алгоритм детермінований і не використовує випадкової солі (seed), зловмисник може заздалегідь згенерувати мільйони різних рядків, що дають абсолютно однаковий hashCode() (наприклад, комбінації "Aa", "BB": "AaAa", "BBBB", "AaBB", "BBAa" мають геш 2060436).

  • У Java 7: Відправка таких рядків у HTTP-заголовках або JSON-параметрах перетворювала HashMap веб-сервера на довгий однозв’язний список, деградуючи пошук до $O(N)$ та викликаючи 100% завантаження CPU (Denial of Service).
  • У Java 8+: Автоматичне перетворення бакета на червоно-чорне дерево гарантує граничну складність пошуку $O(\log N)$ навіть за умови 100% збігу гешів у всіх ключів.

4. Коли String НЕ підходить на роль ключа

  1. Зберігання паролів та API-токенів: Рядки є імутабельними й залишаються в купі до збирання сміття (або назавжди в String Pool). Їх неможливо примусово занулити (zero out) у пам’яті. Для конфіденційних даних слід використовувати масиви char[] або захищені структури.
  2. Надзвичайно довгі рядки: Якщо ключами слугують сирі XML/JSON-документи довжиною в десятки кілобайтів, перший розрахунок гешу займе $O(N)$ часу CPU. Краще використовувати геш-дайджест (SHA-256) у вигляді байтового масиву або обгортки.
  3. Ненормалізований Unicode: Рядки "café" (де é — один символ \u00E9) та "cafe\u0301" (буква e + діакритичний знак) виглядають на екрані однаково, але мають різні hashCode() і не рівні за equals(). Перед використанням інтернаціоналізованих рядків необхідна обов’язкова нормалізація (java.text.Normalizer).

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

1. 30-секундний есенс (Elevator Pitch)

String — ідеальний ключ для HashMap, оскільки він повністю імутабельний (його геш ніколи не змінюється), кешує свій hashCode() (повторні операції виконуються за $O(1)$) та реалізує Comparable<String> (що дозволяє HashMap балансувати червоно-чорні дерева при колізіях без повільної рефлексії). Завдяки String Pool та Compact Strings (Java 9+) досягається миттєве порівняння посилань == та економія до 30% оперативної пам’яті.


2. 4 каверзні питання з глибокими відповідями

Питання 1: Що станеться, якщо виконати new String("key") замість літерала "key" при додаванні в HashMap? Чи буде різниця при пошуку?

Відповідь: З точки зору функціональної коректності map.get("key") поверне значення без помилок:

  • hashCode() в обох рядків повністю однаковий, оскільки залежить лише від символів.
  • equals() поверне true.

Проте з погляду продуктивності та пам’яті:

  1. new String("key") створює новий екземпляр у купі (Heap), оминаючи String Pool, що збільшує навантаження на GC.
  2. У HashMap.getNode(...) швидка перевірка ідентичності посилань first.key == key поверне false, і JVM буде змушена викликати повний посимвольний метод equals().

Питання 2: Чи може виклик String.intern() спричинити OutOfMemoryError: Metaspace?

Відповідь: Ні, починаючи з Java 7 це неможливо. Історично в Java 6 пул рядків розташовувався в пам’яті PermGen (Permanent Generation), яка мала фіксований обсяг і при масовому інтернуванні призводила до OutOfMemoryError: PermGen space. Починаючи з Java 7 (та у версіях Java 8, 17, 21), самі об’єкти String при виклику intern() розміщуються в основній кучі (Java Heap). У Metaspace зберігаються лише метадані класів. Надмірне наповнення пулу рядків може спричинити лише java.lang.OutOfMemoryError: Java heap space або сповільнення внутрішньої таблиці StringTable.

Питання 3: Чому в алгоритмі String.hashCode() використовується множник 31?

Відповідь: Число 31 було обрано творцями мови з двох фундаментальних причин:

  1. Непарне просте число: Множення на парне число призвело б до бітового зсуву ліворуч і швидкої втрати старших бітів через переповнення діапазону int. Просте число мінімізує ймовірність повторення залишків від ділення при розкиданні по бакетах.
  2. Апаратна оптимізація JIT: Множення на 31 на рівні асемблерного коду автоматично замінюється парою інструкцій — зсувом та відніманням: \(31 \cdot i == (i \ll 5) - i\) Ця операція виконується процесором за 1 машинний такт, значно випереджаючи апаратне множення (imul).

Питання 4: Навіщо в Java 13 у клас String було додано приватне поле hashIsZero?

Відповідь: До Java 13 реалізація hashCode() використовувала перевірку:

if (hash == 0 && value.length > 0) {
    hash = calculateHash();
}
return hash;

Якщо рядок мав обчислений геш-код, що в точності дорівнював 0 (класичний приклад — рядок "polygenelubricants" або порожній рядок ""), поле hash після розрахунку так і залишалося рівним 0. У результаті при кожному наступному виклику hashCode() умова hash == 0 знову була істинною, і JVM щоразу заново посимвольно перераховувала весь геш за $O(N)$. Поле hashIsZero (типу boolean) слугує індикатором того, що геш уже було розраховано і його значення дійсно дорівнює нулю, запобігаючи паразитному повторному обчисленню.


3. Типові помилки та Red Flags

❌ «Пул рядків розташований у Metaspace.»
Правильно: Починаючи з Java 7, інтерновані рядки зберігаються у звичайній купі (Java Heap).

❌ «hashCode() рядка — це адреса пам’яті об’єкта.»
Правильно: Це суворо детермінована функція від символів: $s[0] \cdot 31^{n-1} + \dots$.

❌ «String завжди безпечний для зберігання паролів як ключів.»
Правильно: Рядки неможливо примусово очистити з пам’яті; для паролів та токенів слід використовувати масиви char[].


4. Пов’язані питання