Що робити при колізіях ключів при збірці в Map
За замовчуванням метод із двома аргументами Collectors.toMap(keyMapper, valueMapper) не вміє вирішувати такі конфлікти і при першому ж дублікаті ключа викидає виняток: IllegalSt...
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
Колізія ключів виникає, коли два або більше елементів стріму після перетворення функцією keyMapper дають однаковий ключ для Map.
За замовчуванням метод із двома аргументами Collectors.toMap(keyMapper, valueMapper) не вміє вирішувати такі конфлікти і при першому ж дублікаті ключа викидає виняток:
IllegalStateException: Duplicate key <key> (attempted merging values <v1> and <v2>).
Щоб обробити колізію, необхідно використовувати перевантаження методу Collectors.toMap із третім аргументом — функцією злиття (BinaryOperator<U> mergeFunction), яка визначає, яке значення зберегти при конфлікті:
// Залишити перше зустрінуте значення (Keep First):
(existing, replacement) -> existing
// Залишити останнє зустрінуте значення (Keep Last / Overwrite):
(existing, replacement) -> replacement
Приклади стратегій вирішення колізій на Java 21
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class KeyCollisionDemo {
public record Employee(int id, String name, double salary) {}
public static void main(String[] args) {
List<Employee> staff = List.of(
new Employee(101, "Іван (Junior)", 80_000),
new Employee(102, "Анна (Middle)", 160_000),
new Employee(101, "Іван (Senior)", 250_000) // Дублікат ID 101!
);
// Стратегія 1: Залишити перше значення (Keep First)
Map<Integer, Employee> firstStaff = staff.stream()
.collect(Collectors.toMap(
Employee::id,
e -> e,
(existing, replacement) -> existing
));
System.out.println("First: " + firstStaff.get(101).name()); // Іван (Junior)
// Стратегія 2: Перезаписати останнім значенням (Keep Last)
Map<Integer, Employee> latestStaff = staff.stream()
.collect(Collectors.toMap(
Employee::id,
e -> e,
(existing, replacement) -> replacement
));
System.out.println("Latest: " + latestStaff.get(101).name()); // Іван (Senior)
// Стратегія 3: Агрегація (вибір максимальної зарплати)
Map<Integer, Employee> highestPaid = staff.stream()
.collect(Collectors.toMap(
Employee::id,
e -> e,
(e1, e2) -> e1.salary() > e2.salary() ? e1 : e2
));
System.out.println("Max salary: " + highestPaid.get(101).salary()); // 250000.0
}
}
🟡 Middle Level
4 типові стратегії в mergeFunction
Інтерфейс BinaryOperator<U> приймає (U existingValue, U newValue) і повертає підсумкове значення типу U:
1. Дедуплікація (Keep First):
Застосовується, коли перший елемент вважається канонічним:
(oldVal, newVal) -> oldVal
2. Актуалізація (Keep Last):
Застосовується, коли стрім впорядкований хронологічно (за часом події), і потрібно зберегти найсвіжіший стан:
(oldVal, newVal) -> newVal
3. Арифметичне додавання / Конкатенація:
Підсумовування кількостей або об’єднання рядків:
// Підрахунок сумарного обсягу продажів за товарами:
Map<String, Integer> totalSales = orders.stream()
.collect(Collectors.toMap(Order::product, Order::quantity, Integer::sum));
4. Бізнес-вибір (Custom Reducer):
Вибір за бізнес-правилом (наприклад, вибір за пріоритетом статусу замовлення):
(v1, v2) -> v1.priority() > v2.priority() ? v1 : v2
Коли toMap з mergeFunction — архітектурна помилка
Якщо задача вимагає зберегти всі елементи, що відповідають ключу, використовувати toMap() категорично не можна. Правильне рішення — використовувати Collectors.groupingBy():
// ❌ ПОГАНО: спроба зібрати список вручну через toMap
// .toMap(User::dept, u -> new ArrayList<>(List.of(u)), (l1, l2) -> { l1.addAll(l2); return l1; })
// ✅ ПРАВИЛЬНО: groupingBy повертає Map<String, List<User>>:
Map<String, List<User>> usersByDept = users.stream()
.collect(Collectors.groupingBy(User::department));
🔴 Senior Level
Вимога асоціативності mergeFunction у parallelStream()
У паралельному стрімі (parallelStream()) дані розбиваються на чанки між потоками пулу ForkJoinPool:
- Кожен потік формує часткові
Map. - На етапі злиття гілок рантайм
Map.putAll()таMap.merge()викликаєmergeFunctionдля ключів, які зустрілися в різних підзадачах. - Щоб результат багатопотокової збірки був суворо детермінованим,
mergeFunctionзобов’язана бути асоціативною (Associative): \((v_1 \circ v_2) \circ v_3 = v_1 \circ (v_2 \circ v_3)\)
Worker 1: Map [Key=A -> Val 1] \
──► mergeFunction(Val 1, Val 2) \
Worker 2: Map [Key=A -> Val 2] / ──► mergeFunction(Res1, Val 3)
/
Worker 3: Map [Key=A -> Val 3] ────────────────────────────────
Senior Warning: Стратегія
Keep Last ((v1, v2) -> v2)уparallelStream()не гарантує, який саме елемент стане останнім, якщо порядок злиття задач уForkJoinPoolзміниться. Для гарантії детермінованого вибору в паралельному режимі елементи повинні містити незалежний компаратор (наприклад, часову міткуtimestampабо версію).
Аудит та логування колізій у Production
У критичних фінансових сервісах втрата даних при мовчазному відкиданні дублікатів неприпустима:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class SafeMergeCollector {
private static final Logger log = LoggerFactory.getLogger(SafeMergeCollector.class);
public static <T, K, V> Map<K, V> toMapWithAudit(
List<T> items,
java.util.function.Function<T, K> keyMapper,
java.util.function.Function<T, V> valueMapper) {
return items.stream().collect(Collectors.toMap(
keyMapper,
valueMapper,
(existing, replacement) -> {
log.warn("ВИЯВЛЕНО ДУБЛІКАТ КЛЮЧА! Наявне: [{}], Нове: [{}]", existing, replacement);
return existing; // Зберігаємо вихідне
}
));
}
}
Аналізатори якості коду (SonarQube & Error-Prone)
Статичні аналізатори Google Error-Prone та SonarQube (правило S2201 / DuplicateKeyException) відносять використання двопараметричного Collectors.toMap(k, v) до категорії High-Risk Code Smells.
У production-коді рекомендується або явно передавати mergeFunction, або використовувати groupingBy().
🎯 Шпаргалка для інтерв’ю
30-секундна відповідь
«При колізії ключів метод
Collectors.toMap(k, v)без функції злиття викидаєIllegalStateException. Щоб коректно обробити дублікати, передають третій параметр —mergeFunction(BinaryOperator<V>). Основні стратегії:(v1, v2) -> v1(Keep First для дедуплікації),(v1, v2) -> v2(Keep Last для перезапису свіжішим значенням) або агрегація черезInteger::sum. Якщо потрібно зберегти всі елементи за спільним ключем, правильним рішенням єCollectors.groupingBy(), що збирає елементи вMap<K, List<V>>.»
Порівняння підходів при колізіях
| Стратегія | Реалізація mergeFunction | Коли використовувати | Поведінка в parallelStream |
|---|---|---|---|
| Викидання помилки | Без 3-го аргументу | Інваріант: дублікати в БД/API неможливі | 💥 IllegalStateException |
| Keep First | (oldVal, newVal) -> oldVal |
Дедуплікація (беремо перший знайдений) | Детерміновано при впорядкованому джерелі |
| Keep Last | (oldVal, newVal) -> newVal |
Збереження останнього стану | Недетерміновано в parallel (залежить від потоків) |
| Агрегація | Integer::sum / (a, b) -> a + b |
Підрахунок сум, об’єднання текстів | 🟢 Ідеально (асоціативна операція) |
| Вибір за полем | (a, b) -> a.val() > b.val() ? a : b |
Вибір максимального/найкращого | 🟢 Ідеально |
| Групування | Collectors.groupingBy() |
Потрібно зберегти ВСІ елементи | 🟢 Ідеально (Map<K, List<V>>) |
Топ-4 каверзних запитання на інтерв’ю
1. Чому в production-коді заборонено використовувати Collectors.toMap(k, v) з двома аргументами?
- Двопараметричний метод припускає стовідсоткову унікальність ключів. Якщо на вхід сервісу надійдуть непередбачені дані з БД або зовнішнього API з дублікатом ключа, метод негайно впаде з
IllegalStateException, перервавши обробку користувацького запиту. ДодаванняmergeFunctionробить код відмовостійким.
2. Що станеться, якщо в mergeFunction передати (oldVal, newVal) -> null?
- Якщо функція злиття поверне
null, методMap.merge()видалить запис із цим ключем із підсумкової мапи. Це стандартна поведінка контракту методуMap.merge(key, value, remappingFunction).
3. Чому стратегія Keep Last (v1, v2) -> v2 може повернути непередбачуваний результат у parallelStream()?
- У паралельному стрімі порядок злиття результатів робочих потоків
ForkJoinPoolє недетермінованим. Гілка з пізнішим індексом може завершитися і бути об’єднана раніше, ніж гілка з попереднім індексом. У підсумку переможе те значення, яке рантайм передасть другим аргументом уmergeFunctionпід час збирання дерева задач.
4. У чому різниця між Collectors.toMap(k, v, merge) та Collectors.groupingBy(k)?
toMapзавжди зберігає як значення одиничний об’єкт типуV(розмір мапи дорівнює кількості унікальних ключів, колізії вирішуються стисканням в один елемент).groupingByгрупує всі елементи в колекціюMap<K, List<V>>, не втрачаючи жодного об’єкта при збігу ключів.
Типові помилки та Red Flags
- 🚩 Забування
mergeFunctionпри обробці даних із зовнішніх ненадійних джерел. - 🚩 Спроба накопичення списку об’єктів вручну через
toMap()замість використанняgroupingBy(). - 🚩 Використання неасоціативної логіки злиття в паралельних стрімах.
- 🚩 Придушення колізій через
(a, b) -> aбез логування в критично важливих бізнес-транзакціях.