Модель памяти в примерах и не только

  • Tutorial
В продолжение серии топиков под названием «фундаментальные вещи о Java, которые стоит знать, но которые многие не знают». Предыдущий топик: Бинарная совместимость в примерах и не только

Модель памяти Java — нечто, что оказывает влияние на то, как работает код любого java-разработчика. Тем не менее, довольно многие пренебрегают знанием этой важной темы, и порой наталкиваются на совершенно неожиданное поведение их приложений, которое объясняется именно особенностями устройства JMM. Возьмём для примера весьма распространённую и некорректную реализацию паттерна Double-checked locking:

public class Keeper {
    private Data data = null;
    
    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        
        return data;
    }
}

Люди, пишущие подобный код, пытаются добиться улучшения производительности, избегая блокировки, если значение уже было присвоено. К сожалению, эти люди не учитывают многих факторов, в результате проявления которых может случиться зомби-апокалипсис. Под катом я расскажу теорию и приведу примеры того, как что-то может пойти не так. Кроме того, как говорили в одном индийском фильме, «Мало знать, что не так. Нужно знать, как сделать так, чтобы было так». Потому и рецепты успеха вы также сможете найти дальше.

Немножко истории

Первая версия JMM появилась вместе с Java 1.0 в 1995 году. Это была первая попытка создать непротиворечивую и кросс-платформенную модель памяти. К сожалению, или к счастью, в ней было несколько серьёзных изъянов и непоняток. Одной из наиболее печальных проблем было отсутствие каких-либо гарантий для final полей. То есть, один поток мог создать объект с final-полем, а другой поток мог значения в этом final-поле не увидеть. Этому был подвержен даже класс java.lang.String. Кроме того, эта модель не давала компилятору возможности производить многие эффективные оптимизации, а при написании многопоточного кода сложно было быть уверенным в том, что он действительно будет работать так, как это ожидается.

Потому в 2004 году в Java 5 появилась JSR 133, в которой были устранены недостатки первоначальной модели. О том, что получилось, мы и будем говорить.

Atomicity

Хотя многие это знают, считаю необходимым напомнить, что на некоторых платформах некоторые операции записи могут оказаться неатомарными. То есть, пока идёт запись значения одним потоком, другой поток может увидеть какое-то промежуточное состояние. За примером далеко ходить не нужно — записи тех же long и double, если они не объявлены как volatile, не обязаны быть атомарными и на многих платформах записываются в две операции: старшие и младшие 32 бита отдельно. (см. стандарт)

Visibility


В старой JMM у каждого из запущенных потоков был свой кеш (working memory), в котором хранились некоторые состояния объектов, которыми этот поток манипулировал. При некоторых условиях кеш синхронизировался с основной памятью (main memory), но тем не менее существенную часть времени значения в основной памяти и в кеше могли расходиться.

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

Важно отметить, что, в отличие от того же C++, «из воздуха» (out-of-thin-air) значения никогда не берутся: для любой переменной справедливо, что значение, наблюдаемое потоком, либо было ранее ей присвоено, либо является значением по умолчанию.

Reordering

Но и это, как говорится, ещё не всё. Если вы сделаете заказ прямо сейчас, то ваши инструкции переставят местами совершенно бесплатно! Процессоры проявляют невероятную проворность в оптимизации исполнения инструкций. В этом им также помогает компилятор и JIT. Одним из примечательных эффектов может оказаться то, что действия, выполненные одним потоком, другой поток увидит в другом порядке. Эту фразу довольно сложно понять, просто прочитав, потому приведу пример. Пусть есть такой код:

public class ReorderingSample {
    boolean first = false;
    boolean second = false;
    
    void setValues() {
        first = true;
        second = true;
    }
    
    void checkValues() {
        while(!second);
        assert first;
    }
}

И в этом коде из одного потока вызывается метод checkValues, а из другого потока — setValues. Казалось бы, код должен выполняться без проблем, ведь полю second значение true присваивается позже, чем полю first, и потому когда (точнее, если) мы видим, что, второе поле истинно, то и первое тоже должно быть таким. Но не тут-то было.

Хотя внутри одного потока об этом можно не беспокоиться, в многопоточной среде результаты операций, произведённых другими потоками, могут наблюдаться не в том порядке. Чтобы не быть голословным, я хотел добиться того, чтобы на моей машине сработал assertion, но мне это не удавалось настолько долго (нет, я не забыл указать при запуске ключ -ea), что, отчаявшись, я обратился с вопросом «а как же всё-таки спровоцировать reordering» к небезызвестным перформанс-инженерам. Так на мой вопрос ответил Сергей Куксенко:
На машинах с TSO (к коим относится x86) довольно сложно показать
ломающий reordering. Это можно показать на каком-нибудь ARM'е или
PowerPC. Еще можно сослаться на Альфу — процессор с самыми слабыми правилами ордеринга. Альфа — это был ночной кошмар разработчиков компиляторов и ядер операционной системы. Счастье, что он таки умер. В сети можно найти массы историй об этом.

Классический пример:
(пример аналогичен приведённому выше — прим. автора)
… на x86 будет отрабатывать корректно всегда, ибо если вы увидели
стор в «b», то увидите и стор в «a».


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

Итак, вернёмся к нашему изначальному примеру и поймём, как может его испортить reordering. Пусть наш класс Data в конструкторе выполняет какие-то не очень тривиальные вычисления и, главное, записывает какие-то значения в не final поля:

public class Data {

    String question;
    int answer;
    int maxAllowedValue;

    public Data() {
        this.answer = 42;
        this.question = reverseEngineer(this.answer);
        this.maxAllowedValue = 9000;
    }
}


Получится, что тот поток, который первый обнаружит, что data == null, выполнит следующие действия:
  1. Выделит память под новый объект
  2. Вызовет конструктор класса Data
  3.   Запишет значение 42 в поле answer класса Data
  4.   Запишет какую-то строку в поле question класса Data
  5.   Запишет значение 9000 в поле maxAllowedValue класса Data
  6. Запишет только что созданный объект в поле data класса Keeper
Чуете подвох? Ничто не мешает другому потоку увидеть произошедшее в пункте 6 до того, как он увидит произошедшее в пунктах 3-5. В результате этот поток увидит объект в некорректном состоянии, когда его поля ещё не были установлены. Такое, разумеется, никого не устроит, и потому есть жёсткий набор правил, по которым оптимизатору/компилятору/вашему злому двойнику запрещено выполнять reordering.

Happens-before

Определение

Все эти правила заданы с помощью так называемого отношения happens-before. Определяется оно так:
Пусть есть поток X и поток Y (не обязательно отличающийся от потока X). И пусть есть операции A (выполняющаяся в потоке X) и B (выполняющаяся в потоке Y).

В таком случае, A happens-before B означает, что все изменения, выполненные потоком X до момента операции A и изменения, которые повлекла эта операция, видны потоку Y в момент выполнения операции B и после выполнения этой операции.
На словах такое определение, возможно, воспринимается не очень хорошо, потому немного поясню. Начнём с самого простого случая, когда поток только один, то есть X и Y — одно и то же. Внутри одного потока, как мы уже говорили, никаких проблем нет, потому операции имеют по отношению к друг другу happens-before в соответствии с тем порядком, в котором они указаны в исходном коде (program order). Для многопоточного случая всё несколько сложнее, и тут без… картинки не разобраться. А вот и она:


Здесь слева зелёным помечены те операции, которые гарантированно увидит поток Y, а красным — те, что может и не увидеть. Справа красным помечены те операции, при исполнении которых ещё могут быть не видны результаты выполнения зелёных операций слева, а зелёным — те, при исполнении которых уже всё будет видно. Важно заметить, что отношение happens-before транзитивно, то есть если A happens-before B и B happens-before C, то A happens-before C.

Операции, связанные отношением happens-before


Посмотрим теперь, что же именно за ограничения на reordering есть в JMM. Глубокое и подробное описание можно найти, например, в The JSR-133 Cookbook, я же приведу всё на несколько более поверхностном уровне и, возможно, пропущу некоторые из ограничений. Начнём с самого простого и известного: блокировок.

1. Освобождение (releasing) монитора happens-before заполучение (acquiring) того же самого монитора. Обратите внимание: именно освобождение, а не выход, то есть за безопасность при использовании wait можно не беспокоиться.

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

public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

2. Запись в volatile переменную happens-before чтение из той же самой переменной.

То изменение, которое мы внесли, конечно, исправляет некорректность, но возвращает того, кто написал изначальный код, туда, откуда он пришёл — к блокировке каждый раз. Спасти может ключевое слово volatile. Фактически, рассматриваемое утверждение (2) значит, что при чтении всего, что объявлено volatile, мы всегда будем получать актуальное значение. Кроме того, как я говорил раньше, для volatile полей запись всегда (в т.ч. long и double) является атомарной операцией. Ещё один важный момент: если у вас есть volatile сущность, имеющая ссылки на другие сущности (например, массив, List или какой-нибудь ещё класс), то всегда «свежей» будет только ссылка на саму сущность, но не на всё, в неё входящее.

Итак, обратно к нашим Double-locking баранам. С использованием volatile исправить ситуацию можно так:

public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }

        return data;
    }
}

Тут у нас по-прежнему есть блокировка, но только в случае, если data == null. Остальные случаи мы отсеиваем, используя volatile read. Корректность обеспечивается тем, что volatile store happens-before volatile read, и все операции, которые происходят в конструкторе, видны тому, кто читает значение поля.

Кроме того, тут используется интересное предположение, которое стоит проверить: volatile store + read быстрее, чем блокировка. Однако, как неустанно повторяют нам всё те же инженеры производительности, микробенчмарки имеют мало отношения с реальностью, особенно если вы не знаете, как устроено то, что вы пытаетесь измерить. Более того, если вы думаете, что знаете, как оно устроено, то вы, скорее всего, ошибаетесь и не учитываете какие-нибудь важные факторы. У меня нет достаточной уверенности в глубине своих познаний, чтобы производить свои бенчмарки, поэтому таких замеров тут не будет. Впрочем, некоторая информация по производительности volatile есть в этой презентации начиная со слайда #54 (хотя я настойчиво рекомендую прочитать всё). UPD: есть интересный комментарий, в котором говорят, что volatile существенно быстрее синхронизации, by design.

3. Запись значения в final-поле (и, если это поле — ссылка, то ещё и всех переменных, достижимых из этого поля (dereference-chain)) при конструировании объекта happens-before запись этого объекта в какую-либо переменную, происходящая вне этого конструктора.

Это тоже выглядит довольно запутанно, но на самом деле суть проста: если есть объект, у которого есть final-поле, то этот объект можно будет использовать только после установки этого final-поля (и всего, на что это поле может ссылаться). Не стоит, впрочем, забывать, что если вы передадите из конструктора ссылку на конструируемый объект (т.е. this) наружу, то кто-то может увидеть ваш объект в недостроенном состоянии.

В нашем примере оказывается, что достаточно сделать поле, запись в которое происходит последней, final, как всё магически заработает и без volatile и без синхронизации каждый раз:

public class Data {

    String question;
    int answer;
    final int maxAllowedValue;

    public Data() {
        this.answer = 42;
        this.question = reverseEngineer(this.answer);
        this.maxAllowedValue = 9000;
    }

    private String reverseEngineer(int answer) {
        return null;
    }
}

Только в том-то и соль, что заработает оно именно магически, и человек, который не знает о вашем хитроумном приёме, может вас не понять. Да и вы тоже можете о таком довольно быстро позабыть. Есть, конечно же, вариант добавить горделивый комментарий типа «neat trick here!», описывающий, что же тут происходит, но мне это почему-то кажется не очень хорошей практикой.

UPD: Это неправда. В комментариях описано, почему. UPD2: По результатам обсуждения вопроса Руслан написал статью.

Кроме того, важно помнить, что поля бывают ещё и статические, а что инициализацию классов JVM гарантированно выполняет лишь один раз при первом обращении. В таком случае, тот же синглетон (не будем в рамках данной статьи называть его ни паттерном, ни антипаттерном. Статья ведь совсем не об этом ;)) можно реализовать вот так:

public class Singleton {
    
    private Singleton() {}

    private static class InstanceContainer {
        private static final Singleton instance = new Singleton();
    }

    public Singleton getInstance() {
        return InstanceContainer.instance;
    }
}
Это, конечно, не является советом к тому, как нужно реализовывать синглетон, поскольку все, читавшие Effective Java, знают, что если вы совершенно неожидано по какой-то причине вдруг зачем-то решили его написать, то лучше всего использовать enum и получить из коробки решение всех проблем и с многопоточностью, и с сериализацией, и с клонированием. UPD: По поводу того, как лучше реализовать singleton, можно почитать этот топик.

Кстати, тем, кто знает, что final-поля можно изменить через Reflection и заинтересовавшимся, как такие изменения будут видны, могу сказать вот что: «всё, кажется, будет хорошо, только непонятно, почему, и непонятно, действительно ли всё и действительно ли хорошо». Есть несколько топиков на эту тему, наиболее интерен этот. Если кто-нибудь расскажет в комментариях, как оно на самом деле, я буду крайне рад. Впрочем, если никто не расскажет, то я и сам выясню и обязательно расскажу. UPD: В комментариях рассказали.

Наверняка есть ещё какие-то операции, связанные отношением happens-before, которые я не осветил в данной статье, но они уже гораздо более специфичны, и при наличии интереса вы можете их найти сами в стандарте или где-то ещё, а затем поделиться со всеми в комментариях.

Credits, links and stuff

В первую очередь хотелось бы поблагодарить за некоторые консультации и предварительную проверку статьи на содержание клинического бреда упомянутых ранее инженеров производительности: Алексея TheShade Шипилёва и Сергея Walrus Куксенко.

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

Похожие публикации

Комментарии 33
    –7
    Глубина, глубина, отпусти меня…
      +2
      Я тут приводил классчиеский пример реордеринга, возспроизводящийся на x86
      Барьеры памяти и неблокирующая синхронизация в .NET/
      • НЛО прилетело и опубликовало эту надпись здесь
          0
          Там пример именно реордеринга на проце. См раздел «Опасности volatile»
            0
            Если я правильно понимаю, Parallel.Invoke дожидается окончания выполнения каждой из переданных задач. В JMM все действия, выполненные потоком happen-before успешного завершения join на этом потоке, потому все изменения будут видны.
              0
              Ну да. И в .NET так же.
              И что из этого следует то?
        0
        Atomicity
        Хотя многие это знают, считаю необходимым напомнить, что на некоторых платформах некоторые операции записи могут оказаться неатомарными. То есть, пока идёт запись значения одним потоком, другой поток может увидеть какое-то промежуточное состояние. За примером далеко ходить не нужно — записи тех же long и double, если они не объявлены как volatile, не обязаны быть атомарными и на многих платформах записываются в две операции: старшие и младшие 32 бита отдельно.
        Поправьте меня, если я не прав. Читаю сейчас упомянутую книгу Java concurrency in Practice, где по поводу volatile говорилось, что оно никак не делает операции чтения атомарными, только делает значения видимыми между потоками. Т.е. в конкурентной борьбе при check-then-act операциях, проблема с long и double может по-прежнему проявиться.
          +3
          В стандарте явно сказано:
          The load, store, read, and write actions on volatile variables are atomic, even if the type of the variable is double or long.


          Вы, вероятно, путаете с тем, что не являются атомарными операции вроде инкремента, т.е.

          class Foo {
              volatile int bar;
          
              void baz() {
                  bar++; // not atomic!
              }
          }
          
            0
            Да, вероятно. Спасибо за исправление.
          0
          >В нашем примере оказывается, что достаточно сделать поле, запись в которое происходит последней, final, как всё магически заработает и без volatile и без синхронизации каждый раз:

          Каким образом в данном примере гарантируется, что запись в final будет действительно последней, а не зареордерится куда-нибудь вверх?
            0
            Модель памяти это гарантирует. Всё, что поток X делает до выполнения операции записи в final поле (операции A), будет видно другому потоку Y, сохраняющему куда-то объект, содержащий это final поле (операция B). Какие-то из этих операций могут зареордериться по отношению друг к другу, но поток Y увидит сразу все изменения. Операция A не может зареордериться так, чтобы она оказалась раньше какой-то операции, которая находится до неё по program order. Вперёд, впрочем, передвинуться никто не запрещает.
            0
            Вот тут писал про Singleton и Double Checked Lock.
              0
              Добавил ссылку, спасибо.
              0
              Тут тоже интересно:
              «Теория и практика Java: Исправление модели памяти Java»
              Part 1: www.ibm.com/developerworks/ru/library/j-jtp02244/
              Part 2: www.ibm.com/developerworks/ru/library/j-jtp03304/
                0
                jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html и у автора есть цикл статей + JLS/
                Minor niggle: The read of ready doesn't just ensure that Thread 2 sees the contents of memory of Thread 1 up until it wrote to ready, it also ensures that Thread 2 sees the contents of memory of any other thread that wrote to ready up until that point
                более правильное happens before в отношении volatile v java. также его привносит synchronized блок.
                  0
                  Не понял вас. По утверждению, приведённому в моей статье, то, что вы процитировали, тоже так.
                  0
                  final поля и Reflection
                  Запись final или volatile полей через Reflection, действительно, сопровождается memory-barrier'ом. Поэтому все требования JMM в этом случае тоже соблюдены.
                  Убедимся в этом, заглянув в исходники sun.reflect.*
                      // ----- UnsafeFieldAccessorFactory.java -----
                      boolean isQualified = isFinal || isVolatile;
                      ...
                      if (!isQualified) {
                          return new UnsafeObjectFieldAccessorImpl(field);
                      } else {
                          return new UnsafeQualifiedObjectFieldAccessorImpl(field, isReadOnly);
                      }
                  
                      // ----- UnsafeObjectFieldAccessorImpl.java -----
                      public void set(Object obj, Object value) {
                          if (isFinal) {
                              throwFinalFieldIllegalAccessException(value);
                          }
                          ...
                          unsafe.putObject(obj, fieldOffset, value);
                      }
                  
                      // ----- UnsafeQualifiedObjectFieldAccessorImpl.java -----
                      public void set(Object obj, Object value) {
                          if (isReadOnly) {
                              throwFinalFieldIllegalAccessException(value);
                          }
                          ...
                          unsafe.putObjectVolatile(obj, fieldOffset, value);
                      }
                  


                  В свою очередь unsafe.putObjectVolatile(obj, fieldOffset, value) эквивалентен
                      unsafe.putObject(obj, fieldOffset, value);
                      membar();
                  

                    0
                    Класс, спасибо, добавил в топик ссылку на комментарий.

                    Кстати, раз уж речь зашла о final полях, то мне по почте подсказывают, что я не прав относительно того, что happens-before для store финального поля и read объекта, это поле содержащего, — особенный happens-before, который не транзитивен с остальными. То есть, пример, который приведён в статье (сначала инициализировать обычные поля, потом одно final поле, и в результате обычные тоже будут видны) — некорректен.

                    В стандарте по этому поводу что-то есть, но у меня никак не получается осознать (и найти), что имеется в виду отношениями dereferences и mc. Не подскажете?
                      0
                      А, я уже и сам понял, и моё мнение не сошлось с мнением собеседника. Я тут приведу своё понимания, и если мне никто не укажет на ошибку, я обновлю статью.

                      В стандарте есть довольно мутная секция, рассказывающая об этом. Я понял её так:

                      freeze(f) — происходит после выхода (как нормального, так и в результате возникновения исключительной ситуации) из конструктора, создающего объект, содержащий f. Кроме того, freeze(f) происходит при каждом изменении значения f средствами reflection или чего-либо ещё.

                      a dereferences r, если (и):
                      a — операция чтения или записи поля объекта o, который был сконструирован в другом потоке
                      r — операция чтения адреса объекта o, происходящая в том же потоке, что и a

                      a mc b, если (или):
                      • a — операция записи, которую видит операция чтения b
                      • a dereferences b
                      • b — запись адреса объекта, который не был конструирован в этом потоке, a — чтение адреса того же объекта в том же потоке


                      С этими определениями, если у нас есть операция записи w, операция freeze(f), действие a (не являющееся чтением финального поля), операция чтения r, которая читает поле f, и операция чтения r2, и при этом w happens-before freeze(f); freeze(f) happens-before a; a mc r; r dereferences r2, то:

                      w happens-before r2. При этом данное happens-before не транзитивно со всеми остальными.

                      Простыми словами, всё это значит вот что: операции записи переменных, находящихся в dereference chain финального поля, happen-before х чтение. Однако, операции, предшествущие этим операциям записи, уже не обязательно будут видны.
                        0
                        Я всё же был не прав, поскольку проглядел, что поле f — final. Потому предполагал, что раз w — запись любого поля, а не именно финального, она будет видна.

                        Сейчас добавлю в топик сноску.

                        Спасибо человеку по имени Ruslan Cheremin!
                    0
                    Reordering на Intel x86 хорошо описан в 'Intel 64 and IA-32 Architectures Software Developer’s Manual Volume 3A'.

                    Если коротко:

                    Есть program-ordering (другое определие strong-ordering) — модель исполнения reads и writes в порядке, определенном самой программой, и processor-ordering — модель исполнения reads и writes в порядке, улучшающим перворманс. Процессоры 486 и Pentium за небольшим исключением работают в модели program-ordering. Начиная с архитектуры P6 и заканчивая всеми текущими архитектурами x86, процессоры работают в модели processor-ordering. Имеются отличия в processor-ordering правилах для одно-процессорных систем и много-процессорных. Multi-core и включенный HyperThreading относится к много-процессорным системам.

                    Если JIT не изменит порядок операций, то assert в примере на reordering на x86 никогда не сработает, это пример как раз разобран в Developer’s Manual.

                    По поводу производительности volatile: на x86 работа с volatile реализуется с помощью FENCE инструкций, которые значительно влияют на производительность. Не знаю на сколько умны сейчас JIT-компиляторы по отношению к volatile переменным, используемым в одно-поточном коде, но они умеют удалять ненужные
                    синхронизации.
                      0
                      на x86 работа с volatile реализуется с помощью FENCE инструкций, которые значительно влияют на производительность

                      В HotSpot JVM на x86/amd64 запись volatile поля сопровождается инструкцией LOCK ADD [RSP], 0 что достаточно для эффективного membar'а вместо медленного MFENCE. Чтение volatile поля ничем не отличается от чтения обычного поля.

                      Что касается final полей, HotSpot с ними всегда работает как с обычными. Никаких дополнительных действий для обеспечения вышеупомянутой семантики final виртуальная машина не осуществляет, ровно по той причине, что на всех ныне поддерживаемых архитектурах (x86, SPARC, ARM, PPC), пример ReorderingSample никогда не свалится. Случая с Reflection это не касается, поскольку он относится с Class Libraries и реализован за пределами JVM.
                        +1
                        Небольшое уточнение:
                        Работа с final требует StoreStore барьера в конце конструктора. StoreStore барьер это no-op на x86 & SPARC, но таки требуется на ARM & PPC.
                      0
                      Выдержка из Java Concurrency In Practice:
                      Initialization safety makes visibility guarantees only for the values that are reachable through final fields as of the time the constructor finishes. For values reachable through non final fields, or values that may change after construction, you must use synchronization to ensure visibility.

                      Из этого можно сделать вывод что в третьем примере переменные question и answer также необходимо обозначить как final.

                      0
                      Хорошая статья поднимает интересные вопросы. Жаль проблема Word Tearing не была затронута. В спецификации JLS-JavaSE7 пункт 17.6 сказано что-то вроде того, что если процессор не может записывать в память отдельно байты, а может только слова, тогда при конкуррентном доступе данные запросто могут быть искажены. Насколько актуальна эта проблема?
                        0
                        Там же все очень просто сказано — word tearing запрещен.
                        0
                        Статья образец. Проработано несколько источников, отрецензирована и не в стиле «я вчера прочитал, спешу подельться».
                        Спасибо!
                          0
                          Спасибо за добрые слова :)
                          0
                          мда, крутая (имхо) статья собрала всего три десятка комментариев, что какбэ говорит об уровне современной многопоточной Java-разработки.
                            +2

                            Если бы она была написана в 2016, было бы три комментария

                            0
                            Помогите разобраться с Double-check Locking. У вас в статье упоминается синглтон и возможность его использования. В другой статье приводится аналогичный пример и говорится
                            Just define the singleton as a static field in a separate class. The semantics of Java guarantee that the field will not be initialized until the field is referenced, and that any thread which accesses the field will see all of the writes resulting from initializing that field.

                            Правильно ли я понимаю, что в случае с «обычными» полями данных, если началась инициализация, но не закончилась, одним потоком (который первым вошел в synchronized блок), то другой поток может увидеть ссылку на объект data, которая будет уже не null. Будет ли, действительно, решена проблема с использованием static?

                            Применительно к вашему примеру, будет ли это работать, если заменить volatile на static?

                            public class Keeper {
                                private static Data data = null;
                            
                                // остальной код
                            }
                            
                              0
                              Суть приведённой вами цитаты заключается в том, что поле не будет инициализированно до тех пор, пока к нему не обратятся в первый раз, т.е. инициализация статических полей ленивая. Чуть ниже в статье, на которую вы даёте ссылку, приведён код:

                              class HelperSingleton {
                                  static Helper singleton = new Helper();
                              }
                              

                              Как вы можете заметить, присваивается при инициализации не null, а полноценный объект. И именно это присвоение имеет нужную семантику: все, кто обратятся к полю singleton гарантированно увидят один и тот же экземпляр класса Helper и все его поля, которые могли быть установлены в конструкторе.

                              Ваше исправление, конечно, работать не будет: всё, что оно гарантирует — это то, что первый, кто прочтёт data точно увидит null.

                              Про double checked locking хорошо писал TheShade: habrahabr.ru/post/143390/

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

                            Самое читаемое