Статьи

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

1. Введение

Во многих случаях использование неявной блокировки достаточно. В других случаях нам понадобятся более сложные функциональные возможности. В таких случаях пакет java.util.concurrent.locks предоставляет нам объекты блокировки. Когда речь идет о синхронизации памяти, внутренний механизм этих блокировок такой же, как и при неявных блокировках. Разница в том, что явные блокировки предлагают дополнительные функции.

Основные преимущества или улучшения по сравнению с неявной синхронизацией:

  • Разделение замков чтением или записью.
  • Некоторые блокировки разрешают одновременный доступ к общему ресурсу ( ReadWriteLock ).
  • Различные способы получения блокировки:
    • Блокировка: блокировка ()
    • Неблокирующая: tryLock ()
    • Прерываемый: lockInterruptibly ()

2. Классификация объектов блокировки

Объекты блокировки реализуют один из следующих двух интерфейсов:

  • Блокировка : определяет основные функции, которые должен реализовать объект блокировки. По сути, это означает приобретение и снятие блокировки. В отличие от неявных блокировок, эта позволяет получить блокировку неблокирующим или прерываемым способом (в дополнение к блокирующему способу). Основные реализации:
    • ReentrantLock
    • ReadLock (используется ReentrantReadWriteLock)
    • WriteLock (используется ReentrantReadWriteLock)
  • ReadWriteLock : он сохраняет пару блокировок, одну для операций только для чтения и другую для записи. Блокировка чтения может быть получена одновременно различными потоками считывателя (если ресурс еще не получен блокировкой записи), в то время как блокировка записи является исключительной. Таким образом, мы можем иметь несколько потоков, считывающих ресурс одновременно, если нет операции записи. Основные реализации:
    • ReentrantReadWriteLock

Следующая диаграмма классов показывает отношение между различными классами блокировки:

explicitLocking

3. ReentrantLock

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

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

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
public class NoLocking {
    public static void main(String[] args) {
        Worker worker = new Worker();
         
        Thread t1 = new Thread(worker, "Thread-1");
        Thread t2 = new Thread(worker, "Thread-2");
        t1.start();
        t2.start();
    }
     
    private static class Worker implements Runnable {
        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName() + " - 1");
            System.out.println(Thread.currentThread().getName() + " - 2");
            System.out.println(Thread.currentThread().getName() + " - 3");
        }
    }
}

Поскольку приведенный выше код не синхронизирован, потоки будут чередоваться. Давайте посмотрим на вывод:

1
2
3
4
5
6
Thread-2 - 1
Thread-1 - 1
Thread-1 - 2
Thread-1 - 3
Thread-2 - 2
Thread-2 - 3

Теперь мы добавим блокировку повторного входа для сериализации доступа к методу run:

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
public class ReentrantLockExample {
    public static void main(String[] args) {
        Worker worker = new Worker();
         
        Thread t1 = new Thread(worker, "Thread-1");
        Thread t2 = new Thread(worker, "Thread-2");
        t1.start();
        t2.start();
    }
     
    private static class Worker implements Runnable {
        private final ReentrantLock lock = new ReentrantLock();
         
        @Override
        public void run() {
            lock.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " - 1");
                System.out.println(Thread.currentThread().getName() + " - 2");
                System.out.println(Thread.currentThread().getName() + " - 3");
            } finally {
                lock.unlock();
            }
        }
    }
}

Приведенный выше код будет безопасно выполняться без чередования потоков. Вы можете понять, что мы могли бы использовать синхронизированный блок, и эффект был бы таким же. Возникает вопрос: какие преимущества дает нам блокировка повторного входа?

Основные преимущества использования этого типа замка описаны ниже:

  • Дополнительные способы получения блокировки предоставляются путем реализации интерфейса блокировки:
    • lockInterruptibly : текущий поток будет пытаться получить блокировку и станет заблокированным, если другой поток владеет блокировкой, как в методе lock (). Однако, если другой поток прерывает текущий поток, получение будет отменено.
    • tryLock : он попытается получить блокировку и немедленно вернуться, независимо от состояния блокировки. Это предотвратит блокировку текущего потока, если блокировка уже получена другим потоком. Вы также можете установить время ожидания текущего потока перед возвратом (мы увидим пример этого).
    • newCondition : позволяет потоку, которому принадлежит блокировка, ожидать указанное условие.
  • Дополнительные методы, предоставляемые классом ReentrantLock , в основном для мониторинга или тестирования. Например, методы getHoldCount или isHeldByCurrentThread .

Давайте рассмотрим пример использования tryLock перед тем, как перейти к следующему классу блокировки.

3.1 Попытка получения блокировки

В следующем примере у нас есть два потока, пытающихся получить одинаковые две блокировки.

Один поток получает lock2, а затем блокирует попытки получить lock1 :

01
02
03
04
05
06
07
08
09
10
11
12
public void lockBlocking() {
    LOGGER.info("{}|Trying to acquire lock2...", Thread.currentThread().getName());
    lock2.lock();
    try {
        LOGGER.info("{}|Lock2 acquired. Trying to acquire lock1...", Thread.currentThread().getName());
        lock1.lock();
        LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName());
    } finally {
        lock1.unlock();
        lock2.unlock();
    }
}

Другой поток получает lock1, а затем пытается получить lock2 .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
public void lockWithTry() {
    LOGGER.info("{}|Trying to acquire lock1...", Thread.currentThread().getName());
    lock1.lock();
    try {
        LOGGER.info("{}|Lock1 acquired. Trying to acquire lock2...", Thread.currentThread().getName());
        boolean acquired = lock2.tryLock(4, TimeUnit.SECONDS);
        if (acquired) {
            try {
                LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName());
            } finally {
                lock2.unlock();
            }
        }
        else {
            LOGGER.info("{}|Failed acquiring lock2. Releasing lock1", Thread.currentThread().getName());
        }
    } catch (InterruptedException e) {
        //handle interrupted exception
    } finally {
        lock1.unlock();
    }
}

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

Давайте посмотрим полный пример:

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
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class TryLock {
    private static final Logger LOGGER = LoggerFactory.getLogger(TryLock.class);
    private final ReentrantLock lock1 = new ReentrantLock();
    private final ReentrantLock lock2 = new ReentrantLock();
     
    public static void main(String[] args) {
        TryLock app = new TryLock();
        Thread t1 = new Thread(new Worker1(app), "Thread-1");
        Thread t2 = new Thread(new Worker2(app), "Thread-2");
        t1.start();
        t2.start();
    }
     
    public void lockWithTry() {
        LOGGER.info("{}|Trying to acquire lock1...", Thread.currentThread().getName());
        lock1.lock();
        try {
            LOGGER.info("{}|Lock1 acquired. Trying to acquire lock2...", Thread.currentThread().getName());
            boolean acquired = lock2.tryLock(4, TimeUnit.SECONDS);
            if (acquired) {
                try {
                    LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName());
                } finally {
                    lock2.unlock();
                }
            }
            else {
                LOGGER.info("{}|Failed acquiring lock2. Releasing lock1", Thread.currentThread().getName());
            }
        } catch (InterruptedException e) {
            //handle interrupted exception
        } finally {
            lock1.unlock();
        }
    }
     
    public void lockBlocking() {
        LOGGER.info("{}|Trying to acquire lock2...", Thread.currentThread().getName());
        lock2.lock();
        try {
            LOGGER.info("{}|Lock2 acquired. Trying to acquire lock1...", Thread.currentThread().getName());
            lock1.lock();
            LOGGER.info("{}|Both locks acquired", Thread.currentThread().getName());
        } finally {
            lock1.unlock();
            lock2.unlock();
        }
    }
     
    private static class Worker1 implements Runnable {
        private final TryLock app;
         
        public Worker1(TryLock app) {
            this.app = app;
        }
         
        @Override
        public void run() {
            app.lockWithTry();
        }
    }
     
    private static class Worker2 implements Runnable {
        private final TryLock app;
         
        public Worker2(TryLock app) {
            this.app = app;
        }
         
        @Override
        public void run() {
            app.lockBlocking();
        }
    }
}

Если мы выполним код, это приведет к следующему выводу:

1
2
3
4
5
6
13:06:38,654|Thread-2|Trying to acquire lock2...
13:06:38,654|Thread-1|Trying to acquire lock1...
13:06:38,655|Thread-2|Lock2 acquired. Trying to acquire lock1...
13:06:38,655|Thread-1|Lock1 acquired. Trying to acquire lock2...
13:06:42,658|Thread-1|Failed acquiring lock2. Releasing lock1
13:06:42,658|Thread-2|Both locks acquired

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

4. ReentrantReadWriteLock

Этот тип блокировки поддерживает пару внутренних блокировок ( ReadLock и WriteLock ). Как объяснено в интерфейсе, эта блокировка позволяет нескольким потокам одновременно считывать данные с ресурса. Это особенно удобно при наличии ресурса, который часто читает, но мало пишет. Пока нет потока, который нужно писать, к ресурсу будет одновременно обращаться.

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

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
52
public class ReadWriteLockExample {
    private static final Logger LOGGER = LoggerFactory.getLogger(ReadWriteLockExample.class);
    final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private Data data = new Data("default value");
     
    public static void main(String[] args) {
        ReadWriteLockExample example = new ReadWriteLockExample();
        example.start();
    }
     
    private void start() {
        ExecutorService service = Executors.newFixedThreadPool(4);
        for (int i=0; i<3; i++) service.execute(new ReadWorker());
        service.execute(new WriteWorker());
        service.shutdown();
    }
     
    class ReadWorker implements Runnable {
        @Override
        public void run() {
            for (int i = 0; i < 2; i++) {
                readWriteLock.readLock().lock();
                try {
                    LOGGER.info("{}|Read lock acquired", Thread.currentThread().getName());
                    Thread.sleep(3000);
                    LOGGER.info("{}|Reading data: {}", Thread.currentThread().getName(), data.getValue());
                } catch (InterruptedException e) {
                    //handle interrupted
                } finally {
                    readWriteLock.readLock().unlock();
                }
            }
        }
    }
     
    class WriteWorker implements Runnable {
        @Override
        public void run() {
            readWriteLock.writeLock().lock();
            try {
                LOGGER.info("{}|Write lock acquired", Thread.currentThread().getName());
                Thread.sleep(3000);
                data.setValue("changed value");
                LOGGER.info("{}|Writing data: changed value", Thread.currentThread().getName());
            } catch (InterruptedException e) {
                //handle interrupted
            } finally {
                readWriteLock.writeLock().unlock();
            }
        }
    }
}

Вывод консоли показывает результат:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
11:55:01,632|pool-1-thread-1|Read lock acquired
11:55:01,632|pool-1-thread-2|Read lock acquired
11:55:01,632|pool-1-thread-3|Read lock acquired
11:55:04,633|pool-1-thread-3|Reading data: default value
11:55:04,633|pool-1-thread-1|Reading data: default value
11:55:04,633|pool-1-thread-2|Reading data: default value
11:55:04,634|pool-1-thread-4|Write lock acquired
11:55:07,634|pool-1-thread-4|Writing data: changed value
11:55:07,634|pool-1-thread-3|Read lock acquired
11:55:07,635|pool-1-thread-1|Read lock acquired
11:55:07,635|pool-1-thread-2|Read lock acquired
11:55:10,636|pool-1-thread-3|Reading data: changed value
11:55:10,636|pool-1-thread-1|Reading data: changed value
11:55:10,636|pool-1-thread-2|Reading data: changed value

Как вы можете видеть, когда поток записи получает блокировку записи (thread-4), никакие другие потоки не могут получить доступ к ресурсу.

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

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

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