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

Thread'ом Java не испортишь: Часть III — взаимодействие

Статья из группы Random
участников
Краткий обзор особенностей взаимодействия потоков. Ранее мы разобрали, как потоки синхронизируются друг с другом. В этот раз мы окунёмся в проблемы, которые могут появиться при взаимодействии потоков и поговорим о том, как их можно избежать. Также приведём несколько полезных ссылок для более глубокого изучения. 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
Комментарии (26)
  • популярные
  • новые
  • старые
Для того, чтобы оставить комментарий Вы должны авторизоваться
Ann Kudra
Уровень 47
Expert
22 декабря 2023, 10:34
Пребываю в тихом шоке..😳 Ну да ладно, идем дальше!
wokku
Уровень 51
22 августа 2023, 12:47
Starvation (Голодание) в контексте многопоточного программирования означает, что один или несколько потоков не могут получить доступ к ресурсам и, следовательно, не могут выполнить свои задачи из-за постоянного монополизирования этих ресурсов другими потоками. Потоки, страдающие от голодания, могут ожидать неопределенное время, в то время как другие потоки продолжают работу. Это может привести к недостаточному использованию ресурсов, так как ожидающие потоки могут не выполнять полезной работы. Пример: В Java приоритет потока может привести к голоданию. Если высокоприоритетный поток постоянно занимает процессорное время, низкоприоритетные потоки могут голодать. Давайте рассмотрим следующий простой пример:
public class StarvationExample {

    private static Object sharedResource = new Object();

    public static void main(String[] args) {

        Thread highPriorityThread = new Thread(() -> {
            while (true) {
                synchronized (sharedResource) {
                    System.out.println("High priority thread is working...");
                }
            }
        });
        highPriorityThread.setPriority(Thread.MAX_PRIORITY);

        Thread lowPriorityThread = new Thread(() -> {
            while (true) {
                synchronized (sharedResource) {
                    System.out.println("Low priority thread is working...");
                }
            }
        });
        lowPriorityThread.setPriority(Thread.MIN_PRIORITY);

        highPriorityThread.start();
        lowPriorityThread.start();
    }
}
В этом примере у нас есть два потока: один с высоким приоритетом и другой с низким. Оба потока пытаются получить доступ к sharedResource. Из-за того, что высокоприоритетный поток постоянно занимает ресурс, низкоприоритетный поток может голодать и редко (или вовсе никогда) не получать доступ к ресурсу.
Дмитрий
Уровень 110
Expert
1 июля 2023, 14:14
Решил прочитать все статьи со всеми вложенными материалами: Спустя три пары выходных и вечера по будням: третья статья закончена, половину не понял. это жесть, ребята. Рановато для таких статей. Считаю, что новичкам сильно заострять внимание на статьях не стоит
Slava Melnikov
Уровень 51
25 июня 2023, 17:11
Лекции номер 11 и 12 из этого цикла помогли лучше понимать, о чем идет речь.
antlantis
Уровень 41
9 июля 2022, 11:42
ребята, а может кто-нибудь подсказать, почему пример из лекции
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;
    }
}
приводит к бесконечному циклу, то есть наш метод run() так и не узнает, что флаг уже true и пора печатать "Flag is now TRUE" но как только мы в цикл добавляем вывод 1 строки - всё работает и флаг уже видим
public class Main {
    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {

        Runnable whileFlagFalse = () -> {
            while (!flag) { System.out.println("бесконечный цикл");
                }
            System.out.println("Flag is now TRUE");
        };

        new Thread(whileFlagFalse).start();
        Thread.sleep(1000);
        flag = true;
    }
}
13 июля 2022, 17:45
Я думаю тут дело в когерентность кешей. Возможно инструкция while (!flag) {} происходит не спускаясь ниже в кэш L2... "L1-1 отправляет Request-For-Ownership в кэш L2" - цитата из статьи на хабре. Я задавал похожий вопрос. Но еще не нашел ответ.
Anna
Уровень 38
5 марта 2022, 16:50
Да, видео очень длинное. Смотрела от начала и до конца, но такое ощущение он говорил на не знакомом мне языке. Ничего не понятно. Если кто-то видел обьяснения по-проще дайте ссылку. Это просто очень высовая стена для меня, кажется. До видео думала сложноватая тема, а после просмотра видео даже мыслей нет. Просто тихий ужас.
Silendor
Уровень 29
9 августа 2021, 13:05
видео 18:34 А он догадывается.
Anonymous #2524703
Уровень 28
30 июля 2021, 22:32
Видео отличное. Очень доходчиво и сразу все понятно. Смотрел на скорости 1.5х, материал очень легкий.
Вячеслав
Уровень 36
23 февраля 2022, 15:36
сарказм не засчитан :l
Kurama
Уровень 50
21 ноября 2022, 18:23
Я бы даже сказал, что можно постоянно проматывать. Так всё разжевано, что даже тот, кто никогда не кодил, сам сможет эту лекцию провести...
Pig Man Главная свинья в Свинарнике
9 января 2021, 15:47
Видео, "лучше которого не найти" - оно для кого вообще? Для тех, кто уже несколько лет работает в реальных проектах и хочет узнать различные нюансы?
Ivan Konovalov
Уровень 29
18 января 2021, 03:23
Не знаю, обрадую или огорчу, но как человек, который "несколько лет работает в реальных проектах" и посмотрел и другие видео Шипелёва по JMM, могу сказать, что при моём скромном опыте набивания шишек с канкаренси, он (доклад) не приблизил к прорыву. Лично мне доклад Черёмина показался проще для понимания (по крайней мере после него хоть что-то встало на места), но тут уж не знаю, либо он действительно проще, либо после предыдущих зашёл хорошо. Тема не простая и требует поллитра предварительной подготовки, мне бы тоже хотелось что-то для "чайников" посмотреть.
Davilalexius System Engineer
6 декабря 2020, 20:42
Тем кто много чего не понимает, рекомендую таки открывать ссылки предоставленные в этом посте и, как ни странно, читать. "-Даже на буржуйском?" - даже на нём! Переводчик в помощь, если сильно сложно. И ещё: Увы, но программы выполняются исполнителем-процессором, поэтому знать хотя бы базовую структуру нижних уровней нужно. Мне помогает параллельное программирование avr-микропроцессоров (например arduino-подобные), но не в песочнице arduino ide, а в более продвинутой atmel studio. Там есть debug, в котором можно просматривать смоделированное состояние регистров процессора, памяти и пр. Код пишется на C++,C. Помогает осознавать многие понятия, изучаемые в java.
Игорь HDL developer в Y
11 января 2021, 13:54
Не сильно то оно помогает. Да и не к чему это сейчас, только оттягивает от процесса обучения. Если нужно углубленное понимание - пиши на ассемблере.