IllegalMonitorStateException. Красим код в поисках монитора

от автора

Всем привет! Меня зовут Евгений, я — Java-разработчик, и мне хотелось бы поделиться с тобой, дорогой читатель, историей, которая заставила множество разработчиков провести бессонные ночи в поисках истины, а SRE-инженеров написать не один Post Mortem. История достаточно интересная, ведь речь пойдет о багах JDK, поведении JVM и JIT, о коварстве API библиотек, которым мы привыкли доверять, и о том, как привычный, на первый взгляд, код может преподнести неожиданные сюрпризы.

Важно

Проблема, описанная в статье, воспроизводится на далеко не новых версиях зависимостей:

  • OpenJDK 17

  • Spring Kafka 2.8.11.

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

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

Введение

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

На просторах интернета с 2015 года существует уже, кажется ставшая легендарной, статья Боба Нистрома «Какого цвета ваша функция?» (Bob Nystrom «What Color Is Your Function?»). Я не ставлю перед собой задачи пересказать ее, тем более на Хабре существуют статьи по теме:

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

  • Каждая функция имеет цвет

  • Цвет влияет на способ вызова функции

  • Асинхронные функции — красные

  • Красные функции больнее вызывать

  • Только красная функция может вызвать красную функцию

Последний аргумент говорит о том, что асинхронные функции в некоторых языках программирования » заразные». Например, корутины с suspend fun в Kotlin заставит весь вызывающий ее код стать suspend до тех пор, пока мы не упремся в runBlocking блок.

Не совсем относится к теме текущей статьи, однако я не могу не отметить очень важный факт. Существует две парадигмы реализации корутин — stackful coroutines (fiber, green thread, virtual thread, user-mode thread, goroutine) и stackless coroutines (state machine, coroutine).

В случае со stackful coroutine сущности исполнения принадлежит собственный полноценный стек вызовов и собственный набор регистров процессора, сохраняемые при приостановке. Переключение происходит в user space, без участия ядра ОС, кооперативно, т.е. приостановка инициируется самим кодом, а не таймером ядра.

В случае со stackless coroutines у сущности исполнения нет отдельного стека. Компилятор статически анализирует тело async-функции, выделяет все live-переменные через каждую suspend точку и генерирует конечный автомат (state machine). Переключение происходит через коллбэки.

Так вот проблема «цвета функций» относится к языкам, использующих подход stackless coroutines.

Для чего я вспомнил это легендарное чтиво? Давайте запомним, что код имеет «цвет». И иногда это не проблема, а очень удобная абстракция мышления, причем актуальная и для stackless, и для stackful подхода.

Проблема

Представьте себе такую картину. У нас есть продюсер, который уведомляет клиента о завершении операции (например, завершение регистрации). Он всего лишь делегирует вызов в Spring Kafka Template и добавляет коллбэки:

    private fun sendEventToKafka(    message: Message<V>,    mdcContextMap: Map<String, String>?,) {    kafkaTemplate.send(message).apply {        processCallback(mdcContextMap)    }}private fun ListenableFuture<SendResult<K, V>>.processCallback(    mdcContextMap: Map<String, String>?,) {    addCallback({        if (mdcContextMap != null) MDC.setContextMap(mdcContextMap)        logger.info { "Message $eventName successfully sent to kafka" }    }, {        if (mdcContextMap != null) MDC.setContextMap(mdcContextMap)        logger.error(it) { "Failed to send $eventName to kafka: ${it.message}" }    })}

На первый взгляд все логично. На error ветке коллбэка ловим ошибку и пишем ERROR лог. Более того, такой способ реализации продюсеров очень распространенный, т.к. интуитивно понятен. Я видел огромное количество кода, где используется такой подход (возможно я не прав, прошу тебя, читатель, возразить мне).

Однако нас ждет первый сюжетный твист. В таком варианте коллбэки не всегда работают. И это, безусловно, вина API Spring Kafka. Чуть позже разберем почему. Напомню одну важную деталь: kafkaTemplate.send(message) возвращает CompletableFuture/ListenableFuture, что говорит нам о неблокирующей природе метода.

В чем же здесь проблема? Данная конфигурация кафка-продюсера приводит к ошибке IllegalMonitorStateException: current thread is not owner. Причем если мы поймаем эту ошибку на инстансе приложения, то она будет выбрасываться до бесконечности, пока вручную не перезагрузим экземпляр. Еще раз пересмотрите код выше и задайте себе вопрос: «Что же в нем необычного?».

Поиск источника проблемы

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

at PlannerUseCase$makeDecisionsByTasksAsync$1.invokeSuspend(PlannerUseCase.kt:130) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33)

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

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

  • Мы вызываем KafkaTemplate в корутине

  • Там происходит сетевой вызов

  • API неблокирующий. Вспомнте, что возвращает kafkaTemplate.send(message)

Давайте попробуем поменять диспатчер на более подходящий для такого сценария — Dispatchers.IO.

    private suspend fun sendEventToKafka(    message: Message<V>,    mdcContextMap: Map<String, String>?) {    withContext(Dispatchers.IO + MDCContext()) {        kafkaTemplate.send(message).apply {            processCallback(mdcContextMap = mdcContextMap)        }    }}

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

Предлагаю продолжить рассуждение. Если Dispatchers.IO не сработал, давайте вообще заставим корутину выполняться строго на том же треде, чтобы не терять монитор. Благо у нас есть подходящий диспатчер и для этого случая.

    private suspend fun sendEventToKafka(    message: Message<V>,    mdcContextMap: Map<String, String>?) {    withContext(Dispatchers.Unconfined + MDCContext()) {        kafkaTemplate.send(message).apply {            processCallback(mdcContextMap = mdcContextMap)        }    }}

Чтож, Dispatchers.Unconfined тоже не помог. И вот здесь все стандартные или, корректнее сказать, интуитивные варианты, как мне кажется, закончились (если ты, дорогой читатель, не согласен — приглашаю тебя к обсуждению). Значит, причина скрывается от нас гораздо глубже или в другом месте.

Коварный API Spring Kafka

Не буду вас утомлять брождением по коду Spring Kafka, поэтому сразу перейдем к интересному.

// org.apache.kafka.clients.producer.KafkaProducerprivate ClusterAndWaitTime waitOnMetadata(String topic, Integer partition, long nowMs,    long maxWaitMs) throws InterruptedException {  //...  try {    metadata.awaitUpdate(version, remainingWaitMs);  } catch (TimeoutException ex) {    // Rethrow with original maxWaitMs to prevent logging exception with remainingWaitMs    throw new TimeoutException(        String.format("Topic %s not present in metadata after %d ms.",            topic, maxWaitMs));  }  //...  return new ClusterAndWaitTime(cluster, elapsed);}

Уже за пределами Spring, в клиентском коде Kafka Producer, мы видим метод waitOnMetadata(...) и делегирование awaitUpdate() объекту Metadata. Я специально начал анализ именно отсюда, т.к. когда я разбирал цепочку вызовов, слово wait в названии метода меня насторожило. Почему? Я вам напомню — API Spring Kafka говорит нам о том, что вызов неблокирующий, ведь он возвращает CompletableFuture/ListenableFuture.

Код ProducerMetadata (реализация Metadata):

// org.apache.kafka.clients.producer.internals.ProducerMetadatapublic synchronized void awaitUpdate(final int lastVersion, final long timeoutMs)    throws InterruptedException {  long currentTimeMs = time.milliseconds();  long deadlineMs = currentTimeMs + timeoutMs < 0 ? Long.MAX_VALUE : currentTimeMs + timeoutMs;  time.waitObject(this, () -> {    // Throw fatal exceptions, if there are any. Recoverable topic errors will be handled by the caller.    maybeThrowFatalException();    return updateVersion() > lastVersion || isClosed();  }, deadlineMs);  if (isClosed())    throw new KafkaException("Requested metadata update after close");}

Тут всё становится совсем интересным. Мы видим synchronized-блок. Если прочитать JavaDoc, то можно понять, что функция waitOnMetadata(...) в классе KafkaProducer отвечает за ожидание обновления метаданных о брокерах Kafka. Если метаданные ещё не загружены или устарели, продюсер блокирует выполнение, чтобы дождаться их обновления. Как мы понимаем, здесь могут быть:

  • Ошибки сети

  • Исключительные ситуации в целом

Что это для нас значит?

Это значит, API Spring Kafka нас обманывает или, корректнее сказать, недоговаривает:

  1. Не говорит явно о блокирующих вызовах

  2. Что более интересно, как я и говорил ранее — наши коллбэки не работают

    private fun sendEventToKafka(    message: Message<V>,    mdcContextMap: Map<String, String>?,) {    kafkaTemplate.send(message).apply {        processCallback(mdcContextMap)    }}private fun ListenableFuture<SendResult<K, V>>.processCallback(    mdcContextMap: Map<String, String>?,) {    addCallback({        if (mdcContextMap != null) MDC.setContextMap(mdcContextMap)        logger.info { "Message $eventName successfully sent to kafka" }    }, {        if (mdcContextMap != null) MDC.setContextMap(mdcContextMap)        logger.error(it) { "Failed to send $eventName to kafka: ${it.message}" }    })}

Почему? Потому что вся эта логика, где может быть выброшена ошибка, работает до запуска асинхронной операции и формирования Future. Следовательно, если вы хотите, например, писать метрику или лог для ошибок с метадатой, это не будет работать внутри коллбэка, который мы добавили. Нужно оборачивать вызов метода kafkaTemplate.send(message) в try-catch, иначе мы никак не обнаружим error rate именно этой части функционала.

    private fun sendEventToKafka(    message: Message<V>,    mdcContextMap: Map<String, String>?) {    runCatching {        kafkaTemplate.send(message).apply {            processCallback(mdcContextMap = mdcContextMap)        }    }.getOrElse {        /*        * Блок нужен для обработки ошибок блокирующих операций. Смотри:        * org.apache.kafka.clients.producer.KafkaProducer.waitOnMetadata(KafkaProducer.java:1149) ->        * org.apache.kafka.clients.producer.internals.ProducerMetadata.awaitUpdate(ProducerMetadata.java:120) ->        * org.apache.kafka.common.utils.SystemTime.waitObject(SystemTime.java:55)        * */        logger.error(it) {            "Failed kafka template send method invocation for event $eventName: ${it.message}}"        }    }}

Ответьте сами себе на вопрос: «Насколько это (контр)интуитивно?». Что бы вы написали в ревью кода, если бы увидели одновременно try-catch и коллбэк? Оставлю этот вопрос открытым.

Продолжим разбор.

// org.apache.kafka.clients.producer.internals.ProducerMetadatapublic synchronized void awaitUpdate(final int lastVersion, final long timeoutMs)    throws InterruptedException {  //...  time.waitObject(this, () -> {    //...  }, deadlineMs);  //...}

synchronized-блок объявлен на уровне метода. Это значит, что исполняющий поток захватит монитор текущего объекта, т.е. metadata. Далее вызывается метод time.waitObject(...), в который передаём this (т.е. metadata) и какую-то лямбду.

Код SystemTime (реализация Time):

  public void waitObject(Object obj, Supplier<Boolean> condition, long deadlineMs)    throws InterruptedException {  synchronized (obj) {    while (true) {      if (condition.get())        return;      long currentTimeMs = milliseconds();      if (currentTimeMs >= deadlineMs)        throw new TimeoutException("Condition not satisfied before deadline");      obj.wait(deadlineMs - currentTimeMs);    }  }}

Видим ещё один synchronized-блок, в котором уже явно захвачен монитор объекта, который передали как параметр функции, и как мы помним, это metadata. А в конце итерации цикла while (true) вызывается метод obj.wait(...). Это значит, что мы пытаемся освободить монитор metadata, т.е. объекта, владельцем которого являемся.

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

  1. Ошибки всегда возникали во время сетевых сбоев или минут недоступности брокеров.

  2. Монитор терялся именно на metadata.

Глубокое погружение

Что мы выяснили на данном этапе:

  1. Смена диспатчеров не помогает

  2. KafkaTemplate Spring Kafka блокирующий и будет выбрасывать ошибки при сетевых задержках

  3. Блокируемся двойным synchronized-блоком на ожидании метаданных

Но на этом пока что все. Ни место выброса ошибки, ни реализация понимания механики проблемы не дает. Смущает только одно — double synchronized техника. Где-то на уровне интуиции всегда «вижу double synchronized — жду подвоха» (все-таки опыт собеседований дает о себе знать).

Что может сказать Google насчет этой проблемы? Существует тикет в Jira Kafka, там описан именно наш кейс. Упоминается именно тот же код с Metadata. У нас теплится надежда на комментарии, но тикет статусе Open с 2021 года, и дискуссия по нему закончена в 22-м году.

Этот баг также упоминается в тикете OpenJDK JDK-8298446. И в нем мы находим очень важную зацепку: «Ошибка возникает после того, как JIT-компилятор оптимизирует метод KafkaProducer.waitOnMetadata. В логе JIT видно, что метод компилируется дважды: сначала после ошибки в библиотеке Kafka (timestamp 1445193), а затем снова после исчезновения проблемы (timestamp 1445640). После второй компиляции начинается постоянное возникновение IllegalMonitorStateException

В библиотеке Kafka выбрасывается TimeoutException при ожидании метаданных. И действительно, возникшая ошибка будет повторяться постоянно, пока мы не убьём наш экземпляр приложения. Именно такую картину мы и наблюдали. Все сходится.

Почему же метод может компилироваться дважды? Как мы с вами понимаем, для всех JIT-оптимизаций наших приложений нам нужно достаточно места в области памяти CodeCache. А если CodeCache мал, JIT-компилятор может чаще перекомпилировать методы из-за вытеснения старого кода, что увеличивает вероятность возникновения багов, связанных с оптимизациями.

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

  1. Лямбды инлайнятся

  2. Escape-анализ, который проверяет, «убегает» ли объект за пределы метода/потока (доступен ли извне). И как следствие из него:

    1. Устранение аллокаций объектов функциональных интерфейсов

    2. Удаление блокировок (удаление инструкций monitorenter и monitorexit)

    3. Spinlock-style locking, который говорит нам о том, что если блокировка удерживается очень короткое время, дешевле подождать активно, чем блокировать поток.

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

Если с лямбдами и устранением аллокаций всё довольно очевидно, то вот в удаление блокировок верится с трудом, если честно. Давайте проверим, верны ли ли эти предположения.

Lock Elision

Рассмотрим демонстрационный пример.

public class Main {  private static int counter = 0;  private static void perform() {    Object lock = new Object();    synchronized (lock) {      counter++; // полезная работа    }  }  static void main(String[] args) {    for (int i = 0; i < 1_000_000; i++) {      perform();    }    System.out.println(counter);  }}

Важно

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

Запускаем с флагами:

-XX:+UnlockDiagnosticVMOptions-XX:-EliminateLocks // отключена оптимизация-XX:+PrintAssembly-XX:CompileCommand=compileonly,Main::perform

обратите внимание, оптимизация Lock Elision специально отключена.

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

Итак, оптимизация Lock Elision специально отключена. Видим инструкции, связанные с монитором, и нативные x86-64 байты (41 ff 42 70) — это выход из синхронизированной секции.

А если запустим тот же код, но с включённой оптимизацией, то увидим настоящую магию!

Обратите внимание, упоминаний монитора нет! Видна отметка safepoint poll — штатная точка остановки потока, которую HotSpot вставляет на входе в метод. Но ключевое здесь не safepoint, а именно отсутствие инструкций monitorenter/monitorexit — блокировка устранена. Интригующе? Лично я считаю, что да.

Более того, есть тесты, которые прямо говорят нам о том, что такое поведение ожидаемо:

  • Тест прямо в исходном коде OpenJDK, который демонстрирует, что Escape Analysis определяет, что объект не escapes из метода.

  • Тест Lock Elision на основе JMH от Алексея Шипилёва.

Spinlock

Этот механизм включается, когда блокировка удерживается очень короткое время, а значит второму потоку легче потратить такты процессора на ожидание, чем реально захватывать монитор. В нашем сценарии так и происходит — конкурентность изначально низкая, и ресурс блокируется на очень короткое время, если с сетью всё хорошо. Это значит, что даже если блокировка всё ещё существует, высока вероятность, что блокировка останется в дешёвой форме stack-locking (быстрый CAS-захват), и не дойдёт до инфляции в тяжёлый ObjectMonitor. А сама инфляция не бесплатна и происходит не мгновенно.

Inlining

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

Механика проблемы

Имея эти знания, попробуем ответить на вопрос: «Что же всё-таки происходит?». У нас есть такой код, который:

  1. Содержит лямбды

  2. Содержит несколько вложенных synchronized-блоков

  3. Синхронизируется ресурс, к которому обращаемся редко

  4. Сам ресурс получаем быстро (для сети), за миллисекунды.

  5. У нас есть корутины, из которых вызывается код библиотеки kotlinx.coroutines, практически полностью состоящей из лямбда-выражений.

at kafka.BaseKafkaProducer$sendEventToKafka$2.invokeSuspend(BaseKafkaProducer.kt:73) at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.internal.DispatchedContinuationKt.resumeCancellableWith(DispatchedContinuation.kt:367) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable(Cancellable.kt:30) at kotlinx.coroutines.intrinsics.CancellableKt.startCoroutineCancellable$default(Cancellable.kt:25) at kotlinx.coroutines.BuildersKt__Builders_commonKt.withContext(Builders.common.kt:172) at kotlinx.coroutines.BuildersKt.withContext(Unknown Source) at kafka.BaseKafkaProducer.sendEventToKafka(BaseKafkaProducer.kt:71)...at kotlin.coroutines.jvm.internal.BaseContinuationImpl.resumeWith(ContinuationImpl.kt:33) at kotlinx.coroutines.DispatchedTask.run(DispatchedTask.kt:108) at kotlinx.coroutines.EventLoopImplBase.processNextEvent(EventLoop.common.kt:280) at kotlinx.coroutines.BlockingCoroutine.joinBlocking(Builders.kt:85)at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking(Builders.kt:59) at kotlinx.coroutines.BuildersKt.runBlocking(Unknown Source)at kotlinx.coroutines.BuildersKt__BuildersKt.runBlocking$default(Builders.kt:38) at kotlinx.coroutines.BuildersKt.runBlocking$default(Unknown Source)

Проблема: лямбда-выражения с таким огромным уровнем вложенности создают нагрузку на JIT-компилятор и CodeCache (область памяти для хранения скомпилированного кода). При доступности брокера мы получаем такой профиль исполнения участков кода, который позволяет JIT заинлайнить код, а самое главное — удалить синхронизацию из определенных горячих блоков. Примерное и условное визуальное представление этого:

Однако, когда начинаются сетевые задержки (как говорится, следите за руками):

  • характер профиля исполнения резко меняется

  • начинают греться другие участки кода

  • не вписываемся в лимиты max inline ни на одном из уровней компиляции (C1/C2)

  • Размер CodeCache не позволяет хранить все оптимизации

  • JIT вынужден деоптимизировать код

Проверим это на мониторинге JVM:

Именно эту картину мы и наблюдаем: «гуляющий» CodeCache (постоянные циклы компиляции/деоптимизации).

JIT вынужден деоптимизировать код, в том числе и нашу лямбду. И здесь же весьма вероятна деоптимизация от «лёгкой» блокировки, к тяжелой, что происходит не моментально. В результате получаем ситуацию, когда пытаемся обратиться к монитору, владельцем которого мы уже не являемся. Происходит примерно это:

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

И из этого же становится понятно, почему смена диспатчеров корутин не помогает. Когда нагрузка вырастает:

  • Пул потоков диспатчера не может выделить новый поток (Dispatcher.IO – 64 потока)

  • Поступающие задачи запускаются как корутины и начинают накапливаться в очереди, которые затем стартуют в тех же потоках

Результат: проблема с монитором остаётся, так как корутины всё равно выполняются в существующих потоках с битым состоянием синхронизации.

И как вишенка на торте, если все вышесказанное было неубедительным (жду вас в комментариях почему): один из пользователей Reddit переписал код ProducerMetadata.awaitUpdate() без лямбда-выражений и смог избавиться от бага.

Фикс

Как выглядит итоговый фикс? Очевидно, что нужно дать «выдохнуть» JIT-компилятору, чтобы весь скомпилированный код помещался в CodeCache и порядок деоптимизаций не влиял на рантайм негативно ( если деоптимизации вообще будут нужны).

Новая ментальная модель

Очевидно, что причина нашей проблемы — баг в OpenJDK, но триггер — наличие в нашем коде корутин. А нужны ли они вообще в нашем сценарии?

Если вы дочитали до этого момента, то я надеюсь, что у вас возник вопрос к автору статьи. Вопрос: «А причем здесь, собственно говоря, «цвет» функций, с упоминания которого началась статья?».

Сейчас будет обещанный сюжетный твист.

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

Итак, в Java существуют функции, которые используют synchronized-блоки. Они сильно конфликтуют с красными функциями. suspend-функции в корутинах могут:

  1. Приостанавливаться

  2. Переключаться между потоками. А synchronized-блоки рассчитаны на то, что поток, который их захватил, будет удерживать блокировку до конца.

Мое предложение: давайте окрасим их в зеленый.

Что может произойти, если мы будем смешивать красный и зеленый цвет? Корутина НЕ может мигрировать на другой поток. Другие корутины не могут использовать этот поток. Или с другой стороны: корутина приостановится, но монитор НЕ освободится. Сумма этих фактов может привести к целому каскаду проблем, а не только той, которую мы разбираем в данной статье.

Более того, я предлагаю ввести т.н. «презумпцию виновности». Любой код из сторонних библиотек\написанный другим разработчиком должен по умолчанию быть окрашен в зеленый. Иными словами, разработчик, работая в асинхронном контексте, должен задаваться вопросом: «А не вызываю ли я блокирующую (зеленую) функцию, если использую готовый код из другой библиотеки?».

В нашем сценарии эта ментальная модель подвергла бы сомнению использование корутин (иными словами красных функций) там, где они не нужны (ведь зачем нам вызывать «зеленые» функции из «красных», если они не совместимы?). Тем самым мы бы исключили триггер (не причину!) и не создали бы лишнюю нагрузку на CodeCache и ThreadPool, на котором построен диспатчер. И может быть, никогда бы не узнали о существовании бага в OpenJDK, ведь предпосылок его обнаружить стало бы гораздо меньше. Как бы выглядел наш код, использующий предложенную мной парадигму изначально:

А для индустрии в целом (имея в виду stackless и stackfull подход в асинхронности) эта ментальная модель помогла бы избежать проблем, описанных в довольно известных статьях:

И это говорит и о важности проблемы, и самое главное, о востребованности способов их решить или предотвратить.

Послесловие

Я сделал оговорку, что «презумпция виновности» в зеленом цвете устранила бы именно триггер, а не причину IllegalMonitorStateException. Иными словами, используя ее, мы максимально снизим вероятность этой ошибки в клиентском коде Kafka. Почему я использую слово «вероятность»?

Дело в том, что ПРЯМО В ИСХОДНОМ КОДЕ JDK ЕСТЬ ТЕСТ, ГДЕ ПРОВЕРЯЕТСЯ НЕКОРРЕКТНОЕ ПОВЕДЕНИЕ МОНИТОРА ПРИ АГРЕССИВНЫХ ОПТИМИЗАЦИЯХ, используемых во вложенных synchronized блоках: «Nested locks optimization may create unbalanced monitor enterexit code». Тест говорит о том, что при агрессивной оптимизации компилятор может создать несбалансированные пары monitorenter/monitorexit в байт-коде, что может привести к Deadlock’ам и… IllegalMonitorStateException!

Вывод

Надеюсь, ты, мой дорогой читатель, нашел что-то полезное в этой детективной истории, и я очень надеюсь, что ты сделаешь выводы, которые позволят тебе писать работающий и безопасный код 🙂

Напоследок:

  • Следи за своим CodeCache

  • Не доверяй библиотечным функциям (помни про «зеленый» цвет по умолчанию, пока не докажешь обратное)

  • Береги себя и своих близких!

ссылка на оригинал статьи https://habr.com/ru/articles/1064528/