1. Багатопотоковість у Java

Java Virtual Machine підтримує паралельні обчислення. Усі обчислення можна виконувати в контексті одного або кількох потоків. Ми легко можемо налаштувати доступ до одного ресурсу або об'єкта для кількох потоків, а також налаштувати потік виконання окремого блоку коду.

Будь-якому розробнику необхідно синхронізувати роботу з потоками під час операцій читання та запису для ресурсів, на які виділено кілька потоків.

Це важливо, щоб на момент звернення до ресурсу в тебе були актуальні дані, щоб інший потік міг змінити їх, і ти отримав найновішу інформацію. Навіть якщо взяти приклад банківського рахунку, доки на нього не прийшли гроші, користуватися ними ти не можеш, тому важливо завжди мати актуальні дані. У Java є спеціальні класи для синхронізації потоків та управління ними.

Об'єкти потоку

Все починається з головного (основного) потоку, тобто мінімально у твоїй програмі вже є один потік, що виконується. Основний потік може створювати інші потоки за допомогою Callable або Runnable. Створення відрізняється тільки результатом, що повертається; Runnable не повертає результату і не може викинути виняток, що перевіряється. Тому в тебе з'являється хороша можливість побудувати ефективну роботу з файлами, але це дуже небезпечно і потрібно бути обережним.

Також можна планувати виконання потоку на окремому ядрі центрального процесора. Система може легко переміщатися між потоками і виконувати певний потік за умови правильних налаштувань: тобто виконується спочатку потік, який читає дані, як тільки у нас з'явилися дані, далі ми передаємо їх потоку, який відповідає за валідацію, після цього передаємо потоку для виконання якоїсь бізнес-логіки та новим потоком записуємо їх назад. У такій ситуації 4 потоки по черзі обробляють дані, і все працюватиме швидше, ніж один потік. Кожен такий потік перетворюється на нативний потік ОС, а ось яким способом його перетворюватимуть, залежить від реалізації JVM.

Клас Thread служить для створення потоків та роботи з ними. У ньому є як стандартні механізми управління, так і абстрактні, наприклад, класи та колекції з java.util.concurrent.

2. Синхронізація потоків у Java

Комунікація забезпечується за рахунок розподілу доступу до об'єктів. Це дуже ефективно, але водночас дуже легко припуститися помилки під час роботи. Помилки бувають двох типів: thread interference – коли інший потік втручається в твій потік, та memory consistency errors – консистентності пам'яті. Для запобігання цим помилкам у нас є різні методи синхронізації.

Синхронізацією потоків у Java займаються монітори. Це високорівневий механізм, що дозволяє в один момент лише одному потоку виконувати блок коду, захищений цим самим монітором. Поведінка моніторів розглянута у термінах блокувань; один монітор – одне блокування.

Синхронізація має кілька важливих моментів, на яких слід звернути увагу. Перший момент – це взаємне виключення (mutual exclusion) – лише один потік може володіти монітором. Таким чином, синхронізація на моніторі передбачає, що як тільки один потік входить до synchronized-блоку, захищеного монітором, жодний інший потік не може увійти до блоку, захищеного цим монітором, поки перший потік не вийде із synchronized-блоку. Тобто кілька потоків не можуть звернутися до одного блоку synchronized одночасно.

Але синхронізація – це не лише взаємний виняток. Синхронізація гарантує, що дані, які записано до пам'яті до або всередині синхронізованого блоку, стають видимими для інших потоків, які синхронізуються на тому ж моніторі. Після виходу з блоку ми звільняємо монітор, й інший потік може захопити його та почати виконання цього блоку коду.

Коли новий потік захоплює монітор, ми отримуємо доступ і можливість виконання цього блоку коду, і в цей момент змінні будуть завантажені з основної пам'яті. Тоді ми зможемо побачити всі записи, зроблені видимим попереднім звільненням монітора.

Читання-запис у полі – це атомарна операція, якщо поле оголошено volatile, або захищене унікальним блокуванням, яке отримується перед будь-яким читанням-записом. Але якщо ти все-таки стикаєшся з помилкою, то отримуєш помилку про перевпорядкування (зміна порядку прямування, reordering). Вона проявляється у некоректно синхронізованих багатопотокових програмах, де один потік може спостерігати ефекти, які виробляють інші потоки.

Ефект взаємного виключення та синхронізації потоків, тобто їх коректна робота досягається лише входженням до synchronized-блоку або методу, що неявно отримує блокування, або отриманням блокування явним чином. Ми поговоримо про це нижче. Обидва способи роботи впливають на твою пам'ять і важливо не забувати про роботу з volatile-змінними.

3. Volatile поля в Java

Якщо змінна позначена, як volatile, вона доступна глобально. Якщо потік звертається до volatile змінної, він отримає його значення перед тим як використовувати значення з кешу.

Запис працює як звільнення монітора, а читання – як захоплення монітора. Доступ здійснюється у відношенні по типу “виконується раніше”. Якщо розібратися, то все, що буде видно потоку A, коли він звертався до volatile змінної, це змінна для потоку B. Тобто ти гарантовано не втратиш свої зміни з інших потоків.

Volatile змінні атомарні, тобто під час читання такої змінної використовується той самий самий ефект, що й при отриманні блокування: дані в пам'яті оголошуються недійсними або некоректними, і значення volatile змінної знову читається з пам'яті. При записі використовується ефект пам'яті, як і при звільненні блокування — volatile-поле записується у пам'ять.

4. Java Concurrent

Якщо ти хочеш зробити суперефективну та багатопотокову програму, необхідно використовувати класи з бібліотеки JavaConcurrent, які знаходяться в пакеті java.util.concurrent.

Бібліотека дуже об'ємна і має різний функціонал, тому давай розберемо, що є всередині та поділимо все це на деякі модулі:

Java Concurrent

Concurrent Collections – набір колекцій для роботи у багатопотоковому середовищі. Замість базового враппера Collections.synchronizedList з блокуванням доступу до всієї колекції використовуються блокування сегментів даних або wait-free алгоритми для паралельного читання даних.

Queues – неблокуючі та блокуючі черги для роботи в багатопотоковому середовищі. Неблокуючі черги зосереджені на швидкості та роботі без блокування потоків. Блокуючі черги підходять для роботи, коли потрібно пригальмувати потоки Producer або Consumer. Наприклад, у тій ситуації, коли не виконані якісь з умов, черга порожня чи переповнена, чи немає вільного Consumer'a.

Synchronizers – допоміжні утиліти для синхронізації потоків. Є потужною зброєю в “паралельних” обчисленнях.

Executors – фреймворк для зручнішого та легкого створення пулів потоків, легко налаштувати планування роботи асинхронних задач з отриманням результатів.

Locks – багато гнучких механізмів синхронізації потоків у порівнянні з базовими synchronized, wait, notify, notifyAll.

Atomics – класи, які можуть підтримувати атомарні операції над примітивами та посиланнями.