Введение

Межпотоковое взаимодействие и синхронизация очень важны в программировании многопоточных приложений. В достижении этих целей в Java значительную роль играют методы wait(), notify() и notifyAll(). Рассмотрим работу каждого из этих методов, чтобы понять, как он способствуют межпотоковому взаимодействию и синхронизации.

Межпотоковое взаимодействие и синхронизация

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

Потоки и многопоточное выполнение

Поток — это небольшая единица процесса. В Java каждое приложение имеет как минимум один поток: главный поток, в котором выполняется метод main. Дополнительные потоки могут быть созданы и выполняться одновременно с главным потоком. Такое многопотоковое исполнение позволяет решать несколько задач одновременно, повышая производительность и скорость отклика приложений.

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

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

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

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

Межпотоковое взаимодействие

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

Java предоставляет методы wait()notify() и notifyAll() для межпотокового взаимодействия. Являясь частью класса java.lang.Object, эти методы доступны всем объектам в Java. Они используются для облегчения взаимодействия между потоками, которые обладают блокировкой на общем ресурсе.

Роль методов wait(), notify() и notifyAll()

Методы wait()notify() и notifyAll() используются для управления выполнением потоков, которым необходимо дождаться определенных условий или событий. Эти методы должны вызываться из синхронизированного контекста, то есть они должны использоваться внутри синхронизированных блоков или синхронизированных методов.

  • wait(). Этот метод заставляет текущий поток ждать, пока другой поток не вызовет метод notify() или notifyAll() на том же объекте. Он освобождает блокировку объекта и позволяет другим потокам получить ее. Ожидающий поток остается в состоянии ожидания до тех пор, пока не получит уведомление.
  • notify(). Этот метод активизирует один поток, ожидающий на мониторе объекта. Если ожидают несколько потоков, один из них выбирается для активизации. Точный выбор потока зависит от планировщика потоков.
  • notifyAll(). Этот метод активизирует все потоки, ожидающие на мониторе объекта. Эти потоки конкурируют за блокировку, и один из них продолжит работу, как только блокировка будет освобождена.

Применение методов wait(), notify() и notifyAll()

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

Рассмотрим сценарий, в котором поток-производитель генерирует данные, а поток-потребитель их обрабатывает. Производитель не должен генерировать новые данные, пока потребитель не обработает текущие данные, а потребитель не должен обрабатывать данные, пока их не сгенерирует производитель. Используя wait()notify() и notifyAll(), производитель и потребитель могут координировать свои действия, обеспечивая контролируемое производство и потребление данных.

Метод wait()

Метод wait() является основополагающим для межпотокового взаимодействия в Java. Он заставляет текущий поток ждать, пока другой поток не вызовет метод notify() или notifyAll() для того же объекта. Вызывая wait(), поток снимает блокировку с объекта и переходит в состояние ожидания. Это позволяет другим потокам получить блокировку и продолжить выполнение.

Как работает wait()

Когда поток вызывает метод wait(), происходит следующая последовательность событий.

  1. Освобождение блокировки. Поток освобождает блокировку, которой он обладает на объекте.
  1. Введение в состояние ожидания. Поток переходит в состояние ожидания и добавляется в набор ожидания объекта. Набор ожидания — это коллекция потоков, которые ожидают монитора объекта.
  1. Ожидание уведомления. Поток остается в состоянии ожидания до тех пор, пока другой поток не вызовет  notify() или notifyAll() для того же объекта.
  1. Повторное получение блокировки. Получив уведомление, поток пытается повторно получить блокировку на объект. В случае успеха, он выходит из состояния ожидания и возобновляет выполнение с того места, где вызвал wait().

Важно отметить, что метод wait() должен вызываться из синхронизированного блока или метода. Если он будет вызван вне синхронизированного контекста, программа выбросит IllegalMonitorStateException.

Перегруженные методы wait()

Метод wait() перегружается для обеспечения дополнительной функциональности. Существует три версии такой перегрузки.

  • wait(): ожидает неопределенное время до получения уведомления.
  • wait(long timeout): ожидает указанное количество времени (в миллисекундах) или до получения уведомления в зависимости от того, что наступит раньше.
  • wait(long timeout, int nanos): ожидает указанное количество времени (в миллисекундах и наносекундах) или до получения уведомления в зависимости от того, что наступит раньше.

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

Пример: проблема производителя/потребителя

Чтобы проиллюстрировать использование метода wait(), рассмотрим классическую проблему производителя/потребителя. В этом сценарии поток-производитель генерирует данные, а поток-потребитель их обрабатывает. Производитель должен ждать, если буфер заполнен, а потребитель — если буфер пуст.

Вот пример реализации:

class SharedResource {
private int data;
private boolean available = false;

public synchronized void produce(int value) throws InterruptedException {
while (available) {
wait();
}
data = value;
available = true;
notify();
}

public synchronized int consume() throws InterruptedException {
while (!available) {
wait();
}
available = false;
notify();
return data;
}
}

public class WaitNotifyExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();

Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
resource.produce(i);
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
int value = resource.consume();
System.out.println("Consumed: " + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

producer.start();
consumer.start();
}
}

Объяснение примера

  • Класс SharedResource содержит общий ресурс (данные) и методы для производства и потребления данных. Метод produce генерирует данные, а метод consume обрабатывает их. Оба метода синхронизированы, чтобы только один поток мог получить доступ к общему ресурсу в одно и то же время.
  • wait() в методе produce: метод produce вызывает wait(), если данные уже доступны, то есть буфер переполнен; поток производителя ждет, пока потребитель обработает данные и вызовет notify().
  • wait() в методе consume: метод consume вызывает wait(), если данные недоступны, то есть буфер пуст; поток потребителя ждет, пока производитель сгенерирует новые данные и вызовет notify().
  • notify() в методах produce и consume: метод notify() вызывается после производства или потребления данных, чтобы разбудить ожидающий поток, что гарантирует возможность поочередного обращения производителя и потребителя к общему ресурсу.

Лучшие практики использования wait()

  • Всегда вызывайте wait() в цикле. Лучше всего вызывать wait() внутри цикла, в котором проверяется ожидаемое условие. Это связано с тем, что wait() может иногда активироваться непроизвольно (без уведомления). Перепроверяя условие, вы гарантируете, что поток будет выполняться, только когда условие действительно выполнено.
  • Обрабатывайте исключения InterruptedException. Метод wait() выбрасывает исключение InterruptedException, поэтому его необходимо обрабатывать соответствующим образом. Как правило, если вы поймали InterruptedException, для восстановления прерванного состояния следует вызвать Thread.currentThread().interrupt().
  • Минимизируйте область синхронизированных блоков. Чтобы уменьшить количество прерываний и повысить производительность, делайте синхронизированные блоки как можно меньше. Включайте только необходимый код, требующий синхронизации.

Методы notify() и notifyAll()

Методы notify() и notifyAll() используются для активации потоков, ожидающих выполнения условия на мониторе одного и того же объекта. Эти методы играют важную роль в межпотоковом взаимодействии, позволяя потокам уведомлять друг друга о доступности ресурсов или изменении условий.

Метод notify()

Метод notify() активирует один поток, ожидающий на мониторе объекта. Если ожидают несколько потоков, выбор того, какой поток активировать, зависит от планировщика потоков и не гарантирует справедливости.

Как работает notify()

  1. Вызов из синхронизированного контекста. Метод notify() должен вызываться из синхронизированного блока или синхронизированного метода. Если его вызвать вне синхронизированного контекста, он выбросит IllegalMonitorStateException.
  1. Уведомление ожидающего потока. Метод активирует один из потоков, ожидающих на мониторе объекта.
  1. Повторное получение блокировки. Оповещенный поток не выполняет действия немедленно. Он должен повторно получить блокировку объекта, прежде чем продолжить выполнение. Это означает, что текущий поток должен снять блокировку (выйдя из синхронизированного блока или метода), прежде чем оповещенный поток сможет продолжить выполнение.

Пример:

class SharedResource {
private int data;
private boolean available = false;

public synchronized void produce(int value) throws InterruptedException {
while (available) {
wait();
}
data = value;
available = true;
notify();
}

public synchronized int consume() throws InterruptedException {
while (!available) {
wait();
}
available = false;
notify();
return data;
}
}

public class NotifyExample {
public static void main(String[] args) {
SharedResource resource = new SharedResource();

Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
resource.produce(i);
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
int value = resource.consume();
System.out.println("Consumed: " + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

producer.start();
consumer.start();
}
}

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

Метод notifyAll()

Метод notifyAll() активирует все потоки, ожидающие на мониторе объекта. В отличие от notify(), который активирует только один ожидающий поток, notifyAll() гарантирует активацию всех ожидающих потоков. После активации эти потоки будут конкурировать за блокировку, и один из них продолжит работу, как только блокировка будет доступна.

Как работает метод notifyAll()

  • Вызов из синхронизированного контекста: как и notify(), метод notifyAll() должен быть вызван из синхронизированного блока или синхронизированного метода.
  • Уведомление всех ожидающих потоков: метод активирует все потоки, ожидающие на мониторе объекта.
  • Повторное получение потоками блокировки: активированные потоки не выполняют действия немедленно; каждый поток должен повторно получить блокировку объекта, прежде чем продолжить выполнение; они будут конкурировать за блокировку, и один из них продолжит выполнение, когда блокировка станет доступной.

Пример:

class SharedResourceWithNotifyAll {
private int data;
private boolean available = false;

public synchronized void produce(int value) throws InterruptedException {
while (available) {
wait();
}
data = value;
available = true;
notifyAll();
}

public synchronized int consume() throws InterruptedException {
while (!available) {
wait();
}
available = false;
notifyAll();
return data;
}
}

public class NotifyAllExample {
public static void main(String[] args) {
SharedResourceWithNotifyAll resource = new SharedResourceWithNotifyAll();

Thread producer = new Thread(() -> {
try {
for (int i = 0; i < 10; i++) {
resource.produce(i);
System.out.println("Produced: " + i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Thread consumer1 = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
int value = resource.consume();
System.out.println("Consumer 1 consumed: " + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

Thread consumer2 = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
int value = resource.consume();
System.out.println("Consumer 2 consumed: " + value);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});

producer.start();
consumer1.start();
consumer2.start();
}
}

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

Лучшие практики использования методов notify() и notifyAll()

  • Используйте notify() для одного ожидающего потока. Если известно, что только один поток ожидает на мониторе объекта, используйте notify(). Это уменьшает накладные расходы на ненужную активацию нескольких потоков.
  • Используйте notifyAll() для нескольких ожидающих потоков. Если на мониторе объекта может находиться несколько потоков, используйте notifyAll(). Это гарантирует, что все ожидающие потоки получат шанс продолжить работу. Однако имейте в виду, что такой подход может привести к увеличению числа претендентов на блокировку.
  • Избегайте вложенных вызовов монитора. Вызовы notify() или notifyAll() внутри вложенных синхронизированных блоков могут привести к сложным сценариям блокировки, плохо поддающихся отладке.
  • Документируйте взаимодействие потоков. Четкое документирование условий, при которых потоки должны уведомлять друг друга, помогает поддерживать код и облегчает понимание межпотокового взаимодействия.

Заключение

В Java методы wait()notify() и notifyAll() являются важнейшими инструментами управления межпотоковым взаимодействием и синхронизацией. Они помогают потокам координировать действия, обеспечивая безопасный доступ к общим ресурсам и их изменение. Понимая и правильно применяя эти методы, вы сможете предотвратить такие распространенные проблемы совместной обработки потоков, как состояние гонки и взаимоблокировка, что приведет к созданию более сильных и эффективных многопоточных приложений. Не забывайте следовать лучшим практикам, таким как вызов wait() в цикле и выбор между notify() и notifyAll() в зависимости от потребностей приложения, для достижения оптимальной производительности и надежности.

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

Читайте нас в Telegram, VK и Дзен


Перевод статьи Alexander Obregon: Java’s wait(), notify(), and notifyAll() Explained

Предыдущая статьяОсвоение широковещательных приемников в Android