В 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). Также пошаговый отладчик позволяет вам зайти внутрь лямбды:
Давайте теперь заменим лямбду на ссылку на метод:
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:
Вместо этого вы сразу же попадёте внутрь вызываемого метода Objects::requireNonNull. Потому что к моменту вызова функции виртуальная машина уже совершенно не в курсе, где функция была создана. А мало ли сколько у вас ссылок на этот метод в программе, замучаешься все искать!
Вот было бы здорово создать какой-то промежуточный фрейм в стеке и прикрутить к нему нужную отладочную информацию. Погодите, но у нас уже есть промежуточный фрейм! Видите серенькую строчку accept:-1, Test$$Lambda$14/0x0000000800c02508
в отладчике? Вот это он.
Дело в том, что для адаптации функции к функциональному интерфейсу, рантайм Java генерирует маленький классик, который собственно реализует наш интерфейс. Генерация выполняется в методе InnerClassLambdaMetafactory::generateInnerClass. По идее можно пропатчить это место и добавить в этот фрейм отладочную информацию. Но откуда её взять? Очень просто: когда вызывается генерация синтетического класса, текущий стек-трейс содержит всё что нам надо. Чтобы убедиться в этом, достаточно поставить туда breakpoint:
Видите, там всякий внутренний ад, потом "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[i];
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.
Проверяем пошаговый отладчик:
Ура, 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, я даже пытаться не буду. Но никто не мешает сделать это у себя локально. Конечно, я не даю никаких гарантий, что оно будет правильно работать у вас!
Комментарии (3)
pjBooms
22.07.2021 11:56Понятно, что никто в здравом уме не примет такой патч в OpenJDK, я даже пытаться не буду. Но никто не мешает сделать это у себя локально. Конечно, я не даю никаких гарантий, что оно будет правильно работать у вас!
Если правильно оформить JEP и еще немного подумать как сделать красиво, то вполне наверно можно было бы решение задачи "понять откуда ссылка на метод" засунуть в апстрим OpenJDK. Но понятно это время потребует поболее чем давай-ка побыстрому захачим (с другой стороны ты же уже потратил время на статью :). Если вдруг патч станет популярным сам по себе может и JEP появиться сам по себе.
tagir_valeev Автор
22.07.2021 12:44Это ограничивает возможности для дедупликации рантайм-представлений. Была такая инициатива condy-folding по склейке одинаковых метод-референсов (а потенциально и лямбд) в пределах класса в одну константу. Что-то заглохло оно, но может кто-то хотел бы к ней вернуться. Ну и придётся делать какое-то исключение для скрытых классов в стек-трейсах (например, показывать, если там дебаг-инфо есть), то есть уже изменения в хотспотовском рантайме.
Maccimo
В отладчике IDEA есть возможность промаркировать значение (
контекстное меню переменной во вкладке «Variables» ⇒ Mark Object... F11
). Нельзя ли этот механизм задействовать для улучшенияStep Into
?Для тех кто не знает, выглядит примерно так: