告别 printStackTrace(), 使用JDK8+全新堆栈 API—StackWalker

1,509 阅读8分钟

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!


在 Java 应用中,获取堆栈信息是我们的一个常用操作。

它可以为我们 debug 和获取一些需要的日志信息,一般来说他有这两种使用方法:

new RuntimeException().printStackTrace();

第一种场景是当我们碰到异常时往往会打印调用 printStackTrace() 方法打印当前类的堆栈信息,用于定位 Debug。

Thread.currentThread().getStackTrace();

第二种方法就是直接使用当前线程获取堆栈信息,一般这种情况是为了打印当前方法的名字,用于输出到日志流中,如果要做一些链路记录可能会比较常用它。

但是以上两种方法都有严重的性能问题,并不适合大规模使用,接下来我们就看一些具体有哪些性能问题,同时给大家介绍一下官方在 JDK9 之后新推出了一个 API 用于解决这些问题。

printStackTrace 带来的问题

异常打印对我们来说是一个再熟悉不过的场景,但是很多人并没有深究过异常打印的源码实现,如果你查看其源码就会发现这是一个极其消耗性能的操作。

在一个大规模集群中,如果系统出现连锁错误,就会打印 printStackTrace 被大量调用,最终会导致整个 ES 日志集群卡住,@张哈希 曾经遇见过这种问题。

接下来我将带大家看一下 printStackTrace 方法的源码:

private void printStackTrace(PrintStreamOrWriter s) {
        // Guard against malicious overrides of Throwable.equals by
        // using a Set with identity equality semantics.
        Set<Throwable> dejaVu =
            Collections.newSetFromMap(new IdentityHashMap<Throwable, Boolean>());
        dejaVu.add(this);

        synchronized (s.lock()) {
            // Print our stack trace
            s.println(this);
            StackTraceElement[] trace = getOurStackTrace();
            for (StackTraceElement traceElement : trace)
                s.println("\tat " + traceElement);

            // Print suppressed exceptions, if any
            for (Throwable se : getSuppressed())
                se.printEnclosedStackTrace(s, trace, SUPPRESSED_CAPTION, "\t", dejaVu);

            // Print cause, if any
            Throwable ourCause = getCause();
            if (ourCause != null)
                ourCause.printEnclosedStackTrace(s, trace, CAUSE_CAPTION, "", dejaVu);
        }
    }

上面这一段代码就是 Java 中打印异常流的核心方法,传入的参数是一个标准输出 error 流,而获取堆栈的核心方法是:getOurStackTrace。

这是一个本地方法,通过它就能获取当前堆栈的集合,注意这里获取的是整个堆栈的所有链路,就是我们平常看到的一大长串很多层级那种。

对 Java 来说,堆栈的每一层都是一个 StackTraceElement,所以这里获取的结果才是一个集合,所以我们平常看到的打印其实是这个集合每一个元素打印的拼接。

而由于堆栈是一个栈结构,所以显而易见的内部数据结构也是一个栈,所以当我们的调用链路越来越多层的时候,本质上就是这个数组越来越大,当达到这个数组集合承载的上限后, 就会触发栈溢出异常。

所以这里的第一个性能点就是:本质上99% 场景我们 Debug 其实只需要堆栈的前几层就够了,但是这里会获取整个堆栈的所有数据。

然后我们可以看到在整个方法的外围是有一个 synchronized,这个synchronized 锁经过这么多版本的优化其本身开销我们可以暂时忽略,但是它锁定的对象是传入的标准输出 error 流 —— System.err,这是一个全局对象。

public void printStackTrace() {
        printStackTrace(System.err);
    }

全局对象代表着所有调用的 printStackTrace 方法都会共享一个锁对象,所以它必然会出现同步竞争的问题,这也会将 synchronized 升级到重量级锁的几率无限增加,因为很多线程都会争抢这个对象。

所以这是它的第二个性能点:同步重量级锁

顺便插一句,这里因为是同步,所以会直接同步打印,它不像日志框架那样会走异步打印,这也会拖累系统性能。

getStackTrace 带来的问题

当我们想获取一个方法的类名时,往往会这样做:

@GetMapping("/hello")
    public String hello() {
        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
        System.out.println(stackTrace[1].getMethodName());
      return "hello";
    }

通过当前线程获取当前的堆栈,然后获取下标为 1 的 StackTraceElement,接着我们就可以直接打印出当前调用的方法名。

为什么获取下标 1 而不是 0?因为 0 是 getStackTrace 本身。

经过我们前面的学习,这样带点来的第一个性能问题,依然是一次性获取了所有的堆栈信息。

接下来带大家看一下源码实现:

public StackTraceElement[] getStackTrace() {
        if (this != Thread.currentThread()) {
            // check for getStackTrace permission
            SecurityManager security = System.getSecurityManager();
            if (security != null) {
                security.checkPermission(
                    SecurityConstants.GET_STACK_TRACE_PERMISSION);
            }
            // optimization so we do not call into the vm for threads that
            // have not yet started or have terminated
            if (!isAlive()) {
                return EMPTY_STACK_TRACE;
            }
            StackTraceElement[][] stackTraceArray = dumpThreads(new Thread[] {this});
            StackTraceElement[] stackTrace = stackTraceArray[0];
            // a thread that was alive during the previous isAlive call may have
            // since terminated, therefore not having a stacktrace.
            if (stackTrace == null) {
                stackTrace = EMPTY_STACK_TRACE;
            }
            return stackTrace;
        } else {
            return (new Exception()).getStackTrace();
        }
    }

通过上面的源码我们可以看到,源码中的第一步操作就是获取了一个安全管理器,这里会导致可能有额外的安全检查,这个检查一般是为了验证当前线程是否具有权限访问堆栈跟踪信息。

而这里面的 dumpThreads 方法也是一个本地方法,它就是获取整个线程堆栈的方法。

它的具体做法是传入一个只包含当前线程的数组,然后通过本地方法获取堆栈结果,这里实际上是从 ThreadMXBean 接口中获取线程信息的,因为传入的是一个数组,所以返回的结果也是堆栈二维数组。

接着直接从二维数组中取出下标为 0 的数据,就是当前线程的堆栈信息了。

可以看到整段代码并没有明显的同步操作,因为它不需要锁定标准输出流,这就会比上面的那种方式少掉一个很大的开销,同步输出开销。

所以一般来说,它的性能是肯定要比上面的 printStackTrace 方法高的。

StackWalker 正式登场

自从 JDK9 以来,Java 就添加了这个类作为获取堆栈的首选操作,它带来了两个比较重要的优化:

  1. 精细化的控制。
  2. 避免数组创建,节省内存。
  3. 使用了更简洁的 Stream API。
StackWalker walker = StackWalker.getInstance(StackWalker.Option.RETAIN_CLASS_REFERENCE);
        Optional<StackWalker.StackFrame> caller = walker.walk(Stream::findFirst);

        // 打印当前方法的名称
        if (caller.isPresent()) {
            System.out.println("Caller method: " + caller.get().getMethodName());
        } else {
            System.out.println("No caller method found.");
        }

通过上面的这个例子,我们可以使用 StackWalker 获取到当前的方法,很明显 StackWalker 引入了 Stream 和 Lamda 使堆栈信息更易操作,同时使用 Optional 特性来避免空指针。

在第一行的代码中,我们可以看到我传入了一个枚举值,这个枚举值其实是默认参数,不传也可以,它是用来控制整个堆栈信息的精细化的,它具有以下三个枚举:

  1. RETAIN_CLASS_REFERENCE: 默认使用它,类引用级别的堆栈。
  2. SHOW_REFLECT_FRAMES: 这个则可以显示反射操作中的堆栈。
  3. SHOW_HIDDEN_FRAMES: 这个可以显示一个隐藏的堆栈细节。

可以看到堆栈的精细程度由这个枚举控制,并且是越来越细。

除了以上两点以外,你其实可能已经发现了,StackWalker 其实也会获取整个堆栈,因为你可以在 Stream 中操作堆栈,这就代表了其实 StackWalker 是有整个堆栈信息的。

那它的优化在哪呢?迭代器模式。

Stream 使用迭代器的模式,避免了数组的二次复制,平常我们获取整个堆栈的信息时候其实是将所有的堆栈从一个迭代器中添加到一个堆栈数组中,然后再将这个堆栈数组返回给调用方。

这个堆栈数组会存放所有堆栈元素,这个数组也是需要占用内存空间的,而在 StackWalker 直接省略了这一步,直接给你了一个 Stream 流,让你先通过流进行筛选提取你想要的结果,从而避免了额外的数组复制开销。

这对于堆栈这种动辄 30 层起步的场景来说,这其实是一个很好的优化。

同时,StackWalker 是一个全局共享对象,在内部也已经做好了并发控制,它会通过工厂模式创建一个新的类与当前线程绑定真正处理堆栈信息:

StackWalker.getInstance().walk(Stream::findFirst)
StackStreamFactory.makeStackTraverser(this, function).walk();

当我们正常调用 walk 方法获取堆栈时,它其实会通过一个工厂来创建一个 StackFrameTraverser 对象,在这个对象中会绑定当前的线程:

protected AbstractStackWalker(StackWalker walker, int mode, int maxDepth) {
            this.thread = Thread.currentThread();
            this.mode = toStackWalkMode(walker, mode);
            this.walker = walker;
            this.maxDepth = maxDepth;
            this.depth = 0;
        }

同时它会创建 StackWalker 的内部类 StackFrame 的子类 StackFrameInfo 来进行堆栈信息的存储。

总结

好了,以上就是今天的全部内容了,感谢大家能看到这,同时也希望大家能对本篇点点赞,让更多的人看到优质的内容,点赞过 100 一周内更新更多高级 Java 知识,有任何问题都可以在评论区一块讨论,祝有好收获。