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

Вопросы о параллельности Java (+ ответы)

1. Вступление

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

Q1. В чем разница между процессом и потоком?

И процессы, и потоки являются единицами параллелизма, но они имеют принципиальное отличие: процессы не разделяют общую память, а потоки - общие.

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

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

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

Q2. Как создать экземпляр потока и запустить его?

Чтобы создать экземпляр потока, у вас есть два варианта. Сначала передайте экземплярRunnable его конструктору и вызовитеstart(). Runnable - это функциональный интерфейс, поэтому его можно передать как лямбда-выражение:

Thread thread1 = new Thread(() ->
  System.out.println("Hello World from Runnable!"));
thread1.start();

Поток также реализуетRunnable, поэтому другой способ запуска потока - создать анонимный подкласс, переопределить его методrun() и затем вызватьstart():

Thread thread2 = new Thread() {
    @Override
    public void run() {
        System.out.println("Hello World from subclass!");
    }
};
thread2.start();

Q3. Опишите различные состояния потока и когда происходят переходы между состояниями.

СостояниеThread можно проверить с помощью методаThread.getState(). Различные состояния aThread описаны в перечисленииThread.State. Они есть:

  • NEW - новый экземплярThread, который еще не был запущен черезThread.start()

  • RUNNABLE - работающий поток. Он называется runnable, потому что в любой момент времени он может быть запущен или ожидать следующего кванта времени от планировщика потока. ПотокNEW входит в состояниеRUNNABLE, когда вы вызываете на немThread.start()

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

  • WAITING - поток входит в это состояние, если он ожидает, пока другой поток выполнит определенное действие. Например, поток входит в это состояние при вызове методаObject.wait() на мониторе, который он держит, или методаThread.join() на другом потоке.

  • TIMED_WAITING - то же, что и выше, но поток входит в это состояние после вызова синхронизированных версийThread.sleep(),Object.wait(),Thread.join() и некоторых других методов

  • TERMINATED - поток завершил выполнение своего методаRunnable.run() и завершился

Q4. В чем разница между Runnable и Callable интерфейсами? Как они используются?

ИнтерфейсRunnable имеет единственный методrun. Он представляет собой единицу вычисления, которая должна выполняться в отдельном потоке. ИнтерфейсRunnable не позволяет этому методу возвращать значение или генерировать непроверенные исключения.

ИнтерфейсCallable имеет единственный методcall и представляет задачу, имеющую значение. Вот почему методcall возвращает значение. Он также может генерировать исключения. Callable обычно используется в экземплярахExecutorService для запуска асинхронной задачи и последующего вызова возвращенного экземпляраFuture для получения его значения.

Q5. Что такое демоновая нить, каковы ее варианты использования? Как создать демоническую нить?

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

Чтобы запустить поток как демон, вы должны использовать методsetDaemon() перед вызовомstart():

Thread daemon = new Thread(()
  -> System.out.println("Hello from daemon!"));
daemon.setDaemon(true);
daemon.start();

Любопытно, что если вы запустите это как часть методаmain(), сообщение может не быть напечатано. Это могло произойти, если потокmain() завершился бы до того, как демон дойдет до точки печати сообщения. Как правило, вам не следует выполнять какие-либо операции ввода-вывода в потоках демонов, поскольку они даже не смогут выполнять свои блокиfinally и закрывать ресурсы в случае их отказа.

Q6. Что такое флаг прерывания потока? Как вы можете установить и проверить это? Как это относится к исключению прерывания?

Флаг прерывания или статус прерывания - это внутренний флагThread, который устанавливается, когда поток прерывается. Чтобы установить его, просто вызовитеthread.interrupt() в объекте потока.

Если поток в настоящее время находится внутри одного из методов, которые генерируютInterruptedException (wait,join,sleep и т. Д.), То этот метод немедленно генерирует InterruptedException. Поток может обрабатывать это исключение в соответствии со своей логикой.

Если поток не находится внутри такого метода и вызываетсяthread.interrupt(), ничего особенного не происходит. В обязанности потока входит периодическая проверка состояния прерывания с использованием методаstatic Thread.interrupted() или экземпляраisInterrupted(). Разница между этими методами в том, чтоstatic Thread.interrupt() очищает флаг прерывания, аisInterrupted() - нет.

Q7. Что такое Executor и Executorservice? В чем разница между этими интерфейсами?

Executor иExecutorService - два связанных интерфейса фреймворкаjava.util.concurrent. Executor - очень простой интерфейс с единственным методомexecute, принимающим экземплярыRunnable для выполнения. В большинстве случаев это интерфейс, от которого должен зависеть ваш исполняющий код.

ExecutorService расширяет интерфейсExecutor несколькими методами для обработки и проверки жизненного цикла службы одновременного выполнения задач (завершение задач в случае завершения работы) и методами для более сложной асинхронной обработки задач, включаяFuturesс.

Дополнительные сведения об использованииExecutor иExecutorService см. В статьеA Guide to Java ExecutorService.

Q8. Какие есть реализации Executorservice в стандартной библиотеке?

ИнтерфейсExecutorService имеет три стандартных реализации:

  • ThreadPoolExecutor - для выполнения задач с использованием пула потоков. Когда поток завершает выполнение задачи, он возвращается в пул. Если все потоки в пуле заняты, то задача должна ждать своей очереди.

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

  • ForkJoinPool - это специальныйExecutorService для решения задач рекурсивных алгоритмов. Если вы используете обычныйThreadPoolExecutor для рекурсивного алгоритма, вы быстро обнаружите, что все ваши потоки заняты ожиданием завершения нижних уровней рекурсии. ForkJoinPool реализует так называемый алгоритм кражи работы, который позволяет ему более эффективно использовать доступные потоки.

Q9. Что такое модель памяти Java (Jmm)? Опишите его цель и основные идеи.

Модель памяти Java является частью спецификации языка Java, описанной вChapter 17.4. Он определяет, как несколько потоков обращаются к общей памяти в параллельном приложении Java, и как изменения данных одним потоком становятся видимыми для других потоков. Будучи довольно коротким и лаконичным, JMM может быть трудно понять без сильной математической подготовки.

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

Это может привести к нелогичным результатам, когда ваше приложение масштабируется на несколько потоков, поскольку большинство этих оптимизаций учитывают один поток выполнения (межпоточные оптимизаторы по-прежнему чрезвычайно сложны в реализации). Другая огромная проблема заключается в том, что память в современных системах является многослойной: несколько ядер процессора могут хранить некоторые незагруженные данные в своих кэшах или буферах чтения / записи, что также влияет на состояние памяти, наблюдаемое другими ядрами.

Что еще хуже, существование разных архитектур доступа к памяти нарушит обещание Java «писать один раз, запускать везде». К счастью для программистов, JMM устанавливает некоторые гарантии, на которые вы можете положиться при разработке многопоточных приложений. Соблюдение этих гарантий помогает программисту писать многопоточный код, стабильный и переносимый между различными архитектурами.

Основными понятиями JMM являются:

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

  • Synchronization actions, определенное подмножество действий, таких как чтение / запись переменнойvolatile или блокировка / разблокировка монитора

  • Program Order (PO), наблюдаемый общий порядок действий внутри одного потока

  • Synchronization Order (SO), общий порядок между всеми действиями синхронизации - он должен соответствовать порядку программы, то есть, если два действия синхронизации идут одно перед другим в PO, они происходят в том же порядке в SO

  • synchronizes-with (SW) связь между определенными действиями синхронизации, такими как разблокировка монитора и блокировка того же монитора (в другом или том же потоке)

  • Happens-before Order - объединяет PO с SW (в теории множеств это называетсяtransitive closure) для создания частичного упорядочивания всех действий между потоками. Если одно действиеhappens-before другое, то результаты первого действия наблюдаются вторым действием (например, запись переменной в одном потоке и чтение в другом)

  • Happens-before consistency - набор действий HB-согласован, если при каждом чтении наблюдается либо последняя запись в это место в порядке «произошло до», либо какая-либо другая запись через гонку данных

  • Execution - определенный набор упорядоченных действий и правил согласованности между ними

Для данной программы мы можем наблюдать несколько разных исполнений с разными результатами. Но если программа -correctly synchronized, то все ее исполнения выглядят какsequentially consistent, что означает, что вы можете рассуждать о многопоточной программе как о наборе действий, выполняемых в некотором последовательном порядке. Это избавит вас от необходимости думать о переупорядочении, оптимизации или кэшировании данных.

Q10. Что такое нестабильное поле и какие гарантии дает Jmm для такого поля?

Полеvolatile имеет особые свойства в соответствии с моделью памяти Java (см. Q9). Чтение и запись переменнойvolatile являются действиями синхронизации, что означает, что они имеют общий порядок (все потоки будут соблюдать согласованный порядок этих действий). При чтении энергозависимой переменной гарантируется наблюдение последней записи в эту переменную в соответствии с этим порядком.

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

Еще одна гарантия дляvolatile - атомарность записи и чтения 64-битных значений (long иdouble). Без модификатора volatile чтение такого поля может наблюдать значение, частично записанное другим потоком.

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

  • запись в не -volatileint;

  • запись вvolatile int;

  • запись в не -volatile long;

  • запись вvolatile long;

  • увеличениеvolatile long?

Запись в переменнуюint (32-разрядная) гарантированно будет атомарной, независимо от того,volatile или нет. Переменнаяlong (64-битная) может быть записана в два отдельных этапа, например, на 32-битных архитектурах, поэтому по умолчанию нет гарантии атомарности. Однако, если вы укажете модификаторvolatile, переменнаяlong гарантированно будет доступна атомарно.

Операция приращения обычно выполняется в несколько этапов (получение значения, его изменение и обратная запись), поэтому никогда не гарантируется, что он будет атомарным, независимо от того, является ли переменнаяvolatile или нет. Если вам нужно реализовать атомарное приращение значения, вы должны использовать классыAtomicInteger,AtomicLong и т. Д.

Q12. Какие особые гарантии дает Jmm для финальных полей класса?

JVM в основном гарантирует, что поляfinal класса будут инициализированы до того, как какой-либо поток захватит объект. Без этой гарантии ссылка на объект может быть опубликована, т.е. стать видимым для другого потока до инициализации всех полей этого объекта из-за переупорядочения или других оптимизаций. Это может вызвать редкий доступ к этим полям.

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

Q13. Что означает синхронизированное ключевое слово в определении метода? статического метода? Перед блоком?

Ключевое словоsynchronized перед блоком означает, что любой поток, входящий в этот блок, должен получить монитор (объект в скобках). Если монитор уже захвачен другим потоком, бывший поток войдет в состояниеBLOCKED и будет ждать, пока монитор не будет освобожден.

synchronized(object) {
    // ...
}

Метод экземпляраsynchronized имеет ту же семантику, но сам экземпляр действует как монитор.

synchronized void instanceMethod() {
    // ...
}

Для методаstatic synchronized монитором является объектClass, представляющий объявляющий класс.

static synchronized void staticMethod() {
    // ...
}

Q14. Если два потока одновременно вызывают синхронизированный метод для разных экземпляров объекта, может ли один из этих потоков блокироваться? Что делать, если метод статический?

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

Если методstatic, то монитор является объектомClass. Для обоих потоков монитор одинаков, поэтому один из них, вероятно, заблокируется и будет ждать, пока другой выйдет из методаsynchronized.

Q15. Какова цель методов Wait, Notify и Notifyall класса объектов?

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

Когда другой поток, получивший монитор, выполняет условие, он может вызватьobject.notify() илиobject.notifyAll() и освободить монитор. Методnotify пробуждает один поток в состоянии ожидания, а методnotifyAll пробуждает все потоки, ожидающие этого монитора, и все они соревнуются за повторное получение блокировки.

Следующая реализацияBlockingQueue показывает, как несколько потоков работают вместе через шаблонwait-notify. Если мыput элемент в пустой очереди, все потоки, которые ожидали в методеtake, пробуждаются и пытаются получить значение. Если мыput элемент в полную очередь, методputwaits для вызова методаget. Методget удаляет элемент и уведомляет потоки, ожидающие в методеput, о том, что в очереди есть пустое место для нового элемента.

public class BlockingQueue {

    private List queue = new LinkedList();

    private int limit = 10;

    public synchronized void put(T item) {
        while (queue.size() == limit) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        if (queue.isEmpty()) {
            notifyAll();
        }
        queue.add(item);
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        if (queue.size() == limit) {
            notifyAll();
        }
        return queue.remove(0);
    }

}

Q16. Опишите условия тупика, живой блокировки и голода. Опишите возможные причины этих состояний.

Deadlock - это условие в группе потоков, которое не может достичь прогресса, потому что каждый поток в группе должен получить некоторый ресурс, который уже получен другим потоком в группе. Самый простой случай - когда двум потокам необходимо заблокировать два потока, первый ресурс уже заблокирован одним потоком, а второй - другим. Эти потоки никогда не получат блокировку для обоих ресурсов и, следовательно, никогда не будут прогрессировать.

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

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

Q17. Опишите цель и сценарии использования платформы Fork / Join.

Фреймворк fork / join позволяет распараллеливать рекурсивные алгоритмы. Основная проблема с распараллеливанием рекурсии с использованием чего-то вродеThreadPoolExecutor заключается в том, что у вас могут быстро закончиться потоки, потому что для каждого рекурсивного шага потребуется свой собственный поток, в то время как потоки в стеке будут простаивать и ждать.

Точкой входа в структуру fork / join является классForkJoinPool, который является реализациейExecutorService. Он реализует алгоритм кражи работы, при котором незанятые потоки пытаются «украсть» работу из занятых потоков. Это позволяет распределить вычисления между различными потоками и добиться прогресса при использовании меньшего количества потоков, чем это требуется для обычного пула потоков.

Дополнительную информацию и примеры кода для инфраструктуры fork / join можно найти в статье“Guide to the Fork/Join Framework in Java”.