Вопросы по Java на собеседовании (6)

1. Понятия процесса и потока
2. Класс Thread и интерфейс Runnable
3. Взаимодействие потоков
4. Особенности использования методов wait и notify
5. Синхронизация потоков
6. Поток-демон
7. Приостановка потока
8. Использование метода join
9. Взаимная блокировка потоков
10. Методы interrupt, interrupted, isInterrupted
11. «Гонка» в многопоточном приложении
12. «Голодание» в многопоточном приложении
Пакет синхронизации java.util.concurrent
13. Объекты синхронизации пакета java.util.concurrent
14. Объект синхронизации Semaphore
15. Объект синхронизации CountDownLatch
16. Объект синхронизации CyclicBarrier
17. Объект синхронизации Exchanger
18. Объект синхронизации Phaser
19. Потокобезопасные коллекции
20. Атомарные классы пакета util.concurrent
21. Блокирующие и неблокирующие очереди
22. Блокировки пакета concurrent

Вопросы и ответы для собеседование по Java, Содержание.
Вопросы и ответы для собеседование по Java, часть 1.
Вопросы и ответы для собеседование по Java, часть 2.
Вопросы и ответы для собеседование по Java, часть 3.
Вопросы и ответы для собеседование по Java, часть 4.
Вопросы и ответы для собеседование по Java, часть 5.

1. Понятия процесса и потока

Процесс – это совокупность кода и данных, финкционирующих в виртуальном (адресном) пространство. Процессы изолированы друг от друга; прямой доступ к памяти чужого процесса невозможен. Взаимодействие между процессами осуществляется с помощью специальных программных технологий. Операционная система (ОС) для каждого процесса создает своё, так называемое «виртуальное адресное пространство» в памяти, к которому процесс имеет прямой доступ. Данное «виртуальное адресное пространство» принадлежит процессу, содержит только его данные и находится в полном его распоряжении.

Функционирование процесса организованно в виде набора потоков. Так, при старте java приложения ОС стартует виртуальную машину JVM, которая, в свою очередь, находит в приложении класс с методом main и «вызывет» его, запуская, таким образом, главный поток приложения. Приложение может ограничиться только одним главным потоком, либо программист может создать многопоточное приложение, в котором будут функционировать несколько потоков, стартованные из одного или разных классов приложения. В отличие от процессов, потоки могут иметь доступ к общим, разделяемым ресурсам приложения.

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

2. Класс Thread и интерфейс Runnable

Разработчики Java предоставили две возможности создания потоков : расширение (extends) класса Thread и реализацию (implements) интерфейса Runnable. Расширение класса Thread - это путь наследования методов и переменных класса родителя. В этом случае можно наследоваться только от одного родительского класса Thread. Данное ограничение Java можно преодолеть реализацией интерфейса Runnable, который содержит только один метод run().

Пример создания потока с использованием класса Thread :

class ThreadExample extends Thread
{
    @Override
    public void run()
    {
        for(int i = 0; i < 5; i++) {
            try {
                // Приостанавливаем поток
                sleep(1000);
                System.out.println("thread ...");
            } catch(InterruptedException e){}
        }
    }
}

...

public static void main(String[] args)
{
    // Создание потока
    ThreadExample example = new ThreadExample ();
    // Запуск потока
    example.start();
}

Пример создания потока с использованием интерфейса Runnable :

class RunnableExample implements Runnable
{
    Thread thread;
    RunnableExample()
    {
        thread = new Thread(this, "Runnable поток");
        thread.start();
    }
    @Override
    public void run()
    {
        for(int i = 0; i < 5; i++) {
            try {
                // Приостанавливаем поток
                Thread.sleep(1000);
                System.out.println("Runnable поток");
            } catch(InterruptedException e){}
        }
    }
}

...

public static void main(String[] args)
{
    // Создание потока
    new RunnableExample ();
}

Зачем нужно два вида реализации многопоточности; какую из них и когда использовать? Реализация интерфейса Runnable используется в случаях, когда класс уже наследует какой-либо родительский класс и не позволяет расширить класс Thread. К тому же, хорошим тоном программирования в java считается реализация интерфейсов. Это связано с тем, что в java может наследоваться только один родительский класс. Таким образом, унаследовав класс Thread, невозможно наследовать какой-либо другой класс. Расширение класса Thread целесообразно использовать в случае, когда необходимо переопределить другие методы класса, помимо метода run().

3. Взаимодействие потоков

Потоки в многопоточном приложении могут взаимодействовать друг с другом используя методы wait, notify базового класса Object и join класса Thread. Ключевым словом synchronized определяют методы или блоки кода, которые блокируются потоком при выполнении. Пример взаимодействия двух потоков, «спорящих» между собой, что первично, яйцо или курица, можно увидеть здесь.

К сожалению synchronized и методы wait, notify, join не обеспечивают полную синхронизацию, особенно, если речь идет о таких общих, разделяемых разными потоками, ресурсах, как коллекции. Более надежная синхронизация и взаимодействие потоков обеспечивается пакетом java.util.concurrent, включающего различные семаформы, потокобезопасные коллекции и т.д.

4. Особенности использования методов wait и notify

Методы wait и notify должны вызываться только из синхронизированного блока кода. Поток, который вызывает эти методы, должен войти в синхоронизированный блок (метод), иначе будет выдано исключение java.lang.IllegalMonitorStateException. Класс Store демонстрирует пример использования wait и notify в синхронизированных методах get() и put(). Полный код примера использования методов wait и notify можно увидеть здесь.

public class Store
{
    private int counter = 0;
    public synchronized void get()
    {
        while (counter < 1) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        counter--;
        System.out.println("-1 : товар забрали");
        System.out.println("товара на складе : " + counter);
        notify();
    }
    public synchronized void put() {
        while (counter >= 3) {
            try {
                wait();
            }catch (InterruptedException e) {} 
        }
        counter++;
        System.out.println("+1 : товар добавили");
        System.out.println("товара на складе : " + counter);
        notify();
    }
}

5. Синхронизация потоков

Синхронизация — это процесс, позволяющий параллельным потокам в одном приложении использовать общие, разделяемые между потоками, ресурсы. Несколько потоков при обращении к одним и тем же объектам приложения могут мешать друг другу. Для решения данной проблемы используется мьютекс, он же монитор, имеющий два состояния — объект занят и объект свободен. Монитор (мьютекс) относится к высокоуровневым механизмам взаимодействия и синхронизации процессов, обеспечивающий доступ к разделяемым ресурсам. Мьютекс встроен в класс Object и, следовательно, имеется у каждого объекта.

Синхронизация в Java реализуется использованием ключевого слова synchronized. Можно использовать synchronized в классах, определяя синхронизированные методы или блоки. Нельзя использовать synchronized в переменных или атрибутах в определении класса. Когда один из параллельно выполнящихся потоков начинает использовать общий для всех потоков объект, то он должен проверить мьютекс объекта. Если мьютекс свободен, то поток блокирует его (помечает как занятый), и приступает к использованию данного ресурса. После завершения работы, поток разблокирует мьютекс (помечает свободным). Если же поток обнаруживает, что объект заблокирован, то он «переходит» в режим ожидании (освобождения мьютекса). При освобождении мьютекса ожидающий поток тут же заблокирует его и приступит к работе. В случае, если несколько потоков ожидают освобождения мьютекса, то доступ к освобождаемому ресурсу сможет получить только один поток.

Таким образом, при обычной синхронизации потоков используется оператор synchronized для ограничения (блокирования) доступа к определенному методу, блоку кода или объекту без каких-либо условий. При использовании пакета java.util.concurrent, содержащего пять объектов синхронизации, можно накладывать определенные условия для синхронизации потоков.

Пример синхронизации :

class CommonObject {
    int counter = 0;
}

class CounterThread implements Runnable 
{
    CommonObject res;

    CounterThread(CommonObject res) {
        this.res = res;
    }
    @Override
    public void run()
    {
//      synchronized(res) {
            res.counter = 1;
            for (int i = 1; i < 5; i++){
                System.out.printf("'%s' - %d\n", 
                            Thread.currentThread().getName(), 
                            res.counter);
                res.counter++;
                try {
                    Thread.sleep(100);
                }
                catch(InterruptedException e){}
            }
//      }
    }
}
public class SynchronizedThread
{
    public static void main(String[] args) 
    {
        CommonObject commonObject = new CommonObject();
        for (int i = 1; i < 6; i++) {
            Thread t = new Thread(new CounterThread(commonObject));
            t.setName("Поток " + i);
            t.start();
        }
    }
}

В примере определен общий ресурс в виде класса CommonObject c целочисленным полем counter. Данный ресурс используется потоком CounterThread для увеличения в цикле значения counter на единицу. Вы можете запустить пример с синхронизацией общего ресурса (удалив комментарии) и без синхронизации. Результат будет разным. Для тех, кто не хочет напрягаться с примером, а желает увидеть сразу результат, то к описанию примера с выводом результатов следует перейти сюда.

6. Поток-демон

В Java процесс завершается вместе с завершением последнего его потока. Даже, если метод main() уже завершился, но еще выполняются порожденные им потоки, то система будет ждать их завершения. Однако, это правило не относится к особому виду потоков – демонам. Потоки-демоны принудительно завершаются вместе с последним обычным потоком процесса.

В следующем примере создается пять равнозначных потоков, которые в цикле с интервалом в 1 сек. распечатывают свое наименование. Количество циклов для всех потоков, за исключением 3-го (i == 2) равно 3. Для третьего потока количество циклов равно 6, и флаг определения потока-демона 'example.setDaemon(true)' закомментирован.

class ThreadExample extends Thread
{
    int counter;
    public ThreadExample(int counter){
        this.counter = counter;
    }
    @Override
    public void run()
    {
        for(int i = 0; i < counter; i++) {
            try {
                sleep(1000);
                System.out.println("" + this);
            } catch(InterruptedException e){}
        }
    }
}

public class DaemonExample 
{
    public static void main(String[] args)
    {
        ThreadExample example;
        for (int i = 0; i < 5; i++) {
            // Создание потока
            if (i != 2)
                example = new ThreadExample(3);
            else {
                example = new ThreadExample(6);
                // example.setDaemon(true);
            }
            // Запуск потока
            example.start();
        }
        
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
        }
        System.out.println("EXIT");
    }
}

В результате выполнения примера в консоль будут выведены следующие сообщения :


Thread[Thread-1,5,main]
Thread[Thread-4,5,main]
Thread[Thread-3,5,main]
Thread[Thread-0,5,main]
Thread[Thread-2,5,main]
EXIT
Thread[Thread-4,5,main]
Thread[Thread-3,5,main]
Thread[Thread-1,5,main]
Thread[Thread-0,5,main]
Thread[Thread-2,5,main]

Thread[Thread-4,5,main]
Thread[Thread-1,5,main]
Thread[Thread-0,5,main]
Thread[Thread-3,5,main]
Thread[Thread-2,5,main]

Thread[Thread-2,5,main]
Thread[Thread-2,5,main]
Thread[Thread-2,5,main]
 

Главный поток завершает свою работу сразу же после старта всех потоков. Каждый поток выводит по 3 сообщения. Третий поток (Thread-2) выводит 6 сообщений. Но, если его определить как демон, т.е. снять комментарий со строки example.setDaemon(true), то он завершится вместе с последним потоком; т.е. успеет вывести в консоль не более трех сообщений. А при определенных «задержках» может не успеть распечатать даже 3-е сообщение.

7. Приостановка потока

1. Cтатический метод sleep() класса Thread позволяет приостанавливать выполнение потока, в котором он был вызван. После выполнения метода sleep() система в течение заданного интервала времени перестает выделять потоку процессорное время, распределяя его между другими потоками.

2. Метод wait() без параметров переводит поток в режим ожидания на неопределенное время. Вывести поток из этого состояния можно только командой notify. Пример взаимодействия между потоками в Java с использованием wait и notify представлен здесь.

3. Приостановить поток можно с использованием метода join, который позволяет «пропустить вперед» на выполнение другой поток.

4. Полностью остановить выполнение всех потоков можно с использованием System.exit ().

8. Использование метода join

Метод join позволяет текущему потоку «приостановить» выполнение своего кода и «пропустить» вперед другой поток.

В следующем примере в методе main, выполняющего роль главного потока приложения, создается и стартует отдельный (дочерний) поток объекта ThreadClass. При выполнении метода run поток объекта TestClass приостанавливает свое выполнение на 5 сек. В это время основной поток вызывает метод join() и пропускает вперед дочерний поток. Несмотря на то, что дочерний поток перешел в режим ожидания, основной поток продолжает ожидать его завершения. После завершения функционирования дочернего потока сразу же завершает свою работу и основной поток.

import java.util.Date;

class ThreadClass implements Runnable
{
    @Override
    public void run() {
        System.out.println("ThreadClass.run() " + new Date());
        try {
		    System.out.println();
            Thread.sleep(5000);
        } catch (InterruptedException e) {
        }
        System.out.println("afterSleep " + new Date());
    }
}
//-----------------------------------------------
public class JoinExample 
{
    public static void main(String[] args) {
        Thread thread = new Thread(new ThreadClass());
        thread.start();
        try {
            thread.join();
        } catch (InterruptedException e) { }
        System.out.println("END: " + new Date());
    }
}

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


ThreadClass.run() Wed Jul 25 15:32:58 MSK 2018

afterSleep Wed Jul 25 15:33:03 MSK 2018
END: Wed Jul 25 15:33:03 MSK 2018
 

9. Взаимная блокировка потоков

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

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

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

Как избежать deadlock'а? Следует в многопоточном приложении особенно внимательно работать с синхронизируемыми (synchronized) участками кода, когда требуется выполнить обращение к внешнему объекту. Сначала следует подготовить все необходимые данные, после чего входить в synchronized код.

10. Методы interrupt, interrupted, isInterrupted

Методы interrupt, interrupted, isInterrupted связаны с функцией прерывания потока :

  • interrupt() — метод устанавливает флаг прерывания потока;
  • bool Thread.interrupted() — статический метод, позволяющий получить состояние флага и сбросить его;
  • bool isInterrupted() — метод получения состояния флага прерывания.

В случае, когда поток находится в режиме ожидания, вызванное одним из методов wait, sleep, join, и в это время устанавливается флаг прерывания потока, то будет вызвано исключение InterruptedException.

11. Понятие «гонки» в многопоточном приложении

Состояние гонки может наступить в многопоточном приложении, когда два и более потоков одновременно обращаются к одному общему объекту. На рисунке представлен пример гонки в программе. Из примера видно, что при отсутствии синхронизации между потоками возможны проблемные ситуации, когда потоки не «видят» изменений, сделанных друг другом. В этом случае первый поток увеличил значение переменной i, равное 5, на x, а второй – на y. Но в итоге переменная i оказалась равной y+5, а не x+y+5. Таким образом, в программе возникло состояние гонки.

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

12. «Голодание» в многопоточном приложении

В Java имеется планировщик потоков (Thread Scheduler), который контролирует все запущенные потоки и решает, какие потоки должны быть запущены и какая строка кода должна выполняться. Решение основывается на приоритете потока. Поэтому потоки с меньшим приоритетом получают меньше процессорного времени по сравнению с потоками с бо́льшим приоритет. Данное разумное решение может стать причиной проблем при злоупотреблении. То есть, если бо́льшую часть времени исполняются потоки с высоким приоритетом, то низкоприоритетные потоки начинают «голодать», поскольку не получают достаточно времени для того, чтобы выполнить свою работу должным образом.

Чтобы избежать «голодания» рекомендуется использовать приоритет потока только тогда, когда для этого имеются веские основания.

Неочевидный пример «голодания» потока связан с методом finalize(), предоставляющим возможность выполнить код перед тем, как объект будет удалён сборщиком мусора. Однако приоритет финализирующего потока невысокий. Следовательно, возникают предпосылки для потокового голодания, когда методы finalize() объекта тратят слишком много времени (большая задержка) по сравнению с остальным кодом.

Другой пример с «голоданием» может возникнуть от того, что не был определен порядок прохождения потоком блока synchronized. Когда несколько параллельных потоков должны выполнить некоторый код, оформленный блоком synchronized, может получиться так, что одним потокам придётся ждать дольше других, прежде чем войти в блок. Теоретически они могут вообще туда не попасть.

13. Объекты синхронизации пакета java.util.concurrent

Как было отмечено выше, при обычной синхронизации, когда какой-либо поток начинает использовать общий для всех потоков ресурс, он проверяет мьютекс этого объекта. Если мьютекс свободен, то поток блокирует его, помечая как занятый, и приступает к использованию данного ресурса. После завершения работы поток разблокирует мьютекс (помечает свободным). Если же поток обнаруживает, что объект заблокирован, то он «переходит» в режим ожидания освобождения мьютекса. При освобождении мьютекса ожидающий поток тут же заблокирует его и приступит к работе. Но иногда несколько потоков ожидают освобождения мьютекса? Как быть в этом случае : кому и как следует первому предоставить доступ, ведь очередей при обычной синхронизации нет? Эту проблему решают объекты синхронизации пакета java.util.concurrent.

Пакета java.util.concurrent включает следующие объекты синхронизации :

  • Semaphore — объект синхронизации, ограничивающий количество потоков, которые могут «войти» в заданный участок кода;
  • CountDownLatch — объект синхронизации, разрешающий вход в заданный участок кода при выполнении определенных условий;
  • CyclicBarrier — объект синхронизации типа «барьер», блокирующий выполнение определенного кода для заданного количества потоков;
  • Exchanger — объект синхронизации, позволяющий провести обмен данными между двумя потоками;
  • Phaser — объект синхронизации типа «барьер»; в отличие от CyclicBarrier, предоставляет больше гибкости.

14. Объект синхронизации Semaphore

Semaphore (семафор) — объект синхронизации пакета java.util.concurrent, ограничивающий одновременный доступ к общему ресурсу нескольким потокам с помощью счетчика. При инициализации объекта определяется количество потоков, которым будет предоставлен доступ. При запросе разрешения семафор уменьшает значение счетчика. При нулевом значении счетчика доступ к объекту запрещается. При освобождении ресурса значение счетчика семафора увеличивается и ожидающий поток сразу же получает доступ (семафор снова уменьшает счетчик).

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

15. Объект синхронизации CountDownLatch

Объект синхронизации потоков CountDownLatch иначе называют «защелкой с обратным отсчетом». Также, как и семафор CountDownLatch работает со счетчиком, обнуление которого снимает самоблокировки выполняемых потоков.

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

Полное описание использования CountDownLatch с примером представлено здесь.

16. Объект синхронизации CyclicBarrier

Объект CyclicBarrier представляет собой барьерную синхронизацию, как правило используемую в распределённых вычислениях. Особенно эффективно использование барьеров при циклических расчетах. При барьерной синхронизации алгоритм расчета делят на несколько потоков. С помощью барьера организуют точку сбора частичных результатов вычислений, в которой подводится итог этапа вычислений.

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

Класс CyclicBarrier имеет 2 конструктора. В первом конструкторе задается количество потоков, которые должны достигнуть барьера. Во втором конструкторе дополнительно задается реализующий интерфейс Runnable класс, который должен быть запущен после прихода к барьеру всех потоков.

Полное описание использования CountDownLatch с примером представлено здесь.

17. Объект синхронизации Exchanger

Класс Exchanger (обменник) предназначен для упрощения процесса обмена данными между двумя потоками исполнения. Принцип действия Exchanger связан с ожиданием того, что два отдельных потока должны вызвать его метод exchange. Как только это произойдет, то Exchanger произведет обмен данными, предоставляемыми обоими потоками. Обменник является обобщенным классом, он параметризируется типом объекта передачи Exchanger<V>.

Обменник поддерживает передачу NULL значения, что дает возможность использовать его для передачи объекта в одну сторону или места синхронизации двух потоков.

Полное описание использования Exchanger с примером представлено здесь.

18. Объект синхронизации Phaser

Объект синхронизации Phaser (фазировщик), также, как и CyclicBarrier, является реализацией объекта синхронизации типа «Барьер». Но, в отличии от CyclicBarrier, Phaser предоставляет больше гибкости.

Чтобы понять принцип действия Phaser лучше всего рассмотреть простой пример перевозки пассажиров. Так, несколько потоков реализуют перевозку пассажиров городским транспортом. Пассажиры ожидают транспорт на разных остановках. Транспорт, останавливаясь на остановках, одних пассажиров «забирает», других «высаживает».Объект синхронизации Phaser, исполняющий роль транспорта, играет главную роль, а другие потоки вступают в работу при определенном состоянии Phaser. Таким образом, класс Phaser позволяет определить объект синхронизации, ожидающий завершения определенной фазы. После этого он переходит к следующей фазе и снова ожидает ее завершения.

Особенности Phaser :

  • Phaser может иметь несколько фаз (барьеров). Если количество фаз равно 1, то переходим к CyclicBarrier (осталось только все исполнительные потоки остановить у барьера);
  • каждая фаза (цикл синхронизации) имеет свой номер.
  • количество участников-потоков для каждой фазы жестко не задано и может меняться. Исполнительный поток может регистрироваться в качестве участника и отменять свое участие;
  • Исполнительный поток не обязан ожидать, пока все остальные участники соберутся у барьера. Достаточно только сообщить о своем прибытии.

Полное описание использования Phaser с примером представлено здесь.

19. Потокобезопасные коллекции

Обычные коллекции относится к тем типам объектов, которые невозможно полностью синхронизировать. То есть, если один из потоков перебирает элементы синхронизированной коллекции, то другой поток может внести изменения в данную коллекцию, что приведет к возникновению исключения. В JDK 1.2 появились синхронизируемые методы обрамления в Java Collections Framework (JCF) :

  • Collections.synchronizedList(List)
  • Collections.synchronizedSet (Set )
  • Collections.synchronizedMap (Map )

Это методы обрамления для получения синхронизированной (потокобезопасной) коллекции. Однако данный упрощённый подход к синхронизации с использованием методов обрамлений имеет существенный недостаток, связанный с препятствованием масштабируемости : с коллекцией одновременно может работать только один поток. Кроме этого, недостаточно обеспечить настоящую потокобезопасность коллекции, если множество распространённых составных операций всё ещё требуют дополнительной синхронизации. Так простые операции типа get (интерфейс List) и put (интерфейс Map) могут выполняться безопасно без дополнительной синхронизации. Но существует несколько распространённых операций, связанных с итератором Iterator<E> и методом add (put-if-absent), которые всё же нуждаются во внешней синхронизации.

Использование методов обрамления для получения синхронизированных коллекций представляет скрытую угрозу : разработчики полагают, что, если коллекции синхронизированы, то они полностью потокобезопасны, и пренебрегают должной синхронизацией составных операций. Такие программы могут нормально функционировать при лёгкой нагрузке, но при серьёзной нагрузке они могут вызывать NullPointerException или ConcurrentModificationException.

Один из подходов к улучшению масштабируемости коллекции при сохранении потокобезопасности состоит в использовании потокобезопасных коллекций. Пакет java.util.concurrent включает несколько потокобезопасных коллекций :

ConcurrentHashMap коллекция типа HashMap, реализующая интерфейс ConcurrentMap;
CopyOnWriteArrayList коллекция типа ArrayList с алгоритмом CopyOnWrite;
CopyOnWriteArraySet реализация интерфейса Set, использующая за основу CopyOnWriteArrayList;
• ConcurrentNavigableMap расширяет интерфейс NavigableMap;
• ConcurrentSkipListMap аналог коллекции TreeMap с сортировкой данных по ключу и с поддержкой многопоточности;
• ConcurrentSkipListSet реализация интерфейса Set, выполненная на основе класса ConcurrentSkipListMap.

К полному описанию и примерам использования первых трех коллекций Вы можете перейти по соответствующей ссылке.

20. Атомарные классы пакета util.concurrent

Для выполнения атомарных операций пакет java.util.concurrent.atomic содержит девять специальных классов. Операция называется атомарной, если её можно безопасно выполнять при параллельных вычислениях в нескольких потоках, не используя при этом ни блокировок, ни обычную синхронизацию (synchronized).

Если рассмотреть наипростейшие операции инкремента (i++, ++i) и декремента (i--, --i) целочисленных значений с точки зрения программиста, то операции выглядят наглядно и компактно. Но, с точки зрения JVM (виртуальной машины Java), данные операции не являются атомарными, поскольку требуют выполнения нескольких действительно атомарных операции: чтение текущего значения, выполнение инкремента/декремента и запись полученного результата. При работе в многопоточной среде операции инкремента и декремента могут стать источником ошибок, поэтому эти простые с виду операции требуют использование синхронизации и блокировки. Но блокировки содержат массу недостатков, и для простейших операций инкремента/декремента являются тяжеловесными. Выполнение блокировки связано со средствами операционной системы и несёт в себе опасность приостановки с невозможностью дальнейшего возобновления работоспособности потока, а также опасность взаимоблокировки или инверсии приоритетов (priority inversion). Кроме этого, появляются дополнительные расходы на переключение потоков.

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

Представим, что атомарный класс включает переменную value, объявленную с модификатором volatile. Т.е. её значение могут изменить разные потоки одновременно. Модификатор volatile гарантирует выполнение отношения happens-before, что ведет к тому, что измененное значение этой переменной увидят все потоки. Кроме этого, атомарный класс включает метод compareAndSet, представляющий механизм оптимистичной блокировки и позволяющий изменить значение value только в том случае, если оно равно ожидаемому значению (т.е. current). Если значение value было изменено в другом потоке, то оно не будет равно ожидаемому значению. Следовательно, метод compareAndSet вернет значение false.

Специалисты, знакомые с набором команд процессоров, знают, что ряд архитектур имеют инструкцию Compare-And-Swap (CAS), которая является реализацией этой самой операции. Таким образом, на уровне инструкций процессора имеется поддержка необходимой атомарной операции.

Подробное описание атомарных класс с примерами представлено здесь.

21. Блокирующие и неблокирующие очереди

Использование очередей пакета java.util.concurrent может стать решением проблем «взаимных блокировок» и «голодания». Пакет concurrent включает блокирующие и неблокирующие очереди.

Блокирующие очереди пакета java.util.concurrent используются в тех случаях, когда нужно выполнить, либо проверить выполнение, каких-либо условий для продолжения потоками своей работы. Блокирующие очереди могут реализовывать интерфейсы BlockingQueue, BlockingDeque, TransferQueue. В пакете java.util.concurrent имеются следующие реализации блокирующих очередей :

  • ArrayBlockingQueue — реализующая классический кольцевой буфер очередь;
  • LinkedBlockingQueue — односторонняя очередь на связанных узлах;
  • LinkedBlockingDeque — двунаправленная очередь на связанных узлах;
  • SynchronousQueue — блокирующую очередь без емкости (операция добавления одного потока находится в ожидании соответствующей операции удаления в другом потоке);
  • LinkedTransferQueue — реализация очереди на основе интерфейса TransferQueue;
  • DelayQueue — неограниченная блокирующая очередь, реализующая интерфейс Delayed;
  • PriorityBlockingQueue — реализация очереди на основе интерфейса PriorityQueue.

Полное описание блокирующих очередей с примерами можно увидеть здесь.

Для формирования неблокирующих очередей с поддержкой многопоточности пакет java.util.concurrent включает 2 класса :

  • ConcurrentLinkedQueue — неограниченная по емкости и ориентированная на многопоточное исполнение очередь;
  • ConcurrentLinkedDeque — неограниченная по емкости двухсторонняя очередь, ориентированная на многопоточное исполнение.

Класс ConcurrentLinkedQueue реализует интерфейс однонаправленной очереди Queue : принцип FIFO — «первым прибыл, первым убыл». Класс ConcurrentLinkedDeque реализует интерфейс очереди Deque, который расширяет (extends) свойства интерфейса Queue и обеспечивает двусторонний доступ к элементам очереди. Интерфейс Deque включает методы доступа к элементам в обоих концах двухсторонней очереди. Эти методы обеспечивают вставку, удаление и извлечения элемента.

Полное описание неблокирующих очередей с примерами можно увидеть здесь.

22. Блокировки пакета concurrent

Обычная синхронизация не совершена и имеет некоторые функциональные ограничения :

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

Пакет java.util.concurrent.locks включает классы, которые можно использовать для блокировки ресурсов с определенными условиями. Эти классы существенно отличаются от встроенной синхронизации и мониторов, и разрешают намного большую гибкость в использовании блокировок без условий и с условием. Пакет concurrent включает несколько классов-блокировок.

ReentrantLock реализует интерфейс Lock и имеет дополнительные возможности, связанные с опросом о блокировании (lock polling), ожиданием блокирования в течение определенного времени и прерыванием ожидания блокировки. Кроме того, ReentrantLock предлагает гораздо более высокую эффективность функционирования в условиях жесткой состязательности. Другими словами, когда несколько потоков пытаются получить доступ к совместно используемому ресурсу, виртуальной машине JVM потребуется меньше времени на установление очередности потоков и больше времени на ее выполнение. Пример использования блокировки ReentrantLock.

Условие Condition в сочетании с блокировкой позволяет заменить методы монитора/мьютекса (wait, notify и notifyAll) объектом, управляющим ожиданием событий. Блокировка заменяет использование synchronized, а Condition — объектные методы монитора. Условие Condition, иначе именуемое как очередь условия, предоставляет средство управления для одного потока, чтобы приостановить его выполнение до тех пор, пока он не будет уведомлен другим потоком. Объект Condition связывают с блокировкой. Пример использования блокировки ReentrantLock c объектом условия Condition.

Вопросы и ответы для собеседование по Java, Содержание.
Вопросы и ответы для собеседование по Java, часть 1.
Вопросы и ответы для собеседование по Java, часть 2.
Вопросы и ответы для собеседование по Java, часть 3.
Вопросы и ответы для собеседование по Java, часть 4.
Вопросы и ответы для собеседование по Java, часть 5.

  Рейтинг@Mail.ru