Как сделать ссылки на методы дружелюбными для отладки

Kate

Administrator
Команда форума
В Java 8 появилось два вида функциональных выражений — лямбда-выражения вида s -> System.out.println(s) и ссылки на методы вида System.out::println. Поначалу ссылки на методы вызывали больше энтузиазма: они часто компактнее, вам не требуется придумывать имя для переменной, а ещё старожилы говорят, что они несколько оптимальнее, чем лямбда-выражения. Однако со временем энтузиазм ослаб. Одна из проблем со ссылками на методы — затруднённая отладка ошибок.

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


import java.util.Objects;
import java.util.function.Consumer;

public class Test {
public static void main(String[] args) {
Consumer<Object> consumer = obj -> Objects.requireNonNull(obj);

consumer.accept(null);
}
}

Запускать я буду на ранних сборках Java 17, которая уже скоро выйдет. Запускаем и видим:


Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:208)
at Test.lambda$main$0(Test.java:6)
at Test.main(Test.java:8)

Перед вами хороший stack trace. В нём есть как точка вызова функции (Test.java:8), так и точка её определения (Test.java:6). Также пошаговый отладчик позволяет вам зайти внутрь лямбды:


0jfmkh-k0ijahpcuuuxlmjkbis8.png



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


public static void main(String[] args) {
Consumer<Object> consumer = Objects::requireNonNull;

consumer.accept(null);
}

Запускаем снова и видим:


Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:208)
at Test.main(Test.java:8)

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


Кому-то может показаться, что проблема невелика, но в больших программах точка создания и точка вызова функции могут быть в кардинально разных местах, и отсутствие информации о том, где же создана функция, может существенно усложнить диагностику ошибки. Аналогичная проблема в пошаговом отладчике: даже если вы воспользуетесь Force step into, вы никогда не попадёте на строчку Objects::requireNonNull:


-4lfyrj6-febtrbqxmmwn74lw-q.png



Вместо этого вы сразу же попадёте внутрь вызываемого метода Objects::requireNonNull. Потому что к моменту вызова функции виртуальная машина уже совершенно не в курсе, где функция была создана. А мало ли сколько у вас ссылок на этот метод в программе, замучаешься все искать!


Вот было бы здорово создать какой-то промежуточный фрейм в стеке и прикрутить к нему нужную отладочную информацию. Погодите, но у нас уже есть промежуточный фрейм! Видите серенькую строчку accept:-1, Test$$Lambda$14/0x0000000800c02508 в отладчике? Вот это он.


Дело в том, что для адаптации функции к функциональному интерфейсу, рантайм Java генерирует маленький классик, который собственно реализует наш интерфейс. Генерация выполняется в методе InnerClassLambdaMetafactory::generateInnerClass. По идее можно пропатчить это место и добавить в этот фрейм отладочную информацию. Но откуда её взять? Очень просто: когда вызывается генерация синтетического класса, текущий стек-трейс содержит всё что нам надо. Чтобы убедиться в этом, достаточно поставить туда breakpoint:


jv-nh0yufms11rfee1mbpk6_5xo.png



Видите, там всякий внутренний ад, потом "linkCallSite:271, MethodHandleNatives", а после этого уже нужная нам шестая строчка в методе main. Как вытащить эту информацию во время исполнения? Есть модный StackWalker API, который удобный, современный и быстрый. Одна проблема: он требует Stream API, а Stream API создаёт какие-то функции внутри, а функции вызывают InnerClassLambdaMetafactory. Если вы попробуете это сделать, вы получите StackOverflowError на этапе инициализации JVM. Возможно, есть способ обойти эту проблему, например, используя внутренний API Reflection::getCallerClass, чтобы запретить обход стека для функций стандартной библиотеки. Но мы поступим просто по старинке, через new Exception().getStackTrace(). Это может быть медленнее, но мы помним, что бутстрап-метод вызывается только один раз на каждую функцию в исходниках, поэтому горячий код нисколько не пострадает. Напишем что-нибудь такое (эх, без Stream API как без рук):


private static StackTraceElement getCallerFrame() {
StackTraceElement[] trace = new Exception().getStackTrace();
for (int i = 0; i < trace.length - 1; i++) {
StackTraceElement ste = trace;
if (ste.getClassName().equals("java.lang.invoke.MethodHandleNatives") &&
ste.getMethodName().equals("linkCallSite")) {
return trace[i + 1];
}
}
return null;
}

Вернём null, если что-нибудь пошло не так. В этом случае не стоит ломать программу, можно просто вести себя как раньше.


Прекрасно, информацию мы получили. Как её теперь впихнуть в генерируемый класс? Тут хорошая новость: для генерации класса используется старый добрый ASM, подпакованный внутрь JDK. Поэтому всё делается на раз-два. Например, чтобы задать имя файла, надо написать лишь:


StackTraceElement ste = getCallerFrame();
if (ste != null) {
cw.visitSource(ste.getFileName(), null);
}

С номером строчки чуть больше возни: надо передать её в ForwardingMethodGenerator::generate, там создать в начале метода метку и добавить строчку в таблицу номеров строк:


Label start = new Label();
visitLabel(start);
...
if (lineNumber >= 0) {
visitLineNumber(lineNumber, start);
}

Вот, собственно, и всё. Весь патч целиком можно взять тут и приложить его к коду OpenJDK (ревизия 57611b30 на момент написания статьи). Этот файл можно отдельно скомпилировать с помощью Java 17:


"C:\Program Files\Java\jdk-17\bin\javac.exe" -Xlint:all --patch-module java.base=src/ -d mypatch src/java/lang/invoke/*

Мы получим пропатченные класс-файлы в каталоге mypatch. Затем надо запускать приложение с опцией --patch-module java.base=mypatch.


Проверяем пошаговый отладчик:


bsmdpi5rkvhiqv_b3iqecuxmpvm.png



Ура, Force Step Into нас действительно привёл в нужное место! Теперь у метода accept светится номер строки 6, чего мы и добивались! Правда у IDEA немного поехала крыша, потому что она не поняла, где это мы оказались. В результате она решила, что аргумент функции null — это параметр метода main args. Но это нестрашно, можно игнорировать. Да и при желании среду разработки тоже можно научить распознавать такие фреймы. Главное, что теперь заходя в вызов ссылки на метод, мы можем узнать, где она определена.


Что же со стек-трейсом при исключении? К сожалению, там всё то же. Дело в том, что сгенерированный класс-адаптер — это весьма специальный "скрытый" класс. В числе прочего, фреймы из скрытых классов не показываются по умолчанию в стек-трейсах. Включить их можно через опцию виртуальной машины -XX:+UnlockDiagnosticVMOptions -XX:+ShowHiddenFrames. Тогда мы действительно увидим нужный нам фрейм:


Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:208)
at Test$$Lambda$28/0x00000007c00c0880.accept(Test.java:6)
at Test.main(Test.java:8)

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


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

Источник статьи: https://habr.com/ru/post/568966/
 
Сверху