Статьи

Учебник по параллелизму Java — Блокировка: внутренние блокировки

В предыдущих статьях мы рассмотрели некоторые из основных рисков совместного использования данных между различными потоками (например, атомарность и видимость ) и способы разработки классов для безопасного совместного использования ( проекты с защитой потоков ). Однако во многих ситуациях нам нужно будет обмениваться изменяемыми данными, когда одни потоки будут писать, а другие будут выполнять функции читателей. Может случиться так, что у вас есть только одно поле, независимое от других, которое должно быть разделено между разными потоками. В этом случае вы можете использовать атомарные переменные. Для более сложных ситуаций вам потребуется синхронизация.

1. Пример кофейни

Давайте начнем с простого примера, такого как CoffeeStore . Этот класс реализует магазин, где клиенты могут купить кофе. Когда клиент покупает кофе, счетчик увеличивается, чтобы отслеживать количество проданных единиц. Магазин также регистрирует, кто был последним клиентом, который пришел в магазин.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public class CoffeeStore {
    private String lastClient;
    private int soldCoffees;
     
    private void someLongRunningProcess() throws InterruptedException {
        Thread.sleep(3000);
    }
     
    public void buyCoffee(String client) throws InterruptedException {
        someLongRunningProcess();
         
        lastClient = client;
        soldCoffees++;
        System.out.println(client + " bought some coffee");
    }
     
    public int countSoldCoffees() {return soldCoffees;}
     
    public String getLastClient() {return lastClient;}
}

В следующей программе четыре клиента решают прийти в магазин за кофе:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public static void main(String[] args) throws InterruptedException {
    CoffeeStore store = new CoffeeStore();
    Thread t1 = new Thread(new Client(store, "Mike"));
    Thread t2 = new Thread(new Client(store, "John"));
    Thread t3 = new Thread(new Client(store, "Anna"));
    Thread t4 = new Thread(new Client(store, "Steve"));
     
    long startTime = System.currentTimeMillis();
    t1.start();
    t2.start();
    t3.start();
    t4.start();
     
    t1.join();
    t2.join();
    t3.join();
    t4.join();
     
    long totalTime = System.currentTimeMillis() - startTime;
    System.out.println("Sold coffee: " + store.countSoldCoffees());
    System.out.println("Last client: " + store.getLastClient());
    System.out.println("Total time: " + totalTime + " ms");
}
 
private static class Client implements Runnable {
    private final String name;
    private final CoffeeStore store;
     
    public Client(CoffeeStore store, String name) {
        this.store = store;
        this.name = name;
    }
     
    @Override
    public void run() {
        try {
            store.buyCoffee(name);
        } catch (InterruptedException e) {
            System.out.println("interrupted sale");
        }
    }
}

Основной поток будет ожидать завершения всех четырех клиентских потоков, используя Thread.join (). После того, как клиенты ушли, мы, очевидно, должны посчитать четыре кофе, продаваемых в нашем магазине, но вы можете получить неожиданные результаты, подобные приведенному выше:

1
2
3
4
5
6
7
Mike bought some coffee
Steve bought some coffee
Anna bought some coffee
John bought some coffee
Sold coffee: 3
Last client: Anna
Total time: 3001 ms

Мы потеряли одну единицу кофе, а также последний клиент (Джон) не тот, который отображается (Анна). Причина в том, что, поскольку наш код не синхронизирован, потоки чередуются. Наша операция buyCoffee должна быть атомарной.

2. Как работает синхронизация

Синхронизированный блок — это область кода, которая защищена замком. Когда поток входит в синхронизированный блок, он должен получить свою блокировку и после получения он не освободит ее, пока не выйдет из блока или не сгенерирует исключение. Таким образом, когда другой поток пытается войти в синхронизированный блок, он не сможет получить свою блокировку, пока поток-владелец не снимет ее. Это механизм Java, который гарантирует, что только в потоке в данный момент времени выполняется синхронизированный блок кода, обеспечивая атомарность всех действий в этом блоке.

Итак, вы используете блокировку для защиты синхронизированного блока, но что такое блокировка? Ответ заключается в том, что любой объект Java можно использовать в качестве блокировки, которая называется внутренней блокировкой. Теперь мы увидим несколько примеров таких блокировок при использовании синхронизации.

3. Синхронизированные методы

Синхронизированные методы защищены двумя типами блокировок:

  • Методы синхронизированного экземпляра : неявная блокировка — это this, который используется для вызова метода. Каждый экземпляр этого класса будет использовать свою собственную блокировку.
  • Синхронизированные статические методы : блокировка является объектом класса. Все экземпляры этого класса будут использовать одну и ту же блокировку.

Как обычно, это лучше видно на примере некоторого кода.

Во-первых, мы собираемся синхронизировать метод экземпляра. Это работает следующим образом: у нас есть один экземпляр класса, совместно используемый двумя потоками (Thread-1 и Thread-2), и другой экземпляр, используемый третьим потоком (Thread-3):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public class InstanceMethodExample {
    private static long startTime;
     
    public void start() throws InterruptedException {
        doSomeTask();
    }
     
    public synchronized void doSomeTask() throws InterruptedException {
        long currentTime = System.currentTimeMillis() - startTime;
        System.out.println(Thread.currentThread().getName() + " | Entering method. Current Time: " + currentTime + " ms");
        Thread.sleep(3000);
        System.out.println(Thread.currentThread().getName() + " | Exiting method");
    }
     
    public static void main(String[] args) {
        InstanceMethodExample instance1 = new InstanceMethodExample();
         
        Thread t1 = new Thread(new Worker(instance1), "Thread-1");
        Thread t2 = new Thread(new Worker(instance1), "Thread-2");
        Thread t3 = new Thread(new Worker(new InstanceMethodExample()), "Thread-3");
         
        startTime = System.currentTimeMillis();
        t1.start();
        t2.start();
        t3.start();
    }
     
    private static class Worker implements Runnable {
        private final InstanceMethodExample instance;
         
        public Worker(InstanceMethodExample instance) {
            this.instance = instance;
        }
         
        @Override
        public void run() {
            try {
                instance.start();
            } catch (InterruptedException e) {
                System.out.println(Thread.currentThread().getName() + " interrupted");
            }
        }
    }
}

Поскольку метод doSomeTask синхронизирован, можно ожидать, что только один поток выполнит свой код в данный момент времени. Но это неправильно, так как это метод экземпляра; разные экземпляры будут использовать разные блокировки, поскольку выходные данные демонстрируют:

1
2
3
4
5
6
Thread-1 | Entering method. Current Time: 0 ms
Thread-3 | Entering method. Current Time: 1 ms
Thread-3 | Exiting method
Thread-1 | Exiting method
Thread-2 | Entering method. Current Time: 3001 ms
Thread-2 | Exiting method

Поскольку Thread-1 и Thread-3 используют разные экземпляры (и, следовательно, разные блокировки), они оба входят в блок одновременно. С другой стороны, Thread-2 использует тот же экземпляр (и блокировку), что и Thread-1. Следовательно, он должен ждать, пока Thread-1 не снимет блокировку.

Теперь давайте изменим сигнатуру метода и используем статический метод. StaticMethodExample имеет тот же код, за исключением следующей строки:

1
public static synchronized void doSomeTask() throws InterruptedException {

Если мы выполним метод main, мы получим следующий вывод:

1
2
3
4
5
6
Thread-1 | Entering method. Current Time: 0 ms
Thread-1 | Exiting method
Thread-3 | Entering method. Current Time: 3001 ms
Thread-3 | Exiting method
Thread-2 | Entering method. Current Time: 6001 ms
Thread-2 | Exiting method

Поскольку синхронизированный метод является статическим, он защищен блокировкой объекта Class. Несмотря на использование разных экземпляров, все потоки должны будут получить одну и ту же блокировку. Следовательно, любому потоку придется ждать, пока предыдущий поток снимет блокировку.

4. Вернуться к примеру с кофейней

Сейчас я изменил пример Coffee Store, чтобы синхронизировать его методы. Результат выглядит следующим образом:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
public class SynchronizedCoffeeStore {
    private String lastClient;
    private int soldCoffees;
     
    private void someLongRunningProcess() throws InterruptedException {
        Thread.sleep(3000);
    }
     
    public synchronized void buyCoffee(String client) throws InterruptedException {
        someLongRunningProcess();
         
        lastClient = client;
        soldCoffees++;
        System.out.println(client + " bought some coffee");
    }
     
    public synchronized int countSoldCoffees() {return soldCoffees;}
     
    public synchronized String getLastClient() {return lastClient;}
}

Теперь, если мы выполним программу, мы не потеряем продажу:

1
2
3
4
5
6
7
Mike bought some coffee
Steve bought some coffee
Anna bought some coffee
John bought some coffee
Sold coffee: 4
Last client: John
Total time: 12005 ms

Отлично! Ну, это действительно так? Теперь время выполнения программы составляет 12 секунд. Вы наверняка заметили метод someLongRunningProcess, выполняемый во время каждой продажи. Это может быть операция, которая не имеет ничего общего с продажей, но поскольку мы синхронизировали весь метод, теперь каждый поток должен ждать его выполнения. Можем ли мы оставить этот код вне синхронизированного блока? Конечно! Посмотрите на синхронизированные блоки в следующем разделе.

5. Синхронизированные блоки

Предыдущий раздел показал нам, что нам не всегда нужно синхронизировать весь метод. Поскольку весь синхронизированный код вызывает сериализацию всех потоковых операций, мы должны минимизировать длину синхронизированного блока. В нашем примере «Coffee store» мы могли бы исключить длительный процесс. В примере этого раздела мы будем использовать синхронизированные блоки:

В SynchronizedBlockCoffeeStore мы модифицируем метод buyCoffee, чтобы исключить длительный процесс вне синхронизированного блока:

01
02
03
04
05
06
07
08
09
10
11
12
13
public void buyCoffee(String client) throws InterruptedException {
    someLongRunningProcess();
     
    synchronized(this) {
        lastClient = client;
        soldCoffees++;
        System.out.println(client + " bought some coffee");
    }
}
 
public synchronized int countSoldCoffees() {return soldCoffees;}
 
public synchronized String getLastClient() {return lastClient;}

В предыдущем синхронизированном блоке мы использовали «this» в качестве блокировки. Это та же самая блокировка, что и в синхронизированных методах экземпляра. Остерегайтесь использования другой блокировки, так как мы используем эту блокировку в других методах этого класса ( countSoldCoffees и getLastClient ).

Давайте посмотрим результат выполнения модифицированной программы:

1
2
3
4
5
6
7
Mike bought some coffee
John bought some coffee
Anna bought some coffee
Steve bought some coffee
Sold coffee: 4
Last client: Steve
Total time: 3015 ms

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

6. Использование частных замков

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

В PrivateLockExample у нас есть синхронизированный блок, защищенный закрытой блокировкой (myLock):

01
02
03
04
05
06
07
08
09
10
11
public class PrivateLockExample {
    private Object myLock = new Object();
     
    public void executeTask() throws InterruptedException {
        synchronized(myLock) {
            System.out.println("executeTask - Entering...");
            Thread.sleep(3000);
            System.out.println("executeTask - Exiting...");
        }
    }
}

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

Но теперь давайте представим, что кто-то хочет расширить этот класс, чтобы добавить свои собственные методы, и эти методы также необходимо синхронизировать, потому что они должны использовать одни и те же общие данные. Поскольку блокировка является закрытой в базовом классе, расширенный класс не будет иметь к ней доступа. Если расширенный класс синхронизирует свои методы, они будут защищены «this». Другими словами, он будет использовать другую блокировку.

MyPrivateLockExample расширяет предыдущий класс и добавляет собственный синхронизированный метод executeAnotherTask :

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
public class MyPrivateLockExample extends PrivateLockExample {
    public synchronized void executeAnotherTask() throws InterruptedException {
        System.out.println("executeAnotherTask - Entering...");
        Thread.sleep(3000);
        System.out.println("executeAnotherTask - Exiting...");
    }
     
    public static void main(String[] args) {
        MyPrivateLockExample privateLock = new MyPrivateLockExample();
         
        Thread t1 = new Thread(new Worker1(privateLock));
        Thread t2 = new Thread(new Worker2(privateLock));
         
        t1.start();
        t2.start();
    }
     
    private static class Worker1 implements Runnable {
        private final MyPrivateLockExample privateLock;
         
        public Worker1(MyPrivateLockExample privateLock) {
            this.privateLock = privateLock;
        }
         
        @Override
        public void run() {
            try {
                privateLock.executeTask();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
     
    private static class Worker2 implements Runnable {
        private final MyPrivateLockExample privateLock;
         
        public Worker2(MyPrivateLockExample privateLock) {
            this.privateLock = privateLock;
        }
         
        @Override
        public void run() {
            try {
                privateLock.executeAnotherTask();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

Программа использует два рабочих потока, которые будут выполнять executeTask и executeAnotherTask соответственно. Выходные данные показывают, как потоки чередуются, поскольку они не используют одну и ту же блокировку:

1
2
3
4
executeTask - Entering...
executeAnotherTask - Entering...
executeAnotherTask - Exiting...
executeTask - Exiting...

7. Заключение

Мы рассмотрели использование встроенных блокировок с помощью встроенного в Java механизма блокировки. Основная проблема здесь заключается в том, что синхронизированные блоки должны использовать общие данные; должны использовать один и тот же замок.

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

  • Вы можете найти исходный код на Github .