Избегать синхронизации (это) в Java? - PullRequest
358 голосов
/ 14 января 2009

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

Некоторые из приведенных причин:

Другие люди, включая меня, утверждают, что synchronized(this) - это идиома, которая часто используется (также в библиотеках Java), безопасна и понятна. Этого не следует избегать, потому что у вас есть ошибка, и вы не знаете, что происходит в вашей многопоточной программе. Другими словами: если это применимо, используйте его.

Мне интересно посмотреть на некоторые примеры из реальной жизни (без использования foobar), где предпочтительнее избегать блокировки на this, когда synchronized(this) также сделает эту работу.

Поэтому: следует ли вам всегда избегать synchronized(this) и заменить его блокировкой на личную ссылку?


Дополнительная информация (обновляется по мере получения ответов):

  • речь идет о синхронизации экземпляров
  • и неявные (synchronized методы), и явная форма synchronized(this) считаются
  • если вы цитируете Блоха или другие авторитеты по этому вопросу, не пропускайте части, которые вам не нравятся (например, «Эффективная Java», пункт «Безопасность потоков»: Обычно это блокировка самого экземпляра, но Есть исключения.)
  • если вам нужна гранулярность в блокировке, отличной от synchronized(this), то synchronized(this) не применима, поэтому проблема не в этом

Ответы [ 21 ]

121 голосов
/ 14 января 2009

Я рассмотрю каждую точку отдельно.

  1. Какой-то злой код может украсть вашу блокировку (очень популярный, также имеет «случайно» вариант)

    Я больше беспокоюсь о случайно . Это означает, что использование this является частью интерфейса вашего класса и должно быть задокументировано. Иногда требуется способность другого кода использовать вашу блокировку. Это верно для таких вещей, как Collections.synchronizedMap (см. Javadoc).

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

    Это слишком упрощенное мышление; просто избавление от synchronized(this) не решит проблему. Надлежащая синхронизация для пропускной способности займет больше времени.

  3. Вы (излишне) выставляете слишком много информации

    Это вариант № 1. Использование synchronized(this) является частью вашего интерфейса. Если вы не хотите / не нуждаетесь в этом, не делайте этого.

84 голосов
/ 14 января 2009

Ну, во-первых, следует отметить, что:

public void blah() {
  synchronized (this) {
    // do stuff
  }
}

семантически эквивалентно:

public synchronized void blah() {
  // do stuff
}

, что является одной из причин не использовать synchronized(this). Вы можете утверждать, что можете делать вещи вокруг блока synchronized(this). Обычной причиной является попытка вообще избежать синхронизированной проверки, что приводит к всевозможным проблемам параллелизма, в частности к проблеме двойной проверке блокировки , которая просто показывает, насколько трудной может быть сделать относительно простую проверку безопасности потока.

Закрытый замок - это защитный механизм, который никогда не бывает плохой идеей.

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

synchronized(this) просто ничего вам не дает.

51 голосов
/ 14 января 2009

Пока вы используете синхронизированный (это), вы используете экземпляр класса как саму блокировку. Это означает, что в то время как блокировка получена потоком 1, поток 2 должен ждать

Предположим, следующий код

public void method1() {
    do something ...
    synchronized(this) {
        a ++;      
    }
    ................
}


public void method2() {
    do something ...
    synchronized(this) {
        b ++;      
    }
    ................
}

Метод 1, модифицирующий переменную a , и метод 2, модифицирующий переменную b , следует избегать одновременного изменения одной и той же переменной двумя потоками, и это так. НО, в то время как thread1 изменяет a и thread2 , изменяет b , это может быть выполнено без каких-либо условий гонки.

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

Решением является использование 2 различных блокировок для двух различных переменных.

  class Test {
        private Object lockA = new Object();
        private Object lockB = new Object();

public void method1() {
    do something ...
    synchronized(lockA) {
        a ++;      
    }
    ................
}


public void method2() {
    do something ...
    synchronized(lockB) {
        b ++;      
    }
    ................
 }

В приведенном выше примере используются более мелкозернистые блокировки (2 блокировки вместо одной ( lockA и lockB для переменных a и b соответственно ) и в результате позволяет улучшить параллелизм, с другой стороны, он стал более сложным, чем в первом примере ...

14 голосов
/ 14 января 2009

Хотя я согласен с тем, что не следует слепо придерживаться догматических правил, сценарий «воровства замков» кажется вам таким эксцентричным? Поток действительно может получить блокировку вашего объекта «извне» (synchronized(theObject) {...}), блокируя другие потоки, ожидающие синхронизированных методов экземпляра.

Если вы не верите во вредоносный код, учтите, что этот код может быть получен от третьих лиц (например, если вы разрабатываете какой-либо сервер приложений).

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

Так что я согласен со школой мышления "зависит от того, что в классе".


Изменить следующие первые 3 комментария eljenso:

Я никогда не сталкивался с проблемой кражи блокировок, но вот воображаемый сценарий:

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

Я ваш клиент и разверните мой "хороший" сервлет на вашем сайте. Бывает, что мой код содержит вызов getAttribute.

Хакер, замаскированный под другого клиента, размещает свой вредоносный сервлет на вашем сайте. Он содержит следующий код в методе init:

synchronized (this.getServletConfig().getServletContext()) {
   while (true) {}
}

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

Эта атака невозможна, если getAttribute синхронизируется с частной блокировкой, поскольку сторонний код не может получить эту блокировку.

Я допускаю, что пример надуманный и упрощенно дает представление о том, как работает контейнер сервлета, но ИМХО это доказывает это.

Так что я бы сделал свой выбор дизайна исходя из соображений безопасности: получу ли я полный контроль над кодом, который имеет доступ к экземплярам? Каким будет следствие того, что поток удерживает блокировку экземпляра на неопределенный срок?

11 голосов
/ 14 января 2009

Похоже, в лагерях C # и Java существует разное согласие по этому вопросу. Большая часть кода Java, который я видел, использует:

// apply mutex to this instance
synchronized(this) {
    // do work here
}

, тогда как большая часть кода C # выбирает более безопасный:

// instance level lock object
private readonly object _syncObj = new object();

...

// apply mutex to private instance level field (a System.Object usually)
lock(_syncObj)
{
    // do work here
}

Идиома C #, безусловно, безопаснее. Как упоминалось ранее, за пределами экземпляра нельзя получить злонамеренный / случайный доступ к блокировке. В коде Java тоже есть этот риск, , но кажется, что со временем сообщество Java тяготеет к чуть менее безопасной, но немного более краткой версии.

Это не значит, что нужно разбираться с Java, это просто отражение моего опыта работы с обоими языками.

10 голосов
/ 11 сентября 2016

Зависит от ситуации.
Если существует только один объект совместного использования или более одного.

См. Полный рабочий пример здесь

Небольшое вступление.

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

Синхронизированный блок
Блок synchronized () - это способ обеспечить одновременный доступ к разделяемому объекту.
Сначала небольшая аналогия
Предположим, что в умывальной комнате находятся два человека P1, P2 (нити), Раковина (разделяемый объект) и дверь (замок).
Теперь мы хотим, чтобы один человек пользовался раковиной одновременно.
Подход заключается в том, чтобы запереть дверь с помощью P1, когда дверь заперта. P2 ожидает, пока p1 завершит свою работу
P1 отпирает дверь
тогда только p1 может использовать умывальник.

синтаксис.

synchronized(this)
{
  SHARED_ENTITY.....
}

«this» предоставил внутреннюю блокировку, связанную с классом (Java-разработчик разработал класс Object таким образом, чтобы каждый объект мог работать как монитор). Вышеуказанный подход работает нормально, когда есть только одна общая сущность и несколько потоков (1: N).
enter image description here N разделяемых объектов-M потоков
Теперь представьте себе ситуацию, когда внутри умывальника есть две раковины и только одна дверь. Если мы используем предыдущий подход, только p1 может использовать одну раковину за раз, в то время как p2 будет ждать снаружи. Это пустая трата ресурсов, поскольку никто не использует B2 (умывальник).
Более разумным подходом было бы создать меньшую комнату внутри уборной и предоставить им одну дверь на раковину. Таким образом, P1 может получить доступ к B1, а P2 может получить доступ к B2 и наоборот.

washbasin1;  
washbasin2;

Object lock1=new Object();
Object lock2=new Object();

  synchronized(lock1)
  {
    washbasin1;
  }

  synchronized(lock2)
  {
    washbasin2;
  }

enter image description here
enter image description here

Подробнее о темах ----> здесь

7 голосов
/ 14 января 2009

Пакет java.util.concurrent значительно уменьшил сложность моего потокаобезопасного кода. У меня есть только неподтвержденные доказательства, но большинство работ, которые я видел с synchronized(x), похоже, заново реализуют блокировку, семафор или защелку, но используют мониторы более низкого уровня.

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

6 голосов
/ 18 апреля 2016
  1. Сделайте ваши данные неизменными, если это возможно (final переменные)
  2. Если вы не можете избежать мутации общих данных в нескольких потоках, используйте высокоуровневые программные конструкции [например, гранулированный Lock API]

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

Пример кода для использования ReentrantLock, который реализует Lock интерфейс

 class X {
   private final ReentrantLock lock = new ReentrantLock();
   // ...

   public void m() {
     lock.lock();  // block until condition holds
     try {
       // ... method body
     } finally {
       lock.unlock()
     }
   }
 }

Преимущества блокировки по сравнению с синхронизированным (это)

  1. Использование синхронизированных методов или операторов заставляет все получение и снятие блокировки происходить блочно-структурированным способом.

  2. Реализации блокировки предоставляют дополнительные функциональные возможности по сравнению с использованием синхронизированных методов и операторов, предоставляя

    1. Неблокирующая попытка получить блокировку (tryLock())
    2. Попытка получить блокировку, которая может быть прервана (lockInterruptibly())
    3. Попытка получить блокировку, которая может прерваться (tryLock(long, TimeUnit)).
  3. Класс Lock также может предоставлять поведение и семантику, которые сильно отличаются от неявной блокировки монитора, например

    1. гарантированный заказ
    2. без повторного использования
    3. Обнаружение тупика

Посмотрите на этот вопрос SE относительно различного типа Locks:

Синхронизация против блокировки

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

Блокировка объектов поддерживает блокировку идиом, которые упрощают многие параллельные приложения.

Исполнители определяют высокоуровневый API для запуска и управления потоками. Реализации исполнителя, предоставляемые java.util.concurrent, обеспечивают управление пулом потоков, подходящее для крупномасштабных приложений.

Параллельные коллекции упрощают управление большими коллекциями данных и могут значительно сократить потребность в синхронизации.

Атомные переменные имеют функции, которые минимизируют синхронизацию и помогают избежать ошибок согласованности памяти.

ThreadLocalRandom (в JDK 7) обеспечивает эффективное генерирование псевдослучайных чисел из нескольких потоков.

См. Также java.util.concurrent и java.util.concurrent.atomic для других программных конструкций.

5 голосов
/ 14 января 2009

Если вы решили, что:

  • вам нужно заблокировать текущий объект; и
  • вы хотите заблокировать его с зернистостью меньше, чем целый метод;

тогда я не вижу табу на синхронизацию (это).

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

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

4 голосов
/ 04 ноября 2014

Я думаю, что есть хорошее объяснение того, почему каждый из этих жизненно важных приемов стоит у вас в голове, в книге Брайана Гетца «Практический параллелизм Java» Он делает одно замечание очень ясным - вы должны использовать один и тот же замок «ВЕЗДЕ» для защиты состояния вашего объекта. Синхронизированный метод и синхронизация на объекте часто идут рука об руку. Например. Вектор синхронизирует все свои методы. Если у вас есть дескриптор векторного объекта и вы собираетесь выполнить команду «положить, если отсутствует», то простая синхронизация Vector с собственными индивидуальными методами не защитит вас от искажения состояния. Вам нужно синхронизировать с помощью synchronized (vectorHandle). Это приведет к тому, что ЖЕ блокировка будет получена каждым потоком, имеющим дескриптор вектора, и защитит общее состояние вектора. Это называется блокировкой на стороне клиента. Мы действительно знаем, что вектор синхронизирует (это) / синхронизирует все свои методы и, следовательно, синхронизация на объекте vectorHandle приведет к правильной синхронизации состояния векторных объектов. Глупо полагать, что вы потокобезопасны только потому, что используете потокобезопасную коллекцию. Именно поэтому ConcurrentHashMap явно ввел метод putIfAbsent - чтобы сделать такие операции атомарными.

В итоге

  1. Синхронизация на уровне метода позволяет блокировку на стороне клиента.
  2. Если у вас есть объект частной блокировки - блокировка на стороне клиента невозможна. Это хорошо, если вы знаете, что ваш класс не имеет функциональности типа «положить, если отсутствует».
  3. Если вы проектируете библиотеку - тогда синхронизация по этому или синхронизация метода часто более разумна. Потому что вы редко можете решить, как будет использоваться ваш класс.
  4. Если бы Вектор использовал объект закрытого замка - было бы невозможно получить правильное выражение "положить, если отсутствует". Код клиента никогда не получит дескриптор частной блокировки, нарушая тем самым основное правило использования EXACT SAME LOCK для защиты своего состояния.
  5. Синхронизация в этом или синхронизированных методах действительно имеет проблему, как указывали другие: кто-то может получить блокировку и никогда не снимать ее. Все остальные потоки будут ждать, пока не будет снята блокировка.
  6. Так знай, что ты делаешь, и прими то, что правильно.
  7. Кто-то утверждал, что наличие объекта частной блокировки обеспечивает лучшую детализацию - например, если две операции не связаны - они могут быть защищены различными блокировками, что приведет к лучшей пропускной способности. Но я думаю, что это запах дизайна, а не запах кода - если две операции совершенно не связаны, почему они являются частью одного и того же класса? Почему в классном клубе вообще не должно быть функциональных возможностей? Может быть, служебный класс? Хмммм - какой-то утилитой, обеспечивающей манипулирование строками и форматирование календарной даты в одном экземпляре ... не имеет никакого смысла для меня по крайней мере !!
...