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 .