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

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

Клас String — final та іммутабельний. Це гарантує:

Мовні версії: English Russian Ukrainian

🟢 Junior Level

String — найпопулярніший тип ключа в HashMap. І на те є вагомі причини:

  1. Незмінність — рядок не можна змінити після створення
  2. Кешує hashCode — обчислює один раз, потім повертає готове число
  3. Добре розподіляє — різні рядки дають різні хеші

Приклад:

Map<String, User> usersByName = new HashMap<>();
usersByName.put("alice", new User("Alice"));
usersByName.put("bob", new User("Bob"));
// Швидко, надійно, просто

Аналогія: String — це як паспорт з довічним терміном дії. Він ніколи не змінюється, і за ним завжди можна знайти людину.

🟡 Middle Level

1. Іммутабельність

Клас Stringfinal та іммутабельний. Це гарантує:

  • hashCode() ніколи не зміниться
  • equals() завжди дає передбачуваний результат
  • Немає ризику “зламаного ключа”

2. Кешування hashCode

public final class String {
    private int hash; // Кешується при першому виклику hashCode()

    @Override
    public int hashCode() {
        int h = hash;
        if (h == 0 && value.length > 0) {
            hash = h = ...; // Обчислення один раз
        }
        return h;
    }
}

При частих get/put кешування hashCode економить повторне обчислення: для рядка довжиною N це O(N) на перший виклик і O(1) на всі наступні.

3. Реалізація Comparable

String реалізує Comparable<String>, що прискорює treeification в Java 8+ при колізіях.

4. String Pool

Однакові рядкові літерали посилаються на один об’єкт:

String a = "userId";
String b = "userId";
System.out.println(a == b); // true — один об'єкт в пам'яті!

Це економить пам’ять і прискорює порівняння через ==.

Типові помилки

  1. new String("key") — створює новий об’єкт, оминаючи String Pool
  2. Рядки із зовнішнього вводу — можуть бути джерелом Hash Flooding
  3. Дуже довгі рядки — обчислення hashCode дороге (хоч і кешується)

Коли НЕ використовувати String як ключ

  1. Чутливі дані (паролі, токени) — рядки не очистити з пам’яті, використовуйте char[]
  2. Дуже довгі рядки — hashCode дорогий (O(N) на кожну операцію)
  3. International strings без нормалізації — ‘é’ і ‘e\u0301’ будуть різними ключами

🔴 Senior Level

Internal: hashCode алгоритм

// Формула: s[0]*31^(n-1) + s[1]*31^(n-2) + ... + s[n-1]
// Множник 31: JVM оптимізує 31*i = (i << 5) - i

Це дає хороший розподіл для більшості текстових даних.

Compact Strings (Java 9+)

В Java 9+ String використовує byte[] замість char[]:

  • ASCII/Latin-1: 1 байт на символ (економія 50% пам’яті)
  • UTF-16: 2 байти (для не-ASCII)
  • Вплив на HashMap: менше пам’яті на ключі → менше GC pressure

Performance Benchmarks

Операція String Custom Object
hashCode (1-й виклик) O(n) O(1)
hashCode (повторний) O(1) (кеш) O(1)
equals O(n) O(1)
Memory 24-48 байт Залежить від полів

Для коротких рядків (ID, імена полів) String — оптимальний вибір. Для дуже довгих рядків (кілобайти тексту) розгляньте попереднє хешування.

Security: String як ключ

  1. Hash Flooding: Зловмисник може підібрати рядки з колізіями. Java 8+ захищає через treeification
  2. Memory: Рядки в String Pool не збираються GC до завершення класу. Великі ключі = витік
  3. Sensitive data: Рядки з паролями/токенами залишаються в пам’яті. Використовуйте char[] для секретів

Edge Cases

  1. Empty string "": Валідний ключ, hashCode = 0
  2. International strings: Unicode-нормалізація може впливати на equals (“é” vs “e\u0301”)
  3. Interned strings: String.intern() поміщає в пул — економить пам’ять, але Metaspace (раніше PermGen, видалений в Java 8) обмежений. Надмірний intern() може призвести до OutOfMemoryError: Metaspace.

Production Best Practices

  • Короткі рядки (ID, імена полів) — ідеальний варіант
  • Уникайте довгих рядків як ключів (тексти, JSON) — hashCode дорогий
  • Використовуйте intern() для повторюваних ключів у великих мапах
  • Валідуйте вхідні рядки — захист від Hash Flooding

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

Обов’язково знати:

  • String immutable (final class) — hashCode ніколи не зміниться
  • String кешує hashCode — перший виклик O(n), повторні O(1)
  • String Pool: однакові літерали = один об’єкт в пам’яті
  • Реалізує Comparable — прискорює treeification при колізіях
  • Compact Strings (Java 9+): byte[] замість char[] — економія 50% пам’яті для ASCII
  • hashCode алгоритм: s[0]*31^(n-1) + … — множник 31 = (i«5)-i

Часті уточнюючі запитання:

  • Чому new String(“key”) гірше ніж “key”? — new String створює окремий об’єкт, оминаючи String Pool
  • Коли String поганий ключ? — чутливі дані (паролі), дуже довгі рядки, international strings без нормалізації
  • Чому String.intern() небезпечний? — Metaspace обмежений, надмірний intern() → OOM: Metaspace
  • Що таке Hash Flooding через рядки? — зловмисник підбирає рядки з колізіями; Java 8+ захищає treeification

Червоні прапорці (НЕ говорити):

  • «String.hashCode() = адреса рядка в пам’яті» — ні, формула на основі символів
  • «String завжди швидкий ключ» — ні, для дуже довгих рядків hashCode дорогий O(n)
  • «String Pool нескінченний» — ні, обмежений Metaspace

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

  • [[14. Які вимоги пред’являються до ключа HashMap]]
  • [[16. Що таке load factor в HashMap]]
  • [[7. Який контракт у equals() та hashCode()]]