Статьи

Откуда берутся следы стека?

Я считаю, что чтение и понимание трассировки стека является важным навыком, которым должен обладать каждый программист, чтобы эффективно устранять проблемы с каждым языком JVM (см. Также: фильтрация нерелевантных строк трассировки стека в журналах и первопричина исключений в журнале исключений ). Итак, мы можем начать с небольшой викторины? Учитывая следующий фрагмент кода, какие методы будут присутствовать в трассировке стека? foo() , bar() или может и то и другое?

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
public class Main {
 
    public static void main(String[] args) throws IOException {
        try {
            foo();
        } catch (RuntimeException e) {
            bar(e);
        }
    }
 
    private static void foo() {
        throw new RuntimeException('Foo!');
    }
 
    private static void bar(RuntimeException e) {
        throw e;
    }
}

В C # оба ответа возможны в зависимости от того, как исходное исключение перебрасывается в bar()throw e перезаписывает исходную трассировку стека (происходящую из foo() ) с местом, где оно было снова выброшено (в bar() ) , С другой стороны, голое ключевое слово throw перебрасывает исключение, сохраняя исходную трассировку стека. Java следует второму подходу (используя синтаксис первого) и даже не допускает прямого подхода. Но как насчет этой слегка модифицированной версии:

01
02
03
04
05
06
07
08
09
10
11
12
public static void main(String[] args) throws IOException {
    final RuntimeException e = foo();
    bar(e);
}
 
private static RuntimeException foo() {
    return new RuntimeException();
}
 
private static void bar(RuntimeException e) {
    throw e;
}

foo() только создает исключение, но вместо броска возвращает этот объект исключения. Это исключение затем выдается из совершенно другого метода. Как теперь будет выглядеть трассировка стека? Сюрприз, он все еще указывает на foo() , точно так же, как если бы исключение было сгенерировано оттуда, точно так же, как в первом примере:

1
2
3
Exception in thread 'main' java.lang.RuntimeException
    at Main.foo(Main.java:7)
    at Main.main(Main.java:15)

Что происходит, спросите вы? Похоже, что трассировка стека генерируется не при возникновении исключения, а при создании объекта исключения . В подавляющем большинстве ситуаций эти действия происходят в одном и том же месте, поэтому никто не мешает. Многие начинающие Java-программисты даже не знают, что можно создать объект исключения и назначить его переменной или полю или даже передать его.

Но откуда на самом деле происходит трассировка стека исключений? Ответ довольно прост, из Throwable.fillInStackTrace() !

1
2
3
4
5
6
public class Throwable implements Serializable {
 
    public synchronized native Throwable fillInStackTrace();
 
//...
}

Обратите внимание, что этот метод не является final , что позволяет нам немного взломать. Мы не только можем обойти создание трассировки стека и создать исключение без какого-либо контекста, но даже полностью перезаписать стек!

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class SponsoredException extends RuntimeException {
 
    @Override
    public synchronized Throwable fillInStackTrace() {
        setStackTrace(new StackTraceElement[]{
                new StackTraceElement('ADVERTISEMENT', '   If you don't   ', null, 0),
                new StackTraceElement('ADVERTISEMENT', ' want to see this ', null, 0),
                new StackTraceElement('ADVERTISEMENT', '     exception    ', null, 0),
                new StackTraceElement('ADVERTISEMENT', '    please  buy   ', null, 0),
                new StackTraceElement('ADVERTISEMENT', '   full  version  ', null, 0),
                new StackTraceElement('ADVERTISEMENT', '  of  the program ', null, 0)
        });
        return this;
    }
}
 
public class ExceptionFromHell extends RuntimeException {
 
    public ExceptionFromHell() {
        super('Catch me if you can');
    }
 
    @Override
    public synchronized Throwable fillInStackTrace() {
        return this;
    }
}

Бросок исключений выше приведет к следующим ошибкам, напечатанным JVM (серьезно, попробуйте!)

1
2
3
4
5
6
7
8
9
Exception in thread 'main' SponsoredException
    at ADVERTISEMENT.   If you don't   (Unknown Source)
    at ADVERTISEMENT. want to see this (Unknown Source)
    at ADVERTISEMENT.     exception    (Unknown Source)
    at ADVERTISEMENT.    please  buy   (Unknown Source)
    at ADVERTISEMENT.   full  version  (Unknown Source)
    at ADVERTISEMENT.  of  the program (Unknown Source)
 
Exception in thread 'main' ExceptionFromHell: Catch me if you can

Это правильно. ExceptionFromHell еще интереснее. Поскольку он не включает трассировку стека как часть объекта исключения, доступны только имя класса и сообщение. Трассировка стека была потеряна, и ни JVM, ни какая-либо инфраструктура ведения журналов ничего не могут с этим поделать. С какой стати вы это сделали (а я не говорю о SponsoredException )?
Неожиданно генерируемая трассировка стека для некоторых считается дорогой (?) Это native метод, и для построения StackTraceElement необходимо пройти весь стек. Однажды в моей жизни я видел библиотеку, использующую эту технику, чтобы ускорить создание исключений. Таким образом, я написал быстрый тест для суппорта, чтобы увидеть разницу в производительности между RuntimeException обычного RuntimeException и исключения без заполненной трассировки стека и обычного метода, возвращающего значение. Я запускаю тесты с различной глубиной трассировки стека, используя рекурсию:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
public class StackTraceBenchmark extends SimpleBenchmark {
 
    @Param({'1', '10', '100', '1000'})
    public int threadDepth;
 
    public void timeWithoutException(int reps) throws InterruptedException {
        while(--reps >= 0) {
            notThrowing(threadDepth);
        }
    }
 
    private int notThrowing(int depth) {
        if(depth <= 0)
            return depth;
        return notThrowing(depth - 1);
    }
 
    //--------------------------------------
 
    public void timeWithStackTrace(int reps) throws InterruptedException {
        while(--reps >= 0) {
            try {
                throwingWithStackTrace(threadDepth);
            } catch (RuntimeException e) {
            }
        }
    }
 
    private void throwingWithStackTrace(int depth) {
        if(depth <= 0)
            throw new RuntimeException();
        throwingWithStackTrace(depth - 1);
    }
 
    //--------------------------------------
 
    public void timeWithoutStackTrace(int reps) throws InterruptedException {
        while(--reps >= 0) {
            try {
                throwingWithoutStackTrace(threadDepth);
            } catch (RuntimeException e) {
            }
        }
    }
 
    private void throwingWithoutStackTrace(int depth) {
        if(depth <= 0)
            throw new ExceptionFromHell();
        throwingWithoutStackTrace(depth - 1);
    }
 
    //--------------------------------------
 
    public static void main(String[] args) {
        Runner.main(StackTraceBenchmark.class, new String[]{'--trials', '1'});
    }
 
}

Вот результаты:

Мы ясно видим, что чем длиннее трассировка стека, тем больше времени требуется для создания исключения. Мы также видим, что для разумной длины трассировки стека создание исключения не должно занимать более 100 с (быстрее, чем чтение 1 МБ основной памяти ). Наконец, генерирование исключения без трассировки стека происходит в 2-5 раз быстрее. Но, честно говоря, если это проблема для вас, проблема в другом. Если ваше приложение генерирует исключения так часто, что вам действительно приходится оптимизировать его, вероятно, что-то не так с вашим дизайном. Не исправляйте Java тогда, это не сломано.

Резюме:

  • трассировка стека всегда показывает место, где было создано исключение (объект), а не то, где оно было выброшено — хотя в 99% случаев это одно и то же место.
  • у вас есть полный контроль над трассировкой стека, возвращаемой вашими исключениями
  • генерация трассировки стека имеет определенную стоимость, но если она становится узким местом в вашем приложении, вы, вероятно, делаете что-то не так.

Ссылка: Откуда берутся трассировки стека? от нашего партнера JCG Томаша Нуркевича в блоге о Java и соседстве .