Volatile java для чего нужен
Руководство по ключевому слову Volatile в Java
Узнайте о ключевом слове Java volatile и его возможностях.
1. Обзор
При отсутствии необходимых синхронизаций компилятор, среда выполнения или процессоры могут применять всевозможные оптимизации. Несмотря на то, что эти оптимизации в большинстве случаев полезны, иногда они могут вызвать тонкие проблемы.
2. Общая многопроцессорная Архитектура
Процессоры отвечают за выполнение инструкций программы. Поэтому им необходимо извлекать как инструкции программы, так и необходимые данные из оперативной памяти.
Здесь в игру вступает следующая иерархия памяти:
Проще говоря, мы должны дважды подумать о том, что происходит, когда один поток обновляет кэшированное значение.
3. Когда использовать volatile
Чтобы подробнее рассказать о согласованности кэша, давайте позаимствуем один пример из книги Параллелизм Java на практике :
Многие могут ожидать, что эта программа просто напечатает 42 после небольшой задержки. Однако на самом деле задержка может быть гораздо более длительной. Он может даже висеть вечно или даже печатать ноль!
3.1. Видимость памяти
В этом простом примере у нас есть два потока приложений: основной поток и поток чтения. Давайте представим себе сценарий, в котором ОС планирует эти потоки на двух разных ядрах процессора, где:
Учитывая все сказанное, когда основной поток обновляет переменные number и ready , нет никакой гарантии относительно того, что может увидеть поток чтения. Другими словами, поток чтения может увидеть обновленное значение сразу, или с некоторой задержкой, или вообще никогда!
Эта видимость памяти может вызвать проблемы с живучестью в программах, которые полагаются на видимость.
3.2. Изменение порядка
Мы можем ожидать, что читательская нить печатает 42. Однако на самом деле можно увидеть ноль в качестве печатного значения!
Переупорядочение – это метод оптимизации для повышения производительности. Интересно, что различные компоненты могут применять эту оптимизацию:
3.3. порядок энергозависимой памяти
Чтобы гарантировать, что обновления переменных предсказуемо распространяются на другие потоки, мы должны применить летучий модификатор для этих переменных:
4. летучая и потоковая синхронизация
Для многопоточных приложений нам необходимо обеспечить несколько правил для согласованного поведения:
синхронизированные методы и блоки обеспечивают оба вышеуказанных свойства за счет производительности приложения.
5. Бывает-Перед Заказом
5.1. Свиноводство
Используя эту семантику, мы можем определить только несколько переменных в нашем классе как volatile и оптимизировать гарантию видимости.
6. Заключение
В этом уроке мы подробнее изучили ключевое слово volatile и его возможности, а также улучшения, внесенные в него, начиная с Java 5.
Зачем нужно volatile в Java
Многие разработчики Java, даже те, которые пишут на нём уже несколько лет, не знают, что делает ключевое слово volatile в Java.
Пример поля с volatile :
Использование volatile — это один из способов обеспечения согласованного доступа к переменной разными потоками. Более подробно о разработке многопоточных приложений в Java и синхронизации между потоками можно прочесть в моей статье про многопоточность в Java.
Ключевое слово volatile указывается для поля для того, чтобы указать компилятору, что все операции присвоения этой переменной и все операции чтения из неё должны быть атомарными.
Переменная volatile используется в одном из вариантов реализаций паттерна синглетон.
Зачем нужно volatile в Java: 6 комментариев
Многопоточность — зло великое. И простому комьютеру с этим не справится. Два процесса подерутся за ресурсы и компьютер зависнет.
Как можно убедить процессор не помещать значение в кэш?
Ведь это он делает автоматическиЙ
Лишь существам сверхъестественным и могучим возможно за один момент два дела делать.
Лол! Долой параллелизм и супекомпьютеры! Да здравствует MSDOS 6.2!
«Ключевое слово volatile указывается для поля для того, чтобы указать компилятору, что все операции присвоения этой переменной и все операции чтения из неё должны быть атомарными.» — с чего бы это?
Атомарными для volatile являются операции присваивания и чтения значения. У вас используется постфиксная операция инкремента, которая на самом деле состоит из:
i = (int) i + 1;
В случае с volatile никто не гарантирует безопасность таких конструкций. после считывания значения i, а также после прибавления 1, другой поток может запросто успеть записать в исходное i своё значение, которое мы перетрём своим присваиванием значения.
Но сами операции чтения и присваивания в случае volatile атомарны и всегда работают с исходным значением, обеспечивая связь happens-before.
А сами операции чтения и записи могут быть не атомарны? Инкремент да — по сути несколько операций. А чтение — одна операция? Поток может прерваться в середине чтения и потом продолжить чтение? Не совсем понимаю этот момент.
Чтение и запись для long и double не во всегда будут атомарными, например. Особенно для 32-х разрядных процессоров. Чтение и запись int и float на современных системах атомарны, но volatile лучше все равно написать, думаю (но это не точно).
Проблема многопоточности — локальный кэш. Volatile
— Привет, Амиго! Помнишь, Элли тебе рассказывала про проблемы при одновременном доступе нескольких нитей к общему (разделяемому) ресурсу?
— Так вот – это еще не все. Есть еще небольшая проблема.
Как ты знаешь, в компьютере есть память, где хранятся данные и команды (код), а также процессор, который исполняет эти команды и работает с данными. Процессор считывает данные из памяти, изменяет и записывает их обратно в память. Чтобы ускорить работу процессора в него встроили свою «быструю» память – кэш.
Чтобы ускорить свою работу, процессор копирует самые часто используемые переменные из области памяти в свой кэш и все изменения с ними производит в этой быстрой памяти. А после – копирует обратно в «медленную» память. Медленная память все это время содержит старые(!) (неизмененные) значения переменных.
Вспомним вчерашний пример:
Код | Описание |
---|---|
Нить «не знает» о существовании других нитей. В методе run переменная isCancel при первом использовании будет помещена в кэш дочерней нити. Эта операция эквивалентна коду: Вызов метода cancel из другой нити поменяет значение переменной isCancel в обычной (медленной) памяти, но не в кэше остальных нитей. |
— Ничего себе! А для этой проблемы тоже придумали красивое решение, как в случае с synchronized?
Сначала думали отключить работу с кэшем, но потом оказалось, что из-за этого программы работают в разы медленнее. Тогда придумали другое решение.
Вот как нужно исправить наше решение, чтобы все стало отлично работать:
5 вещей, которых вы не знали о многопоточности
Хоть от многопоточности и библиотек, которые её поддерживают, отказываются немногие Java-программисты, но тех, кто нашёл время изучить вопрос в глубину ещё меньше. Вместо этого мы узнаём о потоках только столько, сколько нам требуется для конкретной задачи, добавляя новые приёмы в свой инструментарий лишь тогда, когда это необходимо. Так можно создавать и запускать достойные приложения, но можно делать и лучше. Понимание особенностей компилятора и виртуальной машины Java поможет вам писать более эффективный, производительный код.
В этом выпуске серии «5 вещей …», я представлю некоторые из тонких аспектов многопоточного программирования, в том числе synchronized-методы, volatile переменные и атомарные классы. Речь пойдет в особенности о том, как некоторые из этих конструкций взаимодействуют с JVM и Java-компилятором, и как различные взаимодействия могут повлиять на производительность приложений.
Примечание переводчика: я как раз из тех людей, которые не знали этих пяти вещей о многопоточном программировании, поэтому посчитала, что эта статья стоит того, чтобы её обнародовать здесь, но и поэтому же могла допустить некоторые ошибки в переводе, так что поправки приветствуются с энтузиазмом.
Примечание переводчика2: в комментариях знающие люди делятся ссылками и информацией по теме, не менее интересными, чем содержание статьи)
1. Synchronized-метод или synchronized-блок?
Вы, возможно, уже задумывались о том, объявлять ли синхронизированным весь метод или только ту его часть, которую необходимо обезопасить. В таких ситуациях, полезно знать, что когда компилятор Java преобразует исходный код в байт-код, он работает с synchronized-методами и synchronized-блоками очень по-разному.
Когда JVM выполняет synchronized-метод, выполняющийся поток определяет, что в method_info этого метода проставлен флаг ACC_SYNCHRONIZED. Тогда он автоматически устанавливает блокировку на объект, вызывает метод и снимает блокировку. Если вылетает исключение, поток автоматически снимает блокировку.
С другой стороны, synchronized-блок обходит встроенную в JVM поддержку запросов блокировок объекта и обработку исключений, так что это необходимо описывать явно в байт-коде. Если вы посмотрите на байт-код для блока, увидите в нём кучу дополнительных операций в сравнении с методом. Листинг 1 показывает вызов и того, и другого.
Листинг 1. Два подхода к синхронизации.
package com.geekcap ;
public class SynchronizationExample <
private int i ;
public synchronized int synchronizedMethodGet ( ) <
return i ;
>
public int synchronizedBlockGet ( ) <
synchronized ( this ) <
return i ;
>
>
>
Метод synchronizedMethodGet() method генерирует следующий байт-код:
А вот байт-код для метода synchronizedBlockGet():
Создание synchronized-блока выдало 16 строк байт-кода, тогда как synchronized-метода – только 5.
2. «Внутрипоточные» (ThreadLocal) переменные.
Если вы хотите сохранить один экземпляр переменной для всех экземпляров класса, вы используете статические переменные класса. Если вы хотите сохранить экземпляр переменной для каждого потока, используйте внутрипоточные (ThreadLocal) переменные. ThreadLocal переменные отличаются от обычных переменных тем, что у каждого потока свой собственный, индивидуально инициализируемый экземпляр переменной, доступ к которой он получает через методы get() или set().
Предположим, вы разрабатываете многопоточный трассировщик кода, чьей целью является однозначное определение пути каждого потока через ваш код. Проблема в том, что вам необходимо скоординировать несколько методов в нескольких классах через несколько потоков. Без ThreadLocal это было бы трудноразрешимо. Когда поток начинал бы выполняться, было бы необходимо сгенерировать уникальный маркер для идентификации его трассировщиком, а потом передавать этот маркер каждому методу при трассировке.
С ThreadLocal это проще. Поток инициализирует ThreadLocal переменную в начале выполнения, а затем обращается к нему из каждого метода в каждом классе, и переменная при этом будет хранить трассировочную информацию только для исполняемого в данный момент времени потока. Когда его выполнение завершится, поток может передать свою индивидуальную запись о трассировке объекту управления, ответственному за поддержание всех записей.
Использование ThreadLocal имеет смысл, когда вам необходимо хранить экземпляры переменной для каждого потока.
3. Volatile переменные.
По моим оценкам, лишь половина всех разработчиков Java знает, что в Java есть ключевое слово volatile. Из них лишь около 10 процентов знают, что оно значит, и еще меньше знают, как эффективно его использовать. Короче говоря, определение переменной с ключевым словом volatile(«изменчивый») означает, что значение переменной будет изменяться разными потоками. Чтобы полностью понять, что значит volatile, во-первых, нужно понять, как потоки оперируют с обычными, не-volatile, переменными.
В целях повышения эффективности работы, спецификации языка Java позволяет JRE сохранять локальную копию переменной в каждом потоке, который ссылается на нее. Можно считать эти «внутрипоточные» копии переменных похожими на кэш, помогающий избежать проверки главной памяти каждый раз, когда требуется доступ к значению переменной.
Но представьте, что произойдёт в следующем случае: запустятся два потока, и первый прочитает переменную А как 5, тогда как второй – как 10. Если переменная А изменились от 5 до 10, то первый поток не будет знать об изменении, так что будет иметь неправильное значение А. Однако если переменная А будет помечена как volatile, то то в любое время, когда поток обращается к её значению, он будет получать копию А и считывать её текущее значение.
Если переменные в вашем приложении не меняются, то внутрипоточный кэш имеет смысл. В противном случае, очень полезно знать, что может сделать для вас ключевое слово volatile.
4. Volatile против synchronized.
int temp = 0 ;
synchronize ( myVolatileVar ) <
temp = myVolatileVar ;
>
synchronize ( myVolatileVar ) <
myVolatileVar = temp ;
>
Другими словами, если volatile переменная обновляется неявно, то есть значение читается, измененяется, а затем присваивается как новое, результат будет не-потокобезопасным между двумя синхронными операциями. Вы можете выбирать, следует ли использовать синхронизацию или рассчитывать на поддержку JRE автоматической синхронизации volatile переменных. Наилучший подход зависит от вашего случая: если присвоенное значение volatile переменной зависит от её текущего значения (например, во время операции инкремента), то нужно использовать синхронизацию, если вы хотите, чтобы операция была потокобезопасной.
5. Обновления атомарных полей.
Когда вам требуется примитивный тип, выполняющий операции инкремента и декремента, гораздо лучше выбрать его среди новых атомарных классов в пакете java.util.concurrent.atomic, чем писать synchronized блок самому. Атомарные классы гарантируют, что определённые операции будут выполняться потокобезопасно, например операции инкремента и декремента, обновления и добавления(add) значения. Список атомных классов включает AtomicInteger, AtomicBoolean, AtomicLong, AtomicIntegerArray, и так далее.
Своеобразным вызовом программисту в использовании атомарных классов является то, что все операции класса, включая get, set и семейство операций get-set тоже атомарные. Это значит, что операции чтения и записи, которые не изменяют значения атомарной переменной, синхронизированы, а не только важные операции чтения-обновления-записи. Если вы хотите более детального контроля над развертыванием синхронизированного кода, то обходной путь заключается в использовании атомарного апдейтера поля.
Использование атомарного апдейтера.
Атомарные апдейтеры типа AtomicIntegerFieldUpdater, AtomicLongFieldUpdater, и AtomicReferenceFieldUpdater по существу оболочки применяющиеся к volatile полям. Внутри, библиотеки классов Java используют их. Хотя они не часто используются в коде приложений, но у вас нет причин не начать облегчать свою жизнь с их помощью.
Листинг 2 демонстрирует пример класса, который использует атомарные обновления для изменения книги, которую кто-то читает:
Листинг 2. Класс Book.
public class Book
<
private String name ;
public String getName ( )
<
return name ;
>
Класс Book – просто POJO (plain old Java object – незамысловатый старый Java объект), у которого есть только одно поле: name.
Листинг 3. Класс MyObject.
/**
*
* @author shaines
*/
public class MyObject
<
private volatile Book whatImReading ;
public Book getWhatImReading ( )
<
return whatImReading ;
>
Класс MyObject в листинге 3 представляет, как и можно было ожидать, get и set методы, но метод set делает кое-что иное. Вместо того, чтобы просто предоставить свою внутреннюю ссылку на указанную книгу (что было бы выполнено закомментированным кодом в листинге 3), он использует AtomicReferenceFieldUpdater.
AtomicReferenceFieldUpdater
Javadoc определяет AtomicReferenceFieldUpdater так:
A reflection-based utility that enables atomic updates to designated volatile reference fields of designated classes. This class is designed for use in atomic data structures in which several reference fields of the same node are independently subject to atomic updates.
(Основанная на отражении утилита, которая разрешает атомарные обновления назначенным volatile ссылочным полям назначенных классов. Этот класс предназначен для использования в атомарных структурах данных, в которых несколько ссылочных полей одной и той же записи являются независимыми субъектами для атомарных обновлений)убейте меня, я не знаю, как это нормально перевести
В листинге 3 AtomicReferenceFieldUpdater создан через вызов метода newUpdater, который принимает три параметра.
• класс объекта, содержащего поле (в данном случае, MyObject)
• класс объекта, который будет обновляться атомарно (в данном случае, Book)
• имя поля для атомарного обновления
Значимым здесь является то, что метод getWhatImReading выполняется без синхронизации любого рода, в то время как setWhatImReading выполняется как атомарная операция.
В листинге 4 показано, как использовать setWhatImReading () и доказывается, что переменная изменяется правильно:
Листинг 4. Тест-кейс атомарного апдейтера.
import org.junit.Assert ;
import org.junit.Before ;
import org.junit.Test ;
public class AtomicExampleTest
<
private MyObject obj ;
@Before
public void setUp ( )
<
obj = new MyObject ( ) ;
obj. setWhatImReading ( new Book ( «Java 2 From Scratch» ) ) ;
>
Как избежать тупиковых блокировок в Java
В прошлой статье мы обсуждали многопоточность. В этот раз поговорим о главной проблеме многопоточных приложений — тупиковых взаимных блокировках, известных как deadlocks. Такие блокировки возникают, когда минимум два потока одновременно пытаются работать с общими ресурсами и ограничить доступ к этим ресурсам друг для друга. При этом часто создаётся ситуация, когда ни один поток не может ни получить нужный ресурс, ни освободить занимаемый. Для блокировки «конкурентов» поток может использовать mutex, критическую секцию или семафор.
Как происходит блокировка
Два потока работают с общими ресурсами.
Поток 1 захватывает Ресурс 1 и начинает операции с ним.
Поток 2 последовательно захватывает Ресурс 2 и Ресурс 1.
Поток 2 не получает доступа к Ресурсу 1 и в ступоре ждёт, когда тот освободится.
Поток 1 не завершил работу с Ресурсом 1, но пытается захватить Ресурс 2 и тоже впадает в ступор.
Как это выглядит в коде:
Видимо-невидимо
Вторая проблема — видимость данных. Если два потока работают с одной переменной, каждый из них хранит её копию в кэше процессора, на котором запущен. Изменения в одной копии не отражаются мгновенно в основной памяти и других копиях. Это ведёт к путанице: одни потоки работают с актуальным значением, другие — с устаревшим.
Есть несколько способов уберечь Java-приложение от «падений» и «зависаний», связанных с противоречиями в работе потоков. Это механизмы synchronized и volatile, алгоритмы, реализованные в классах Java Concurrent, но главное — забота о структуре вашего приложения.
Ключевое слово volatile в Java
Модификатор volatile используют, когда нужно:
Как только один поток записал что-то в volatile-переменную, значение идёт прямо в общую память и тут же доступно остальным потокам:
Но учтите, что модификатор volatile никак не ограничивает одновременный доступ к данным. А значит, в работу одного потока с полем может вмешаться другой поток. Вот что будет, если два потока одновременно получат доступ к операции увеличения на единицу (i++):
Поток 1: читает переменную (0)
Поток 1: прибавляет единицу
Поток 2: читает переменную (0)
Поток 1: записывает значение (1)
Поток 2: прибавляет единицу
Поток 2: записывает значение (1)
Если бы два потока не мешали друг другу, а работали последовательно, мы получили бы на выходе значение «2», но вместо этого видим единицу. Чтобы такого не происходило, нужно обеспечить атомарность операции. Атомарными называют операции, которые могут быть выполнены только полностью. Если они не выполняются полностью, они не выполняются вообще, но прервать их невозможно.
В примере с увеличением на единицу мы видим сразу три действия: чтение, сложение, запись. Чтение и запись — операции атомарные, но между ними могут вклиниться действия другого потока. Поэтому составная операция инкремента (i++) полностью атомарной не является.
Обратите внимание: с volatile-переменной возможны как атомарные, так и неатомарные операции. Ключевое слово volatile позволяет сделать так, чтобы все потоки читали одно и то же из основной памяти, но не более того.
Простейший способ гарантировать атомарность — выстроить потоки в очередь за ресурсами с помощью механизма synchronized. Представьте, что на электронный счёт одновременно переводят деньги два клиента. Уж лучше попросить одного из них немного подождать, чем допустить ошибки в денежных расчетах.
Ключевое слово synchronized
Модификатор synchronized исключает доступ второго и последующих потоков к данным, с которыми уже работает один поток. Это ключевое слово используют только для методов и произвольных блоков кода.
Используйте synchronized, чтобы:
Метод может быть статическим или нет — без разницы. Но синхронизация влияет на видимость данных в памяти. В прошлой статье мы говорили о взаимном исключении (mutex’e). С его помощью synchronized ограничивает доступ к данным. Образно говоря, это замок, с помощью которого поток запирается наедине с объектом, чтобы никто не мешал работать. Обратите внимание: замок запирают до начала работы. То есть проверка, не заняты ли ресурсы кем-то другим, происходит на входе в synchronized-блок или метод.
Разблокировка же ресурсов происходит на выходе. Поэтому атомарность операций гарантирована.
Данные, которые изменились внутри метода или блока sychronized, находятся в кэше поверх основной памяти и видны следующему потоку, к которому перешёл мьютекс.
Подсказки по блокирующей синхронизации
Главное при работе с synchronized — правильно выбрать объект, по которому будет происходить проверка доступности ресурсов. Если вам нужна синхронизация на входе в метод, учитывайте, принадлежит этот метод классу или объекту. Вход в статичный метод блокируют по объекту класса, а в абстрактный метод — по this. Если по ошибке заблокировать метод класса по this, доступ к ресурсам останется открыт для всех.
Никогда не используйте synchronized в конструкторе — получите ошибку компиляции. А ещё остерегайтесь «матрёшек», когда синхронизированные методы одного класса вызывают внутри себя синхронизированные методы других классов.
Помните, что синхронизация требует ресурсов. При обработке большого массива данных вызов мьютексов становится особенно затратным. Чтобы гарантировать атомарность без синхронизации, используют классы Concurrent.
Атомарность с помощью Java Concurrent
Вернёмся к составной операции «чтение-изменение-запись». Когда нам нужно развести потоки по углам, но без мьютекса, можно использовать инструкцию «сравнение с обменом» — compare and swap (CAS).
Для этого сначала заводят переменную, по значению которой можно понять, заняты ли ресурсы и, если да, — кем. Например, пока ресурсы свободны, переменная хранит «-1», а если заняты — номер процессора, который с ними работает (0,1 и т.д.).
Поток приходит за свободными ресурсами, видит «-1», перезаписывает значение на номер процессора, на котором сам работает, выполняет действия. После завершения всех операций переменной возвращается значение «-1». Если же поток на входе видит номер какого-то процессора, он получает отказ и не может выполнить намеченную операцию. Это простейший случай сравнения с обменом. Важно понимать, что поток, который получил отказ, не блокируется. Он может сообщить программе, что у него проблемы, и перейти к запасному плану действий.
Можно сказать, что это более интеллигентная форма взаимодействия между потоками. Они уже не бодаются за ресурс и не закрывают дверь перед носом оппонента, а обмениваются сообщениями в духе: «Хотелось бы поработать вот с этим» — «Извините, оно пока занято. Не желаете ли кофе?».
На этом принципе построен целый ряд алгоритмов синхронизации, которые называют неблокирующими (non-blocking). Создание таких алгоритмов — задача не для новичка. Но, к статью, в Java «из коробки» есть несколько готовых неблокирующих решений. Они собраны в пакете java.util.concurrent.
ConcurrentLinkedQueue
На русский название класса переводится как «параллельная очередь». Работает такая очередь по принципу First In First Out («Первым зашёл — первым выйдешь»). Алгоритм основан на CAS, быстр и оптимизирован под работу со сборщиком мусора.
Если вы хотите создать очередь из объектов, а затем добавлять и удалять их в нужный момент, не нужно писать и синхронизировать методы вручную. Достаточно создать классы для потоков, производящих и потребляющих данные, а затем поставить эти потоки в очередь ConcurrentLinkedQueue.
В прошлой статье мы говорили, что в Java поток можно создать как экземпляр класса Thread или как отдельный класс с интерфейсом Runnable. Сейчас мы используем второй подход. Единственный метод интерфейса Runnable — run(). Чтобы задать нужное поведение для потребителя и производителя, мы будем переопределять этот метод в каждом случае по-своему.
Поток-производитель:
Поток-потребитель:
Обратите внимание, если очередь пуста, метод poll() вернёт значение null. Поэтому нам нужно было убедиться, что он возвращает что-то другое.
Чтобы узнавать, сколько всего элементов в очереди, у класса ConcurrentLinkedQueue есть метод size(). Он работает медленно, поэтому злоупотреблять им не стоит. При необходимости можно вывести весь список элементов очереди методом toArray(), но сейчас нам это не нужно.
Очередь, в которой будут работать потоки:
Запустите и посмотрите, как добавляются и выполняются задачи.
Атомарные классы
Пакет java.util.concurrent.atomic включает в себя классы для работы с:
Давайте посмотрим, как два потока могут работать с переменной AtomicInteger без синхронизации. Для этого создадим класс myThread, в котором будет атомарный счетчик:
Для операций над числами мы использовали метод updateAndGet() класса AtomicInteger. Этот метод увеличивает число на основе аргумента — лямбда-выражения
Теперь посмотрим на всё это в действии. Создадим и запустим два потока myThread:
В результате работы кода получим два значения: первое из них будет случайным числом в диапазоне от 2000 до 4000, а второе — всегда 4000. Например, при первом запуске я получила результат:
Запустите код ещё раз и убедитесь, что меняется только первое значение. Второе число предсказуемо именно благодаря работе атомарного счётчика. Если бы мы использовали для счётчика обычный (неатомарный) Integer, второе число было бы случайным.
Блокирующие и неблокирующие алгоритмы в Java
Блокирующие алгоритмы парализуют работу потока — навсегда или до момента, пока другой поток не выполнит нужное условие. Представьте, что поток A заблокирован и ждёт, когда поток B завершит операцию. Но тот не завершает, потому что заблокирован кем-то ещё. Случайно создать такую западню в приложении очень легко, а вот просчитать, когда она сработает — трудно. Если без синхронизации можно обойтись, лучше обойдитесь — ради экономии нервов и ресурсов.
Неблокирующие алгоритмы тоже могут вести к проблемам, например, к live-lock. Это ситуация, когда несколько потоков буксуют: продолжают работать, но без реального результата. Причиной может быть загруженность потоков сообщениями друг от друга. Чем больше сообщение, тем больше оно грузит память. Другая возможная причина — неудачная реализация алгоритма, при которой в очереди возникает конфликт. Поэтому начинающим лучше не мастерить велосипед, а сначала разобраться с чужими наработками.
Важно понимать, что панацеи не существует. Использовать блокирующие или неблокирующие алгоритмы, либо их комбинацию — придётся решать в каждом конкретном случае. Всё будет зависеть от структуры вашего приложения и от того, какие ресурсы вы делаете общими.
В прошлой статье мы обсуждали многопоточность. В этот раз поговорим о главной проблеме многопоточных приложений — тупиковых взаимных блокировках, известных как deadlocks. Такие блокировки возникают, когда минимум два потока одновременно пытаются работать с общими ресурсами и ограничить доступ к этим ресурсам друг для друга. При этом часто создаётся ситуация, когда ни один поток не может ни получить нужный ресурс, ни освободить занимаемый. Для блокировки «конкурентов» поток может использовать mutex, критическую секцию или семафор.
Как происходит блокировка
Два потока работают с общими ресурсами.
Поток 1 захватывает Ресурс 1 и начинает операции с ним.
Поток 2 последовательно захватывает Ресурс 2 и Ресурс 1.
Поток 2 не получает доступа к Ресурсу 1 и в ступоре ждёт, когда тот освободится.
Поток 1 не завершил работу с Ресурсом 1, но пытается захватить Ресурс 2 и тоже впадает в ступор.
Как это выглядит в коде:
Видимо-невидимо
Вторая проблема — видимость данных. Если два потока работают с одной переменной, каждый из них хранит её копию в кэше процессора, на котором запущен. Изменения в одной копии не отражаются мгновенно в основной памяти и других копиях. Это ведёт к путанице: одни потоки работают с актуальным значением, другие — с устаревшим.
Есть несколько способов уберечь Java-приложение от «падений» и «зависаний», связанных с противоречиями в работе потоков. Это механизмы synchronized и volatile, алгоритмы, реализованные в классах Java Concurrent, но главное — забота о структуре вашего приложения.
Ключевое слово volatile в Java
Модификатор volatile используют, когда нужно:
Как только один поток записал что-то в volatile-переменную, значение идёт прямо в общую память и тут же доступно остальным потокам:
Но учтите, что модификатор volatile никак не ограничивает одновременный доступ к данным. А значит, в работу одного потока с полем может вмешаться другой поток. Вот что будет, если два потока одновременно получат доступ к операции увеличения на единицу (i++):
Поток 1: читает переменную (0)
Поток 1: прибавляет единицу
Поток 2: читает переменную (0)
Поток 1: записывает значение (1)
Поток 2: прибавляет единицу
Поток 2: записывает значение (1)
Если бы два потока не мешали друг другу, а работали последовательно, мы получили бы на выходе значение «2», но вместо этого видим единицу. Чтобы такого не происходило, нужно обеспечить атомарность операции. Атомарными называют операции, которые могут быть выполнены только полностью. Если они не выполняются полностью, они не выполняются вообще, но прервать их невозможно.
В примере с увеличением на единицу мы видим сразу три действия: чтение, сложение, запись. Чтение и запись — операции атомарные, но между ними могут вклиниться действия другого потока. Поэтому составная операция инкремента (i++) полностью атомарной не является.
Обратите внимание: с volatile-переменной возможны как атомарные, так и неатомарные операции. Ключевое слово volatile позволяет сделать так, чтобы все потоки читали одно и то же из основной памяти, но не более того.
Простейший способ гарантировать атомарность — выстроить потоки в очередь за ресурсами с помощью механизма synchronized. Представьте, что на электронный счёт одновременно переводят деньги два клиента. Уж лучше попросить одного из них немного подождать, чем допустить ошибки в денежных расчетах.
Ключевое слово synchronized
Модификатор synchronized исключает доступ второго и последующих потоков к данным, с которыми уже работает один поток. Это ключевое слово используют только для методов и произвольных блоков кода.
Используйте synchronized, чтобы:
Метод может быть статическим или нет — без разницы. Но синхронизация влияет на видимость данных в памяти. В прошлой статье мы говорили о взаимном исключении (mutex’e). С его помощью synchronized ограничивает доступ к данным. Образно говоря, это замок, с помощью которого поток запирается наедине с объектом, чтобы никто не мешал работать. Обратите внимание: замок запирают до начала работы. То есть проверка, не заняты ли ресурсы кем-то другим, происходит на входе в synchronized-блок или метод.
Разблокировка же ресурсов происходит на выходе. Поэтому атомарность операций гарантирована.
Данные, которые изменились внутри метода или блока sychronized, находятся в кэше поверх основной памяти и видны следующему потоку, к которому перешёл мьютекс.
Подсказки по блокирующей синхронизации
Главное при работе с synchronized — правильно выбрать объект, по которому будет происходить проверка доступности ресурсов. Если вам нужна синхронизация на входе в метод, учитывайте, принадлежит этот метод классу или объекту. Вход в статичный метод блокируют по объекту класса, а в абстрактный метод — по this. Если по ошибке заблокировать метод класса по this, доступ к ресурсам останется открыт для всех.
Никогда не используйте synchronized в конструкторе — получите ошибку компиляции. А ещё остерегайтесь «матрёшек», когда синхронизированные методы одного класса вызывают внутри себя синхронизированные методы других классов.
Помните, что синхронизация требует ресурсов. При обработке большого массива данных вызов мьютексов становится особенно затратным. Чтобы гарантировать атомарность без синхронизации, используют классы Concurrent.
Атомарность с помощью Java Concurrent
Вернёмся к составной операции «чтение-изменение-запись». Когда нам нужно развести потоки по углам, но без мьютекса, можно использовать инструкцию «сравнение с обменом» — compare and swap (CAS).
Для этого сначала заводят переменную, по значению которой можно понять, заняты ли ресурсы и, если да, — кем. Например, пока ресурсы свободны, переменная хранит «-1», а если заняты — номер процессора, который с ними работает (0,1 и т.д.).
Поток приходит за свободными ресурсами, видит «-1», перезаписывает значение на номер процессора, на котором сам работает, выполняет действия. После завершения всех операций переменной возвращается значение «-1». Если же поток на входе видит номер какого-то процессора, он получает отказ и не может выполнить намеченную операцию. Это простейший случай сравнения с обменом. Важно понимать, что поток, который получил отказ, не блокируется. Он может сообщить программе, что у него проблемы, и перейти к запасному плану действий.
Можно сказать, что это более интеллигентная форма взаимодействия между потоками. Они уже не бодаются за ресурс и не закрывают дверь перед носом оппонента, а обмениваются сообщениями в духе: «Хотелось бы поработать вот с этим» — «Извините, оно пока занято. Не желаете ли кофе?».
На этом принципе построен целый ряд алгоритмов синхронизации, которые называют неблокирующими (non-blocking). Создание таких алгоритмов — задача не для новичка. Но, к статью, в Java «из коробки» есть несколько готовых неблокирующих решений. Они собраны в пакете java.util.concurrent.
ConcurrentLinkedQueue
На русский название класса переводится как «параллельная очередь». Работает такая очередь по принципу First In First Out («Первым зашёл — первым выйдешь»). Алгоритм основан на CAS, быстр и оптимизирован под работу со сборщиком мусора.
Если вы хотите создать очередь из объектов, а затем добавлять и удалять их в нужный момент, не нужно писать и синхронизировать методы вручную. Достаточно создать классы для потоков, производящих и потребляющих данные, а затем поставить эти потоки в очередь ConcurrentLinkedQueue.
В прошлой статье мы говорили, что в Java поток можно создать как экземпляр класса Thread или как отдельный класс с интерфейсом Runnable. Сейчас мы используем второй подход. Единственный метод интерфейса Runnable — run(). Чтобы задать нужное поведение для потребителя и производителя, мы будем переопределять этот метод в каждом случае по-своему.
Поток-производитель:
Поток-потребитель:
Обратите внимание, если очередь пуста, метод poll() вернёт значение null. Поэтому нам нужно было убедиться, что он возвращает что-то другое.
Чтобы узнавать, сколько всего элементов в очереди, у класса ConcurrentLinkedQueue есть метод size(). Он работает медленно, поэтому злоупотреблять им не стоит. При необходимости можно вывести весь список элементов очереди методом toArray(), но сейчас нам это не нужно.
Очередь, в которой будут работать потоки:
Запустите и посмотрите, как добавляются и выполняются задачи.
Атомарные классы
Пакет java.util.concurrent.atomic включает в себя классы для работы с:
Давайте посмотрим, как два потока могут работать с переменной AtomicInteger без синхронизации. Для этого создадим класс myThread, в котором будет атомарный счетчик:
Для операций над числами мы использовали метод updateAndGet() класса AtomicInteger. Этот метод увеличивает число на основе аргумента — лямбда-выражения
Теперь посмотрим на всё это в действии. Создадим и запустим два потока myThread:
В результате работы кода получим два значения: первое из них будет случайным числом в диапазоне от 2000 до 4000, а второе — всегда 4000. Например, при первом запуске я получила результат:
Запустите код ещё раз и убедитесь, что меняется только первое значение. Второе число предсказуемо именно благодаря работе атомарного счётчика. Если бы мы использовали для счётчика обычный (неатомарный) Integer, второе число было бы случайным.
Блокирующие и неблокирующие алгоритмы в Java
Блокирующие алгоритмы парализуют работу потока — навсегда или до момента, пока другой поток не выполнит нужное условие. Представьте, что поток A заблокирован и ждёт, когда поток B завершит операцию. Но тот не завершает, потому что заблокирован кем-то ещё. Случайно создать такую западню в приложении очень легко, а вот просчитать, когда она сработает — трудно. Если без синхронизации можно обойтись, лучше обойдитесь — ради экономии нервов и ресурсов.
Неблокирующие алгоритмы тоже могут вести к проблемам, например, к live-lock. Это ситуация, когда несколько потоков буксуют: продолжают работать, но без реального результата. Причиной может быть загруженность потоков сообщениями друг от друга. Чем больше сообщение, тем больше оно грузит память. Другая возможная причина — неудачная реализация алгоритма, при которой в очереди возникает конфликт. Поэтому начинающим лучше не мастерить велосипед, а сначала разобраться с чужими наработками.
Важно понимать, что панацеи не существует. Использовать блокирующие или неблокирующие алгоритмы, либо их комбинацию — придётся решать в каждом конкретном случае. Всё будет зависеть от структуры вашего приложения и от того, какие ресурсы вы делаете общими.