Java
7 декабря 2011

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

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 Куксенко.

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

+92
173,5k 827
Комментарии 34

Рекомендуем