JEP 358: Helpful NullPointerExceptions
Информативные сообщения NullPointerException
| Authors | Goetz Lindenmaier, Ralf Schmelter |
| Ответственный | Goetz Lindenmaier |
| Тип | Feature |
| Область | JDK |
| Статус | Closed / Delivered |
| Выпуск | 14 |
| Компонент | hotspot / runtime |
| Обсуждение | hotspot dash runtime dash dev at openjdk dot java dot net, core dash libs dash dev at openjdk dot java dot net |
| Трудоёмкость | S |
| Длительность | S |
| Рецензенты | Alex Buckley, Coleen Phillimore |
| Одобрен | Mikael Vidstedt |
| Создан | 2019/03/15 10:27 |
| Обновлён | 2021/12/22 14:02 |
| Задача | 8220715 |
Аннотация
Сделать исключения NullPointerException, которые генерирует JVM, удобнее: в них будет точно указано, какая переменная была null.
Цели
-
Дать разработчикам и сотрудникам поддержки полезную информацию о преждевременном завершении программы.
-
Упростить понимание программы: связь исключения, возникшего во время выполнения, со статическим кодом программы станет нагляднее.
-
Уменьшить растерянность и беспокойство, которые у начинающих разработчиков часто вызывают исключения
NullPointerException.
Что не является целью
-
Целью не является поиск того места, где в итоге появилась ссылка
null. Нужно найти только того, кто неудачно попытался её использовать. -
Целью не является выбрасывать больше исключений
NullPointerExceptionили выбрасывать их в другой момент.
Мотивация
С исключениями NullPointerException (NPE) сталкивался каждый Java-разработчик. NPE может возникнуть почти в любом месте программы, поэтому перехватывать их и восстанавливать после них работу, как правило, непрактично. В результате разработчики полагаются на JVM: она указывает источник NPE, когда исключение действительно возникает. Например, пусть NPE возникает в этом коде:
a.i = 99;
JVM выведет метод, имя файла и номер строки, где возникло NPE:
Exception in thread "main" java.lang.NullPointerException
at Prog.main(Prog.java:5)
Это сообщение обычно попадает в отчёт об ошибке. С его помощью разработчик может найти a.i = 99; и сделать вывод, что значение a было null. Однако в более сложном коде без отладчика невозможно определить, какая переменная была null. Пусть NPE возникает в этом коде:
a.b.c.i = 99;
Имя файла и номер строки не показывают точно, какая переменная была null. Это a, b или c?
Похожая проблема возникает при обращении к массиву и присваивании элементу массива. Пусть NPE возникает в этом коде:
a[i][j][k] = 99;
Имя файла и номер строки не показывают точно, какой элемент массива был null. Это a, a[i] или a[i][j]?
В одной строке кода может быть несколько путей доступа, и каждый из них может оказаться источником NPE. Пусть NPE возникает в этом коде:
a.i = b.j;
Имя файла и номер строки не показывают, какой путь доступа виноват. Был null a или b?
Наконец, NPE может быть следствием вызова метода. Пусть NPE возникает в этом коде:
x().y().i = 99;
Имя файла и номер строки не показывают, какой вызов метода вернул null. Это x() или y()?
Смягчить недостаток точности JVM помогают различные приёмы. Например, разработчик, столкнувшийся с NPE, может разбить пути доступа, присваивая промежуточные значения локальным переменным. (Здесь может пригодиться ключевое слово var.) В результате в сообщении JVM переменная со значением null будет указана точнее, но переформатировать код, чтобы отследить исключение, нежелательно. В любом случае большинство NPE возникает в производственной среде, где инженер поддержки, который видит NPE, находится далеко от разработчика, чей код его вызвал.
Вся экосистема Java выиграла бы, если бы JVM выдавала информацию, достаточную, чтобы точно найти источник NPE и затем установить его первопричину, без дополнительных инструментов и без перестановки кода. Коммерческая JVM компании SAP делает это с 2006 года, и разработчики и инженеры поддержки высоко это оценили.
Описание
JVM выбрасывает NullPointerException (NPE) в том месте программы, где код пытается разыменовать ссылку null. Анализируя инструкции байт-кода программы, JVM будет точно определять, какая переменная была null, и описывать эту переменную (в терминах исходного кода) в null-detail message (сообщение с подробностями о null) внутри NPE. Затем сообщение null-detail будет выводиться в сообщении JVM вместе с методом, именем файла и номером строки.
Примечание: JVM выводит сообщение исключения в той же строке, что и тип исключения, поэтому строки могут получаться длинными. Чтобы текст было удобнее читать в браузере, в этом JEP сообщение null-detail показано на второй строке, после типа исключения.
Например, NPE в операторе присваивания a.i = 99; приведёт к такому сообщению:
Exception in thread "main" java.lang.NullPointerException:
Cannot assign field "i" because "a" is null
at Prog.main(Prog.java:5)
Если NPE выбрасывает более сложный оператор a.b.c.i = 99;, сообщение разберёт оператор на части и укажет причину, показав полный путь доступа, который привёл к null:
Exception in thread "main" java.lang.NullPointerException:
Cannot read field "c" because "a.b" is null
at Prog.main(Prog.java:5)
Полный путь доступа полезнее, чем одно имя поля со значением null: он помогает разработчику разобраться в сложной строке исходного кода, особенно если в этой строке одно и то же имя встречается несколько раз.
Аналогично, если NPE выбрасывает оператор обращения к массиву и присваивания a[i][j][k] = 99;:
Exception in thread "main" java.lang.NullPointerException:
Cannot load from object array because "a[i][j]" is null
at Prog.main(Prog.java:5)
Аналогично, если NPE выбрасывает a.i = b.j;:
Exception in thread "main" java.lang.NullPointerException:
Cannot read field "j" because "b" is null
at Prog.main(Prog.java:5)
В каждом примере сообщения null-detail вместе с номером строки достаточно, чтобы найти в исходном коде выражение, которое равно null. В идеале сообщение null-detail показывало бы сам исходный код, но это сложно сделать из-за того, как исходный код соотносится с инструкциями байт-кода (см. ниже). Кроме того, если в выражении есть обращение к массиву, сообщение null-detail не может показать фактические индексы массива, которые привели к элементу null, например значения i и j во время выполнения, когда a[i][j] равно null. Дело в том, что индексы массива хранились в стеке операндов метода, а он был потерян, когда было выброшено NPE.
Сообщение null-detail будет только в тех NPE, которые создаёт и выбрасывает непосредственно JVM. NPE, которые явно создаются и/или явно выбрасываются программами, работающими на JVM, не подвергаются анализу байт-кода и созданию сообщения null-detail, описанным ниже. Кроме того, сообщение null-detail не выводится для NPE, вызванных кодом в скрытых методах. Это специальные низкоуровневые методы, которые JVM генерирует и вызывает, например, чтобы оптимизировать конкатенацию строк. У скрытого метода нет имени файла и номера строки, которые помогли бы найти источник NPE, поэтому выводить сообщение null-detail было бы бесполезно.
Вычисление сообщения null-detail
Исходный код, например a.b.c.i = 99;, компилируется в несколько инструкций байт-кода. Когда выбрасывается NPE, JVM точно знает, какая инструкция байт-кода в каком методе за это отвечает, и использует эту информацию, чтобы вычислить сообщение null-detail. Сообщение состоит из двух частей:
-
Первая часть —
Cannot read field "c"— это следствие NPE. Она сообщает, какое действие не удалось выполнить из-за того, что инструкция байт-кода сняла со стека операндов ссылкуnull. -
Вторая часть —
because "a.b" is null— это причина NPE. Она воссоздаёт ту часть исходного кода, которая поместила ссылкуnullв стек операндов.
Первая часть сообщения null-detail вычисляется по инструкции байт-кода, которая сняла со стека null, как показано в таблице 1:
| байт-код | 1-я часть |
|---|---|
aload | "Cannot load from <тип элемента> array" |
arraylength | "Cannot read the array length" |
astore | "Cannot store to <тип элемента> array" |
athrow | "Cannot throw exception" |
getfield | "Cannot read field "<имя поля>"" |
invokeinterface, invokespecial, invokevirtual | "Cannot invoke "<method>"" |
monitorenter | "Cannot enter synchronized block" |
monitorexit | "Cannot exit synchronized block" |
putfield | "Cannot assign field "<имя поля>"" |
| Любой другой байт-код | NPE невозможно, сообщения нет |
<method> раскрывается как <class name>.<method name>(<parameter types>)
Вторая часть сообщения null-detail сложнее. Она указывает путь доступа, который привёл к появлению ссылки null в стеке операндов, но сложные пути доступа состоят из нескольких инструкций байт-кода. По последовательности инструкций метода не очевидно, какая из предыдущих инструкций поместила в стек ссылку null. Поэтому по всем инструкциям метода выполняется простой анализ потока данных. Он вычисляет, какая инструкция помещает значение в какую ячейку стека операндов, и передаёт эту информацию инструкции, которая снимает значение с этой ячейки. (Время анализа линейно зависит от числа инструкций.) По результатам анализа можно пройти назад по инструкциям, из которых состоит путь доступа в исходном коде. Вторая часть сообщения собирается шаг за шагом по инструкции байт-кода на каждом шаге, как показано в таблице 2:
| байт-код | 2-я часть |
|---|---|
aconst_null | "null" |
aaload | вычислить 2-ю часть для инструкции, которая поместила в стек ссылку на массив, затем добавить "[«, затем вычислить 2-ю часть для инструкции, которая поместила в стек индекс, затем добавить »]" |
iconst_*, bipush, sipush | значение константы |
getfield | вычислить 2-ю часть для инструкции, которая поместила в стек ссылку, к которой обращается этот getfield, затем добавить ".<field name>" |
getstatic | "<имя класса>.<имя поля>" |
invokeinterface, invokevirtual, invokespecial, invokestatic |
Если это первый шаг, то "the return value of <method>«, иначе »<method>" |
iload*, aload* | Для локальной переменной 0 — "this«. Для других локальных переменных и параметров — имя переменной, если доступна таблица локальных переменных, иначе »<parameter i >« или »<local i >". |
| Любой другой байт-код | Ко второй части не относится. |
Путь доступа может состоять из произвольного числа инструкций байт-кода. Сообщение null-detail не обязательно охватывает их все. Чтобы вывод не был слишком сложным, алгоритм проходит назад по инструкциям только на ограниченное число шагов. Если достигнуто максимальное число шагов, выводятся заполнители, например «...». В редких случаях пройти назад по инструкциям невозможно, и тогда сообщение null-detail будет содержать только первую часть («Cannot ...», без пояснения «because ...»).
Сообщение null-detail — Cannot read field "c" because "a.b" is null — вычисляется по запросу, когда JVM вызывает Throwable::getMessage для своего сообщения. Обычно сообщение, которое несёт исключение, нужно передать при создании объекта исключения, но это вычисление затратно и может быть нужно не всегда, поскольку многие NPE программы перехватывают и отбрасывают. Для вычисления нужны инструкции байт-кода метода, вызвавшего NPE, и индекс инструкции, которая сняла со стека null. К счастью, реализация Throwable содержит эту информацию о происхождении исключения.
Возможность можно включать и выключать новым логическим параметром командной строки -XX:{+|-}ShowCodeDetailsInExceptionMessages. Сначала у параметра будет значение по умолчанию «false», поэтому сообщение выводиться не будет. В одном из следующих выпусков предполагается по умолчанию включить подробности о коде в сообщениях исключений.
Пример вычисления сообщения null-detail
Вот пример на основе следующего фрагмента исходного кода:
a().b[i][j] = 99;
В байт-коде этот исходный код представлен так:
5: invokestatic #7 // Method a:()LA;
8: getfield #13 // Field A.b, an array
11: iload_1 // Load local variable i, an array index
12: aaload // Load b[i], another array
13: iload_2 // Load local variable j, another array index
14: bipush 99
16: iastore // Store to b[i][j]
Предположим, что a().b[i] равно null. Тогда при записи в b[i][j] будет выброшено NPE. JVM выполнит байт-код 16: iastore и выбросит NPE, потому что байт-код 12: aaload поместил null в стек операндов. Сообщение null-detail будет вычислено так:
Cannot store to int array because "Test.a().b[i]" is null
Вычисление начинается с метода, содержащего инструкции байт-кода, и индекса байт-кода 16. Инструкция с индексом 16 — это iastore, поэтому по таблице 1 первая часть сообщения — «Cannot store to int array».
Для второй части сообщения алгоритм возвращается к инструкции, которая поместила в стек null, на свою беду снятый со стека инструкцией iastore. Анализ потока данных показывает, что это 12: aaload — загрузка из массива. По таблице 2, если ссылку на массив null дала загрузка из массива, мы возвращаемся к инструкции, которая поместила в стек операндов ссылку на массив (а не индекс массива), — 8: getfield. Затем, снова по таблице 2, если в путь доступа входит getfield, мы возвращаемся к инструкции, которая поместила в стек ссылку, используемую getfield, — 5: invokestatic. Теперь можно собрать вторую часть сообщения:
- Для
5: invokestaticвыводим «Test.a()» - Для
8: getfieldвыводим «.b» - Для
12: aaloadвыводим «[» и возвращаемся к инструкции, которая поместила в стек индекс, —11: iload_1. Выводим «i» (имя локальной переменной #1), затем «]».
Алгоритм никогда не переходит к 13: iload_2, которая помещает в стек индекс j, или к 14: bipush, которая помещает в стек 99, потому что они не связаны с причиной NPE.
К этому JEP приложены файлы с множеством примеров сообщений null-detail: в output_with_debug_info.txt перечислены сообщения для случая, когда class-файлы содержат таблицу локальных переменных, а в output_no_debug_info.txt — сообщения для случая, когда class-файлы не содержат таблицы локальных переменных.
Альтернативы
Наличие сообщения null-detail
JVM могла бы передавать информацию null-detail и другими способами, например записывать её в stdout или использовать средства трассировки или журналирования. Однако исключения — стандартный способ сообщать о проблемах в JVM, а NPE уже сообщает, где было выброшено исключение: оно содержит трассировку стека с номерами строк. Этой информации недостаточно, чтобы найти причину, поэтому естественно улучшить NPE, добавив недостающую информацию.
По умолчанию сообщение null-detail отключено. Его можно включить параметром командной строки -XX:+ShowCodeDetailsInExceptionMessages. Указать, что интересны только некоторые байт-коды, выбрасывающие NPE, нельзя. Сообщение null-detail может быть нужно не во всех случаях по следующим причинам:
-
Производительность. Алгоритм добавляет некоторые накладные расходы к построению трассировки стека. Однако они сопоставимы с обходом стека, который выполняется при выбрасывании исключения. Если приложение так часто выбрасывает исключения и выводит сообщения, что вывод влияет на производительность, то уже само выбрасывание исключения создаёт накладные расходы, которых определённо следует избегать.
-
Безопасность. Сообщение null-detail даёт представление об исходном коде, которое иначе получить непросто. Чтобы этого избежать, сообщение можно было бы отключить, но сообщения исключений как раз и должны нести информацию о причине исключения, чтобы проблему можно было исправить. Если раскрывать эту информацию недопустимо, приложение должно не выводить сообщение, а перехватывать и отбрасывать его. Решать это настройкой JVM не следует.
-
Совместимость. Традиционно JVM не добавляла сообщение к NPE, и появление сообщения сейчас может вызвать проблемы у инструментов, которые разбирают трассировки стека слишком чувствительным к изменениям образом. Однако программы на Java всегда могли выбрасывать NPE с сообщениями, поэтому ожидается, что инструменты адаптируются к сообщениям в NPE от JVM. Связанный риск состоит в том, что инструменты могут зависеть от точного формата сообщения null-detail.
Мы намерены включить сообщение null-detail по умолчанию в одном из будущих выпусков.
Вычисление сообщения null-detail
То, что сообщение null-detail вычисляется по запросу, влияет на его доступность в сложных сценариях:
-
При выполнении удалённого кода через RMI любое исключение, выброшенное удалённым кодом, доставляется вызывающей стороне с помощью сериализации. При сериализации объекта исключения его внутренние структуры данных не сохраняются, поэтому если удалённый код выбрасывает и, следовательно, сериализует NPE, то при последующей десериализации получится NPE, для которого сообщение null-detail по запросу вычислить нельзя.
-
Если инструкции байт-кода метода меняются во время работы программы, например из-за переопределения метода Java-агентом через JVMTI, то исходные инструкции сохраняются какое-то время, но могут быть отброшены во время цикла сборки мусора. Для вычисления сообщения null-detail нужны исходные инструкции, поэтому в этом случае сообщение null-detail по запросу вычислено не будет.
Решение не поддерживать сериализацию было принято, чтобы свести к минимуму изменения в самом классе NullPointerException. Если понадобится сохранять сообщение null-detail при сериализации, в этом классе можно будет реализовать writeReplace. Другой вариант — вычислять сообщение null-detail при создании объекта исключения; тогда сообщение null-detail сохранялось бы и при сериализации, и при переопределении метода.
Формат сообщения null-detail
Сообщение null-detail состоит из двух частей: первая описывает действие, которое не удалось выполнить (следствие NPE), а вторая — выражение, которое ранее поместило ссылку null в стек операндов (причину NPE). В некоторых случаях получается многословный текст, хотя чтобы точно определить выражение null в исходном коде, на самом деле нужна лишь часть сообщения. Например, сократить сообщение было бы полезно в следующих двух сценариях:
-
При неудачном обращении к массиву —
Cannot load from object array because "a[i][j]" is null.— второй части"a[i][j]" is nullдостаточно, чтобы точно определить выражениеnullв исходном кодеa[i][j][k] = 99;. -
При неудачном вызове метода —
Cannot invoke "NullPointerExceptionTest.callWithTypes(String[][], int[][][], float, long, short, boolean, byte, double, char)" because...— объявляющий тип и типы параметров метода часто громоздки, и их можно опустить, не сильно мешая разработчику точно определить выражениеnull.
Тем не менее сообщение null-detail эту информацию не опускает. Алгоритм, вычисляющий сообщение, работает с произвольными последовательностями инструкций байт-кода, поэтому ему не всегда удаётся собрать полезное сообщение. Например, при неудачном обращении к массиву он может вообще не суметь вычислить вторую часть, и если бы первая часть была опущена, никакое сообщение не было бы выведено; в этом случае одной первой части может быть достаточно, чтобы точно определить выражение null в исходном коде. В целом, поскольку сообщение собирается из отдельных фрагментов для каждой посещённой инструкции, алгоритмически решить, собрано ли в какой-то момент достаточно информации, чтобы опустить остальные части без ущерба для полезности сообщения, практически невозможно. Поэтому было решено выводить всю информацию, чтобы сообщение было полезным в как можно большем числе ситуаций.
Риски и допущения
В информативном NPE сообщение null-detail может содержать имена переменных из исходного кода. В частности, если в файл class включена отладочная информация (с помощью javac -g), то выводятся имена локальных переменных. Раньше API рефлексии не раскрывали эти имена напрямую: программе пришлось бы получать их косвенным путём, исследуя файл class с помощью ClassLoader::getResourceAsStream(). Раскрытие этих имён в NPE может считаться риском безопасности, но если их не выводить, польза от сообщения null-detail уменьшится.
Предполагается, что вычисление сообщения null-detail будет расширено, если в спецификацию JVM добавят новые байт-коды.
Тестирование
Прототип этой возможности реализован в JDK-8218628. Прототип содержит модульный тест, проверяющий каждую часть сообщения. Предшествующая реализация используется в коммерческой JVM компании SAP с 2006 года и доказала свою стабильность.
Чтобы избежать регрессий, следует прогнать довольно большие объёмы кода. Следует запустить тесты jtreg, чтобы выявить другие тесты, которые обрабатывают сообщение и требуют адаптации.