Статьи

Глубокое погружение в Java 9 Stack-Walking API

API стека , выпущенный как часть Java 9 , предлагает эффективный способ доступа к стеку выполнения. (Стек выполнения представляет цепочку вызовов методов — он начинается с public static void main(String[]) метода public static void main(String[]) или метода run потока, содержит фрейм для каждого метода, который был вызван, но еще не возвращен, и заканчивается в точке StackWalker вызова StackWalker .) В этой статье мы рассмотрим различные функциональные возможности API, работающего со стеком, а затем рассмотрим его характеристики производительности.

Эта статья требует практического знания Java, особенно лямбда-выражений и потоков .

Кто меня звал?

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

До Java 9 большинство людей Throwable доступ к информации стека через создание экземпляра Throwable и использование его для получения трассировки стека.

 StackTraceElement[] stackTrace = new Throwable().getStackTrace(); 

Это работает, но это довольно дорого и хакерски. Он захватывает все кадры — кроме скрытых — даже если вам нужны только первые 2, и не дает вам доступа к фактическому экземпляру Class в котором объявлен метод. Чтобы получить класс, вам нужно расширить SecurityManager , у которого есть защищенный метод getClassContext который будет возвращать массив Class .

Для устранения этих недостатков Java 9 представляет новый API для работы со стеком (с JEP 259 ). Теперь мы рассмотрим различные функциональные возможности API, а затем рассмотрим его характеристики производительности.

Основы StackWalker

Java 9 поставляется с новым типом, StackWalker , который предоставляет доступ к стеку. Теперь мы увидим, как получить экземпляр и как использовать его для выполнения простого обхода стека.

Получение StackWalker

StackWalker легко доступен с помощью статических методов getInstance :

 StackWalker stackWalker1 = StackWalker.getInstance(); StackWalker stackWalker2 = StackWalker.getInstance(RETAIN_CLASS_REFERENCE); StackWalker stackWalker3 = StackWalker.getInstance( Set.of(RETAIN_CLASS_REFERENCE, SHOW_HIDDEN_FRAMES)); StackWalker stackWalker4 = StackWalker.getInstance(Set.of(RETAIN_CLASS_REFERENCE), 32); 

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

Получив StackWalker вы можете получить доступ к информации о стеке следующими способами.

Метод forEach

Метод forEach перенаправляет все нефильтрованные кадры в указанный Consumer<StackFrame> вызов Consumer<StackFrame> . Так, например, чтобы просто напечатать кадры, которые вы делаете:

 stackWalker.forEach(System.out::println); 

Иди walk

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

 <T> T walk(Function<Stream<StackWalker.StackFrame>, T> function) 

Вы можете спросить, почему он не просто возвращает Stream ? Давайте вернемся к этому позже . Сначала посмотрим, как мы можем это использовать. Например, чтобы собрать кадры в List вы должны написать:

 // collect the frames List<StackWalker.StackFrame> frames = stackWalker.walk( frames -> frames.collect(Collectors.toList())); 

Чтобы посчитать их:

 // count the number of frames long nbFrames = stackWalker.walk( // the lambda returns a long frames -> frames.count()); 

Одно из больших преимуществ использования метода обхода состоит в том, что, поскольку API обхода стека лениво оценивает кадры, использование оператора limit фактически уменьшает количество восстанавливаемых кадров. Следующий код извлечет первые два кадра, что, как мы увидим позже , намного дешевле, чем захват полного стека.

 List<StackWalker.StackFrame> caller = stackWalker.walk( frames -> frames .limit(2) .collect(Collectors.toList())); 
Бетонный стек Уокер

Опубликованный Рори Хайдом под CC-BY-SA 2.0 / SitePoint изменил окраску и поле зрения и делится под той же лицензией

Продвинутый StackWalker

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

Почему брать Function вместо того, чтобы просто возвращать Stream ?

При обсуждении стека проще всего представить его как стабильную структуру данных, которую JVM мутирует только в верхней части, добавляя или удаляя отдельные кадры при входе или выходе из методов. Это не совсем верно, хотя. Вместо этого вы должны думать о стеке как о чем-то, что виртуальная машина может реструктурировать в любое время (в том числе в середине выполняемого кода) для повышения производительности.

Поэтому для того, чтобы ходящий мог видеть согласованный стек, API должен убедиться, что стек стабилен во время построения фреймов. Это возможно только в том случае, если он контролирует стек вызовов, что означает, что обработка потока должна происходить в вызове API. Вот почему поток не может быть возвращен, но должен быть пройден внутри вызова. (Кроме того, обратные вызовы обхода выполняются из собственной функции JVM, как вы можете видеть в комментарии для StackStreamFactory и doStackWalk .)

Если вы попытаетесь утечь поток, передав функцию идентификации, он выдаст IllegalStateException только вы попытаетесь его обработать.

 Stream<StackWalker.StackFrame> doNotDoThat = stackWalker.walk(frames -> frames); doNotDoThat.count(); // throws an IllegalStateException 

Метод getCallerClass

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

 Class<?> callerClass = StackWalker .getInstance(RETAIN_CLASS_REFERENCE) .getCallerClass() 

Этот вызов быстрее, чем аналогичный вызов через Stream и быстрее, чем использование SecurityManager (более подробно в тесте производительности )

StackFrame

Методы forEach и walk передают экземпляры StackFrame в потоке или обратному вызову потребителя. Этот класс обеспечивает прямой доступ к:

  • Индекс байт-кода : индекс текущей инструкции байт-кода относительно начала метода.
  • Имя класса : имя класса, объявляющего вызываемый метод.
  • Объявление класса : объект Class класса, объявляющего вызываемый метод (вы не можете просто использовать Class.forName(frame.getClassName()) поскольку у вас может не быть правого ClassLoader ; он доступен только при использовании RETAIN_CLASS_REFERENCE .)
  • Имя метода : имя вызываемого метода.
  • Является ли метод родным.

Это также дает ленивый доступ к имени файла и номеру строки, но это создаст StackTraceElement которому он будет делегировать вызов. Создание StackTraceElement является дорогостоящим и откладывается до тех пор, пока оно не понадобится впервые. Метод toString также делегирует StackTraceElement .

Опции StackWalker

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

Мы рассмотрим кадры, создаваемые следующей иерархией вызовов:

 public static void delegateViaReflection(Runnable task) throws Exception { StackWalkerOptions .class .getMethod("runTask", Runnable.class) .invoke(null, task); } public static void runTask(Runnable task) { task.run(); } 

Задача Runnable task будет лямбда-печатью стека с использованием StackWalker::forEach . Стек выполнения затем содержит рефлексивный код delegateViaReflection и скрытый фрейм, связанный с лямбда-выражением.

Параметры видимости кадра

По умолчанию стековый ходок пропускает скрытые и отражающие кадры.

 delegateViaReflection(() -> StackWalker .getInstance() .forEach(System.out::println)); 

Вот почему мы видим только кадры в нашем собственном коде:

org.github.arnaudroger. ) org.github.arnaudroger.StackWalkerOptions.main (StackWalkerOptions.java:15)

С опцией SHOW_REFLECT_FRAMES мы увидим рамки отражения, но скрытая рамка все еще пропускается:

 delegateViaReflection(() -> StackWalker .getInstance(Option.SHOW_REFLECT_FRAMES) .forEach(System.out::println) ); 

org.github.arnaudroger.StackWalkerOptions.lambda $ Основной $ 1 (StackWalkerOptions.java:18)
org.github.arnaudroger.StackWalkerOptions.runTask (StackWalkerOptions.java:10)
java.base / jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (собственный метод)
java.base / jdk.internal.reflect.NativeMethodAccessorImpl.invoke (NativeMethodAccessorImpl.java:62)
java.base / jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke (DelegatingMethodAccessorImpl.java:43)
java.base / java.lang.reflect.Method.invoke (Method.java:538)
org.github.arnaudroger.StackWalkerOptions.delegateViaReflection (StackWalkerOptions.java:6)
org.github.arnaudroger.StackWalkerOptions.main (StackWalkerOptions.java:18)

И, наконец, с SHOW_HIDDEN_FRAMES он выводит все отражения и скрытые кадры:

 delegateViaReflection(() -> StackWalker .getInstance(Option.SHOW_HIDDEN_FRAMES) .forEach(System.out::println) ); 

org.github.arnaudroger.StackWalkerOptions.lambda $ Основной $ 2 (StackWalkerOptions.java:21)
org.github.arnaudroger.StackWalkerOptions $$ Lambda $ 11 / 968514068.run (неизвестный источник)
org.github.arnaudroger.StackWalkerOptions.runTask (StackWalkerOptions.java:10)
java.base / jdk.internal.reflect.NativeMethodAccessorImpl.invoke0 (собственный метод)
java.base / jdk.internal.reflect.NativeMethodAccessorImpl.invoke (NativeMethodAccessorImpl.java:62)
java.base / jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke (DelegatingMethodAccessorImpl.java:43)
java.base / java.lang.reflect.Method.invoke (Method.java:538)
org.github.arnaudroger.StackWalkerOptions.delegateViaReflection (StackWalkerOptions.java:6)
org.github.arnaudroger.StackWalkerOptions.main (StackWalkerOptions.java:21)

Сохранить ссылку на класс

По умолчанию, если вы попытаетесь получить доступ к методу getDeclaringClass он выдаст getDeclaringClass UnsupportedOperationException :

 delegateViaReflection(() -> StackWalker .getInstance() .forEach(frame -> System.out.println( "declaring class = " // throws UnsupportedOperationException + frame.getDeclaringClass()))); 

Вам нужно будет добавить опцию RETAIN_CLASS_REFERENCE чтобы получить к ней доступ.

 delegateViaReflection(() -> StackWalker .getInstance(Option.RETAIN_CLASS_REFERENCE) .forEach(frame -> System.out.println( "declaring class = " + frame.getDeclaringClass()))); 

Производительность

Основной причиной появления нового API было повышение производительности, поэтому имеет смысл взглянуть и сравнить его. Следующие тесты были созданы с помощью JMH , Java Microbenchmarking Harness, который является отличным инструментом для тестирования небольших фрагментов кода без несвязанных оптимизаций компилятора (например, устранение мертвого кода) с перекосом чисел. Проверьте репозиторий GitHub для кода и инструкции о том, как его запустить.

Exception против StackWalker

В этом тесте мы сравниваем производительность получения стека с помощью Throwable и StackWalker . Чтобы сделать это честно, мы используем StackWalker::forEach , тем самым форсируя создание всех фреймов (потому что использование Throwable делает то же самое). Для нового API мы также различаем работу на StackFrame и создание более дорогого StackTraceElement .

Исключение эталонного тестаStackTrace 31.929, stackWalkerForEach 15.350, stackWalkerForEachRetainClass 1.366, stackWalkerForEachToStackTraceElement 39,675 us / op

Мы видим, что:

  • StackWalker быстрее, чем исключение, если вы не StackTraceElement экземпляр StackTraceElement .
  • StackTraceElement довольно дорого, поэтому остерегайтесь getFileName , getLineNumber и toString .
  • Получение доступа к объявленному классу не требует никаких затрат, это просто проверка доступа.

Частичный захват стека

StackWalker уже быстрее при захвате полного стека, но что если мы получим только часть кадров стека? Мы ожидаем, что ленивая оценка кадров еще больше увеличит производительность. Чтобы исследовать это, мы используем StackWalker::walk и Stream::limit для потока, который он нам передает.

Влияние limit

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

 StackWalker .getInstance() .walk(frames -> { frames.limit(limit).forEach(b::consume); return null; }); 

( b в b::consume — это класс JMH, Blackhole . Он гарантирует, что что-то действительно происходит, чтобы предотвратить устранение мертвого кода, но быстро, чтобы предотвратить искажение результатов.)

Вот результаты:

Тест 1 3.233, 2 3.062, 4 3.341, 6 3.331, 8 9.663, 10 8.724, 12 8.946, 14 8.921, 16 14.480 us / op

Похоже, что есть пороговый эффект, когда стоимость увеличивается на 8 и 16. Если вы посмотрите на то, как реализована walk , это не удивительно. StackWalker извлекает кадры с начальным StackWalker 8, если предполагаемый размер не указан, и извлекает другой пакет, как только все кадры будут использованы.

Влияние limit и расчетного размера

Можем ли мы сделать вызов более производительным, если бы мы указали предполагаемый размер? Нам нужно будет добавить 2 к пределу, так как первые два кадра зарезервированы. Нам также нужно использовать опцию SHOW_HIDDEN_FRAMES поскольку скрытые и отражающие кадры будут занимать слот, даже если мы их пропустим. StackWalker в тесте выглядит следующим образом:

 int estimatedSize = limit + 2; StackWalker .getInstance(Set.of(SHOW_HIDDEN_FRAMES), estimatedSize) .walk(frames -> { frames.limit(limit).forEach(b::consume); return null; }); 

Как вы можете видеть здесь, пороговый эффект исчезает:

Тест 1 3.371, 2 3.112, 4 3.101, 6 3.245, 8 3.897, 10 4.897, 12 5.627, 14 6.458, 16 6.986 us / op

Влияние skip

Если limit снижает стоимость захвата, помогает ли skip ? Еще один тест для проверки влияния skip на разные значения:

 StackWalker .getInstance() .walk(frames -> { frame.skip(skip).forEach(b::consume); return null; }); 

Тест 1 14.797, 2 14.701, 4 14.650, 6 14.748, 8 14.709, 10 14.566, 12 14.424, 14 14.826, 16 14.774 us / op

Как и следовало ожидать, StackWalker все еще должен проходить мимо пропущенных кадров, что не дает никаких преимуществ.

Вывод

Как мы уже видели, API для работы со стеком обеспечивает простой способ доступа к текущему стеку выполнения, просто написав:

 StackWalker.getInstance().walk(frames -> ...); 

Его основные характеристики:

  • поведение по умолчанию исключает скрытые и отражающие рамки, но опции позволяют их включение
  • API предоставляет декларирующий класс, который раньше был громоздким для доступа
  • это значительно быстрее, чем использование Throwable когда вы избегаете создания экземпляра StackTraceElement — включая getLineNumber , getFileName , toString
  • производительность может быть улучшена за счет уменьшения количества восстановленных кадров с помощью Stream.limit