Подтвердить что ты не робот

Являются ли конечные поля действительно полезными для обеспечения безопасности потоков?

Я работаю ежедневно с моделью памяти Java уже несколько лет. Я думаю, что я хорошо понимаю концепцию расчётов данных и различные способы их избежать (например, синхронизированные блоки, изменчивые переменные и т.д.). Тем не менее, все еще есть что-то, что я не думаю, что полностью понимаю модель памяти, так как конечные поля классов должны быть потокобезопасными без дальнейшей синхронизации.

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

В частности, стандарт (http://docs.oracle.com/javase/specs/jls/se7/html/jls-17.html#jls-17.4) говорит:

Модель использования для окончательных полей проста: установите конечные поля для объекта в этом объектном конструкторе; и не пишите ссылка на объект, который строится в месте, где поток может видеть его до завершения конструктора объекта. Если это, тогда, когда объект рассматривается другим потоком, это нить всегда будет видеть правильно построенную версию этого конечные поля объекта. Он также будет видеть версии любого объекта или массив, на который ссылаются те конечные поля, которые, по крайней мере, являются последними как конечные поля.

Они даже приводят следующий пример:

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        if (f != null) {
            int i = f.x;  // guaranteed to see 3  
            int j = f.y;  // could see 0
        } 
    } 
}

В котором поток A должен запускать "reader()" , и поток B должен запускать "writer()".

До сих пор так хорошо, по-видимому.

Моя главная проблема связана с... действительно ли это на практике? Насколько я знаю, для создания потока A (который работает "reader()" ) см. Ссылку на "f" , мы должны использовать какой-то механизм синхронизации, например, сделать f volatile или использовать блокировки для синхронизации доступа к е. Если мы этого не сделаем, мы даже не гарантируем, что "reader()" сможет увидеть инициализированное "f" , то есть, поскольку у нас не синхронизирован доступ к "f" , читатель потенциально увидит "null" вместо объекта, который был создан потоком записи. Этот вопрос указан в http://www.cs.umd.edu/~pugh/java/memoryModel/jsr-133-faq.html#finalWrong, который является одной из основных ссылок для модели памяти Java [смелый акцент мой]:

Теперь, сказав все это, если после того, как поток построит неизменный объект (то есть объект, который содержит только конечные поля), вы хотите убедиться, что все это правильно видно нить, вам все равно обычно нужно использовать синхронизацию. Нет другим способом обеспечить, например, то, что ссылка на неизменяемую объект будет видеть второй поток. Гарантии программы получает от окончательных полей, следует тщательно закалять с помощью глубоких и тщательное понимание того, как concurrency управляется в вашем коде.

Итак, если нам даже не гарантировано увидеть ссылку на "f" , и поэтому мы должны использовать типичные механизмы синхронизации (volatile, locks и т.д.), и эти механизмы уже приводят к тому, что голы данных уходят, необходимость для финала я бы даже не подумал. Я имею в виду, если для того, чтобы сделать "f" видимым для других потоков, нам все равно нужно использовать энергозависимые или синхронизированные блоки, и они уже делают внутренние поля видимыми для других потоков... что точка (в условиях безопасности потоков) в в первую очередь, финал поля?

4b9b3361

Ответ 1

Я думаю, что вы не понимаете, что должен показать пример JLS:

static void reader() {
    if (f != null) {
        int i = f.x;  // guaranteed to see 3  
        int j = f.y;  // could see 0
    } 
}

Этот код не гарантирует, что последнее значение f будет отображаться потоком, который вызывает reader(). Но это говорит о том, что если вы видите f как ненулевой, то f.x гарантированно будет 3... несмотря на то, что мы фактически не выполняли какую-либо явную синхронизацию.

Хорошо ли эта неявная синхронизация для финалов в конструкторах полезна? Конечно, это... ИМО. Это означает, что нам не нужно выполнять какую-либо дополнительную синхронизацию каждый раз, когда мы обращаемся к неизменному состоянию объекта. Это хорошо, потому что синхронизация обычно влечет за собой чтение через кеш или запись, что замедляет работу вашей программы.

Но то, что говорит Пью, заключается в том, что вам обычно нужно синхронизировать, чтобы в первую очередь получить ссылку на неизменяемый объект. Он подчеркивает, что использование неизменяемых объектов (реализованных с использованием final) не оправдывает вас необходимостью синхронизации... или из-за необходимости понимать реализацию concurrency/синхронизации вашего приложения.


Проблема в том, что нам все еще нужно быть уверенным, что читатель будет иметь ненулевой "f", и это возможно только в том случае, если мы будем использовать другой механизм синхронизации, который уже предоставит семантику, позволяющую нам видеть 3 для f.x. И если это так, зачем беспокоиться об использовании финальной версии для безопасности потоков?

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

Ответ 2

TL; DR:. Большинство разработчиков программного обеспечения должны игнорировать специальные правила относительно конечных переменных в модели памяти Java. Они должны придерживаться общего правила: если в программе нет данных, все исполнения будут последовательно согласованы. В большинстве случаев конечные переменные не могут использоваться для повышения производительности параллельного кода, поскольку специальное правило в модели памяти Java создает дополнительные затраты для конечных переменных, что делает волатильнее превосходящую конечные переменные практически для всех случаев использования.

Специальное правило о конечных переменных в некоторых случаях предотвращает, что конечная переменная может показывать разные значения. Однако по эффективности правило не имеет значения.


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

Специальное правило о конечных переменных в модели памяти Java каким-то образом подразумевает, что это имеет значение для Java VM и Java JIT-компилятора, если переменная-член является окончательной или если она отсутствует.

public class Int {
    public /* final */ int value;
    public Int(int value) {
        this.value = value;
    }
}

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

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

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

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


Остается вопрос: существуют ли варианты использования, когда специальное правило относительно конечных переменных помогает повысить производительность параллельного кода?

Это сложно сказать, потому что это зависит от фактической реализации Java VM и архитектуры памяти машины. До сих пор я не видел таких случаев использования. Быстрый взгляд на исходный код пакета java.util.concurrent также ничего не показал.

Проблема заключается в том, что инициализация конечной переменной примерно такая же дорогая, как запись изменчивой или атомной переменной. Если вы используете переменную volatile для ссылки на вновь созданный объект, вы получаете то же поведение и затраты за исключением того, что ссылка также будет опубликована немедленно. Таким образом, в использовании конечных переменных для параллельного программирования нет никакой пользы.

Ответ 3

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

Насколько я знаю, чтобы сделать поток A (который работает "reader()" ), см. ссылку на "f", мы должны использовать некоторый механизм синхронизации, например, сделать f volatile или использовать блокировки для синхронизировать доступ к f.

Создание f volatile не является механизмом синхронизации; он заставляет потоки читать память каждый раз, когда к переменной обращаются, но она не синхронизирует доступ к ячейке памяти. Блокировка - это способ синхронизации доступа, но на практике не обязательно гарантировать надежную передачу данных обоими потоками. Например, вы можете использовать класс ConcurrentLinkedQueue<E>, который представляет собой блокируемую блокировку для сбора данных * для передачи данных из потока читателя в поток записи и предотвращения синхронизации. Вы также можете использовать AtomicReference<T> для обеспечения надежного параллельного доступа к объекту без блокировки.

При использовании lock-free concurrency гарантируется гарантия видимости полей final. Если вы создаете сборку без блокировки и используете ее для хранения неизменяемых объектов, ваши потоки смогут получить доступ к содержимому объектов без дополнительной блокировки.

*ConcurrentLinkedQueue<E> - это не только блокировка, но и сбор без ожидания (т.е. сбор без блокировки с дополнительными гарантиями, не относящимися к этому обсуждению).

Ответ 4

Да, конечные конечные поля полезны с точки зрения безопасности потоков. Это может быть не полезно в вашем примере, однако если вы посмотрите на старую реализацию ConcurrentHashMap, метод get не будет применять какую-либо блокировку при поиске значения, хотя существует риск, что при поиске вверх список может изменить (подумайте о ConcurrentModificationException). Однако CHM использует список, сделанный из окончательной подачи для "следующего" поля, гарантирующий согласованность списка (элементы спереди/еще не видят, что они не будут расти или уменьшаться). Таким образом, преимущество в обеспечении безопасности потока устанавливается без синхронизации.

Из статьи

Использование неизменяемости

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

В то время как новый JMM обеспечивает безопасность инициализации для конечных переменных, старая JMM не делает, а это значит, что возможно другое чтобы увидеть значение по умолчанию для конечного поля, а не значение, размещенное там конструктором объекта. Реализация должны быть готовы также обнаружить это, что что значение по умолчанию для каждого поля ввода не является допустимым значением. Список построен таким образом, что если какое-либо из полей ввода появляется имеют значение по умолчанию (ноль или нуль), поиск будет неудачным, запрос реализации get() для синхронизации и цепь снова.

Ссылка на статью: https://www.ibm.com/developerworks/library/j-jtp08223/