Что такое трассировка стека и как использовать её для отладки ошибок в приложении?
Иногда, когда я запускаю своё приложение, я получаю ошибку, которая выглядит так:
Exception in thread "main" java.lang.NullPointerException
at com.example.myproject.Book.getTitle(Book.java:16)
at com.example.myproject.Author.getBookTitles(Author.java:25)
at com.example.myproject.Bootstrap.main(Bootstrap.java:14)
Люди называют это "стеком вызовов" (stack trace). Что такое стек вызовов? Что он может рассказать мне об ошибке, которая происходит в моей программе?
О вопросе - довольно часто я вижу, как начинающий программист "получает ошибку" и просто вставляет свой стек вызовов и какой-то случайный фрагмент кода, не понимая, что такое стек вызовов и как его можно использовать. Этот вопрос предназначен в качестве справки для начинающих программистов, которым может понадобиться помощь в понимании значимости стека вызовов.
5 ответ(ов)
В простых словах, стек вызовов — это список вызовов методов, в которых находилось приложение, когда было выброшено исключение.
Простой пример
С помощью примера, приведенного в вопросе, мы можем точно определить, где произошло исключение в приложении. Рассмотрим стек вызовов:
Исключение в потоке "main" java.lang.NullPointerException
по адресу com.example.myproject.Book.getTitle(Book.java:16)
по адресу com.example.myproject.Author.getBookTitles(Author.java:25)
по адресу com.example.myproject.Bootstrap.main(Bootstrap.java:14)
Это очень простой стек вызовов. Если мы начнем с начала списка "по адресу ...", мы сможем понять, где произошла ошибка. Мы ищем самый верхний вызов метода, который относится к нашему приложению. В данном случае это:
по адресу com.example.myproject.Book.getTitle(Book.java:16)
Чтобы отладить это, мы можем открыть Book.java
и посмотреть на строку 16
, которая выглядит так:
15 public String getTitle() {
16 System.out.println(title.toString());
17 return title;
18 }
Это указывает на то, что что-то (вероятно, title
) является null
в приведенном коде.
Пример с цепочкой исключений
Иногда приложения перехватывают исключение и выбрасывают его повторно в качестве причины другого исключения. Это обычно выглядит так:
34 public void getBookIds(int id) {
35 try {
36 book.getId(id); // этот метод выбрасывает NullPointerException на строке 22
37 } catch (NullPointerException e) {
38 throw new IllegalStateException("У книги есть свойство с null", e);
39 }
40 }
Это может дать вам стек вызовов, который выглядит так:
Исключение в потоке "main" java.lang.IllegalStateException: У книги есть свойство с null
по адресу com.example.myproject.Author.getBookIds(Author.java:38)
по адресу com.example.myproject.Bootstrap.main(Bootstrap.java:14)
Причина: java.lang.NullPointerException
по адресу com.example.myproject.Book.getId(Book.java:22)
по адресу com.example.myproject.Author.getBookIds(Author.java:36)
... 1 дополнительно
Что отличает этот случай, так это секция "Причина". Иногда исключения имеют несколько секций "Причина". В таких случаях вы обычно хотите найти "коренную причину", которая будет одной из самых нижних секций "Причина" в стеке вызовов. В нашем случае это:
Причина: java.lang.NullPointerException <-- коренная причина
по адресу com.example.myproject.Book.getId(Book.java:22) <-- важная строка
Опять же, в случае с этим исключением мы хотели бы взглянуть на строку 22
в Book.java
, чтобы увидеть, что может привести к NullPointerException
здесь.
Сложный пример с библиотечным кодом
Обычно стеки вызовов значительно сложнее, чем два приведенных выше примера. Вот пример (он длинный, но демонстрирует несколько уровней цепочек исключений):
javax.servlet.ServletException: Что-то пошло не так
по адресу com.example.myproject.OpenSessionInViewFilter.doFilter(OpenSessionInViewFilter.java:60)
по адресу org.mortbay.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1157)
по адресу com.example.myproject.ExceptionHandlerFilter.doFilter(ExceptionHandlerFilter.java:28)
...
Причина: com.example.myproject.MyProjectServletException
по адресу com.example.myproject.MyServlet.doPost(MyServlet.java:169)
...
Причина: org.hibernate.exception.ConstraintViolationException: не удалось вставить: [com.example.myproject.MyEntity]
по адресу org.hibernate.exception.SQLStateConverter.convert(SQLStateConverter.java:96)
...
Причина: java.sql.SQLException: Нарушение уникального ограничения MY_ENTITY_UK_1: дублирующие значения для столбца(ов) MY_COLUMN в операторе [...]
по адресу org.hsqldb.jdbc.Util.throwError(Unknown Source)
...
В этом примере много деталей. Что нам важно, так это поиск методов, которые принадлежат нашему коду, то есть всему, что находится в пакете com.example.myproject
. Сначала мы хотим найти коренную причину, которая представлена:
Причина: java.sql.SQLException
Тем не менее, все вызовы метода ниже этой причины являются библиотечным кодом. Поэтому мы перейдем вверх к "Причине" выше, и в этом блоке "Причина" мы ищем первый метод, происходящий из нашего кода, который представляет собой:
по адресу com.example.myproject.MyEntityService.save(MyEntityService.java:59)
Как и в предыдущих примерах, мы должны посмотреть на MyEntityService.java
в строке 59
, потому что именно там возникла эта ошибка (в данном случае ошибка явно объясняется в SQLException, но важно следовать процедуре отладки).
Что такое Stacktrace?
Stacktrace — это очень полезный инструмент для отладки. Он показывает стек вызовов (то есть последовательность функций, которые были вызваны до определенного момента) в момент, когда возникло необработанное исключение (или в момент, когда stacktrace был сгенерирован вручную). Это особенно полезно, потому что он не только показывает, где произошла ошибка, но и как программа дошла до этой точки в коде.
Это приводит нас к следующему вопросу:
Что такое Исключение?
Исключение — это то, что среда выполнения использует для того, чтобы сообщить вам о произошедшей ошибке. Популярные примеры включают NullPointerException, IndexOutOfBoundsException и ArithmeticException. Каждое из этих исключений возникает, когда вы пытаетесь сделать что-то невозможное. Например, NullPointerException возникает, когда вы пытаетесь разыменовать объект, равный null:
Object a = null;
a.toString(); // эта строка вызывает NullPointerException
Object[] b = new Object[5];
System.out.println(b[10]); // эта строка вызывает IndexOutOfBoundsException,
// потому что массив b содержит только 5 элементов
int ia = 5;
int ib = 0;
ia = ia/ib; // эта строка вызывает ArithmeticException с сообщением
// "/ by 0", так как вы пытаетесь
// делить на 0, что невозможно.
Как мне справляться с Stacktrace/Исключениями?
Прежде всего, выясните, что вызывает Исключение. Попробуйте погуглить название исключения, чтобы узнать, что его вызвало. Чаще всего причиной будет неверный код. В приведенных выше примерах все исключения вызваны ошибками в коде. Например, в случае с NullPointerException, вам следует убедиться, что a
никогда не равен null в этот момент. Вы можете, например, инициализировать a
или добавить проверку:
if (a != null) {
a.toString();
}
Таким образом, проблемная строка не будет выполнена, если a == null
. То же самое относится и к другим примерам.
Иногда вы не можете гарантировать, что исключение не возникнет. Например, если вы используете сетевое подключение в вашей программе, вы не можете предотвратить потерю интернет-соединения (например, вы не можете остановить пользователя от отключения сетевого подключения компьютера). В этом случае библиотека для работы с сетью, вероятно, выбросит исключение. Теперь вам следует поймать это исключение и обработать его. Это значит, что в примере с сетевым подключением, вам следует попытаться восстановить соединение или уведомить пользователя и т.д. Также, когда вы используете catch
, всегда ловите только те исключения, которые вам нужны; не используйте широкие конструкции типа catch (Exception e)
, которые поймают все исключения. Это очень важно, иначе вы можете случайно поймать неверное исключение и, соответственно, среагировать неверно.
try {
Socket x = new Socket("1.1.1.1", 6789);
x.getInputStream().read();
} catch (IOException e) {
System.err.println("Не удалось установить соединение, пожалуйста, попробуйте позже!");
}
Почему не следует использовать catch (Exception e)
?
Давайте рассмотрим небольшой пример, чтобы понять, почему не стоит ловить все исключения:
int mult(Integer a, Integer b) {
try {
int result = a / b;
return result;
} catch (Exception e) {
System.err.println("Ошибка: Деление на ноль!");
return 0;
}
}
Этот код пытается поймать ArithmeticException
, возникающее при делении на 0. Но он также отлавливает возможное NullPointerException
, которое возникает, если a
или b
равны null
. Это означает, что вы можете получить NullPointerException
, но отреагируете на него как на ArithmeticException
, что, вероятно, приведет к неправильным действиям. В лучшем случае, вы просто не заметите, что произошло исключение NullPointerException
. Подобные вещи сильно усложняют отладку, так что не стоит этого делать.
Итоги
- Узнайте, что вызывает исключение, и исправьте это, чтобы исключение не возникало вовсе.
- Если первый пункт неосуществим, поймайте конкретное исключение и обработайте его.
- Никогда просто не добавляйте
try/catch
, а затем не игнорируйте исключение! Не делайте этого! - Никогда не используйте
catch (Exception e)
, всегда ловите конкретные исключения. Это значительно сэкономит вам время и нервы.
- Никогда просто не добавляйте
В дополнение к тому, что упомянул Роб. Установка точек останова в вашем приложении позволяет пошагово просматривать стек вызовов. Это дает разработчику возможность использовать отладчик и увидеть, в какой именно момент метод выполняет действия, которые могут быть непредвиденными.
Так как Роб использовал NullPointerException
(NPE) для иллюстрации распространенной проблемы, давайте рассмотрим, как можно избавиться от этой ошибки следующим образом:
Если у нас есть метод, который принимает параметры, например: void (String firstName)
,
в нашем коде мы должны проверить, содержит ли firstName
значение. Это можно сделать так: if(firstName == null || firstName.equals("")) return;
Такой подход предотвращает использование firstName
как ненадежного параметра. Таким образом, выполняя проверки на null
перед обработкой, мы можем гарантировать, что наш код будет работать корректно. Чтобы расширить пример с использованием объекта и его методов, можно рассмотреть следующий код:
if(dog == null || dog.firstName == null) return;
Это правильный порядок проверки на null
: мы сначала проверяем базовый объект, в данном случае dog
, а затем продолжаем спускаться по дереву возможных значений, чтобы убедиться, что все валидно, прежде чем продолжить обработку. Если порядок проверки будет нарушен, это может привести к выбросу NPE и падению нашей программы.
В Java есть одна особенность, предоставляемая классом Throwable, которая позволяет манипулировать информацией об отчете об ошибках (stack trace).
Стандартное поведение:
package test.stack.trace;
public class SomeClass {
public void methodA() {
methodB();
}
public void methodB() {
methodC();
}
public void methodC() {
throw new RuntimeException();
}
public static void main(String[] args) {
new SomeClass().methodA();
}
}
В результате выполнения кода мы получим следующий стек вызовов:
Исключение в потоке "main" java.lang.RuntimeException
at test.stack.trace.SomeClass.methodC(SomeClass.java:18)
at test.stack.trace.SomeClass.methodB(SomeClass.java:13)
at test.stack.trace.SomeClass.methodA(SomeClass.java:9)
at test.stack.trace.SomeClass.main(SomeClass.java:27)
Манипулированный стек вызовов:
package test.stack.trace;
public class SomeClass {
...
public void methodC() {
RuntimeException e = new RuntimeException();
e.setStackTrace(new StackTraceElement[]{
new StackTraceElement("OtherClass", "methodX", "String.java", 99),
new StackTraceElement("OtherClass", "methodY", "String.java", 55)
});
throw e;
}
public static void main(String[] args) {
new SomeClass().methodA();
}
}
Теперь стек вызовов будет выглядеть следующим образом:
Исключение в потоке "main" java.lang.RuntimeException
at OtherClass.methodX(String.java:99)
at OtherClass.methodY(String.java:55)
Как вы можете видеть, мы изменили стек вызовов, чтобы отразить произвольные данные. Это может быть полезно для улучшения читаемости и удобства отладки при работе с исключениями.
Чтобы дополнить другие примеры, существует такой концепт, как вложенные (nested) классы, которые обозначаются знаком $
. Например, рассмотрим следующий код:
public class Test {
private static void privateMethod() {
throw new RuntimeException();
}
public static void main(String[] args) throws Exception {
Runnable runnable = new Runnable() {
@Override public void run() {
privateMethod();
}
};
runnable.run();
}
}
Этот код приведёт к следующему стек-трейсу:
Exception in thread "main" java.lang.RuntimeException
at Test.privateMethod(Test.java:4)
at Test.access$000(Test.java:1)
at Test$1.run(Test.java:10)
at Test.main(Test.java:13)
Как видно из стек-трейса, метод privateMethod
был вызван внутри анонимного класса Runnable
, который представлен как Test$1
. Метод access$000
— это специальный метод, автоматически сгенерированный компилятором Java, который предоставляет доступ к приватным методам из вложенных классов.
Как преобразовать стек вызовов в строку?
Что значит "Не удалось найти или загрузить основной класс"?
Почему в RecyclerView отсутствует onItemClickListener()?
Что значит 'synchronized'?
Почему нет ConcurrentHashSet, если есть ConcurrentHashMap?