Клас дедлоков про дедлок класів



Чи знаєте ви, як уникнути дедлоков у своїй програмі? Так, цьому вчать, про це запитують на співбесідах… І тим не менш, взаємні блокування зустрічаються навіть у популярних проектах серйозних компаній на кшталт Google. А в Java є особливий клас дедлоков, пов'язаний з ініціалізацією класів, пробачте за каламбур. Такі помилки легко допустити, але важко зловити, тим більше, що сама віртуальна машина вводить програміста в оману.

Сьогодні піде мова про взаємні блокування при ініціалізації класів. Я розповім, що це таке, проілюструю прикладами з реальних проектів, попутно знайду багу в JVM, і покажу, як не допустити такі блокування в своєму коді.



Дедлок без локов
Якщо я попрошу вас навести приклад взаємної блокування на Java, швидше за все, побачу код з парою synchronized або ReentrantLock. А як щодо дедлока взагалі без synchronized та java.util.concurrent? Повірте, це можливо, причому дуже лаконічним і простим способом:

static class A {
static final B b = new B();
}

static class B {
static final A a = A new();
}

public static void main(String[] args) {
new Thread(A::new).start();
new B();
}

Справа в тому, що згідно §5.5 специфікації JVM у кожного класу є унікальний initialization lock, який захоплюється на час ініціалізації. Коли інший потік спробує звернутися до инициализируемому класу, він буде заблокований на цьому локе до завершення ініціалізації першим потоком. При конкурентній ініціалізації декількох посилаються один на одного класів неважко натрапити на взаємне блокування.

Саме це і сталося, наприклад, у проекті QueryDSL:

public final class Ops {
public static final Operator<Boolean> EQ = new OperatorImpl<Boolean>(NS, "EQ");
public static final Operator<Boolean> NE = new OperatorImpl<Boolean>(NS, "NE");
...

public final class OperatorImpl<T> implements Operator<T> {

static {
try {
// initialize all fields of Ops
List<Field> fields = new ArrayList<Field>();
fields.addAll(Arrays.asList(Ops.class.getFields()));
for (Class<?> cl : Ops.class.getClasses()) {
fields.addAll(Arrays.asList(cl.getFields()));
}
...

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

 

Проблема курки і яйця
У точності такий же дедлок може виникнути всякий раз, коли в статичному инициализаторе створюється екземпляр класу нащадка. По суті це приватний випадок описаної вище проблеми, оскільки ініціалізація нащадка автоматично призводить до ініціалізації батьків (див. JVMS §5.5). На жаль, такий шаблон можна зустріти досить часто, особливо коли клас батька абстрактний:

public abstract class ImmutableList<E> ... {

private static final ImmutableList<Object> EMPTY =
new RegularImmutableList<Object>(ObjectArrays.EMPTY_ARRAY);

Це реальний фрагмент коду з бібліотеки Google Guava. Завдяки йому частина наших серверів після чергового апдейта намертво підвисла при запуску. Як з'ясувалося, причиною тому послужило оновлення Guava з версії 14.0.1 до 15.0, де і з'явився нещасливий шаблон неправильної статичної ініціалізації.

Звичайно ж, ми повідомили про помилку, і через деякий час її виправили в репозиторії, однак будьте уважні: останній на момент написання статті публічний реліз Guava 18.0 все ще містить помилку!

 

В одну сходинку
Java 8 подарувала нам Стріми і Лямбды, а разом з ними і новий головний біль. Так, тепер можна красиво одним рядком у функціональному стилі оформити цілий алгоритм. Але при цьому можна і так само, одним рядком, вистрілити собі в ногу.

Хочете вправа для самоперевірки? Я склав програму, яка обчислює суму ряду; що вона надрукує?

public class StreamSum {
static final int SUM = IntStream.range(0, 100).parallel().reduce((n, m) -> n + m).getAsInt();

public static void main(String[] args) {
System.out.println(SUM);
}
}

А тепер приберіть .parallel() або, як варіант, замініть на лямбду Integer::sum — що-небудь зміниться?

Так у чому ж справа?Тут знову має місце дедлок. Завдяки директиві parallel() згортка стріму виконується в окремому пулі потоків.
З цих потоків тепер викликається тіло лямбды, записане в байткоде у вигляді спеціального private static методу всередині того ж класу StreamSum. Але цей метод не може бути викликаний, поки не завершиться статичний инициализатор класу, який в свою чергу очікує обчислення згортки.

пеклаЗовсім підриває мозок те, що наведений фрагмент працює по-різному в різних середовищах. На однопроцесорній машині він відпрацює коректно, а на багатопроцесорної, швидше за все зависне. Причина криється в механіці паралелізму стандартного Fork-Join пулу.

Перевірте самі, запускаючи приклад з різним значенням
-Djava.util.concurrent.ForkJoinPool.common.parallelism=N


Лукавий Хотспот
Зазвичай дедлоки легко виявити з Thread Dump: проблемні потоки будуть висіти в змозі BLOCKED або WAITING і JVM в стектрейсах покаже, які монітори той чи інший потік тримає, а які намагається захопити. Так йде справа з нашими прикладами? Візьмемо перший, з класами A і B. Дочекаємося зависання і знімемо thread dump (з допомогою утиліти jstack або клавішами Ctrl+\ в Linux або Ctrl+Break Windows):

"Thread-0" #12 prio=5 os_prio=0 tid=0x000000001a098800 nid=0x1cf8 in Object.wait() [0x000000001a95e000]
java.lang.Thread.State: RUNNABLE
at Example1$A<clinit>(Example1.java:4)
at Example1$$Lambda$1/1418481495.run(Unknown Source)
at java.lang.Thread.run(Thread.java:745)

"main" #1 prio=5 os_prio=0 tid=0x000000000098e800 nid=0x23b4 in Object.wait() [0x000000000228e000]
java.lang.Thread.State: RUNNABLE
at Example1$B.<clinit>(Example1.java:8)
at Example1.main(Example1.java:13)

Ось наші потоки. Обидва зависли в межах статичного ініціалізатор <clinit>, але при цьому обидва RUNNABLE! Якось не стикується зі здоровим глуздом, не обманює нас JVM?

Особливість initialization lock полягає в тому, що Java програми його не видно, а захоплення і звільнення відбувається всередині віртуальної машини. Строго кажучи, по специфікації Thread.State тут не може бути ні BLOCKED (бо немає synchronized блоку), ні WAITING (оскільки методи Object.wait, Thread.join LockSupport.park тут не викликаються). Більш того, initialization lock взагалі не зобов'язаний бути Java об'єктом. Таким чином, формально єдиним допустимим станом залишається RUNNABLE.

На цю тему є давній баг JDK-6501158, закритий «Not an issue», і сам Девід Холмс мені в листуванні зізнався, що у нього немає ні часу, ні бажання повертатися до цього питання.

Якщо неочевидне стан потоку ще можна вважати «фичей», то іншу особливість initialization lock інакше як «багом» не назвеш. Розбираючись з проблемою, я наткнувся в исходниках HotSpot на дивність у відправці JVMTI повідомлень: подія MonitorWait надсилається функції JVM_MonitorWait, відповідної Java-методом Object.wait, в той час як симетричне йому подію MonitorWaited надсилається з низькорівневої функції ObjectMonitor::wait.

Як ми вже з'ясували, для очікування initialization lock метод Object.wait не викликається, таким чином, подій MonitorWait для них ми не побачимо, зате MonitorWaited будуть приходити, як і для звичайних Java-моніторів, що, погодьтеся, не логічно.

Знайшов помилку — повідомте розробнику. Такого правила дотримуємося і ми: JDK-8075259.

 

Висновок
Для забезпечення потокобезопасной ініціалізації класів JVM використовує синхронізацію на невидимому програмісту initialization lock, що є в кожного класу.

Неакуратне написання инициализаторов може призвести до дедлокам. Щоб цього уникнути
  • слідкуйте за тим, щоб статичні инициализаторы не зверталися до інших неинициализированным класів;
  • не створюйте в статичних инициализаторах примірники дочірніх класів;
  • не створюйте потоки і уникайте конкурентного виконання коду в статичних инициализаторах;
  • нікому не довіряйте; вивчайте вихідний код і повідомляйте про помилки, знайдених в інших проектах.
За результатами аналізу дедлоков ініціалізації були виявлені помилки в Querydsl, Guava і HotSpot JVM.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.