📦 Раздел 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-виджетов или прав доступа с сущностью User, пока этот объект активен в сессии:

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 для освобождения памяти!
            }
        }
    }
    

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

Цепочка сильных ссылок: WeakHashMap.table $\to$ Entry $\to$ Entry.value (OrderMetadata) $\to$ 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, TTL, размерных квот).

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


2. Скрытая утечка памяти при отсутствии вызовов (Idle Map Leak)

Поскольку очистка expungeStaleEntries() вызывается исключительно при обращении к методам мапы, возникает эффект «мертвой памяти»:

  • Если в WeakHashMap было добавлено 1 000 000 записей с тяжелыми value (например, byte-массивами), а затем мапа перестала опрашиваться (наступил простой сервиса), GC удалит ключи, но массив Entry[] table будет продолжать удерживать все тяжелые объекты value через поле e.value.
  • Память под value освободится только тогда, когда к мапе обратятся снова или когда сама мапа станет недостижимой.
  • По этой причине метод size() в WeakHashMap не возвращает поле size за $O(1)$, а сначала вызывает expungeStaleEntries() и пересчитывает оставшиеся элементы.

🎯 Шпаргалка для интервью

30-секундный ответ (Elevator Pitch)

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


3 каверзных вопроса на интервью с ответами

1. Почему добавление записи с ключом "some_string" в WeakHashMap может привести к неудаляемой записи?

Ответ: Строковые литералы размещаются в String Pool и имеют постоянную сильную ссылку от системного загрузчика классов (ClassLoader). Поскольку на такой ключ всегда существует сильная ссылка в JVM, он никогда не станет кандидатом на сборку мусора, и запись из WeakHashMap никогда не будет удалена автоматически.

2. Как и почему сильная ссылка из объекта-значения на объект-ключ ломает работу WeakHashMap?

Ответ: Внутренний массив бакетов table[] сильно ссылается на Entry, который сильно ссылается на поле value. Если value сильно ссылается на key, образуется непрерывная цепочка сильных ссылок от корневого объекта мапы до ключа (table -> Entry -> value -> key). В результате ключ всегда остается сильно достижимым (Strongly Reachable), GC не может поместить его WeakReference в ReferenceQueue, и запись зависает в памяти навсегда.

3. Есть ли в WeakHashMap фоновый поток для очистки мертвых записей?

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


Типичные ошибки и Red Flags

  • ❌ Использование WeakHashMap для кэша данных: Данные исчезают при первой же сборке мусора. Для кэшей нужен Caffeine или Ehcache.
  • ❌ Хранение примитивных оберток в качестве ключей: Integer.valueOf(10) закэширован JVM и никогда не удалится.
  • ❌ Предположение, что значения (values) тоже хранятся слабо: Слабыми являются только ключи; значения удерживаются по сильной ссылке до вызова expungeStaleEntries().
  • ❌ Утверждение о потокобезопасности: WeakHashMap не синхронизирован. Параллельное чтение и запись приведут к повреждению связных списков бакетов.

Связанные темы