Вопрос 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()]]