Рубрики
Без рубрики

Эффективная Java: Синхронизация доступа к общим изменяемым данным

Погружение в главу 78 “Эффективная Java”. Помеченный как java, эффективный, синхронизация, архитектура.

Этот пункт открывает новый раздел Effective Java , посвященный параллелизму, а также советам и рекомендациям по этой теме. В этом первом разделе основное внимание уделяется использованию синхронизации. Когда разработчики Java думают о синхронизации, они, скорее всего, думают о ключевом слове synchronized . Это неспроста, это ключевое слово очень полезно. Одно из основных применений, для которого разработчики Java используют ключевое слово synchronized , – это принудительное применение взаимного исключения . Использование ключевого слова synchronized для обеспечения взаимного исключения позволяет различным потокам приложения видеть согласованное представление данных. Хотя это очень полезно, это еще не вся история того, что предоставляет ключевое слово synchronized .

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

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

Это станет яснее на примере. Давайте рассмотрим простое приложение, в котором запускается поток, а затем приложение завершается примерно через секунду.

public class StopThread {
  private static boolean stopRequested;

  public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> {
      int i =0;
      while (!stopRequested)
        i++;
    });
    backgroundThread.start();

    TimeUnit.SECONDS.sleep(1);
    stopRequested = true;
  }
}

Этот код кажется разумным. Синхронизации нет, но благодаря атомной природе мы можем чувствовать себя в безопасности. К сожалению, этот код на самом деле никогда не выполняется? Но как это может быть возможно? Без синхронизации нет никакой гарантии, когда фоновый поток увидит измененное значение. Без синхронизации компилятор вполне может изменить:

while(!stopRequested)
  i++;

к

if(!stopRequested)
  while (true)
    i++;

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

public class StopThread {
  private static boolean stopRequested;

  private static synchronized void requestStop() {
    stopRequested = true;
  }

  private static synchronized bool stopRequested() {
    return stopRequested;
  }

  public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> {
      int i =0;
      while (!stopRequested())
        i++;
    });
    backgroundThread.start();

    TimeUnit.SECONDS.sleep(1);
    requestStop();
  }
}

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

public class StopThread {
  private static volatile boolean stopRequested;

  public static void main(String[] args) throws InterruptedException {
    Thread backgroundThread = new Thread(() -> {
      int i = 0;
      while(!stopRequested) {
        i++;
      }
    });
    backgroundThread.start();

    TimeUnit.SECONDS.sleep(1);
    stopRequested = true;  
  }
}

То, что мы имеем здесь, – это ключевое слово volatile . Ключевое слово volatile не обеспечивает взаимного исключения, но оно гарантирует, что любой поток, считывающий отмеченное поле, увидит самое последнее записанное значение. Даже при использовании ключевого слова volatile вы можете попасть в беду, если не будете осторожны. Давайте рассмотрим следующий код:

private static volatile int nextSerialNumber = 0;

public static int generatedSerialNumber() {
  return nextSerialNumber++;
}

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

Есть несколько способов исправить это. Вы могли бы использовать ключевое слово synchronized для генерировать серийный номер . После этого вы можете удалить volatile ключевое слово, поскольку синхронизация ключевого слова volatile выполняется ключевым словом synchronized . Лучше, чем даже ключевое слово synchronized , использовать что-то вроде AtomicLong который не блокируется и потокобезопасен. Для дополнительной меры мы должны изменить метод, чтобы использовать long вместо int и выдать исключение перед переносом.

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

Как всегда, избегание изменяемого состояния будет лучшим способом обмена состоянием между потоками. Неизменяемое состояние облегчает многие вещи. Если неизменяемое состояние не является вариантом, нам действительно нужно рассмотреть, какая синхронизация требуется. Без синхронизации во многих случаях нет гарантии, что потоки увидят изменения друг друга. Возникающие проблемы могут варьироваться от сбоев в работе, проблем с состоянием и сбоев в обеспечении безопасности. Когда общение – это все, что вам нужно, а не взаимное исключение, ключевое слово volatile может облегчить это. Наконец, рассмотрите возможность использования опций из пакета java.util.concurrent , поскольку это может позволить вам использовать лучший в своем классе параллельный код без лишней суеты.

Оригинал: “https://dev.to/kylec32/effective-java-synchronize-access-to-shared-mutable-data-1i68”