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-вещь", я чувствую, что у вас есть дженерики с стиранием, что-то, о чем знает только статический контроль кода. Это не так: оба замка и барьеры памяти являются чистыми артефактами времени исполнения. Фактически, компилятор вообще не может рассуждать о них.