Команда Spring АйО подготовила перевод разбора реального бага в HotSpot от разработчика OpenJDK. Во время работы над Project Valhalla его Java-объекты и классы начали «исчезать» без участия сборщика мусора — и поиск причины привёл к одному неверному биту в заголовке объекта, miscompilation в C2 и очень нетривиальному отладочному квесту. Этот текст показывает, как устроены mark word и Compact Object Headers, чем живёт Valhalla и как системное мышление плюс флаги JVM помогают выловить самые коварные ошибки.

Комментарий от Михаила Поливахи

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

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


Сегодня я расскажу о недавнем приключении в роли разработчика виртуальной машины HotSpot для проекта OpenJDK. Во время тестирования новой фичи я внезапно заметил, что мои Java-объекты и классы самопроизвольно исчезают! Дальше начался, пожалуй, самый интересный отладочный квест в моей жизни (по крайней мере, пока) — и я решил поделиться им с миром.

Этот текст рассчитан на широкую аудиторию, знакомство с Java или JVM не требуется — достаточно лёгкого любопытства к низкоуровневому программированию. Цели текста:

  • познакомить с Project Valhalla и value-объектами;

  • показать, как устроен HotSpot изнутри (и, возможно, вдохновить кого-то поучаствовать);

  • на практике продемонстрировать, как флаги JVM могут помочь разработчику;

  • поделиться уроками, которые я вынес из отладки;

  • задокументировать процесс для себя и коллег;

  • ну и дать себе возможность похвастаться достижением (и нарисовать ужасное ASCII-искусство).

Получилось много. В начале каждого раздела есть краткое резюме без выводов. Оно самодостаточно, так что можете читать выборочно то, что интересно. Хотя, честно говоря, я думаю, вы не пожалеете прочитав весь пост целиком.

Введение

Итак, что же я делал? Я менял структуру markWord, чтобы привести её к формату, описанному в JEP 450 для Project Valhalla. Скорее всего, эти термины вам пока ни о чём не говорят, так что давайте разбираться по порядку.

Быстрый ликбез по Java

Java — универсальный язык программирования. Код компилируется в платформенно-независимый Java-байткод. Этот байткод исполняется виртуальной машиной Java, которая, помимо прочего, занимается автоматической сборкой мусора. Референсная реализация JVM называется HotSpot. Она включает JIT-компилятор, который превращает часть часто выполняемых Java-методов в машинный код, чтобы повысить производительность.

Заголовки объектов

Java-объекты живут в куче. У каждого объекта есть метаданные, хранящиеся в заголовке объекта. Если вам нужно вынести из этого раздела что-то одно — запомните: внутри заголовка есть несколько очень интересных битов.

Традиционно заголовок состоит из mark word (в исходниках — markWord) и class word (указатель на класс, который нам сегодня совершенно не нужен). В сумме это 96–128 бит на 64-битных архитектурах. О mark word я расскажу подробнее чуть позже.

Чтобы уменьшить размер заголовка, в JDK 24 появился JEP 450: Compact Object Headers. Вкратце: выяснилось, что можно уместить указатель на класс внутрь mark word, и тогда class word становится не нужен. В результате в режиме Compact Object Headers заголовок объекта занимает 64 бита на 64-битных системах.

Что же хранится в mark word? JEP 450 хорошо объясняет точную разметку, но для этой статьи важны только одиннадцать младших бит. На 64-битных системах они выглядят так:

    7   3  0
 VVVVAAAASTT
          ^^----- tag-биты
         ^------- self-forwarding bit (GC)
     ^^^^-------- age-биты (GC)
 ^^^^------------ Valhalla-резерв

Коротко о значении каждого поля:

  • TT — два tag-бита, используются для блокировок. В мониторинге мы разбираться не будем, но если кратко: 01 означает, что объект не заблокирован, а 00 и 10 — что он находится в лёгкой или мониторной блокировке соответственно.

  • S — self-forwarding bit. Его используют некоторые сборщики мусора, детали нам не важны.

  • AAAA — age-биты. Поколенческие сборщики мусора используют их, чтобы отслеживать «возраст» объекта.

  • VVVV — четыре Valhalla-зарезервированных бита (в реализации называются unused_gap_bits). О них позже.

Проект Valhalla

Project Valhalla часто называют «эпическим рефакторингом Java» — в нём разрабатывается целый набор фич. Нас здесь интересует JEP 401: Value Classes and Objects. Если коротко, value-объекты определяются исключительно своими полями, что позволяет применять такие оптимизации, как flattening и скаляризация. Обычные классы/объекты в Valhalla называются identity-классами/объектами.

Valhalla — это форк JDK. Хотя мы регулярно подтягиваем изменения из основной ветки, Valhalla неизбежно отстаёт от мейнлайна. Когда Compact Object Headers вливали в Valhalla, для одиннадцати младших бит выбрали чуть иной формат, чтобы интеграция прошла менее болезненно. Формат выглядел так (в сравнении с JEP 450):

    7   3  0
 VVVVAAAASTT  <- JEP 450
 VVVAAAAVSTT  <- Valhalla
        ^------- этот бит нам важен — бит value object

Как видно, младший из Valhalla-битов стоял под age-битами. Обратите внимание на V в позиции 3. Именно этот бит, когда он установлен, показывает, что объект — value-объект. Это ключевое понятие всей истории. Биты 8–10 можно смело игнорировать.

Зачем вообще нужен этот бит? Потому что HotSpot в множестве мест ведёт себя по-разному для value-объектов и identity-объектов (например, применяет упомянутые оптимизации). Нам нужен быстрый и простой способ узнать тип объекта — логично хранить такой флажок прямо в заголовке.

Моё изменение

Изменение было довольно простым: обновить разметку одиннадцати младших бит с VVVAAAAVSTT на VVVVAAAASTT (как требует JEP 450). Можно представить это как перенос value-object-бита выше (или, наоборот, age-битов ниже).

Итог

mark word — часть заголовка объекта, содержащая метаданные Java-объекта. В Project Valhalla появились value-объекты — сущности, определяемые исключительно значениями своих полей. В Valhalla один из битов заголовка указывает, что объект относится к value-объектам. Раньше этот бит находился на позиции 3, но его нужно было перенести на позицию 7, чтобы соответствовать Compact Object Headers (JEP 450).

Массовые сбои

Само изменение было довольно простым. Увы, оно привело к 75 упавшим тестам на разных архитектурах и платформах. Проблема этих падений была в том, что они одновременно были:

  • массовыми — ломались разные подсистемы (в основном за пределами самой VM);

  • нестабильными — иногда проходили, иногда падали. Как позже выяснилось, их ещё и сложно воспроизвести;

  • нетривиальными — в VM у нас огромное количество ассершенов. Обычно, если что-то идёт не так, падает асёршен или JVM крашится, например, по segmentation fault. Здесь же почти все ошибки всплывали на уровне прикладного кода.

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

Комментарий от Михаила Поливахи

Исходный код только ядра Spring Framework (без учёта Spring Cloud, Spring Boot и т.д.) имеет около 1.5 млн. строк кода: https://openhub.net/p/spring

Просто вдумайтесь в это. То есть, грубо, в 3 раза больше, чем HotSpot. Это фантастика.

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

Симптом A: упавшие unit-тесты

В HotSpot есть юнит-тесты (на GoogleTest). Четыре теста markWord падали:

[  FAILED  ] 4 tests, listed below:
[  FAILED  ] markWord.inline_type_prototype_vm
[  FAILED  ] markWord.null_free_flat_array_prototype_vm
[  FAILED  ] markWord.nullable_flat_array_prototype_vm
[  FAILED  ] markWord.null_free_array_prototype_vm

Симптом B: java.lang.AssertionError

Несколько тестов вроде java/util/jar/JarFile/mrjar/MultiReleaseJarProperties.java или
java/util/logging/modules/GetResourceBundleTest.java падали в TestNG с AssertionError.

java.lang.AssertionError: l should not be null
    at org.testng.ClassMethodMap.removeAndCheckIfLast(ClassMethodMap.java:55)
    ...
    at com.sun.javatest.regtest.agent.MainActionHelper$AgentVMRunnable.run(...)
    at java.base/java.lang.Thread.run(Thread.java:1474)

Что особенно странно: в исходниках переменную l явно проверяют на null, так что она не может просто исчезнуть!

public boolean removeAndCheckIfLast(ITestNGMethod m, Object instance) {
  Collection<ITestNGMethod> l = classMap.get(instance);
  if (l == null) {
    throw new IllegalStateException(
        "Could not find any methods associated with test class instance " + instance);
  }
  l.remove(m);
  for (ITestNGMethod tm : l) { // <- тут выбрасывается AssertionError
    if (tm.getEnabled() && tm.getTestClass().equals(m.getTestClass())) {
      return false;
    }
  }
  return true;
}

Симптом C: java.lang.NoClassDefFoundError

Некоторые тесты внутри sun/security/krb5, например
sun/security/krb5/etype/UnsupportedKeyType.java, валились с NoClassDefFoundError. Пропадающий класс различался от теста к тесту — и даже от запуска к запуску одного и того же теста. Классика нестабильного поведения.

java.lang.NoClassDefFoundError: sun/security/krb5/internal/Krb5
    at java.base/jdk.internal.loader.NativeLibraries.load(Native Method)
    ...
    at java.base/java.lang.Thread.run(Thread.java:1474)

Итог

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

Акт I: Семантические трудности

Самым простым входом в проблему были упавшие unit-тесты. Во-первых, они написаны на C++, а во-вторых, напрямую вызывают тестируемые функции. Ошибка была простой:

EXPECT_TRUE(mark.decode_pointer() == nullptr);

mark — это markWord value-объекта. Заглядываем в исходники:

// Recover address of oop from encoded form used in mark
inline void* decode_pointer() const {
  return (EnableValhalla && _value < static_prototype_value_max) ? nullptr :
    (void*) (clear_lock_bits().value());
}

Сразу стало понятно, что здесь есть какое-то Valhalla-специфичное колдовство (EnableValhalla). Но что такое static_prototype_value_max?

Static Prototypes

В markWord.hpp нашлось несколько связанных констант:

static const uintptr_t static_prototype_mask    = ...;
static const uintptr_t static_prototype_mask_in_place = static_prototype_mask << lock_bits;
static const uintptr_t static_prototype_value_max = (1 << age_shift) - 1;

Оказалось, что static_prototype_mask и static_prototype_mask_in_place вообще нигде не используются. А static_prototype_value_max — это просто маска, где все биты до age-битов равны 1. Напомню: прежний формат имел биты в виде AAAAVSLL. Значит, маска была 1111. Но я изменил расположение age-битов — маска тоже изменилась. Возможно, тесты ломаются из-за этого?

Зачем вообще нужен этот код? В доках пишут:

//  Static types bits are recorded in the "klass->prototype_header()", displaced
//  mark should simply use the prototype header as "slow path", rather chasing
//  monitor or stack lock races.

Что за static type bits? Что за static type? Без понятия. Коллеги сказали, что это часть старой модели Valhalla, которая уже не актуальна. Вердикт: удалить условие и весь код, связанный со static types. Я так и сделал — после лёгкой правки юнит-тесты стали зелёными, но остальные ошибки никуда не делись.

Эмуляция

И в теории, и на практике нас учат: сначала пытайся понять, что происходит. Но это не всегда применимо к HotSpot — его никто не понимает целиком. Знания очень фрагментированы, и небольшое изменение в одной части может нарушить скрытое допущение в другой — и вызвать загадочные сбои.

Чтобы не свихнуться от попыток всё это увязать, я решил попробовать эмулировать старое поведение decode_pointer(): вручную переводить новый markWord в старый формат и затем оставлять проверку static type. Вдруг где-то в VM был какой-то невидимый инвариант, который делал старую схему рабочей. Если бы эмуляция заработала, это подсказало бы, куда копать.

Но эмулировать было сложно:

  • Любое изменение markWord приводило к пересборке огромного числа файлов — по ~10 минут на билд. Очень мешает, когда пытаешься быстро экспериментировать.

  • Чтобы запускать тесты (включая unit-тесты), надо собрать JDK. Чтобы собрать JDK, нужен javac, а javacиспользует HotSpot. Если ты внёс критическую ошибку в HotSpot — крашится сборка JDK, и ты вообще не можешь добраться до C++-тестов.

  • Больше всего мешал вот этот ассёршен:

markWord m = markWord::encode_pointer_as_mark(p);
assert(m.decode_pointer() == p, "encoding must be reversible");

Не важно, что именно делает код — важно, что преобразование markWord должно быть обратимым. А мои изменения в decode_pointer() ломали эту гарантию.

Из-за этого я просто скопировал весь markWord.{hpp,cpp} в отдельный маленький C++-проект и начал экспериментировать там. Цикл “поправил — собрал — проверил” стал быстрее, и я мог тестировать обратимость без риска положить сборку JDK. Не изящно, но работало.

Увы, даже с эмуляцией тесты всё равно падали. Те же AssertionError и NoClassDefFoundError никуда не исчезли. После этого я откинул эмуляцию, снова выкинул код про static types, обновил unit-тесты и перестал тратить на это время.

Итог

Разбирая упавшие unit-тесты, я выяснил, что они проверяли семантику, которой в коде уже не существует. Да, я удалил мёртвый код и обновил тесты, но основная лавина ошибок на уровне Java-приложений осталась нетронутой.

Акт II. Сократить, воспроизвести и переработать

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

Сбои в прикладном коде порой невероятно сложно отлаживать. Особенно тяжело, когда непонятно, где именно зародилась проблема. Если HTTP-сервер падает с ошибкой, вероятнее всего, дело в контроллере этого роута или в цепочке middleware. Это не значит, что разобраться легко (особенно когда нужно докопаться до причины каскадного падения микросервисов!), но по крайней мере есть точка отсчёта.

У HotSpot много взаимодействующих компонент, так что нередко это добавляет задаче дополнительных измерений. Во временной оси ошибка может проявиться намного позже, чем была внесена. Например, только во время десятого цикла сборки мусора. Локализация тоже бывает совсем не очевидной. Представим miscompilation: она может изменить состояние переменной и нарушить инвариант в компоненте виртуальной машины, который вообще никак не связан с местом ошибки. Проверки-утверждения помогают не дать багу распространиться слишком далеко, но не гарантируют локальность.

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

План действий

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

  • Когда мы падаем? Может ли измениться вероятность падения, если что-то увеличить или уменьшить?

  • Где мы падаем? Этот пункт включает несколько подпунктов:

    • В каком компоненте или фиче (например, сборка мусора, Compact Object Headers) проявляется баг?

    • Есть ли подозрительные места в исходниках HotSpot?

    • Где именно в прикладном коде проявляется ошибка?

C2

Поверх байткод-интерпретатора в HotSpot есть два JIT-компилятора: C1 и C2. «Того самого JIT-компилятора» не существует. C1 компилирует быстро, но делает меньше оптимизаций; C2 генерирует крайне оптимизированный машинный код, но работает заметно дольше. Раньше клиентские VM (для конечных пользователей) поставлялись с C1, а серверные — с C2. Сейчас же и C1, и C2 участвуют в процессе tiered compilation. При этом JIT-компилируются далеко не все методы: HotSpot активно профилирует выполнение, чтобы решить, какие методы и каким компилятором стоит компилировать.

В кодовой базе есть шесть сборщиков мусора; какие из них попадают в вашу JDK — зависит от вашего вендора ПО для разработчиков. В моей конфигурации доступны Serial и Parallel, G1 и Z.

Комментарий от Михаила Поливахи

OpenJDK имеет некоторый набор GC, но разные вендоры могут добавлять/дописывать свои или патчить уже существующий.

Например, у Azul-а есть свой - С4. А liberica, например (кстати, не только они уже), портировала Shenandoah в восьмерку. Так что GC зависят сильно от дистрибутива и вендора.

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

КОМПОНЕНТ

ФЛАГИ JVM

ОПИСАНИЕ

Interpreter

-Xint

Заставляет выполнять весь код в интерпретаторе.

Только C1, тривиальные методы

-Xcomp -XX:TieredStopAtLevel=1

Принудительная JIT-компиляция всего и остановка после того, как C1 скомпилирует тривиальные методы.

Только C1, полная компиляция

-Xcomp -XX:TieredStopAtLevel=3

То же, что выше, но с большим объёмом профилирования.

Только C2

-Xcomp -XX:-TieredCompilation

Компиляция без уровней, forcing C2.

Serial GC

-XX:+UseSerialGC

Просит HotSpot использовать Serial.

Parallel GC

-XX:+UseParallelGC

Просит HotSpot использовать Parallel.

G1 GC

-XX:+UseG1GC

Просит HotSpot использовать G1, хотя чаще всего он и так стоит по умолчанию.

Z GC

-XX:+UseZGC

Просит HotSpot использовать Z.

Я на самом деле не прогонял все возможные комбинации компиляторов и сборщиков мусора (всего 16). Вместо этого я предположил, что между ними нет взаимодействия, и тестировать их можно независимо. Почему? Я поигрался с размерами кучи и увидел, что на частоту падений это почти не влияет. Обычно меньшие кучи означают более частую сборку мусора и более высокую вероятность проявления багов. Честно говоря, это был риск: отсутствие взаимодействия не гарантировано, и если бы я ошибся, то потратил бы кучу времени впустую.

Так что же я сделал на самом деле? И окупился ли риск?

  1. Я взял падающий тест и прогнал его в режиме только интерпретатора — 20 прогонов прошли успешно. Значит, проблема скорее всего в компиляторе.

  2. Раз интерпретатор работает, я пропустил -Xcomp и добавил только -XX:TieredStopAtLevel=1 для тривиальной компиляции C1. В этом режиме код исполняется интерпретатором, пока HotSpot не решит, что стоит скомпилировать какие-то тривиальные методы. 20 прогонов — опять всё ок.

  3. На этом этапе я мог бы попробовать полную компиляцию C1, но у меня было чувство, что дело в C2. Поэтому я запустил с -XX:-TieredCompilation, и — voilà — баг проявился!

  4. Для проверки здравого смысла я прогнал ту же конфигурацию C2 со сборщиками Serial, Parallel, G1 и Z. Хотел убедиться, что все GC видят падения — и так и оказалось.

Я бы сказал, что риск окупился. Я прогнал ещё несколько тестов — они тоже подтвердили, что виновата именно компиляция C2. С флагом -XX:-TieredCompilation баг проявлялся примерно в половине запусков. Коллеги предложили проверить, не повлияет ли включение Compact Object Headers через -XX:+UseCompactObjectHeaders. И это оказалось в точку: при включённом COH воспроизвести проблему не удалось. Значит, отвечая на вопрос «когда мы падаем»: когда используется C2 без Compact Object Headers.

Что это нам говорит? Сбои в логике приложения без крашей виртуальной машины (через assertion failure или иным способом) указывают на то, что, вероятно (хотя не обязательно), при отключённых Compact Object Headers возникает хотя бы одна miscompilation в C2. Это очень неудачный расклад, потому что C2 невероятно сложен, и отслеживать miscompilation — одно из самых трудных занятий.

Сага о jtreg (воспроизводимость — это сложно)

Хотя уже было ясно, что баг проявляется при использовании C2 без Compact Object Headers, я всё ещё не понимал, где именно происходит miscompilation. В HotSpot/C2-коде много if (UseCompactObjectHeaders), но внутри рантайма столько неявных взаимодействий, что найти проблему таким способом — одна надежда на удачу.

Как инженеры VM, мы располагаем множеством инструментов, помогающих справиться с такой неопределённостью. Например, GDB позволяет пошагово проходить код и смотреть состояние. rr замечателен тем, что один раз фиксирует поведение программы, после чего можно детерминированно проходить исполнение пошагово (обычно вперёд, но и назад тоже). Это бесценно при плавающих сбоях.

Эти инструменты принимают вызов java с аргументами. В OpenJDK тесты запускаются через самодельный фреймворк jtreg, вызов которого происходит через make test, а тот в свою очередь создаёт Java-процесс jtreg. Когда тест падает, он выводит “re-run command”, позволяющую разработчику запустить тест напрямую через java. Эта команда весьма громоздка, потому что subprocess jtreg обычно создаёт ещё несколько собственных процессов и поддерживает разные режимы выполнения. К тому же два из трёх моих воспроизводимых случаев были под TestNG, что добавляло ещё один слой сложности.

Увы, баг ни разу не проявился при повторных запусках. Я потратил кучу дней, перебирая флаги, — безрезультатно. А если нельзя наблюдать падения напрямую (а только через make test), то отладка мгновенно переходит в хардкорный режим. Казалось, я упёрся в тупик.

Минимизация

Без чёткой точки входа для отладки у меня оставалась единственная надежда — минимизировать тесты. Тогда я смог бы использовать нетипичные методы вроде printf или добавления ShouldNotReachHere()-проверок. В качестве исходной точки я взял java/util/jar/JarFile/mrjar/MultiReleaseJarProperties.java.

Обычно я минимизирую в два шага:

  1. Удаляю половину вызовов методов. Если тест перестаёт падать/баг перестаёт проявляться — возвращаю вызовы по одному.

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

Существуют инструменты, которые минимизируют код автоматически. Например, creduce — отличная штука, он умеет работать и с Java. Я же сокращал MultiReleaseJarProperties.java вручную — это дало мне неплохое погружение в Java-библиотеки, с которыми я раньше не сталкивался. Да и вообще полезная практика.

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

Итоги

Перебрав разные флаги HotSpot, я сузил проблему до компонента C2 при выключенных Compact Object Headers. Мне удалось существенно уменьшить тест, но не настолько, насколько хотелось бы. Я начал подозревать miscompilation, а это, увы, делает поиск источника ошибки крайне сложным.

Акт III. Майним компиляции

Когда HotSpot JIT-компилирует программы, он генерирует огромное количество машинного кода — и для прикладного кода, и для самого Java-ланчера. Изучать каждую компиляцию вручную невозможно (или же займёт чудовищно много времени).

В моём случае тест дальше минимизировать не удавалось. Значит, пришлось опираться на автоматизированные приёмы, чтобы попытаться локализовать место, где прячется баг.

Что компилируется?

Чтобы узнать, какие методы попадают в компиляцию, можно использовать -XX:+PrintCompilation. Он выводит длинный список скомпилированных методов, например:

154    1     n       jdk.internal.misc.Unsafe::getReferenceVolatile (native)
206    2     n       java.lang.Object::hashCode (native)
207    3     n       java.lang.invoke.MethodHandle::linkToStatic(LLLLLLL)L (native)   (static)
219    4             java.lang.Enum::ordinal (5 bytes)
227    5     n       java.lang.System::arraycopy (native)   (static)
...

В моём случае нас интересовало только имя метода. За объяснением того, что означают цифры и символы, можно обратиться к блогу Стивена Фокса.

У меня получилось около 400 компиляций. Ручной разбор был бы абсолютно невыносим. Нужно было автоматизировать.

Поиск виновника(ов)

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

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

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

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

Как же управлять компиляцией? С помощью -XX:CompileCommand. С его помощью можно указать, что нужно компилировать только конкретный метод или наоборот исключить его. Например:

  • -XX:CompileCommand=compileonly,java.lang.Enum::ordinal — если HotSpot захочет скомпилировать Enum::ordinal, он скомпилирует только его.

  • -XX:CompileCommand=exclude,java.lang.Enum::ordinal — HotSpot вообще никогда не будет его компилировать.

Так как мы компилируем только C2 (-XX:-TieredCompilation), эти CompileCommand управляют именно поведением C2.

Получив список всех методов через -XX:+PrintCompilation и немного его предобработав, я написал небольшой скрипт, который автоматизирует поиск. Основная идея была такой:

// main(String[] args), перебираем все методы:
String flag = "-XX:CompileCommand=compileonly," + method;
if (!run(flag)) {
    System.err.println("Failure for " + method);
}

// run(String otherFlags):
ProcessBuilder pb = new ProcessBuilder(
    "bash",
    "-c",
    "cd ~/valhalla && make test TEST=\"" + TEST + "\" JTREG=\"REPEAT_COUNT=5;VM_OPTIONS=-XX:-TieredCompilation -XX:+PrintCompilation " + otherFlags + "\""
);

И — к моему удовольствию — он выдал результат:

Failure for java.util.concurrent.ConcurrentHashMap::get

Значит, что-то связанное с ConcurrentHashMap::get вызывает проблему! Пока неясно, идёт ли речь именно о miscompilation или баг просто проявляется при компиляции этого метода. Но теперь у меня хотя бы появилось направление, куда копать дальше.

Inlining (встраивание)

Помните, я говорил, что -XX:CompileCommand=compileonly заставляет компилировать только один конкретный метод? На практике это не совсем так: C2 также включает встроенные методы, если решает, что их стоит встраивать. Флаг -XX:+PrintInlining показывает, что именно было встроено.

Предупреждение: вывод бывает настолько огромным, что make test его обрезает.

Посмотрим на вывод при запуске с
-XX:CompileCommand=compileonly,java.util.concurrent.ConcurrentHashMap::get -XX:+PrintInlining:

@ 1   java.lang.Object::hashCode (0 bytes)   (intrinsic, virtual)
@ 4   java.util.concurrent.ConcurrentHashMap::spread (10 bytes)   inline (hot)
@ 34   java.util.concurrent.ConcurrentHashMap::tabAt (21 bytes)   inline (hot)
  @ 14   jdk.internal.misc.Unsafe::getReferenceAcquire (7 bytes)   (intrinsic)
@ 67   java.util.Objects::equals (51 bytes)   force inline by annotation
  @ 0   jdk.internal.misc.PreviewFeatures::isEnabled (4 bytes)   inline (hot)
  @ 39   java.lang.Object::equals (11 bytes)   failed to inline: virtual call
@ 137   java.util.Objects::equals (51 bytes)   force inline by annotation
  @ 0   jdk.internal.misc.PreviewFeatures::isEnabled (4 bytes)   inline (hot)
  @ 39   java.lang.Object::equals (11 bytes)   failed to inline: virtual call

Теперь я мог отключать методы по одному и смотреть, исчезает ли баг. Например, отключить встраивание ConcurrentHashMap::spread:

-XX:CompileCommand=dontinline,java.util.concurrent.ConcurrentHashMap::spread

Единственное исключение — intrinsic-методы: на них этот флаг не действует, они всё равно будут встроены.

Когда я отключил все встроенные методы и ошибка всё равно проявлялась, стало ясно, что остались два варианта:

  1. баг находится прямо в ConcurrentHashMap::get;

  2. баг в одном из intrinsic’ов.

Заглянув в исходники ConcurrentHashMap::get, я понял, что маловероятно, что дело в нём. Код там использует очень распространённые конструкции, которые наверняка ломали бы и другие тесты.

У меня появилась догадка, что баг — в intrinsic-е Object::hashCode, просто потому, что Valhalla активно работает с hashCode. И действительно: если отключить intrinsic, заставив соответствующую функцию возвращать false, проблема исчезала.

Похоже, что дело именно в intrinsic-е Object::hashCode. Однако на этом этапе всё ещё нельзя было со стопроцентной уверенностью утверждать, что это miscompilation, — хотя признаки указывали именно на это. Но теперь объём компиляции был достаточно мал, чтобы переходить к тяжёлой артиллерии.

Итоги

Методом индивидуальной компиляции всех методов, которые C2 компилировал в запуске, где баг проявлялся, я выяснил, что подозрение падает на ConcurrentHashMap::get. После анализа встроенных методов стало ясно, что виноват intrinsic Object::hashCode.

Акт IV. Баг

Исходники intrinsic-а довольно объёмные. Мой преподаватель по ООП сказал бы, что у него «высокая цикломатическая сложность». Я раскидал несколько print-ов по всем веткам, чтобы понять, какой путь выполнения мы проходим. Напомню: воспроизвести баг в gdb у меня не получалось, так что print-ы были единственным инструментом.

Во время этого я заметил условие if (!UseObjectMonitorTable). И тут у меня возникла гипотеза! Баг не проявлялся при включённых Compact Object Headers. Может ли быть так, что COH устанавливает этот флаг? Проверить было легко:

  • java -XX:+UseCompactObjectHeaders -XX:+PrintFlagsFinal -version | grep UseObjectMonitorTable → флаг включён

  • java -XX:-UseCompactObjectHeaders -XX:+PrintFlagsFinal -version | grep UseObjectMonitorTable → флаг выключен

То есть Compact Object Headers действительно включает этот флаг и пропускает соответствующую ветку. Значит, проблема — скорее всего — именно в коде внутри этой ветки. Ветка добавляет пачку дополнительных инструкций, но визуально ничего подозрительного.

Граф компиляции C2

Я сравнил различия в сгенерированном машинном коде. При конфигурации HotSpot с флагами --enable-hsdis-bundling --with-hsdis=llvm можно включить инспекцию машинного кода. После чего флаг
-XX:CompileCommand=print,java.util.concurrent.ConcurrentHashMap::get
показывает дизассемблированный машинный код всего метода, включая встроенные методы и intrinsic-и. Как и ожидалось, я увидел дополнительные инструкции при выключенном Compact Object Headers / UseObjectMonitorTable.

Но думать нужно было уровнем выше. C2, как и многие компиляторы, оптимизирует программу через промежуточное представление (IR). Это не байткод и ещё не машинный код. IR C2 — огромный граф под названием Sea of Nodes. Оптимизации происходят через преобразования и анализ этого графа. Используя Ideal Graph Visualizer, я сделал diff графов ConcurrentHashMap::get при включённом и выключенном UseObjectMonitorTable — как в начале оптимизаций, так и в конце. Для понимания: этот метод создаёт примерно 400 узлов и 1000+ рёбер, так что скриншота не будет.

Увы, diff не показал ничего интересного. Различия объяснялись только разными адресами или вероятностями ветвлений.

Неужели это не miscompilation? Может быть, настоящая проблема глубже в рантайме, а данный юнит просто место, где баг проявляется? Фича-флаг я нашёл, но не объяснил, что происходит.

Битовая маска

Нужно было понять, что делает UseObjectMonitorTable. Вкратце: object monitor используется для блокировок и синхронизации. Статья на wiki по соответствующему pull request'у оказалась хорошей отправной точкой. Для моего бага важна только первая строка PR-а (inflating — это состояние markWord):

When inflating a monitor the ObjectMonitor* is written directly over the markWord and any overwritten data is displaced into a displaced markWord.

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

  1. Запуск теста с -XX:+ShowMessageBoxOnError и -XX:AbortVMOnException=java.lang.AssertionError.

  2. Ожидание зависания теста — значит, произошёл сбой. ShowMessageBoxOnError ждёт ввод, но так как jtreg всё оборачивает, ввести его нельзя, и процесс висит до тайм-аута.

  3. jps — смотрим список Java-процессов.

  4. lldb -p <PID> → thread backtrace all — находим процесс, у которого сейчас открыто message box-и окно. Это и есть упавший процесс.

Но, по правде говоря, присоединение не особо помогло. Оно лишь показало, что некоторые потоки находятся в состоянии ожидания, в режиме “лёгкой” блокировки (детали не важны). Это вернуло нас к рассмотрению intrinsic-а, в частности ветки LM_LIGHTWEIGHT. Комментарий с неправильным выравниванием привожу в точности — так в исходниках:

  // Test the header to see if it is safe to read w.r.t. locking.
// This also serves as guard against inline types
  Node *lock_mask      = _gvn.MakeConX(markWord::inline_type_mask_in_place);
  Node *lmasked_header = _gvn.transform(new AndXNode(header, lock_mask));
  if (LockingMode == LM_LIGHTWEIGHT) {
    Node *monitor_val   = _gvn.MakeConX(markWord::monitor_value);
    Node *chk_monitor   = _gvn.transform(new CmpXNode(lmasked_header, monitor_val));
    Node *test_monitor  = _gvn.transform(new BoolNode(chk_monitor, BoolTest::eq));
    generate_slow_guard(test_monitor, slow_region);
  } else { /* ... */ }

Логика такова:

  1. создаётся константа — битовая маска markWord::inline_type_mask_in_place (логическое ИЛИ бита value object и двух lock-битов);

  2. выполняется AND над заголовком объекта (там лежит markWord) и маской;

  3. создаётся другая константа — markWord::monitor_value (0b10);

  4. сравниваем замаскированный результат с 0b10, и если равно — идём по медленному пути синхронизации.

Баг — в первом шаге. Схема ниже показывает старую и новую маску для 11 младших битов:

    7   3  0
 VVVAAAAVSTT   <- до моего изменения
        ^-^^---- 1011 маска

 VVVVAAAASTT   <- после моего изменения
    ^-----^^---- 10000011 маска

Когда объектный монитор «раздувается» (inflating), markWord заменяется нативным адресом ObjectMonitor* (без младших lock-битов). Обозначим этот адрес как P..PP. Получаем:

    7   3  0
 PPPPPPPPPTT   <- до изменения
        ^-^^---- 1011 маска

 PPPPPPPPPTT   <- после изменения
    ^-----^^---- 10000011 маска

Мы применяем маску к указателю, а не к метаданным! Если нам не повезёт и 3-й или 7-й бит (в зависимости от версии) указателя выставлен в 1, маска зацепит лишний бит. Тогда сравнение с 0b10 даст «не равно», и медленный путь не будет выбран, хотя должен! На самом деле нужно было проверять только lock-биты. Это и есть miscompilation!

Исправление маски на markWord::lock_mask_in_place устранило Симптом B (java.lang.AssertionError) и Симптом C (java.lang.NoClassDefFoundError). Напомню, Симптом A (проваленные тесты) был исправлен ранее. Прогон обычного набора tiered-тестов показал отсутствие регрессий.

Итоги

Использовалась неправильная битовая маска, которая проверяла биты указателя, а не метаданных, когда UseObjectMonitorTable был выключен. Если соответствующий бит в указателе был равен 1, медленный путь синхронизации пропускался. Compact Object Headers включал UseObjectMonitorTable, поэтому проблема не проявлялась.

Акт V: Разбор полётов

Главный вопрос — почему это не влияло на виртуальную машину до моего изменения? Это можно объяснить тем, что 64-битные указатели на нативную память, получаемые через malloc, выравниваются по 16 байтам. У них всегда будут четыре завершающих нуля, и два из них могут быть перезаписаны метаданными блокировки. Если посмотреть на диаграмму:

    7   3  0
 PPPPPPP00??  <- До моего изменения
        ^-^^---- битмаска 1011
 PPPPPPP00??  <- После моего изменения
    ^-----^^---- битмаска 10000011

Она показывает, что бит в позиции 3 всегда был равен нулю. Поэтому бит «value object» в битмаске при AND с нулём давал ноль. Вот почему баг проявился только после того, как я переставил бит «value object».

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

Итог

Баг не проявлялся до изменения положения бита «value object», потому что нам просто везло с выравниванием нативной памяти. Бит, который проверяла некорректная битмаска, всегда был равен нулю. Почему объекты превращались в null и почему классы «не находились» — я не знаю.

Напоследок

Хочу выделить следующие выводы:

  • Имейте чёткую методологию и проверяйте свои предположения. С такой большой кодовой базой, как HotSpot, нормальная работа с багами невозможна без строгого логического анализа. Подберите методику, подходящую под конкретную проблему.

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

  • Пользуйтесь инструментами! Да, иронично, учитывая что у меня не получилось толком воспользоваться lldb, но инструменты отладки невероятно полезны. Хотя бы разберитесь с базой — всё не так сложно и страшно, как кажется.

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (0)