JavaRush /Java блог /Random UA /Thread'ом Java не зіпсуєш: Частина III - взаємодія
Viacheslav
3 рівень

Thread'ом Java не зіпсуєш: Частина III - взаємодія

Стаття з групи Random UA
Короткий огляд особливостей взаємодії потоків. Раніше ми розібрали, як потоки синхронізуються один з одним. На цей раз ми поринемо в проблеми, які можуть виникнути при взаємодії потоків і поговоримо про те, як їх можна уникнути. Також наведемо кілька корисних посилань для глибшого вивчення. Thread'ом Java не зіпсуєш: Частина III - взаємодія - 1

Вступ

Отже, ми знаємо, що в Java є потоки, про що можна прочитати в огляді " Thread'ом Java не зіпсуєш: Частина I - потоки " і що потоки можна синхронізувати між собою, з чим ми розбиралися в огляді " Thread'ом Java не зіпсуєш: Частина II – синхронізація ”. Настав час поговорити про те, як потоки взаємодіють між собою. Як вони ділять спільні ресурси? Які із цим можуть бути проблеми?

Deadlock

Найстрашнішою проблемою є Deadlock. Коли два і більше потоків завжди чекають один одного — це називається Deadlock. Візьмемо приклад із сайту Oracle з опису поняття " Deadlock ":
public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s has bowed to me!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s has bowed back to me!%n",
                    this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(() -> alphonse.bow(gaston)).start();
        new Thread(() -> gaston.bow(alphonse)).start();
    }
}
Deadlock тут може проявитися не з першого разу, але якщо у вас виконання програми зависло, саме час запустити jvisualvm: Thread'ом Java не зіпсуєш: Частина III - взаємодія - 2Якщо в JVisualVM встановлений плагін (через Tools -> Plugins), ми зможемо побачити, де стався дідлок:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
Потік 1 чекає на лок від потоку 0. Чому так виходить? Thread-1починає виконання і виконує метод Friend#bow. Він позначений ключовим словом synchronized, тобто ми забираємо монітор this. Ми на вхід у метод отримали посилання на іншого Friend. Тепер, потік Thread-1хоче виконати метод в іншого Friend, тим самим отримавши лок і в нього. Але якщо інший потік (в даному випадку Thread-0) встиг увійти в метод bow, то лок вже зайнятий і Thread-1чекає Thread-0, і навпаки. Блокування нерозв'язне, тому воно Dead, тобто мертве. Як мертва хватка (яку не розтиснути), так і мертве блокування, з якого не вийти. На тему дідлока можна подивитися відео: " Deadlock - Concurrency #1 - Advanced Java ".

Livelock

Якщо є Deadlock, чи є Livelock? Так, є) Livelock полягає в тому, що потоки зовні живуть, але при цьому не можуть нічого зробити, т.к. умова, якими вони намагаються продовжити своєї роботи, що неспроможні виконатися. Насправді Livelock схожий на deadlock, але тільки потоки не "зависають" на системному очікуванні монітора, а щось завжди роблять. Наприклад:
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class App {
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";

    public static void log(String text) {
        String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
        String color = ANSI_BLUE;
        int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
        if (val != 0) {
            color = ANSI_PURPLE;
        }
        System.out.println(color + name + ": " + text + color);
        try {
            System.out.println(color + name + ": wait for " + val + " sec" + color);
            Thread.currentThread().sleep(val * 1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        Lock first = new ReentrantLock();
        Lock second = new ReentrantLock();

        Runnable locker = () -> {
            boolean firstLocked = false;
            boolean secondLocked = false;
            try {
                while (!firstLocked || !secondLocked) {
                    firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
                    log("First Locked: " + firstLocked);
                    secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
                    log("Second Locked: " + secondLocked);
                }
                first.unlock();
                second.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        };

        new Thread(locker).start();
        new Thread(locker).start();
    }
}
Успішність цього коду залежить від того, як планувальник потоків Java запустить потоки. Якщо першим запуститься Thead-1, ми отримаємо Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
Як видно з прикладу, обидва потоки по черзі намагаються захопити обидва локи, але їм це не вдається. При цьому вони не в deadlock, тобто візуально з ними все добре, і вони виконують свою роботу. Thread'ом Java не зіпсуєш: Частина III - взаємодія - 3По JVisualVM ми бачимо періоди sleep і період park (це коли потік намагається зайняти лок, він переходить у стан park, як ми розбирали раніше, говорячи про синхронізацію потоків ). На тему лайвлока можна подивитися приклад: " Java - Thread Livelock ".

Starvation

Крім блокувань (deadlock та livelock) є ще одна проблема при роботі з багатопоточністю - Starvation, або "голодування". Від блокувань це явище відрізняється тим, що потоки не заблоковані, а просто не вистачає ресурсів на всіх. Тому поки одні потоки на себе беруть весь час виконання, інші не можуть виконатися: Thread'ом Java не зіпсуєш: Частина III - взаємодія - 4

https://www.logicbig.com/

Супер приклад можна подивитися тут: " Java - Thread Starvation and Fairness ". У цьому прикладі показано, як працюють потоки при Starvation і як одна маленька зміна Thread.sleep на Thread.wait дозволяє розподілити навантаження рівномірно. Thread'ом Java не зіпсуєш: Частина III - взаємодія - 5

Race Condition

Працюючи з многопоточностью є таке поняття, як " стан гонки " . Це явище полягає в тому, що потоки ділять між собою певний ресурс і код написаний таким чином, що не передбачає коректної роботи в такому випадку. Погляньмо на приклад:
public class App {
    public static int value = 0;

    public static void main(String[] args) {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                int oldValue = value;
                int newValue = ++value;
                if (oldValue + 1 != newValue) {
                    throw new IllegalStateException(oldValue + " + 1 = " + newValue);
                }
            }
        };
        new Thread(task).start();
        new Thread(task).start();
        new Thread(task).start();
    }
}
Цей код може видати помилку не з першого разу. І виглядати вона може таким чином:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
    at App.lambda$main$0(App.java:13)
    at java.lang.Thread.run(Thread.java:745)
Як видно, поки що присвоювалося newValueщось пішло не так, і newValueстало більше. Якийсь із потоків у стані гонки встиг змінити valueміж цими двома командами. Як бачимо, виявилася гонка між потоками. А тепер уявіть, як важливо не робити схожі помилки з грошовими операціями... Приклади та схеми можна подивитися ще й тут: "Code to simulate race condition in Java thread ".

Volatile

Говорячи про взаємодію потоків, варто особливо відзначити ключове слово volatile. Подивимося на простий приклад:
public class App {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        Runnable whileFlagFalse = () -> {
            while(!flag) {
            }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
Найцікавіше, що він із високою ймовірністю не відпрацює. Новий потік не побачить зміни flag. Щоб це виправити для поля, flagпотрібно вказати ключове слово volatile. Як же і чому? Усі дії виконує процесор. Але результати обчислень треба десь зберігати. Для цього є основна пам'ять і є апаратний кеш процесора. Ці кеші процесора - свого роду маленький шматочок пам'яті для швидшого звернення до даних, ніж звернення до основної пам'яті. Але всього є і мінус: дані в кеші можуть бути не актуальні (як у прикладі вище, коли значення прапора не оновилося). Так ось, ключове слово volatileвказує на JVM, що ми не хочемо кешувати нашу змінну. Це дає змогу побачити актуальний результат у всіх потоках. Це дуже спрощене формулювання. На темуvolatileнастійно рекомендується до прочитання перекладу " JSR 133 (Java Memory Model) FAQ ". Детальніше раджу також ознайомитися з матеріалами " Java Memory Model " та " Java Volatile Keyword ". Крім того, важливо пам'ятати, що volatileце про видимість, а не про атомарність змін. Якщо взяти код з "Race Condition", то ми побачимо в IntelliJ Idea підказку: Thread'ом Java не зіпсуєш: Частина III - взаємодія - 6Ця перевірка (Inspection) була додана до IntelliJ Idea в рамках issue IDEA-61117 , який вказаний у Release Notes ще в далекому 2010 році.

Атомарність

Атомарні операції це операції, які не можна розділити. Наприклад, операція надання значення змінної — атомарна. На жаль, інкремент перестав бути атомарної операцією, т.к. для інкременту потрібно аж три операції: отримати старе значення, додати до нього одиницю, зберегти значення. Чому важлива атомарність? У прикладі з інкрементом, якщо з'явиться стан гонки, будь-якої миті загальний ресурс (тобто загальне значення) може раптово змінитися. Крім того, важливо, що 64-бітові структури теж не атомарні, наприклад, longі double. Детальніше можна прочитати тут: " Ensure atomicity when reading and writing 64-bit values ​​". Приклад проблем з атомарністю можна побачити на прикладі:
public class App {
    public static int value = 0;
    public static AtomicInteger atomic = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        Runnable task = () -> {
            for (int i = 0; i < 10000; i++) {
                value++;
                atomic.incrementAndGet();
            }
        };
        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
        Thread.sleep(300);
        System.out.println(value);
        System.out.println(atomic.get());
    }
}
Спеціальний клас для роботи з атомарним Integerзавжди виводитиме нам 30000, а ось valueзмінюватиметься від разу до разу. На цю тему є невеликий огляд " An Introduction to Atomic Variables in Java ". В основі Atomic'ів лежить алгоритм "Compare-and-Swap". Докладніше про нього можна прочитати у статті на хабрі " Порівняння Lock-free алгоритмів - CAS і FAA на прикладі JDK 7 і 8 " або на вікіпедії у статті про " Порівняння з обміном ". Thread'ом Java не зіпсуєш: Частина III - взаємодія - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

Happens Before

Є цікава та загадкова штука – Happens Before. Розмірковуючи про потоки, про неї варто також прочитати. Відношення Happens Before показує, в якому порядку буде видно дії між потоками. Існує чимало трактувань та тлумачень. Однією з останніх доповідей на цю тему є ось ця доповідь:
Напевно, краще, ніж це відео, нічого не розповість про це. Тому я просто залишу посилання на відео. Прочитати можна " Java - Understanding Happens-before relationship ".

Підсумки

У цьому огляді ми переглянули особливості взаємодії потоків. Обговорабо проблеми, які можуть виникнути та способи їх виявлення та усунення. Список додаткових матеріалів на тему: #Viacheslav
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ