8

Что такое трассировка стека и как использовать её для отладки ошибок в приложении?

1

Иногда, когда я запускаю своё приложение, я получаю ошибку, которая выглядит так:

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 ответ(ов)

7

В простых словах, стек вызовов — это список вызовов методов, в которых находилось приложение, когда было выброшено исключение.

Простой пример

С помощью примера, приведенного в вопросе, мы можем точно определить, где произошло исключение в приложении. Рассмотрим стек вызовов:

Исключение в потоке "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, но важно следовать процедуре отладки).

1

Что такое 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. Подобные вещи сильно усложняют отладку, так что не стоит этого делать.

Итоги

  1. Узнайте, что вызывает исключение, и исправьте это, чтобы исключение не возникало вовсе.
  2. Если первый пункт неосуществим, поймайте конкретное исключение и обработайте его.
    • Никогда просто не добавляйте try/catch, а затем не игнорируйте исключение! Не делайте этого!
    • Никогда не используйте catch (Exception e), всегда ловите конкретные исключения. Это значительно сэкономит вам время и нервы.
0

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

Так как Роб использовал NullPointerException (NPE) для иллюстрации распространенной проблемы, давайте рассмотрим, как можно избавиться от этой ошибки следующим образом:

Если у нас есть метод, который принимает параметры, например: void (String firstName),

в нашем коде мы должны проверить, содержит ли firstName значение. Это можно сделать так: if(firstName == null || firstName.equals("")) return;

Такой подход предотвращает использование firstName как ненадежного параметра. Таким образом, выполняя проверки на null перед обработкой, мы можем гарантировать, что наш код будет работать корректно. Чтобы расширить пример с использованием объекта и его методов, можно рассмотреть следующий код:

if(dog == null || dog.firstName == null) return;

Это правильный порядок проверки на null: мы сначала проверяем базовый объект, в данном случае dog, а затем продолжаем спускаться по дереву возможных значений, чтобы убедиться, что все валидно, прежде чем продолжить обработку. Если порядок проверки будет нарушен, это может привести к выбросу NPE и падению нашей программы.

0

В 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)

Как вы можете видеть, мы изменили стек вызовов, чтобы отразить произвольные данные. Это может быть полезно для улучшения читаемости и удобства отладки при работе с исключениями.

0

Чтобы дополнить другие примеры, существует такой концепт, как вложенные (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, который предоставляет доступ к приватным методам из вложенных классов.

Чтобы ответить на вопрос, пожалуйста, войдите или зарегистрируйтесь