📦 Розділ 4 · Питання #17

Що таке WeakHashMap

Якщо на об'єкт-ключ більше немає сильних посилань (StrongReference) у всій програмі, збирач сміття (GC) автоматично утилізує цей ключ під час чергового циклу збирання, а відпові...


🟢 Junior Level

1. Що таке WeakHashMap

WeakHashMap — це спеціалізована реалізація інтерфейсу Map, у якій ключі зберігаються з використанням слабких посилань (WeakReference).

Якщо на об’єкт-ключ більше немає сильних посилань (StrongReference) у всій програмі, збирач сміття (GC) автоматично утилізує цей ключ під час чергового циклу збирання, а відповідний запис («ключ — значення») автоматично видаляється з мапи:

Map<Object, String> weakMap = new WeakHashMap<>();

Object key = new Object(); // Створено сильне посилання на ключ
weakMap.put(key, "Метадані об'єкта");

System.out.println(weakMap.size()); // 1

key = null; // Сильне посилання знищено, залишилося тільки слабке всередині WeakHashMap
System.gc(); // Рекомендація JVM запустити GC

// При наступному зверненні до мапи запис зникає:
System.out.println(weakMap.size()); // 0

2. Головне призначення: збереження супутніх метаданих

Основна сфера застосування WeakHashMap — прив’язка додаткових атрибутів або тимчасових метаданих до об’єктів, життєвим циклом яких керує зовнішній код.

Приклад: Асоціювання стану UI-віджетів або контексту мережевої сесії із сутністю, поки цей об’єкт активний у програмі:

WeakHashMap<SocketChannel, SessionContext> sessions = new WeakHashMap<>();
// Щойно сокет закрито та видалено з мережевого селектора, сесія вичищається автоматично

🟡 Middle Level

1. Механіка роботи під капотом: ReferenceQueue та expungeStaleEntries()

Всупереч поширеній помилковій думці, збирач сміття не видаляє записи з масиву table напряму. JVM та GC нічого не знають про внутрішні структури колекцій.

Механізм очищення працює в такий спосіб:

  1. Нода мапи розширює WeakReference: Внутрішній клас Entry<K,V> успадковує WeakReference<Object> і під час створення реєструє ключ у черзі ReferenceQueue:
    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;
        }
    }
    
  2. Робота GC: Коли збирач виявляє, що на ключ немає сильних посилань, він обнуляє посилання всередині WeakReference (referent = null) і поміщає сам об’єкт Entry в ReferenceQueue.

  3. Ледаче очищення (Lazy Expunge): У WeakHashMap немає фонового потоку-демона. Очищення відбувається пасивно при виклику будь-якого публічного методу (get, put, remove, size). Метод викликає внутрішню процедуру:
    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);
                // Вилучення ноди з ланцюжка бакета table[i]
                // Обнулення посилання e.value для звільнення пам'яті!
                e.value = null;
            }
        }
    }
    

2. Смертельна пастка: циклічне посилання з Value в Key

Якщо об’єкт-значення (value) прямо або опосередковано зберігає сильне посилання на свій ключ (key), механізм автоматичного збирання сміття повністю ламається:

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

WeakHashMap<Order, OrderMetadata> map = new WeakHashMap<>();
Order order = new Order(123);
map.put(order, new OrderMetadata(order)); // АРХІТЕКТУРНА ПОМИЛКА

order = null; // Сильне посилання ззовні обнулено!
System.gc();

// ВИТІК ПАМ'ЯТІ: запис НІКОЛИ не буде видалено!
System.out.println(map.size()); // 1

Ланцюжок сильних посилань: \(\text{GC Root} \longrightarrow \text{WeakHashMap.table} \longrightarrow \text{Entry} \overset{\text{Strong}}{\longrightarrow} \text{OrderMetadata (Value)} \overset{\text{Strong}}{\longrightarrow} \text{Order (Key)}\) Оскільки Order залишається досяжним за сильним посиланням із масиву table, GC вважає його живим і ніколи не помістить Entry в ReferenceQueue.


🔴 Senior Level

1. Архітектурний антипатерн: чому WeakHashMap НЕ МОЖНА використовувати як кеш

Один із найгрубіших архітектурних антипатернів — побудова кешу застосунку на базі WeakHashMap.

Причини непридатності для кешування:

  1. Агресивність збирання: Слабкі посилання очищаються при кожному Minor GC (збиранні в Young Generation), якщо на об’єкт немає зовнішніх сильних посилань. У високонавантаженому мікросервісі Minor GC відбувається кожні кількасот мілісекунд. Якщо ви закешували результат важкого SQL-запиту за ключем-параметром, щойно метод завершить роботу і ключ вийде зі стека, при першому ж GC кеш зникне. Hit rate такого кешу прямуватиме до нуля.
  2. Проблема інтернованих ключів: Якщо ключами кешу є рядки-літерали ("USER_123"), об’єкти Long або кешовані Integer (від -128 до 127), вони жорстко утримуються в String Pool або статичних кешах обгорток JVM. Вони ніколи не будуть зібрані GC, перетворюючи WeakHashMap на звичайну необмежену HashMap із перманентним витоком пам’яті.
  3. Відсутність політик витіснення: У WeakHashMap немає підтримки eviction-стратегій (LRU, LFU, W-TinyLFU, TTL, лімітів за розміром або пам’яттю).

[!TIP] Для реалізації надійних кешів у Java необхідно використовувати спеціалізовані бібліотеки (наприклад, Caffeine Cache з алгоритмом Window TinyLFU) або структури на SoftReference, які GC утилізує тільки при реальній загрозі браку пам’яті (OOM) в JVM Heap.


2. Прихований витік пам’яті за відсутності викликів (Idle Map Leak)

Оскільки очищення expungeStaleEntries() викликається виключно при зверненні до методів мапи, виникає ефект «мертвої пам’яті»:

  • Якщо у WeakHashMap було додано 1 000 000 записів із важкими value (наприклад, byte-масивами), а потім мапу перестали опитувати (настав простій сервісу), GC видалить ключі, але масив Entry[] table продовжуватиме утримувати всі важкі об’єкти value через поле e.value.
  • Пам’ять під value звільниться лише тоді, коли до мапи звернуться знову або коли сама мапа стане недосяжною для GC Roots.
  • З цієї причини метод size() у WeakHashMap не повертає закешоване поле за $O(1)$, а спочатку викликає expungeStaleEntries() і перераховує наявні живі елементи.

4 Tricky Questions

1. Чому додавання запису з рядковим літералом "session_token" у WeakHashMap може призвести до незнищенного запису?

Відповідь: Рядкові літерали автоматично інтернуються в JVM String Constant Pool, розташований у Heap. Об’єкти в String Pool утримуються постійними сильними посиланнями від системного завантажувача класів (ClassLoader). Оскільки на такий ключ завжди існує сильне посилання в JVM, він ніколи не стане кандидатом на збирання сміття, і запис із WeakHashMap ніколи не буде видалено автоматично.


2. Як і чому сильне посилання з об’єкта-значення на об’єкт-ключ ломає роботу WeakHashMap?

Відповідь: Внутрішній масив бакетів table[] сильно посилається на Entry, який сильно посилається на поле value. Якщо value сильно посилається на key, утворюється неперервний ланцюжок сильних посилань від кореневого об’єкта мапи до ключа (table -> Entry -> value -> key). У результаті ключ залишається сильно досяжним (Strongly Reachable), GC не може очистити слабке посилання та помістити його в ReferenceQueue, і запис назавжди зависає в пам’яті.


3. Чи є у WeakHashMap фоновий потік-демон для очищення мертвих записів?

Відповідь: Ні. У WeakHashMap повністю відсутній будь-який фоновий потік. Очищення мертвих записів здійснюється суто «ледаче» (on-demand) всередині методу expungeStaleEntries(), який опитує ReferenceQueue.poll() на початку виклику публічних операцій (get, put, size, remove). Якщо звернень до мапи немає, важкі значення value продовжують займати пам’ять у купі.


4. Чому метод size() у WeakHashMap не працює за $O(1)$ як у HashMap?

Відповідь: У HashMap розмір зберігається в лічильнику size, що повертається миттєво за $O(1)$. У WeakHashMap ключі можуть бути зібрані збирачем сміття асинхронно без відома самої структури даних. Якби size() просто повертав числове поле, воно містило б застарілі та вже мертві записи. Тому WeakHashMap.size() спочатку викликає expungeStaleEntries() для очищення черги від мертвих нод, а потім перераховує фактично збережені живі елементи.


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

30-секундна відповідь (Elevator Pitch)

«WeakHashMap — це хеш-таблиця, у якій ключі обгорнуті у WeakReference. Коли на об’єкт-ключ не залишається сильних посилань, GC утилізує його і поміщає ноду Entry в асоційовану чергу ReferenceQueue. Очищення записів відбувається ледаче під час наступних викликів методів мапи через expungeStaleEntries(). Основне призначення — зберігання супутніх метаданих для об’єктів зовнішнього життєвого циклу. WeakHashMap категорично не підходить для кешування через миттєве вимивання даних при Minor GC і схильна до витоків пам’яті при циклічних посиланнях із value в key».

Архітектурні факти високого рівня

  • Слабкими є тільки ключі: Значення утримуються за сильними посиланнями до виклику expungeStaleEntries(), де явно виконується e.value = null.
  • Ледаче витіснення (Lazy Eviction): Немає фонових прибиральників; очищення черги відбувається лише під час викликів get(), put(), remove(), size().
  • Вразливість до петель посилань: Ланцюг $\text{Key} \leftarrow \text{Value}$ вимикає слабку досяжність, назавжди блокуючи запис у пам’яті.
  • Антипатерн кешування: Слабкі посилання агресивно очищаються на кожному Minor GC; для продакшн-кешів слід використовувати Caffeine.

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

  • ❌ Використання WeakHashMap для кешу даних: Дані вимиваються при першому ж збиранні сміття, знижуючи hit rate до нуля.
  • ❌ Зберігання рядкових літералів або примітивних обгорток як ключів: "string" або Integer.valueOf(10) ніколи не видаляться через кеші JVM.
  • ❌ Припущення, що значення (values) теж слабкі: Значення утримуються сильно, доки мапа не буде опитана.
  • ❌ Твердження про потокобезпечність: WeakHashMap не синхронізована; паралельні операції пошкоджують структуру зв’язаних списків у бакетах.

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