Перейти к основному содержимому

Плохая практика с синхронизацией

· 6 мин. чтения

1. Обзор

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

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

2. Принцип синхронизации

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

Другими словами, использовать для синхронизации объединенные в пул или многократно используемые объекты — плохая практика . Причина в том, что объединенный/повторно используемый объект доступен другим процессам в JVM, и любое изменение таких объектов внешним/ненадежным кодом может привести к взаимоблокировке и недетерминированному поведению.

Теперь давайте обсудим принципы синхронизации, основанные на определенных типах, таких как String , Boolean , Integer и Object .

3. Строковый литерал

3.1. Плохая практика

Строковые литералы объединяются и часто повторно используются в Java. Поэтому не рекомендуется использовать тип String с ключевым словом synchronized для синхронизации :

public void stringBadPractice1() {
String stringLock = "LOCK_STRING";
synchronized (stringLock) {
// ...
}
}

Точно так же, если мы используем литерал private final String , на него по-прежнему ссылаются из постоянного пула:

private final String stringLock = "LOCK_STRING";
public void stringBadPractice2() {
synchronized (stringLock) {
// ...
}
}

Кроме того , считается плохой практикой интернировать String для синхронизации:

private final String internedStringLock = new String("LOCK_STRING").intern();
public void stringBadPractice3() {
synchronized (internedStringLock) {
// ...
}
}

Согласно Javadocs , внутренний метод дает нам каноническое представление для объекта String . Другими словами, внутренний метод возвращает String из пула — и явно добавляет его в пул, если его там нет — с тем же содержимым, что и эта String .

Таким образом, проблема синхронизации повторно используемых объектов сохраняется и для интернированного объекта String .

Примечание. Все строковые литералы и константные выражения со строковым значением автоматически интернируются .

3.2. Решение

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

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

Затем мы сохраняем объект закрытым и окончательным , чтобы предотвратить доступ к нему любого внешнего/ненадежного кода:

private final String stringLock = new String("LOCK_STRING");
public void stringSolution() {
synchronized (stringLock) {
// ...
}
}

4. Логический литерал

Тип Boolean с двумя значениями, true и false , не подходит для целей блокировки. Подобно строковым литералам в JVM, логические литеральные значения также совместно используют уникальные экземпляры логического класса.

Давайте посмотрим на пример плохого кода, синхронизирующего объект логической блокировки:

private final Boolean booleanLock = Boolean.FALSE;
public void booleanBadPractice() {
synchronized (booleanLock) {
// ...
}
}

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

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

5. Примитив в штучной упаковке

5.1. Плохая практика

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

Например, давайте напишем пример плохого кода, синхронизирующего коробочный тип Integer :

private int count = 0;
private final Integer intLock = count;
public void boxedPrimitiveBadPractice() {
synchronized (intLock) {
count++;
// ...
}
}

5.2. Решение

Однако, в отличие от логического литерала, решение для синхронизации примитива в штучной упаковке заключается в создании нового экземпляра.

Подобно объекту String , мы должны использовать ключевое слово new для создания уникального экземпляра объекта Integer с его собственной внутренней блокировкой и сохранения его приватным и окончательным :

private int count = 0;
private final Integer intLock = new Integer(count);
public void boxedPrimitiveSolution() {
synchronized (intLock) {
count++;
// ...
}
}

6. Синхронизация классов

JVM использует сам объект в качестве монитора (свою встроенную блокировку), когда класс реализует синхронизацию методов или синхронизацию блоков с ключевым словом this .

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

6.1. Плохая практика

Например, давайте создадим класс Animal с синхронизированным методом setName и методом setOwner с синхронизированным блоком:

public class Animal {
private String name;
private String owner;

// getters and constructors

public synchronized void setName(String name) {
this.name = name;
}

public void setOwner(String owner) {
synchronized (this) {
this.owner = owner;
}
}
}

Теперь давайте напишем плохой код, который создает экземпляр класса Animal и синхронизируется с ним:

Animal animalObj = new Animal("Tommy", "John");
synchronized (animalObj) {
while(true) {
Thread.sleep(Integer.MAX_VALUE);
}
}

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

6.2. Решение

Решением для предотвращения этой уязвимости является объект приватной блокировки .

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

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

Итак, давайте внесем необходимые изменения в наш класс Animal :

public class Animal {
// ...

private final Object objLock1 = new Object();
private final Object objLock2 = new Object();

public void setName(String name) {
synchronized (objLock1) {
this.name = name;
}
}

public void setOwner(String owner) {
synchronized (objLock2) {
this.owner = owner;
}
}
}

Здесь, для лучшего параллелизма, мы детализировали схему блокировки, определив несколько закрытых объектов окончательной блокировки, чтобы разделить наши проблемы синхронизации для обоих методов — setName и setOwner .

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

private static int staticCount = 0;
private static final Object staticObjLock = new Object();
public void staticVariableSolution() {
synchronized (staticObjLock) {
count++;
// ...
}
}

7. Заключение

В этой статье мы обсудили несколько неправильных практик, связанных с синхронизацией определенных типов, таких как String , Boolean , Integer и Object .

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

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

Как обычно, исходный код доступен на GitHub .