Java

Прежде чем перейти к самой синхронизации, я объясню многопоточность на примере простого кода. 

Рисунок 1

Первым классом будет класс “Countdown”, а класс “ThreadColor” будет выглядеть вот так:

public class ThreadColor {
    public static final 
    public static final String ANSI_RED = "\u001B[31m";
    public static final String ANSI_GREEN = "\u001B[32m";
    public static final String ANSI_YELLOW = "\u001b[33m";
    public static final String ANSI_BLUE = "\u001B[34m";
    public static final String ANSI_PURPLE = "\u001B[35m";
    public static final String ANSI_CYAN = "\u001B[36m";
    public static final String ANSI_WHITE = "\u001b[37m";
}
Рисунок 2

Здесь я создал второй класс, расширяющий Thread:

Рисунок 3

Переходим к методу main, так как мы создали два класса, а затем запустили два потока из инструкции switch (рисунок 1), которая состоит из первого потока “Thread 1”, выводящего голубой текст, и второго потока “Thread 2”, выводящего фиолетовый текст. 

Теперь давайте посмотрим, что происходит.

Рисунок 4

Здесь вы видите Thread1 в голубом цвете, а Thread2 в фиолетовом. Мы не можем предсказать, каков будет результат, т.е. порядок этих двух цветов. Можете заметить, что, повторяя выполнение кода, мы будем получать разный вывод. 

А теперь мы добавим переменную экземпляра “Private int I;”, которая заменит локальную переменную “I”. Взглянем на результат:

Рисунок 5

Теперь он получился совсем иным. Вместо последовательного выполнения каждым потоком отсчёта от 10 до 1, мы видим, что некоторые числа повторяются. 

Почему?Очевидно, что повторяется число 10, а также несколько других. Единственное же, что мы сделали, — это поменяли локальную переменную на переменную экземпляра:

class Countdown {
    private int i;

Куча — это память приложения, которая совместно используется всеми потоками, будучи разделённой на их стеки, которые представляют собой отдельные отсеки памяти для каждого потока. 

Говоря проще, никакой поток не может обратиться к стеку другого потока, но при этом все потоки имеют доступ к куче. Локальная переменная хранится в стеке потока, поэтому каждый поток содержит свою собственную копию этой переменной, в то время как переменные экземпляра хранятся в куче

Поэтому, когда несколько потоков работают совместно над одним объектом, они используют этот объект вместе. В этом случае если один поток изменяет значение, другой поток использует это изменённое значение. Аналогичным образом, когда “i” выступала в роли локальной переменной, потоки имели свои собственные версии этой переменной, но как только мы сделали “i” переменной экземпляра, два потока стали обращаться к этому общему ресурсу, хранящемуся в куче, поэтому каждый поток и пропускал некоторые числа.

Цикл for
Он уменьшает I на 1 и проверяет условие i>0. Суть цикла for заключается в выполнении нескольких шагов, а именно уменьшения, проверки и т.д. Отсюда получается, что поток может быть приостановлен между этими шагами. Он может быть приостановлен после уменьшения “i”, перед проверкой состояния или же сразу после выполнения всего кода и вывода результата в консоль. Уменьшение “i”, проверка условия и вывод в консоль значений: эти три шага могут послужить причиной остановки текущего потока.

Как можно догадаться, в первой попытке оба потока рассматривали значение “i” как 10, поэтому Thread 1 вывел 9, но Thread 2 вывел 8. Почему? 

В то время как Thread 1 выполнял цикл for, Thread 2 должно быть его опередил, получил значение “i” в виде 9, выполнил блок for и вывел 8. 

При обращении к общим ресурсам, мы вынуждены пройти через эту ситуацию, которая называется “Thread interference” (коллизия потоков) или “Race condition” (состояние гонки). Помните, что всегда возникает серьёзная проблема, когда дело доходит до написания или обновления общего ресурса. 

Мы можем сделать это без пропуска чисел или избежания коллизии, т.е. передать один и тот же объект Countdown обоим потокам.

public class Main {

    public static void main(String[] args) {
        Countdown countdown1 = new Countdown();
        Countdown countdown2 = new Countdown();

        CountdownThread t1 = new CountdownThread(countdown1);
        t1.setName("Thread 1");
        CountdownThread t2 = new CountdownThread(countdown2);
        t2.setName("Thread 2");

        t1.start();
        t2.start();
    }
}

Взгляните на экземпляр выше, где присутствуют два новых объекта для потоков, не использующих общую кучу. Проверьте результат и вы не увидите никакой коллизии. Каждый поток успешно выполняет отсчёт от 10 до 1.

Рисунок 6

Но главный вопрос в том, будет ли это применимо в реальных ситуациях? Будет ли это работать, к примеру, для счёта в банке, где кто-либо вносит на него деньги, в то время как вы снимаете некую сумму с банкомата? Отсюда следует, что нам нужно использовать одинаковый объект с целью поддержания целостности данных, поскольку это единственный способ, который позволяет нам знать точный баланс счёта в банке после выполнения нескольких потоков (транзакций). Ведь так? 

В схожих ситуациях может одновременно присутствовать несколько потоков, ожидающих своей очереди на изменение баланса счёта. Следовательно, нам нужно позволить этим нескольким потокам изменить его, предотвратив при этом состояние гонки.

Синхронизация

Поскольку реальные приложения не могут использовать приведённую выше реализацию, нам нужно искать решение, которое не избегает состояния гонки в процессе изменения общего ресурса. Для этого мы можем добавить в объявление метода ключевое слово synchronized, что позволит синхронизировать этот метод:

class Countdown {
    private int i;
    public synchronized void doCountdown() {
        String color;   
}

Проверим результат:

Рисунок 7

После добавления ключевого слова synchronized весь метод выполняется до того, как к нему получит доступ другой поток.

Следовательно, в этом сценарии два потока никогда не столкнутся.

Но является ли этот способ единственным для предотвращения состояния гонки?

В принципе, мы можем добавить synchronized только в блок инструкции, а не для всего метода. 

Каждый объект в Java имеет Intrinsic Lock(монитор). Когда синхронизированный метод вызывается из потока, ему нужно получить этот монитор. Монитор будет освобождён после того, как поток завершит выполнение метода. Таким образом, мы можем синхронизировать блок инструкций, работающий с объектом, принудив потоки получать монитор, прежде чем выполнять блок инструкций. Помните, что монитор одновременно может удерживаться только одним потоком, поэтому другие потоки, желающие получить его, будут приостановлены до завершения работы текущего потока. Только после этого конкретный ожидающий поток сможет получить монитор и продолжить выполнение. 

Единственный блок кода метода “doCountdown”, в который мы можем добавить ключевое слово synchronized, — это блок “цикла for”. Итак, какой же объект нам следует использовать для синхронизации цикла for? Переменную “i”? Не думаю, потому что это примитивный тип, а не объект. Монитор же присутствует только в объектах. А что насчёт объекта “color”? Давайте просто удалим synchronized из объявления метода и добавим следующим образом:

synchronized (color){
    for(i=10; i > 0; i--) {
        System.out.println(color + Thread.currentThread().getName() + ": i =" + i);
    }
}

Вы видите тот же результат, что и на рисунке 5 в коде, где синхронизация не применялась. Почему же? Мы используем локальную переменную “color” для синхронизации. Как я уже пояснял выше относительно стеков потоков и прочего, использование локальной переменной здесь не работает, но объекты String переиспользуются внутри jvm, так как jvm для размещения строчных объектов использует пулы строк. Да, иногда это тоже может оказаться подходящим решением.

В качестве правила просто помните, что не нужно использовать локальную переменную для синхронизации.

Итак, давайте обновим синхронизированный блок кода таким образом:

synchronized (this){
    for(i=10; i > 0; i--) {
        System.out.println(color + Thread.currentThread().getName() + ": i =" + i);
    }
}

Взглянув на результат, вы увидите, что потоки не столкнутся и не пропустят числа. Блок цикла for одновременно может выполняться только одним потоком.

Кроме того, мы можем синхронизировать статические методы и использовать статические объекты.

Читайте также:


Перевод статьи Sachintha Hewawasam: Java Synchronization- part 1

Предыдущая статья2 черты отличных программистов
Следующая статьяСинхронизация в Java. Часть 2