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

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

Стаття з групи Random UA
учасників
Прийшов час поговорити про те, як же потоки взаємодіють між собою. Як вони ділять спільні ресурси? Які з цим можуть бути проблеми?

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 поклонився(лася) мені!%n",
                    this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s повернув(ла) уклін мені!%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".

Підсумки

У цьому огляді ми розглянули особливості взаємодії потоків. Обговорили проблеми, які можуть виникнути, і способи їх виявлення та усунення.
Коментарі
  • популярні
  • нові
  • старі
Щоб залишити коментар, потрібно ввійти в систему
Для цієї сторінки немає коментарів.