Як правильно підібрати початкову capacity для HashMap
Якщо конструктор викликається без параметрів (new HashMap<>()), за замовчуванням обирається місткість 16 із порогом розширення 12 ($16 \times 0.75$). Якщо заздалегідь відомо, що...
🟢 Junior Level
Початкова місткість (Initial Capacity) — це розмір внутрішнього масиву бакетів, який резервується в пам’яті під час створення HashMap.
Якщо конструктор викликається без параметрів (new HashMap<>()), за замовчуванням обирається місткість 16 із порогом розширення 12 ($16 \times 0.75$).
Якщо заздалегідь відомо, що в мапу буде додано, наприклад, 1 000 елементів, створення HashMap за замовчуванням призведе до 7 послідовних ресайзів ($16 \to 32 \to 64 \to 128 \to 256 \to 512 \to 1024 \to 2048$), кожен з яких вимагає виділення нового масиву й копіювання всіх наявних елементів.
Золоте правило розрахунку
Щоб додати $N$ елементів без жодного ресайзу, початкову місткість обчислюють з урахуванням коефіцієнта завантаження loadFactor (типово $0.75$):
import java.util.HashMap;
import java.util.Map;
public class InitialCapacityDemo {
public static void main(String[] args) {
int expectedSize = 100;
// Розрахунок вручну: 100 / 0.75 = 133.33 -> округлюємо вгору до 134
int capacity = (int) Math.ceil(expectedSize / 0.75f);
Map<String, Integer> map = new HashMap<>(capacity);
// Починаючи з Java 19 — сучасний офіційний спосіб:
Map<String, Integer> modernMap = HashMap.newHashMap(expectedSize);
}
}
Аналогія: Якщо ви чекаєте на банкет 100 гостей, ви не замовляєте спочатку столик на 16 місць, а потім 7 разів поспіль не змінюєте залу на все більшу й більшу в міру прибуття гостей. Ви одразу бронюєте залу на 134+ місць, щоб усім вистачило простору з комфортом.
🟡 Middle Level
Чому наївний виклик new HashMap<>(1000) призводить до ресайзу?
Поширена помилка на співбесідах — вважати, що виклик new HashMap<>(1000) запобіжить ресайзу для 1000 елементів:
- Ви передали
1000. МетодtableSizeFor(1000)округлює це число до найближчого степеня двійки вгору $\to$ 1024. - Поріг розширення розраховується як: \(\text{threshold} = 1024 \times 0.75 = 768\)
- Ви починаєте наповнювати мапу елементами.
- Під час додавання 769-го елемента умова
++size > threshold($769 > 768$) стає істинною! - Відбувається високовартісний ресайз таблиці з 1024 до 2048 бакетів.
Правильний розрахунок для 1000 елементів:
\(\text{initialCapacity} = (int) \left(\frac{1000}{0.75f}\right) + 1 = 1334\)
tableSizeFor(1334)округлює вгору до степеня двійки $\to$ 2048.- Поріг: $\text{threshold} = 2048 \times 0.75 = 1536$.
- Усі 1000 елементів гарантовано вміщуються в таблицю без ресайзу ($1000 \le 1536$).
Офіційний API: фабричні методи Java 19+
Починаючи з Java 19 (JDK 19), розробникам більше не потрібно вручну виконувати ділення на 0.75f. До стандартної бібліотеки було додано статичні фабричні методи:
// Створює HashMap із місткістю, достатньою для 10 000 елементів без ресайзу:
Map<String, Order> orders = HashMap.newHashMap(10_000);
// Аналогічно для зв'язаних мап та множин:
Map<String, Order> linkedOrders = LinkedHashMap.newLinkedHashMap(10_000);
Set<String> uniqueTags = HashSet.newHashSet(10_000);
Усередині OpenJDK метод HashMap.newHashMap реалізований саме через формулу ділення:
static int calculateHashMapCapacity(int numMappings) {
return (int) Math.ceil(numMappings / (double) DEFAULT_LOAD_FACTOR);
}
🔴 Senior Level
Вплив завищеної місткості на продуктивність (Over-allocation Overhead)
Існує спокуса завжди задавати місткість «з величезним запасом» (наприклад, new HashMap<>(1_000_000) під 100 елементів). Це породжує серйозні негативні наслідки:
- Витрати Heap Memory:
Масив посилань
tableна 1 000 000 комірок за увімкненого прапорцяUseCompressedOopsмиттєво алокує 4 МБ пам’яті під порожні посиланняnull(або 8 МБ без стиснення покажчиків) ще до вставки даних. - Деградація швидкості ітерації:
Складність обходу
map.keySet(),map.values()таmap.entrySet()становить $O(\text{capacity} + \text{size})$. Ітератор змушений послідовно перебирати весь масивtable.length. Приcapacity = 1_000_000таsize = 10ітератор виконає 999 990 порожніх перевірок комірок пам’яті наnull, що спричинить тисячі апаратних промахів кешу CPU (L1/L2/L3 Cache Misses). - Навантаження на збирач сміття (GC Pressure): Великі масиви посилань вимагають від GC сканування Card Table під час відстеження міжрегіональних покажчиків (G1 / ZGC), що збільшує час пауз на фазах розмітки.
Порівняння підходів до створення HashMap
| Підхід | Ресайзи при $N=1000$ | Підсумковий Capacity | Пам’ять масиву посилань | Швидкість вставки |
|---|---|---|---|---|
new HashMap<>() (default) |
7 ресайзів | 2048 | 8 КБ | Повільно (латентні сплески) |
new HashMap<>(1000) (помилковий) |
1 ресайз (на 769-му) | 2048 | 8 КБ | Середньо (один латентний спайк) |
HashMap.newHashMap(1000) |
0 ресайзів | 2048 | 8 КБ | Максимальна (стабільний latency) |
new HashMap<>(100_000) (надлишковий) |
0 ресайзів | 131 072 | 512 КБ | Швидка вставка / повільна ітерація |
4 Tricky Questions
1. Що станеться, якщо створити new HashMap<>(12) і додати рівно 12 елементів? Чи спрацює resize()?
Відповідь: Для 12 елементів — ні, але для 13 — так! Простежимо логіку HotSpot:
- Ви передали
12. МетодtableSizeFor(12)округлює 12 вгору до найближчого степеня двійки $\to$ 16. - Поріг розширення розраховується як: \(\text{threshold} = 16 \times 0.75 = 12\)
- Вставляємо 11 елементів:
sizeстає 11 ($11 \le 12$). - Вставляємо 12-й елемент:
putValвиконує перевірку:if (++size > threshold) resize();Підставляємо значення:12 > 12$\implies$false. Ресайзу поки немає! - Але якщо ми додамо 13-й елемент: $13 > 12 \implies$ спрацює
resize(). Якщо ж вимагалося зберегти рівно 12 елементів із гарантією відсутності ресайзу,new HashMap<>(12)витримує рівно 12 елементів на межі дозволеного. Проте при 13 елементах таблиця розшириться.
2. Чому в старих бібліотеках (Guava) формула виглядала як (int)(expected / 0.75f + 1.0f), а не просто (int)(expected / 0.75f)?
Відповідь:
Причина криється в особливостях арифметики чисел із рухомою комою (float) та операції зведення до цілого числа ((int)):
- У Java приведення
(int) floatValueвідкидає дробову частину (виконує округлення вниз,floor). - Наприклад, для
expected = 3: $3 / 0.75 = 4.0$. Однак через похибки представленняfloatрезультат ділення може виявитися3.9999998f. - Приведення
(int) 3.9999998fдасть 3! У результатіtableSizeFor(3)поверне 4,threshold = 4 * 0.75 = 3. Під час вставки 3-го або 4-го елемента відбудеться ресайз. Додавання+ 1.0fабо використанняMath.ceil()гарантує захист від неточного округлення чисел із рухомою комою.
3. Чи має сенс задавати initialCapacity для колекцій, у яких гарантовано зберігатиметься не більше 3–5 елементів?
Відповідь:
Зазвичай ні.
Дефолтний розмір 16 комірок вміщує до 12 елементів ($16 \times 0.75$) без жодного ресайзу. Масив на 16 посилань займає лише близько 80 байтів в оперативній пам’яті.
Обчислення кастомного capacity заради економії 40 байтів створює непотрібне ускладнення коду без відчутного виграшу. Задавати initialCapacity має сенс тоді, коли очікуваний розмір колекції перевищує стандартний поріг у 12 елементів (десятки, сотні або тисячі записів).
4. Як правильно задати початкову місткість, якщо одночасно змінюється loadFactor (наприклад, передано 0.5f)?
Відповідь:
Формула залишається універсальною:
\(\text{capacity} = \left\lceil \frac{\text{expectedSize}}{\text{loadFactor}} \right\rceil\)
Якщо очікується 100 елементів, а loadFactor = 0.5f:
\(\text{capacity} = \left\lceil \frac{100}{0.5f} \right\rceil = 200\)
У конструктор передається:
Map<Key, Value> map = new HashMap<>(200, 0.5f);
tableSizeFor(200) округлить місткість до 256. Поріг threshold стане $256 \times 0.5 = 128$, що дозволить гарантовано вмістити 100 елементів без розширення масиву.
🎯 Шпаргалка для інтерв’ю
- Формула розрахунку місткості: $\text{initialCapacity} = \lceil \text{expectedSize} / 0.75 \rceil$.
- Помилка
new HashMap<>(1000): Округлюється до 1024, поріг стає 768. Ресайз відбудеться на 769-му елементі! - Правильне значення для 1000:
capacity = 1334(округлиться до 2048, поріг 1536 $\implies$ без ресайзів). - Сучасний стандарт (Java 19+): Використовуйте
HashMap.newHashMap(expectedSize)абоHashSet.newHashSet(expectedSize). - Небезпека завищення: Надлишковий
capacityпризводить до марнування пам’яті та різкого уповільнення ітерації ($O(\text{capacity} + N)$). - Маленькі колекції: При розмірі до 12 елементів дефолтного конструктора
new HashMap<>()більш ніж достатньо.
Червоні прапорці (чого уникати)
- Твердження, що
new HashMap<>(1000)вміщує 1000 елементів без ресайзу (поріг буде 768). - Думка, що завищення capacity не несе жодних негативних наслідків (сповільнює ітерацію до $O(\text{capacity} + \text{size})$).
- Спроби змінювати capacity після створення об’єкта (воно фіксується і змінюється лише автоматичним ресайзом).