4 пункта, чтобы прояснить разницу между синхронизированным и изменчивым в Java

Java
Автор: Холлис

Просмотрите два ключевых слова: синхронизированный и изменчивый.

1. Чтобы решить проблемы атомарности, видимости и упорядоченности в параллельном программировании, язык Java предоставляет ряд ключевых слов, связанных с параллельной обработкой, таких как синхронизированные, изменчивые, окончательные, параллельные пакеты и т. д.
2. С помощью блокировки, synchronized можно использовать как одно из решений, когда требуются три характеристики атомарности, видимости и упорядоченности, что кажется «универсальным». Действительно, большинство одновременных операций управления можно выполнить с помощью synchronized.
3. Volatile обеспечивает видимость и упорядоченность переменных в параллельных сценариях, вставляя барьеры памяти до и после операции с volatile-переменными.
4. Ключевое слово volatile не может гарантировать атомарность, а synchronized может гарантировать, что код, измененный с помощью synchronized, может быть доступен только одному потоку одновременно с помощью двух инструкций monitorenter и monitorexit, что гарантирует, что больше не будет квантов процессорного времени. , Переключение между потоками обеспечивает атомарность.
Затем мы знаем, что два ключевых слова, синхронизированный и изменчивый, — это два ключевых слова, которые часто используются в параллельном программировании на Java, и из предыдущего обзора мы знаем, что синхронизированный может гарантировать отсутствие атомарности, видимости и параллелизма в параллельном программировании. Проблема упорядочения, а volatile может гарантировать только видимость и упорядоченность, тогда что синхронизируется, почему volatile?
Далее в этой статье будет обсуждаться, почему ключевое слово synchronized уже существует в Java, а также предоставляется ключевое слово volatile.

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

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

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

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

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

Что касается принципа реализации синхронизации, будь то метод синхронизации или блок кода синхронизации, будь то ACC_SYNCHRONIZED или monitorenter и monitorexit, все они реализованы на основе Monitor.
На основе объекта Monitor, когда несколько потоков одновременно обращаются к фрагменту кода синхронизации, они сначала войдут в Entry Set.Когда один поток получает блокировку объекта, он может войти в область владельца, а другие потоки будут продолжать ждать во входном наборе. А когда поток вызывает метод ожидания, он снимет блокировку и войдет в набор ожидания для ожидания.
Таким образом, блокировка, реализованная синхронизацией, по сути является блокирующей блокировкой, а это означает, что несколько потоков должны стоять в очереди для доступа к одному и тому же общему объекту.
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 не выполнит этот шаг заново.
Однако проблема в том, что описанный выше процесс не является атомарной операцией, и компилятор может изменить порядок, если вышеуказанные шаги перегруппировать в:
  • А. 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   }  

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

Как насчет гарантии заказа Synchronized?

Увидев это, некоторые друзья могут спросить.В конечном счете, вышеуказанная проблема заключается в том, что произошла перестановка инструкций.На самом деле, это все еще проблема упорядочения.Разве не сказано, что синхронизация может гарантировать упорядоченность?Почему она не может работать здесь?
Во-первых, понятно, что synchronized не может запретить переупорядочивание команд и оптимизацию процессора. Так как же он гарантирует порядок?
Это немного расширяет концепцию заказа. Естественное упорядочение в программах на Java можно описать одним предложением: если оно наблюдается в этом потоке, все операции упорядочены естественным образом. Если один поток наблюдает за другим потоком, все операции выполняются не по порядку.
Приведенное выше предложение также является исходным предложением в «Глубоком понимании виртуальной машины Java», но как его понять? Чжоу Чжимин подробно не объяснил. Здесь я просто расширяю, это на самом деле связано с как бы серийной семантикой.
Смысл семантики «как если бы» означает, что результат выполнения однопоточной программы не может быть изменен, как бы он ни был переупорядочен. Компиляторы и процессоры должны подчиняться семантике «как если бы» независимо от того, как они оптимизируют.
Семантика "как-будто-последовательно" здесь не описывается, проще говоря, семантика "как-будто-последовательно" гарантирует, что в одном потоке, как бы ни переставлялись инструкции, окончательный результат выполнения не может быть изменен.
Итак, давайте вернемся к примеру с блокировкой с двойной проверкой только что. проблема с этой оптимизацией. Это не повлияет на результат выполнения этого потока.
Однако изменение порядка инструкций внутри Thread1 влияет на Thread2.
Тогда мы можем сказать, что порядок, гарантированный синхронизацией, — это порядок между несколькими потоками, то есть заблокированное содержимое должно выполняться несколькими потоками по порядку. Однако код внутренней синхронизации все равно будет переупорядочиваться, но, поскольку и компилятор, и процессор следуют семантике «как если бы-последовательно», мы можем считать, что эти переупорядочивания игнорируются в рамках одного потока.

Суммировать

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

Наконец

Прошу всех обратить внимание на мой официальный аккаунт [Программист в погоне за ветром], в нем будут обновляться статьи, а также размещаться отсортированная информация.