Поскольку синхронизированный является «универсальным», зачем вам volatile?

Java

GitHub 6.6k Star Путь Java-инженера к тому, чтобы стать богом, разве вы не хотите узнать об этом?

GitHub 6.6k Star Путь Java-инженера к тому, чтобы стать богом, разве вы не хотите узнать об этом?

GitHub 6.6k Star Путь Java-инженера к становлению богом, вы действительно уверены, что не хотите узнать об этом?

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

Кратко рассмотрим соответствующий контент:

1. Чтобы решить проблемы атомарности, видимости и упорядоченности в параллельном программировании, язык Java предоставляет ряд ключевых слов, связанных с параллельной обработкой, таких как синхронизированные, изменчивые, окончательные, параллельные пакеты и т. д. (Если кто-то спросит вас, что такое модель памяти Java, отправьте ему эту статью.)

2. С помощью блокировки, synchronized можно использовать как одно из решений, когда требуются три характеристики атомарности, видимости и упорядоченности, что кажется «универсальным». Действительно, большинство одновременных операций управления можно выполнить с помощью synchronized.Если кто-то спросит вас, что такое synchronized, пришлите ему эту статью.

3. Volatile обеспечивает видимость и упорядоченность переменных в одновременных сценариях, вставляя барьеры памяти до и после операции с volatile-переменными.Если кто-то спросит вас, что такое volatile, отправьте ему и эту статью.

4. Ключевое слово volatile не может гарантировать атомарность, а synchronized может гарантировать, что код, измененный с помощью synchronized, может быть доступен только одному потоку одновременно с помощью двух инструкций monitorenter и monitorexit, что гарантирует, что больше не будет квантов процессорного времени. , Переключение между потоками обеспечивает атомарность.В чем проблема многопоточности в параллельном программировании на Java?

Затем мы знаем, что два ключевых слова, синхронизированный и изменчивый, — это два ключевых слова, которые часто используются в параллельном программировании на Java, и из предыдущего обзора мы знаем, что синхронизированный может гарантировать отсутствие атомарности, видимости и параллелизма в параллельном программировании. Проблема заказа и изменчивость могут гарантировать только видимость и порядок, тогдаИ синхронизированный, и изменчивый?

Проблема с синхронизацией

Все мы знаем, что synchronized на самом деле является механизмом блокировки, поэтому, поскольку это блокировка, у него, естественно, есть следующие недостатки:

1. Есть потеря производительности

Хотя для синхронизации в JDK 1.6 было сделано много оптимизаций, таких как адаптивное вращение, устранение блокировок, огрубление блокировок, облегченные блокировки и смещенные блокировки (Глубокое понимание многопоточности (5) - технология оптимизации блокировок виртуальной машины Java), но он все-таки своего рода замок.

Все приведенные выше оптимизации пытаются избежать мониторинга (монитор (Глубокое понимание многопоточности (4) - принцип реализации Monitor) для блокировки, но не все случаи можно оптимизировать, а даже если и оптимизировать, то процесс оптимизации занимает много времени.

Следовательно, независимо от того, используется ли метод синхронизации или блок кода синхронизации, все равно необходимо заблокировать перед операцией синхронизации и разблокировать после операции синхронизации.Этот процесс блокировки и разблокировки требует потери производительности.

Что касается сравнения производительности двух, нам трудно количественно оценить разрыв в производительности между ними из-за множества исключений и оптимизаций, реализованных виртуальной машиной для блокировок, но основной принцип, который мы можем определить, таков: производительность чтения Работа с volatile-переменными Небольшие обычные переменные почти неразличимы, но операции записи выполняются медленнее из-за необходимости вставлять барьеры памяти, но даже при этом volatile в большинстве случаев дешевле блокировок.

2. заблокировать

мы вГлубокое понимание многопоточности (1) - принцип реализации SynchronizedПредставленный в реализации принцип синхронизации, будь то метод синхронизации или блок кода синхронизации, будь то ACC_SYNCHRONIZED или monitorenter, monitorexit основаны на Monitor.

На основе объекта Monitor, когда несколько потоков получают одновременный доступ к фрагменту кода синхронизации, они сначала входят в Entry Set.Когда один поток получает блокировку объекта, он может войти в область Owner, а другие потоки будут продолжать ждать во входном наборе. И когда поток вызывает метод ожидания, он снимает блокировку и переходит в состояние ожидания.

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

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

Дополнительные возможности volatile

В дополнение к лучшей производительности volatile по сравнению с synchronized, о которой мы упоминали ранее, volatile на самом деле имеет хорошую дополнительную функцию, которая заключается в запрещении перестановки инструкций.

Давайте сначала возьмем пример, чтобы увидеть, что произойдет, если мы используем только синхронизированный, а не volatile, давайте возьмем одноэлементный шаблон, с которым мы более знакомы.

Мы реализуем синглтон путем двойной проверки блокировки, и ключевое слово volatile здесь не используется:

 1   public class Singleton {  
 2      private static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

В приведенном выше коде, используя synchronized для блокировки Singleton.class, мы можем гарантировать, что только один поток может одновременно выполнять содержимое в синхронизированном блоке кода, то есть операция singleton = new Singleton() будет выполняться только один раз, который реализован как синглтон.

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

Мы предполагаем, что когда два потока Thread1 и Thread2 одновременно запрашивают метод Singleton.getSingleton:

Step1, Thread1 выполняется до строки 8 и начинает инициализировать объект.

Step2, Thread2 выполняется до строки 5 и определяет, что singleton == null.

Step3, Thread2 обнаружил синглтон после решения! = null, поэтому выполняется строка 12 и возвращается синглтон.

Шаг 4, после того как Thread2 получает объект singleton, он начинает выполнять последующие операции, такие как вызов singleton.call().

Описанный выше процесс не кажется проблемой, но на самом деле на шаге 4, когда Thread2 вызывает singleton.call(), можно выдать исключение нулевого указателя.

Все NPE будут выброшены, потому что на шаге 3 одноэлементный объект, полученный Thread2, не является полным объектом.

Давайте проанализируем, что делает строка кода singleton = new Singleton(); Общий процесс выглядит следующим образом:

1. Виртуальная машина встречает новую инструкцию и находит символическую ссылку этого класса в пуле констант.

2. Проверьте, был ли загружен, разрешен и инициализирован класс, представленный символической ссылкой.

3. Виртуальная машина выделяет память для объекта.

4. Виртуальная машина инициализирует выделенное пространство памяти нулевым значением.

5. Виртуальная машина производит необходимые настройки объекта.

6. Выполните метод и инициализируйте переменные-члены.

7. Укажите ссылку объекта на эту область памяти.

Давайте упростим этот процесс в 3 шага:

А. JVM выделяет часть памяти M для объекта

б.Инициализировать объект в памяти M

в) Скопируйте адрес памяти M в одноэлементную переменную

Поскольку присвоение адреса памяти переменной singleton является последним шагом, перед тем, как Thread1 выполнит этот шаг, решение Thread2 относительно singleton==null всегда будет истинным, поэтому он будет продолжать блокироваться до тех пор, пока Thread1 не выполнит этот шаг Finish.

Однако описанный выше процесс не является атомарной операцией, и компилятор может изменить порядок, если вышеуказанные шаги перегруппированы в:

А. JVM выделяет часть памяти M для объекта

в. Скопируйте адрес памяти в переменную singleton

б.Инициализировать объект в памяти M

В этом случае Thread1 сначала выполнит выделение памяти, затем присвоит переменную и, наконец, выполнит инициализацию объекта.То есть, когда Thread1 не инициализировал объект, Thread2 может войти, чтобы определить, что singleton==null и получить ложное , будет возвращен неполный объект Sigleton, поскольку он еще не был инициализирован.

Как только это произойдет, мы получим незавершенный объект-синглтон, и при попытке использовать этот объект с большой долей вероятности возникнет исключение NPE.

Итак, как решить эту проблему? Поскольку переупорядочивание инструкций вызывает эту проблему, достаточно избегать переупорядочения инструкций.

Таким образом, volatile пригодится, потому что volatile может избежать перестановки инструкций. Эту проблему можно решить, просто изменив код на следующий код:

 1   public class Singleton {  
 2      private volatile static Singleton singleton;  
 3       private Singleton (){}  
 4       public static Singleton getSingleton() {  
 5       if (singleton == null) {  
 6           synchronized (Singleton.class) {  
 7               if (singleton == null) {  
 8                   singleton = new Singleton();  
 9               }  
 10           }  
 11       }  
 12       return singleton;  
 13       }  
 14   }  

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

Как насчет гарантии заказа синхронизированного?

Увидев это, некоторые друзья могут спросить.Ведь вышеописанная проблема все-таки проблема упорядоченности.Разве не сказано, что синхронизация может гарантировать упорядоченность?Почему она не может работать здесь?

Прежде всего ясно, что:Synchronized не может запретить переупорядочивание инструкций и оптимизацию процессора. Так как же он гарантирует порядок?

Это немного расширяет концепцию заказа. Естественное упорядочение в программах на Java можно описать одним предложением: если оно наблюдается в этом потоке, все операции упорядочены естественным образом. Если один поток наблюдает за другим потоком, все операции выполняются не по порядку.

Приведенное выше предложение также является исходным предложением в «Глубоком понимании виртуальной машины Java», но как его понять? Чжоу Чжимин подробно не объяснил. Здесь я просто расширяю, это на самом деле связано с как бы серийной семантикой.

Смысл семантики «как если бы» означает, что результат выполнения однопоточной программы не может быть изменен, как бы он ни был переупорядочен. Компиляторы и процессоры должны подчиняться семантике «как если бы» независимо от того, как они оптимизируют.

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

Итак, давайте вернемся к примеру с блокировкой с двойной проверкой. с этой оптимизацией проблем нет, она не повлияет на результат выполнения этого потока.

Однако изменение порядка инструкций внутри Thread1 влияет на Thread2.

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

Суммировать

В этой статье обсуждается важность и незаменимость volatile с двух точек зрения:

С одной стороны, поскольку synchronized является механизмом блокировки, возникают проблемы с блокировкой и производительностью, а volatile не является блокировкой, поэтому проблем с блокировкой и производительностью нет.

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