Как работает увеличение счетчика в Java без использования блокировок, volatile и synchronized, в двух потоках

Есть 2 потока Т1 и Т2. Каждый из них в цикле от 0 до 10 делает counter++ без каких либо блокировок без volatile, без synchronized. Изначально counter равен 0.

Вопрос - какое минимальное значение может принять counter и почему? Просьба объяснить пошагово.

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


Ответы (1 шт):

Автор решения: Serge3leo

Поскольку вопрос в enSO, IMHO, несколько отличается, дам немного исправленную формулировку.

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

Во-вторых, в вопросе "какое минимальное знаечение может принять counter" игнорируется, что по JSR-133 разные потоки могут видеть разное значение. В том вопросе, синхронизация видимых значений всех трёх потоков обеспечивается start() и join(), но в вашем вопросе дополнительная синхронизация запрещена.

Сам пошаговый сценарий отличный:

  1. thread A reads 0
  2. thread B reads 0 and writes 1
  3. thread B reads 1 and writes 2
  4. ...
  5. thread B reads 8 and writes 9
  6. thread A writes 1
  7. thread B reads 1
  8. thread A reads 1 and writes 2
  9. thread A reads 2 and writes 3
  10. ...
  11. thread A reads 9 and writes 10
  12. thread A finished
  13. thread B writes 2
  14. thread B finished

Только он изложен как чтение/запись в строго синхронизируемую разделяемую память (примерно, а ля Intel/AMD). Что, собственно говоря, точно так же неточно, как и заданный вопрос. Но модель памяти Java (JSR-133) существенно мягче.

И без синхронизации результат этого сценария определяется только "Правилом № 2: нет невесть откуда взявшихся значений. Чтение любой переменной (кроме не-volatile long и double, для которых это правило может не выполняться) выдаст либо значение по умолчанию (ноль), либо что-то, записанное туда другой командой."

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

Таким образом, при некоторых разумных предположениях об JVM, минимум, на момент окончания, поток A видит 1 (запись на шаге 2), поток B видит 2 (запись на шаге 13), а третий поток, внешний к этим двум - видит 0 (грубо говоря, поскольку его кэш никаких сигналов от кэшей потоков 1 и 2 не получал, ибо "без каких либо блокировок без volatile, без synchronized").

То есть должно быть конкретное число ниже которого программа никогда не выдаст.

Состояние гонки, результат зависит от расположения оператора выдачи. К примеру, если мы, в обеих потоках, расположим два println() на шагах 12. и 14. (неважно сразу после суммирования или после длительной задержки), то:

  • println() шага 12. может выдать минимум 1 (запись на шаге 2);
  • println() шага 14. может выдать минимум 2 (запись на шаге 13);

Ну, или наоборот, при симметричной схеме шагов. Итого: минимум 1.

А в вопросе enSO println() в другом месте, в родительском потоке, который синхронизируется join(), и там, минимум 2.

(Дополнительно, в качестве строгого математического извращения, если предложить, что некая странная JVM при выполнении counter++ выполняет несколько записей, то результат может получится ещё меньше (0 и 1). Скажем, на какой нибудь странной машине как запись по 1 биту или как tmp = counter; counter = 0; counter = tmp + 1, так и того меньше)

Как-то, вот так.

→ Ссылка