Коли відбувається rehashing в HashMap
У класі HashMap процедура resize() ініціюється в таких ключових ситуаціях:
🟢 Junior Level
Короткий опис для співбесіди (30 секунд)
Rehashing (рехешування / resize) — це внутрішня процедура виділення нового масиву бакетів збільшеної місткості (удвічі більше за попередню) та перерозподілу всіх наявних елементів по нових комірках таблиці.
У класі HashMap процедура resize() ініціюється в таких ключових ситуаціях:
- Перший виклик
put()(лінива ініціалізація): При створенніnew HashMap()масив бакетів не виділяється в оперативній пам’яті (table == null). Пам’ять під масив (за замовчуванням на 16 комірок) виділяється лише під час першого додавання даних. - Перевищення порогу завантаження (
threshold): Коли поточна кількість елементівsizeпісля чергової вставки перевищує значенняcapacity * loadFactor(за замовчуванням $16 \times 0.75 = 12$). - Конфлікт колізій при малому розмірі таблиці: Якщо в одному бакеті накопичилося 8 елементів, проте загальна довжина таблиці менша за 64 (
table.length < 64), замість побудови червоно-чорного дерева викликаєтьсяresize().
Приклад у коді
import java.util.HashMap;
import java.util.Map;
public class RehashingTriggerDemo {
public static void main(String[] args) {
// 1. Створення: table == null
Map<Integer, String> map = new HashMap<>();
// 2. Перший put: спрацьовує resize(), створюється table місткістю 16, threshold = 12
map.put(1, "A");
// 3. Додаємо елементи до порогу 12 включно
for (int i = 2; i <= 12; i++) {
map.put(i, "V" + i);
}
// 4. Вставка 13-го елемента: умова (++size > threshold) істинна (13 > 12)
// Спрацьовує resize(): capacity подвоюється до 32, threshold стає 24
map.put(13, "V13");
}
}
[!NOTE] Аналогія: Рехешування схоже на переїзд офісу компанії в нову будівлю, вдвічі більшу за попередню. Переїзд відбувається або в перший робочий день (коли офіс фактично відкривається), або коли всі робочі місця зайняті більш ніж на 75%, щоб уникнути тисняви та черг у коридорах.
🟡 Middle Level
Усі тригери запуску методу resize()
У сирцевому коді java.util.HashMap (Java 8+) виклик методу resize() здійснюється в чотирьох точках:
| Тригер | Умова в коді | Що відбувається |
|---|---|---|
| 1. Лінива ініціалізація | tab == null \|\| tab.length == 0 |
Виділяється початковий масив Node<K,V>[] (типово 16) |
2. Перевищення порогу при put |
if (++size > threshold) |
Поточна місткість подвоюється ($N \to 2N$), поріг перераховується |
| 3. Недостатній розмір при трифікації | tab.length < MIN_TREEIFY_CAPACITY (64) |
При 8 колізіях у бакеті замість дерева подвоюється масив таблиці |
4. Масова вставка putAll() |
targetCap > threshold у putMapEntries() |
Превентивний разовий resize() під розмір вхідної колекції |
Часова вартість та вплив на Latency
Рехешування — найбільш ресурсомістка операція в життєвому циклі HashMap:
- Алокація пам’яті: Створюється новий масив посилань подвоєного розміру ($O(N)$).
- Перенесення вузлів: Усі наявні елементи
Node<K,V>скануються та перелінковуються в нові бакети ($O(N)$). - Стрибок затримок (Latency Spike): У latency-sensitive системах (HighLoad, мікросекундний SLA) раптовий
resize()на мапі з 1 000 000 елементів може спричинити «залипання» потоку на 20–100 мс.
Запобігання ресайзу через розрахунок початкової місткості
Щоб уникнути багаторазових каскадних ресайзів при відомому розмірі колекції, місткість резервують заздалегідь:
// До Java 19:
int expectedElements = 10_000;
int initialCapacity = (int) Math.ceil(expectedElements / 0.75f);
Map<String, User> map = new HashMap<>(initialCapacity);
// Починаючи з Java 19:
Map<String, User> modernMap = HashMap.newHashMap(expectedElements);
🔴 Senior Level
Глибокий аналіз коду тригерів у HotSpot JVM
1. Пост-інкрементний тригер у putVal
У Java 8+ перевірка порогу виконується після того, як новий вузол уже успішно вставлено в бакет:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// ... логіка пошуку та вставки вузла ...
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
Важлива відмінність від Java 7: У Java 7 перевірка була попередньою (if (size >= threshold && null != table[bucketIndex])). У Java 8+ спочатку збільшується лічильник size, і якщо він суворо перевищив threshold, викликається resize().
2. Тригер із treeifyBin
Коли через колізії довжина зв’язного списку в окремому кошику досягає TREEIFY_THRESHOLD = 8, викликається метод treeifyBin:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY = 64
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
// Повноцінна конвертація зв'язного списку в TreeNode (RB-Tree)
}
}
Архітектурне рішення HotSpot: Перетворення зв’язного списку на червоно-чорне дерево суттєво збільшує споживання пам’яті (кожен TreeNode займає 56 байтів замість 32 байтів у Node). Якщо вся таблиця ще маленька (наприклад, 16 або 32 бакети), колізія з 8 елементів частіше спричинена невдалим модулем (n-1) & hash, а не патологією геш-функції. Тому JVM віддає перевагу подвоєнню масиву бакетів: при розширенні з 32 до 64 додається новий біт маски, і довгий ланцюжок із високою ймовірністю розпадеться на два короткі!
3. Превентивний тригер у putMapEntries (putAll)
При додаванні іншої колекції через конструктор new HashMap<>(Map m) або виклик map.putAll(m):
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
if (table == null) { // Якщо таблиця ще порожня
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
} else {
// Якщо таблиця вже ініціалізована та елементів більше порогу
while (s > threshold && table.length < MAXIMUM_CAPACITY)
resize();
}
}
// далі в циклі послідовні виклики putVal
}
HashMap заздалегідь резервує необхідний розмір, уникаючи десятків каскадних перебудов у циклі.
Чому саме геометричне подвоєння ($2 \times$)?
Якби при ресайзі масив збільшувався на фіксовану кількість комірок (арифметична прогресія, наприклад, $+16$), додавання $N$ елементів вимагало б $\frac{N}{16}$ ресайзів, що призвело б до квадратичної складності вставки $O(N^2)$.
Подвоєння розміру ($2 \times$) гарантує амортизовану складність $O(1)$ на операцію put, оскільки сумарна кількість копіювань при додаванні $N$ елементів є сумою геометричної прогресії:
\(1 + 2 + 4 + 8 + \dots + N \le 2N\)
що дає константну вартість на один елемент.
🎯 Шпаргалка для інтерв’ю
1. 30-секундний есенс (Elevator Pitch)
Рехешування (resize) в HashMap запускається в 4 випадках: під час першого put() (лінива ініціалізація з 0 до 16), при перевищенні порогу ++size > threshold (після додавання чергового елемента), коли в бакеті накопичилося 8 елементів, але загальний capacity < 64 (ресайз замість побудови дерева) та превентивно у putAll(). Складність операції $O(N)$ за часом та пам’яттю. Завдяки геометричному подвоєнню ($2 \times$) досягається амортизована складність $O(1)$. Зворотного стиснення при видаленні не відбувається.
2. 4 каверзні питання з глибокими відповідями
Питання 1: У який момент відбувається resize() при перевищенні порогу — до чи після фактичної вставки нового елемента?
Відповідь:
У Java 8+ виклик resize() здійснюється після фізичного додавання нового елемента в таблицю (код: if (++size > threshold) resize();).
У Java 7 логіка була зворотною: resize() виконувався до додавання елемента і лише за умови виконання двох вимог: size >= threshold ТА в бакеті, куди планувалася вставка, уже був хоча б один елемент (null != table[bucketIndex]). Якщо бакет був порожнім, Java 7 дозволяла вставити елемент без ресайзу, навіть якщо size >= threshold.
Питання 2: Скільки разів викличеться resize(), якщо в порожню new HashMap<>() додати 10 000 елементів через putAll()?
Відповідь:
Рівно 1 раз.
При передачі колекції в порожню HashMap спрацьовує метод putMapEntries. У гілці if (table == null) він розраховує:
\(t = \text{tableSizeFor}\left(\left\lceil \frac{10000}{0.75} \right\rceil + 1\right) = \text{tableSizeFor}(13334 + 1) = 16384\)
Значення threshold відразу встановлюється в 16384. Потім під час додавання першого елемента в циклі метод resize() викликається один-єдиний раз і відразу алокує масив table на 16 384 комірки. Усі 10 000 записів вставляються без жодного повторного ресайзу.
Питання 3: Чи може статися так, що в бакеті накопичилося 12 елементів, проте бакет усе ще залишається зв’язним списком і не перетворюється на дерево?
Відповідь:
Так, може.
Це трапляється, якщо capacity таблиці менша за 64.
Наприклад, якщо початкова місткість таблиці дорівнює 16, і всі додані ключі мають однаковий геш-код (жорстка колізія):
- При 8-му елементі спрацює умова
tab.length < 64$\to$ ресайз до 32. - При 9-му, 10-му елементах колізія зберігається. При досягненні порогу знову спрацює ресайз до 64.
- Доки
tab.length < 64, методtreeifyBinвикликатимеresize()замість трифікації, і елементи залишатимуться в однозв’язному спискуNode. Лише коли розмір таблиці досягне 64, цей бакет остаточно конвертується в червоно-чорне деревоTreeNode.
Питання 4: Якщо з HashMap із 1 000 000 елементів видалити 999 999 елементів через remove(), чи відбудеться зворотне рехешування (Shrinking)?
Відповідь:
Ні, ніколи.
У java.util.HashMap відсутній механізм автоматичного зменшення масиву бакетів. Масив table залишиться розміром понад 1 мільйон комірок (найближчий степінь двійки — $1\,048\,576$), займаючи близько 4–8 МБ пам’яті під порожні посилання. Якщо необхідно звільнити цю пам’ять, слід вручну створити новий екземпляр new HashMap<>(map) і віддати старий на збирання сміття (GC).
3. Типові помилки та Red Flags
❌ «Ресайз відбувається перед кожною вставкою нового елемента.»
Правильно: У Java 8+ resize() викликається лише після перевищення threshold або під час першої вставки.
❌ «При досягненні 8 елементів у бакеті завжди створюється червоно-чорне дерево.»
Правильно: Якщо загальний розмір таблиці менший за 64 (table.length < 64), замість дерева викликається resize().
❌ «HashMap стискається у разі видалення більшості елементів.»
Правильно: Масив ніколи не зменшується назад.