1. Чому звичайні колекції не підходять для багатопоточності
Згадаймо, як ми працювали з колекціями в нашому основному застосунку (наприклад, чат-кімнатою):
List<String> messages = new ArrayList<>();
messages.add("Привіт!");
messages.add("Як справи?");
В однопоточній програмі все чудово. Але якщо кілька потоків одночасно додаватимуть, видалятимуть або читатимуть елементи з однієї й тієї самої колекції — ласкаво просимо у світ гонок даних (race conditions), неузгодженого стану та загадкових багів.
Наприклад, один потік додає елемент, інший видаляє, третій ітерує — і раптово отримуємо ConcurrentModificationException, а інколи навіть ArrayIndexOutOfBoundsException або просто «биту» колекцію.
Класика жанру:
List<String> list = new ArrayList<>();
Runnable writer = () -> {
for (int i = 0; i < 1000; i++) {
list.add("msg-" + i);
}
};
Runnable reader = () -> {
for (String msg : list) {
// ...
}
};
// Запускаємо writer і reader у різних потоках — отримаєте баги!
Висновок: Звичайні колекції (ArrayList, HashMap, HashSet та ін.) не є thread-safe. Їх не можна використовувати з кількох потоків без додаткової синхронізації (synchronized, блокування тощо).
2. Які бувають thread-safe колекції в Java
Java не залишає вас на призволяще. Для багатопоточних задач у пакеті java.util.concurrent є ціла колекція колекцій (вибачте за тавтологію), які можна безпечно використовувати з кількох потоків.
Основні thread-safe колекції:
| Колекція | Де використовувати | Особливості |
|---|---|---|
|
Map, кеш, частий доступ | Висока продуктивність, без глобального блокування |
|
List, рідко змінюється, часто читається | Швидкі читання, повільні зміни |
|
Set, рідко змінюється, часто читається | Аналогічно списку на Copy-On-Write |
|
Черга, FIFO | Швидка, без блокувань, для черг завдань |
|
Map із сортуванням (NavigableMap) | Потокобезпечний аналог TreeMap |
|
Set із сортуванням | Потокобезпечний аналог TreeSet |
|
Черги з блокуванням (пули потоків) | Інтерфейс, багато реалізацій |
Важливо! Старий-добрий Collections.synchronizedList(list) і подібні — це не зовсім те саме, що сучасні колекції з java.util.concurrent. Детальніше — трохи нижче.
3. ConcurrentHashMap: ваш друг у світі багатопоточності
ConcurrentHashMap<K, V> — це, по суті, той самий HashMap, лише прокачаний для багатопоточності. Він дозволяє кільком потокам одночасно безпечно читати й записувати дані, не блокуючи всю мапу цілком.
У звичайному HashMap, якщо хочеться зробити доступ потокобезпечним, доводиться ставити блокування на всю структуру — і вона миттєво перетворюється на «вузьке горлечко»: поки один потік працює, інші чекають.
ConcurrentHashMap розв’язує цю проблему розумніше. У ранніх версіях мапа ділилася на сегменти з окремими блокуваннями, у нових реалізаціях задіяні легкі атомарні операції (CAS) на рівні окремих бакетів. Завдяки цьому потоки можуть спокійно працювати паралельно, якщо не торкаються одних і тих самих даних.
Приклад використання ConcurrentHashMap
import java.util.concurrent.ConcurrentHashMap;
public class ChatStats {
private final ConcurrentHashMap<String, Integer> userMessageCount = new ConcurrentHashMap<>();
public void increment(String user) {
// Атомарно збільшуємо значення
userMessageCount.merge(user, 1, Integer::sum);
}
public int getCount(String user) {
return userMessageCount.getOrDefault(user, 0);
}
}
Що тут важливо:
- Можна викликати методи з різних потоків — усе буде коректно.
- Метод merge атомарний: якщо кілька потоків одночасно збільшують лічильник, результат буде правильним.
- Для читання не потрібна додаткова синхронізація.
Чим ConcurrentHashMap кращий за synchronizedMap?
Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
Коли ви використовуєте synchronizedMap, будь-яка операція — читання, запис або видалення — блокує всю мапу. Поки один потік працює з даними, інші змушені чекати своєї черги.
ConcurrentHashMap влаштований значно витонченіше: дозволяє кільком потокам одночасно читати й навіть змінювати дані, якщо вони не звертаються до тих самих ділянок мапи (бакетів). У результаті в реальних багатопоточних системах він показує значно кращу продуктивність — інколи різниця сягає десятків разів.
4. CopyOnWriteArrayList і CopyOnWriteArraySet
CopyOnWriteArrayList і CopyOnWriteArraySet — це особливі колекції, які під час кожної зміни (наприклад, при виклику add() або remove()) створюють нову копію всього масиву. Натомість читання з них відбувається без будь-якої синхронізації й повністю безпечно для потоків.
Уявіть, що у вас є список гостей на вечірці. Щоразу, коли хтось приходить або йде, ви переписуєте список заново та роздаєте всім свіжі копії. Трохи марнотратно, але зате ніхто не заплутається, хто зараз присутній.
Коли це справді зручно
- Читання відбуваються часто, а зміни — рідко.
- Класичний кейс — список слухачів подій: обробники додаються нечасто, зате події приходять постійно.
Приклад: слухачі чату
import java.util.concurrent.CopyOnWriteArrayList;
public class ChatRoom {
private final CopyOnWriteArrayList<ChatListener> listeners = new CopyOnWriteArrayList<>();
public void addListener(ChatListener listener) {
listeners.add(listener);
}
public void removeListener(ChatListener listener) {
listeners.remove(listener);
}
public void sendMessage(String message) {
// Безпечно для багатопоточності, навіть якщо хтось підписується/відписується просто зараз
for (ChatListener listener : listeners) {
listener.onMessage(message);
}
}
}
Важливі особливості:
- Ітерація по CopyOnWriteArrayList ніколи не кине ConcurrentModificationException.
- Зміни (add/remove) дорогі за часом і пам’яттю (копіюється весь масив!).
- Не варто використовувати для великих колекцій із частими змінами.
5. Інші thread-safe колекції
ConcurrentLinkedQueue
ConcurrentLinkedQueue — це неблокувальна черга, що працює за принципом FIFO. Вона дозволяє кільком потокам одночасно безпечно додавати й забирати елементи без використання явних блокувань. Часто застосовується для передачі завдань між потоками — швидко й без «заторів».
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
queue.add("task1");
String task = queue.poll(); // поверне null, якщо черга порожня
ConcurrentSkipListMap і ConcurrentSkipListSet
- Потокобезпечні аналоги TreeMap і TreeSet.
- Елементи завжди відсортовані.
- Використовуються, коли важливо підтримувати порядок ключів.
import java.util.concurrent.ConcurrentSkipListMap;
ConcurrentSkipListMap<Integer, String> sortedMap = new ConcurrentSkipListMap<>();
sortedMap.put(10, "a");
sortedMap.put(2, "b");
System.out.println(sortedMap.firstEntry()); // 2=b
BlockingQueue та його реалізації
- Інтерфейс черги, що підтримує операції блокування (чекати, доки з’явиться/звільниться місце).
- Реалізації: ArrayBlockingQueue, LinkedBlockingQueue, PriorityBlockingQueue та ін.
- Використовуються у пулах потоків, для патерна «виробник–споживач».
import java.util.concurrent.ArrayBlockingQueue;
ArrayBlockingQueue<String> blockingQueue = new ArrayBlockingQueue<>(10);
blockingQueue.put("task"); // Блокує, якщо черга заповнена
String t = blockingQueue.take(); // Блокує, якщо черга порожня
6. Приклади: безпечні операції з колекціями
Приклад 1: Потокобезпечний Map для підрахунку повідомлень
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> messageCount = new ConcurrentHashMap<>();
// Потік 1
messageCount.put("Анна", 1);
// Потік 2
messageCount.put("Анна", messageCount.getOrDefault("Анна", 0) + 1); // Неатомарно!
// Правильно (атомарно):
messageCount.merge("Анна", 1, Integer::sum);
Приклад 2: Ітерація по CopyOnWriteArrayList
import java.util.concurrent.CopyOnWriteArrayList;
CopyOnWriteArrayList<String> users = new CopyOnWriteArrayList<>();
users.add("Антон");
users.add("Марія");
for (String user : users) {
System.out.println(user);
users.remove(user); // Не кине ConcurrentModificationException!
}
System.out.println(users); // []
Приклад 3: Черга завдань між потоками
import java.util.concurrent.ConcurrentLinkedQueue;
ConcurrentLinkedQueue<String> queue = new ConcurrentLinkedQueue<>();
// Потік-виробник
queue.add("task-1");
// Потік-споживач
String task = queue.poll(); // null, якщо порожньо
7. Корисні нюанси
Коли (і навіщо) використовувати thread-safe колекції
Використання thread-safe колекцій виправдане, якщо:
- Одна й та сама колекція розділяється між кількома потоками.
- Немає бажання вручну синхронізувати кожну операцію.
- Важливо уникнути гонок даних і помилок узгодженості.
Типові сценарії:
- Кеш у багатопотоковій системі (наприклад, ConcurrentHashMap для зберігання сесій користувачів).
- Черги завдань між потоками (ConcurrentLinkedQueue, BlockingQueue).
- Списки слухачів подій (CopyOnWriteArrayList).
- Багатопотокова обробка даних (наприклад, у стилі MapReduce).
Обмеження та підводні камені
- Операції над кількома елементами не атомарні. Конструкція виду if (!map.containsKey(k)) map.put(k, v) не атомарна. Використовуйте putIfAbsent, computeIfAbsent, merge.
- CopyOnWriteArrayList неефективна за частих змін. За великих розмірів і частих add/remove накладні витрати зростають лавиноподібно.
- Ітерація по ConcurrentHashMap «слабка». Обхід дає слабкоконсистентний знімок: можна не побачити частину паралельних змін.
- Thread-safe колекції не розв’язують усіх проблем синхронізації. Якщо логіка зачіпає кілька колекцій/змінних одразу, знадобиться зовнішня синхронізація (synchronized, локи, атомарні класи).
8. Типові помилки під час роботи з thread-safe колекціями
Помилка № 1: Очікування магії від thread-safe колекцій. «Оскільки колекція thread-safe, можна робити що завгодно й не думати про синхронізацію». На жаль, послідовності з кількох операцій (перевірка + додавання) не атомарні. Використовуйте спеціалізовані методи: putIfAbsent, compute, merge.
Помилка № 2: Використання CopyOnWriteArrayList для великих і часто змінюваних колекцій. Підходить для списків слухачів, але за 10 000+ елементів і частих змін отримаєте великі витрати пам’яті й часу.
Помилка № 3: ConcurrentModificationException під час використання звичайних колекцій. Ітеруєте по ArrayList або HashMap, а інший потік змінює колекцію — ловіть ConcurrentModificationException. Використовуйте спеціалізовані колекції або блокуйте доступ вручну.
Помилка № 4: Забування про атомарність складних операцій. Якщо потрібно змінити одразу кілька колекцій або виконати серію пов’язаних дій — thread-safe колекції не допоможуть. Застосовуйте зовнішню синхронізацію або транзакційну логіку.
Помилка № 5: Помилки під час ітерації по ConcurrentHashMap. Ітерація слабкоконсистентна: не можна використовувати ітератор як «знімок» стану мапи. Для узгодженого знімка застосовуйте копіювання даних в окрему структуру.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ