Java: Как точно синхронизированные операции связаны с волатильностью?

Извините, это такой длинный вопрос.

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

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

  • Если это так В чем заключается цель синхронизации простого метода getter? (см. пример 1). Кроме того, ВСЕ изменения отправляются в основную память, пока поток синхронизирован ни на чем? например, если он отправлен для выполнения нагрузок по всему месту внутри синхронизации с очень высоким уровнем, каждое последующее изменение будет сделано в основной памяти и никогда не будет кэшироваться до тех пор, пока оно не будет разблокировано?
  • Если не Должно ли изменение должно быть явно внутри синхронизированного блока или может, например, использовать java для использования объекта Lock? (см. пример 3)
  • Если либо Связан ли синхронизированный объект с каким-либо образом связанным с ссылкой/примитивом (например, непосредственным объектом, который его содержит)? Могу ли я писать, синхронизируясь на одном объекте и читать с другим, если он в противном случае безопасен? (см. пример 2)

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

Пример 1:

class Counter{
  int count = 0;

  public synchronized void increment(){
    count++;
  }

  public int getCount(){
    return count;
  }
}

В этом примере increment() необходимо синхронизировать, поскольку ++ не является атомной операцией. Таким образом, два потока, увеличивающиеся одновременно, могут приводить к общему увеличению на 1 счета. Первоначальный счетчик должен быть атомарным (например, не длинным/двойным/ссылкой), и это нормально.

Нужно ли синхронизировать getCount() и почему именно? Объяснение, которое я слышал больше всего, заключается в том, что я не буду гарантировать, будет ли возвращенный счет возвратным или последующим приращением. Однако это похоже на объяснение чего-то немного другого, что оказалось не в том месте. Я имею в виду, если бы мне нужно было синхронизировать getCount(), то я до сих пор не вижу никакой гарантии - теперь это не значит, что вы не знаете порядок блокировки, не понимая, происходит ли фактическое чтение до/после фактической записи.

Пример 2:

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

class Counter{
  private final Object lock1 = new Object();
  private final Object lock2 = new Object();
  private final Object lock3 = new Object();
  int count = 0;

  public void increment1(){
    synchronized(lock1){
      count++;
    }
  }

  public void increment2(){
    synchronized(lock2){
      count++;
    }
  }

  public int getCount(){
    synchronized(lock3){
      return count;
    }
  }

}

Пример 3:

Является ли отношение "происхождение-до" просто концепцией Java, или это реальная вещь, встроенная в JVM? Несмотря на то, что я могу гарантировать, что концептуальное происходит - до отношений для этого следующего примера, достаточно ли java, чтобы забрать его, если он встроен? Я предполагаю, что это не так, но является ли этот пример фактически потокобезопасным? Если его потокобезопасность, а что если getCount() не заблокировал?

class Counter{
  private final Lock lock = new Lock();
  int count = 0;

  public void increment(){
    lock.lock();
    count++;
    lock.unlock();
  }

  public int getCount(){
    lock.lock();
    int count = this.count;
    lock.unlock();
    return count;
  }
}

Ответ 1

Да, чтение также должно быть синхронизировано. Эта страница говорит:

Результаты записи по одному потоку гарантируются на вид читать другой поток, только если операция записи происходит - до операции чтения.

[...]

Разблокировка (синхронизированный выход блока или метода) монитора происходит до каждой последующей блокировки (синхронизированный блок или метод запись) того же монитора

На той же странице написано:

Действия до "освобождения" таких методов синхронизации, как Lock.unlock, Семафор.реклама и CountDownLatch.countDown случаются - перед действиями после успешного метода "приобретения", такого как Lock.lock

Таким образом, блокировки обеспечивают одинаковые гарантии видимости в виде синхронизированных блоков.

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

  • Ваш пример 1 неверен: геттер также должен быть синхронизирован, если вы хотите увидеть последнее значение счетчика.

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

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

Ответ 2

Попробуйте просмотреть его в терминах двух разных простых операций:

  • Блокировка (взаимное исключение),
  • Предел памяти (синхронизация кэша, барьер переупорядочения команд).

Ввод блока synchronized влечет за собой как блокировку, так и барьер памяти; оставляя блок synchronized влечет за собой разблокировку + барьер памяти; чтение/запись поля volatile влечет за собой только барьер памяти. Думаю, в этих терминах я думаю, что вы можете прояснить для себя весь вопрос выше.

Как и в примере 1, поток чтения не будет иметь никаких барьеров памяти. Это не только между просмотром значения до/после чтения, а с никогда наблюдением любого изменения в var после запуска потока.

Пример 2. является наиболее интересной проблемой, которую вы поднимаете. В этом случае вы не получите никаких гарантий от JLS. На практике вам не будут даны какие-либо заказывающие гарантии (так, как если бы фиксирующий аспект вообще не был), но вы по-прежнему будете иметь преимущество в барьерах памяти, чтобы вы наблюдали изменения, в отличие от первого примера. В принципе, это точно так же, как удаление synchronized и пометка int как volatile (кроме затрат времени на запуск блокировок).

Что касается примера 3, "просто Java-вещь", я чувствую, что у вас есть дженерики с стиранием, что-то, о чем знает только статический контроль кода. Это не так: оба замка и барьеры памяти являются чистыми артефактами времени исполнения. Фактически, компилятор вообще не может рассуждать о них.