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

Що таке WeakHashMap

WeakHashMap — це спеціальна реалізація інтерфейсу Map, яка автоматично видаляє пару «ключ-значення», коли на об'єкт ключа більше не залишилося сильних посилань (Strong Reference...


🟢 Junior Level

WeakHashMap — це спеціальна реалізація інтерфейсу Map, яка автоматично видаляє пару «ключ-значення», коли на об’єкт ключа більше не залишилося сильних посилань (Strong References) поза межами самої мапи, і він був зібраний збирачем сміття (Garbage Collector).

Головні особливості:

  1. Слабкі посилання на ключі: Ключі огортаються у WeakReference. Для збирача сміття наявність слабкого посилання не є перешкодою для видалення об’єкта з пам’яті.
  2. Сильні посилання на значення: Самі значення (values) утримуються за сильним посиланням доти, доки запис не буде видалено з мапи.
  3. Ліниве очищення: Фізичне видалення «мертвих» записів із внутрішнього масиву таблиці відбувається не миттєво під час спрацьовування GC, а ліниво — під час наступних звернень до методів мапи (get, put, size).
import java.util.Map;
import java.util.WeakHashMap;

public class WeakHashMapDemo {
    public static void main(String[] args) {
        Map<Object, String> map = new WeakHashMap<>();

        Object key = new Object(); // Сильне посилання на ключ
        map.put(key, "Metadata for Key");

        System.out.println("До GC: " + map.size()); // 1

        key = null; // Обнуляємо сильне зовнішнє посилання
        System.gc(); // Запит GC (у навчальних цілях)

        // Під час виклику size() спрацьовує внутрішнє очищення
        System.out.println("Після GC: " + map.size()); // 0 (запис автоматично видалено)
    }
}

Аналогія: Уявіть гардероб у готелі, де речі гостей прив’язані до електронних карток-ключів. Щойно гість виписується з готелю та здає картку (сильне посилання зникло), тимчасові речі з гардероба автоматично утилізуються без додаткових дзвінків.


🟡 Middle Level

Механізм WeakReference та ReferenceQueue

В основі архітектури лежить успадкування внутрішнього вузла Entry від класу WeakReference:

private static class Entry<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
    V value; // УВАГА: значення утримується за СИЛЬНИМ посиланням!
    final int hash;
    Entry<K,V> next;

    Entry(Object key, V value, ReferenceQueue<Object> queue, int hash, Entry<K,V> next) {
        super(key, queue); // Ключ реєструється в черзі ReferenceQueue
        this.value = value;
        this.hash  = hash;
        this.next  = next;
    }
}

Життєвий цикл видалення запису:

  1. GC фаза: Збирач сміття визначає, що на об’єкт ключа немає звичайних (Strong) посилань.
  2. Очищення референса: GC обнуляє посилання всередині WeakReference (ref.get() == null) і поміщає сам об’єкт Entry у пов’язану чергу ReferenceQueue.
  3. Метод expungeStaleEntries(): Під час кожного наступного виклику методів мапи (get, put, size, remove, resize) викликається опитування черги:
    private void expungeStaleEntries() {
        for (Object x; (x = queue.poll()) != null; ) {
            synchronized (queue) {
                Entry<K,V> e = (Entry<K,V>) x;
                int i = indexFor(e.hash, table.length);
                // Видалення вузла e зі зв'язного списку бакета table[i]
                // e.value = null; (обнулення посилання на значення для GC)
            }
        }
    }
    
  4. Звільнення пам’яті: Посилання e.value зануляється, дозволяючи збирачу сміття утилізувати важкий об’єкт значення.

Порівняння рівнів досяжності (Reachability) в Java

Тип посилання Клас Коли видаляється GC Застосування
Strong Звичайне посилання T obj = new T() Ніколи, поки досяжне з GC Roots Стандартні бізнес-об’єкти
Soft SoftReference<T> Тільки за реальної загрози OutOfMemoryError In-memory кеші даних у RAM
Weak WeakReference<T> Під час будь-якого циклу збирача сміття WeakHashMap, кеші метаданих
Phantom PhantomReference<T> Після фіналізації об’єкта Очищення нативних ресурсів (Cleaner)

🔴 Senior Level

Головна пастка: циклічна залежність (Value-to-Key Leak)

Найнебезпечніша та найпоширеніша архітектурна помилка під час використання WeakHashMap — ситуація, коли об’єкт значення прямо чи опосередковано посилається на свій ключ:

class UserMetadata {
    private final User owner; // Сильне посилання на ключ!
    public UserMetadata(User owner) { this.owner = owner; }
}

Map<User, UserMetadata> map = new WeakHashMap<>();
User user = new User("Alice");
map.put(user, new UserMetadata(user));

user = null; // Ми сподіваємося, що ключ видалиться збирачем сміття...
System.gc();

Чому тут виникає вічний витік пам’яті (Memory Leak):

  1. Внутрішній масив table утримує об’єкт Entry.
  2. Поле Entry.value утримує UserMetadata за сильним посиланням.
  3. UserMetadata.owner утримує об’єкт User (ключ) за сильним посиланням.
  4. Результат: Об’єкт ключа залишається досяжним із GC Roots через ланцюжок table -> Entry -> value -> key. Ключ ніколи не стане слабодосяжним (Weakly Reachable), GC ніколи не помістить його в ReferenceQueue, і запис навічно залишиться в купі!

Відсутність фонового демона очищення

У WeakHashMap немає жодного фонового потоку, який періодично очищував би пам’ять:

  • Якщо ви поклали у WeakHashMap 1 000 000 записів із важкими значеннями.
  • Занулили всі зовнішні посилання на ключі.
  • GC виконав збирання сміття та помістив 1 000 000 записів у ReferenceQueue.
  • Але ваш застосунок більше ніколи не викликає методи цієї мапи:

Усі 1 000 000 об’єктів значень value продовжать займати сотні мегабайтів у Heap, тому що метод expungeStaleEntries() викликається виключно зсередини мутувальних або читальних методів самої WeakHashMap.

Чому WeakHashMap не підходить для кешування в Production?

Багато розробників помилково намагаються зробити на базі WeakHashMap кеш відповідей API або сутностей БД. Це невдала ідея:

  1. Некерований життєвий цикл: Кеш очищається не за бізнес-правилами, а за волею збирача сміття. У фазах інтенсивного виділення пам’яті GC Young Generation вимиватиме записи кожні 500 мілісекунд.
  2. Немає політик витіснення: Відсутній контроль максимального розміру (Max Size), часу життя (TTL, TTI) та алгоритмів евікшену (LRU, W-TinyLFU).
  3. Не потокобезпечна: Вимагає синхронізації через Collections.synchronizedMap, що перетворює доступ на вузьке місце (bottleneck). Для реального продакшену слід використовувати спеціалізовані бібліотеки кешування: Caffeine або Guava Cache.

4 Tricky Questions

1. Що станеться, якщо використовувати рядкові літерали (наприклад, "userId") як ключі у WeakHashMap?

Відповідь: Записи з такими ключами ніколи не будуть видалені збирачем сміття. Рядкові літерали розміщуються JVM у внутрішньому пулі рядків (String Pool), який утримує сильні посилання на об’єкти рядків доти, доки завантажувач класів (ClassLoader) не буде вивантажено з пам’яті. Оскільки на рядок "userId" завжди існує сильне посилання зі String Pool, він ніколи не стане слабодосяжним, і WeakHashMap поводитиметься як звичайна нескінченно зростаюча HashMap. Те саме стосується закешованих об’єктів Integer.valueOf(10) (з пулу від -128 до 127).


2. Чи підтримує WeakHashMap червоно-чорні дерева (TreeNode) у разі великої кількості колізій у бакеті?

Відповідь: Ні, не підтримує. На відміну від HashMap, яка в Java 8 була модернізована червоно-чорними деревами (TreeNode), клас WeakHashMap залишився на старій архітектурі зв’язних списків Java 1.2. За великої кількості колізій в одному бакеті вузли Entry завжди зв’язуються в однозв’язний список, і пошук деградує до лінійного часу $O(N)$ незалежно від розміру таблиці.


3. Чи може розмір WeakHashMap змінитися між двома послідовними рядками коду без виклику методів мутації?

Відповідь: Так, абсолютно. Розглянемо сценарій:

int size1 = map.size(); // 1. Викликав expungeStaleEntries(), повернув 10
// У цей мікромомент спрацював GC і зібрав 5 ключів!
int size2 = map.size(); // 2. Знову викликав expungeStaleEntries(), повернув 5

Між двома викликами map.size() у коді не було жодного виклику put() чи remove(), проте розмір мапи зменшився вдвічі через фонову роботу збирача сміття та ліниве очищення черги під час другого виклику.


4. Як правильно організувати двосторонню асоціацію ключ-значення у WeakHashMap, щоб уникнути витоку пам’яті?

Відповідь: Якщо значення зобов’язане посилатися на свій ключ, сильне посилання у значенні необхідно також замінити на слабке посилання (WeakReference):

class SafeValue {
    private final WeakReference<User> userRef; // Слабке посилання запобігає циклу!

    public SafeValue(User user) {
        this.userRef = new WeakReference<>(user);
    }
}

У цьому разі ланцюжок Entry -> value -> WeakReference -> key не містить сильних посилань на ключ, і збирач сміття зможе безперешкодно утилізувати ключ і сам запис.


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

  • Сутність WeakHashMap: Ключі огортаються у WeakReference. Коли на ключ немає strong-посилань ззовні, GC утилізує ключ, а запис видаляється з мапи.
  • Значення — Strong References: Значення (value) утримується за сильним посиланням.
  • Фатальна помилка (Memory Leak): Якщо value посилається на key, утворюється сильний цикл Entry -> value -> key, і запис ніколи не видалиться!
  • Ліниве очищення: Видалення записів відбувається не за таймером, а під час виклику методів мапи через опитування ReferenceQueue (expungeStaleEntries()).
  • Пастка літералів: Рядки "str" та Integer від -128 до 127 закешовані JVM, мають сильні посилання та ніколи не видаляються з WeakHashMap.
  • Не для кешів: У WeakHashMap немає TTL, LRU та ліміту пам’яті. Для production-кешу використовуйте Caffeine.

Червоні прапорці (чого уникати)

  • Твердження, що WeakHashMap є повноцінним in-memory кешем.
  • Нерозуміння витоку пам’яті через циклічне посилання значення на ключ (value -> key).
  • Очікування, що WeakHashMap має фоновий демон, який очищає мапу без виклику її методів.
  • Використання рядкових літералів або кешованих Integer як ключів у WeakHashMap.

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