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

Цель данной статьи — показать читателям что JCStress можно и нужно использовать не только в лабораторных работах для демонстрации эффектов связанных с моделью памяти, но и для доказательства правильности преобразований кода. Тренироваться будем на кошках JDK.

Зачин

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

Почему именно JCStress? Это неудачливый брат JMH, и если последнему выпала долгая и счастливая жизнь вместе с мировым признанием, то JCStress куда менее известен и востребован. Если о JMH на момент написания статьи задано 395 вопросов, то о JCStress — лишь 7. Мне кажется, что причиной этого является либо неосведомлённость о существовании фреймворка, либо мнение о нём как об испытательном стенде, годном только для обучения и/или тестирования примитивов из java.util.concurrent.

Теперь к делу. Порядок работы такой:

  • возьмём код JDK, покрутим его, поищем лазейки для улучшения

  • построим предположение о том, как можно изменить код

  • напишем и запустим тест

  • сделаем вывод

В моей повседневной работе я чаще всего имею дело со Спрингом и прочим энтерпрайзом разной степени кровавости, поэтому наиболее интересен для меня пакет java.lang.reflect.

Например, java.lang.reflect.Parameter.isNamePresent() многократно вызывается при загрузке контекста приложения:

public boolean isNamePresent() {
  return executable.hasRealParameterData() && name != null;
}

В свою очередь метод j.l.r.Executable.hasRealParameterData() дважды обращается к volatile-полям:

private transient volatile boolean hasRealParameterData;
private transient volatile Parameter[] parameters;

boolean hasRealParameterData() {
  if (parameters == null) {
    privateGetParameters();
  }
  return hasRealParameterData;
}

private Parameter[] privateGetParameters() {
  Parameter[] tmp = parameters;

  if (tmp == null) {

    // Otherwise, go to the JVM to get them
    try {
      tmp = getParameters0();
    } catch(IllegalArgumentException e) {
      // Rethrow ClassFormatErrors
      throw new MalformedParametersException("Invalid constant pool index");
    }

    // If we get back nothing, then synthesize parameters
    if (tmp == null) {
      hasRealParameterData = false;
      tmp = synthesizeAllParams();
    } else {
      hasRealParameterData = true;
      verifyParameters(tmp);
    }

    parameters = tmp;
  }
  return tmp;
}

Обратите внимание, что метод privateGetParameters() ведёт себя как ленивый сеттер, проставляющий начальное значение двух полей (hasRealParameterData и parameters). Поставим вопрос следующим образом:

1) можно ли считать присвоение значений двум полям безопасной публикацией?

2) если публикация безопасна, то можем ли мы избавиться от одного из двух volatile?

О безопасной публикации

Под безопасной публикацией здесь и далее подразумевается открытие доступа к объекту и/или его свойствам таким образом, что все доступившиеся потоки "видят" одно и тоже правильное состояние (состояние в данном случае - это и ссылка на объект, и все его поля). Беглый поиск по словосочетанию "safe publication" выдаёт несколько ссылок:

Большинство из них предлагает классический набор подходов:

  • статика

  • синхронизация

  • final поля

Все они неприменимы в нашем случае, поэтому воспользуемся менее известным (скажем так — реже встречающимся в письменных источниках) приёмом "последний volatile".

Отступление о первоисточниках

Этот подход упоминается в ставшем уже классическим докладе "Близкие контакты JMM-степени".

На эту же тему есть другой доклад — "Workshop: Java Concurrency Stress" о двух частях (раз, два). Я категорически советую к вдумчивому просмотру по меньшей мере первую часть, она во многом пересекается с "Близкими контактами...", но более ориентирована на практику. Возможно, это просто совпадение, но несмотря на родной русский, я намного лучше понял модель памяти именно благодаря англоязычному докладу с живыми примерами.

Указанная техника упоминается и в первом, и во втором докладах.

В примерах к JCS похожий случай рассматривается в BasicJMM_06_Causality$VolatileGuard.

Предположение

Рассуждать будем примерно так: нужно доказать, что поведение кода под гонкой не изменится, если мы уберём volatile из объявления поля hasRealParameterData. Предположим, два потока (П1 и П2) одновременно вызывают hasReadParameterData(). Поскольку метод не синхронизирован, то оба потока исполняют его одновременно. Рассмотрим возможные исполнения:

1) П1 отработал быстрее и П2 прочитал не null из поля parameter. Что вернёт в этом случае П1? Очевидно, он вернёт то же значение, которое записал П1 в переменную hasRealParameterData, ведь если мы "увидели" запись не null в волатильное поле parameter, то "увидим" и все предшествующие ей записи.

2) Предположим, что оба прочли null, соответственно оба попадают в privateGetParameterData() и тут вновь развилка:

  • П{1, 2} уже записал массив в поле parameters, следовательно П{2, 1} вернёт значение уже записанное в hasRealParameterData (см. п. 1)

  • оба потока читают null, и единственным следствием этого будет вызов метода getParameters0(), который вернёт два разных массива (arr1 != arr2, но с тем же содержимым). Также оба потока запишут одно и то же значение в поле hasRealParameterData

Изменится ли это поведение с удалением volatile из объявления поля hasRealParameterData? На первый взгляд — нет.

Не торопитесь открывать, подумайте самостоятельно

Страница 50 "Близких контактов...":

Применительно к нашему коду запись в волатильное поле parameters — это т. н. "releasing store", а чтение из него — "acquiring read". Таким образом, если мы читаем не null из поля parameters, то гарантированно "видим" все предшествующие записи.

Запись в поле hasRealParameterData, предшествующая записи в волатильное поле parameters, как бы защищена этой самой записью, соответственно описанные выше исполнения не меняются.

Теперь нужно доказать это строго, и в этом нам поможет JCStress.

Доказательство

Вот полный код теста, пояснение ниже

@State
@JCStressTest
@Outcome(id = "true",  expect = ACCEPTABLE, desc = "Boolean value is guarded")
@Outcome(id = "false", expect = FORBIDDEN, desc = "Boolean value is not guarded")
public class ConcurrencyTest {

  ConcurrencyTest.Value value = new ConcurrencyTest.Value();

  @Actor
  public void actor1(Z_Result r) {
    r.r1 = value.method.hasRealParameterData();
  }

  @Actor
  public void actor2(Z_Result r) {
    r.r1 = value.method.hasRealParameterData();
  }

  static class Value {
    final Executable method;

    public Value() {
      try {
        method = getClass().getMethod("foo", int.class);
      } catch (NoSuchMethodException e) {
        throw new RuntimeException(e);
      }
    }

    public void foo(int parameter) {
    }
  }
}

Аннотации @State и @JCStress являются служебными. Повторяющиеся аннотации @Outcome необходимы для описания допустимых и запрещённых значений (кроме ACCEPTABLE и FORBIDDEN есть ещё ACCEPTABLE_INTERESTING и UNKNOWN; в данном случае они нас не интересуют).

Далее описано разделяемое состояние:

ConcurrencyTest.Value value = new ConcurrencyTest.Value();

static class Value {
  final Executable method;

  public Value() {
    try {
      method = getClass().getMethod("foo", int.class);
    } catch (NoSuchMethodException e) {
      throw new RuntimeException(e);
    }
  }

  public void foo(int parameter) {
  }
}

Обратите внимание, что JCStress самостоятельно воссоздаёт значение поля value для каждого прогона. Иными словами, при каждом вызове одного или нескольких помеченных @Actor методов они будут работать со свежим полем.

Теперь логика:

@Actor
public void actor1(Z_Result r) {
  r.r1 = value.method.hasRealParameterData();
}

@Actor
public void actor2(Z_Result r) {
  r.r1 = value.method.hasRealParameterData();
}

Здесь всё просто: методы вызываются из разных потоков создавая гонку на одном объекте. Полученный результат записывается в особый объект класса что-то_Result, похожий на хорошо знакомый по JMH Blackhole с той лишь разницей, что у "воронки" много перегруженных методов для "затягивания" разных типов данных, а типы, с которыми работает результат, определяются приставкой. В нашем случае Z_Result — это результат имеющий одно логическое поле (Z — принятое в Java обозначение типа boolean). Результату с двумя логическими полями соответствует ZZ_Result, паре boolean-intZI_Result и т. д. Тысячи их!

Для доказательства нашего предположения необходимо выполнить тестирование для существующего и изменённого кода. Если результаты совпадут, то код с исключённым volatile работает как прежде (читай — правильно), и корректность программы сохранена.

Теперь необходимо решить задачу по "раскрытию" метода Executable.hasRealParameterData() для внешнего мира. Для этого нужно сделать две вещи: очевидную и не очень.

  • Объявить метод публичным и добавить документацию, чтобы компилятор не ругался при сборке.

  • Добавить объявление метода в раздел class name java/lang/reflect/Executable файла java.base-H.sym.txt, который показывает, какие именно части стандартной библиотеки можно открывать миру.

Получается так:

class name java/lang/reflect/Executable
header extends java/lang/reflect/AccessibleObject implements java/lang/reflect/Member,java/lang/reflect/GenericDeclaration sealed true flags 421
innerclass innerClass java/lang/invoke/MethodHandles$Lookup outerClass java/lang/invoke/MethodHandles innerClassName Lookup flags 19
method name hasRealParameterData descriptor ()Z flags 1

Формат несколько необычный: сперва указывается вид и имя метода, потом тип возвращаемого значения и флаг доступа.

Сборку JDK из исходников я не описываю, всё подробно изложено в документации.

Получив готовый JDK можно приступать к сборке теста. Основа для него создаётся из архетипа, как описано в README. Для сборки нужно выполнить

mvn clean package

Обратите внимание, что по умолчанию компилятор Java не будет использовать в скомпилированном коде указанные в исходниках имена переменных, соответственно Executable.hasRealParameterData() всегда будет возвращать "ложь". Это большое неудобство, ведь данное значение совпадает со значением по умолчанию логического типа. Значит мы не можем достоверно определить, является ли прочитанное значение проставленным программой или оно таково по умолчанию. Поэтому сперва нужно сделать так, чтобы указанный метод возвращал "истину". Подсказали, что это можно сделать с помощью флага -parameters, в случае с мавеном это делается так:

<plugin>
  <artifactId>maven-compiler-plugin</artifactId>
  <version>3.8.1</version>
  <configuration>
    <compilerArgs>
      <arg>-parameters</arg>
    </compilerArgs>
  </configuration>
</plugin>

Теперь можно запустить сам тест (выполняется из корневой папки проекта):

java -jar target/jcstress.jar -t ConcurrencyTest -v

Итак, для исходного JDK с открытым Executable.hasRealParameterData() получаем:

  Results across all configurations:

  RESULT      SAMPLES     FREQ      EXPECT  DESCRIPTION
   false            0    0,00%   Forbidden  Boolean value is not guarded
    true  154?130?432  100,00%  Acceptable  Boolean value is guarded

Теперь убираем лишний volatile, пересобираемся и идём на второй круг:

  Results across all configurations:

  RESULT      SAMPLES     FREQ      EXPECT  DESCRIPTION
   false            0    0,00%   Forbidden  Boolean value is not guarged
    true  187 932 672  100,00%  Acceptable  Boolean value is guarged

Выводы

Таким образом, мы не только теоретически обосновали возможность упрощения кода, но и подтвердили это. По ходу дела был создан запрос на слияние, код в нём пришлось немного переделать (полный отказ от volatile в пользу @Stable).

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

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

До новых встреч!

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


  1. sparhawk
    12.07.2022 19:21
    +5

    Отличная работа! И практика, и теория.

    Кстати, тесты JCStress лучше запускать на процессорах с Relaxed Memory Ordering: ARM, Power.

    X86 обеспечивает более строгое упорядочивание, чем гарантируемое моделью памяти Java, и некоторые ошибки можно пропустить. (Если, конечно, код публичный и претендует на работу где-то, кроме X86.) Шипилёв это тоже демонстрировал.


    1. tsypanov Автор
      12.07.2022 23:55
      +1

      Спасибо, про АРМ не знал. В копилочку )