async-profiler 4.6 能把采集到的 profile 按 OpenTelemetry 的 OTLP 格式,直接 POST 到一个 HTTP 端点。我在一个 Spring Boot 4.1.1 应用上接了个本地接收端,不写路径时请求会打到 /v1development/profiles,请求体是 protobuf。有一点要注意:不加 -t 时,请求体里搜不到任何线程名,加上 -t 才有。

async-profiler 是 JVM 上常用的低开销采样分析器,能采 CPU、内存分配、锁和 wall clock,常用来画火焰图。4.6 的 GitHub release 发布时间换算成北京时间是 10 月 20 日 7 点 51 分,发布说明在 GitHub Releases。

4.6 改了什么

新功能有三条。#1791 支持把 profile 直接发到兼容 OpenTelemetry 的端点。#1777 把 wall clock 的线程过滤从按线程 id 改成按线程名,4.6 的选项文档里对应的是 --ithread 和 --xthread,模式区分大小写,可以在前后加 * 通配。#1775 给 JFR 事件加了可配置的限流。

改进里和日常使用关系比较大的,是 #1780 把火焰图和树状视图合进同一个 HTML,#1784 在 OTLP 输出里加了 service.name 资源属性,#1800 支持跟踪 JEP 401 值类的内存分配。

破坏性变更有三条:#1776 删掉了内置的 HTTP 服务器;#1777 让 filter 选项不再接受参数;#1821 把 macOS 的最低版本提到 11。按 4.6 的选项文档,filter 现在只能通过 API 使用,开启后在调用 AsyncProfiler.addThread() 之前不采样任何线程。

OTLP 输出在文档里标的是实验特性,后续版本可能有不兼容的改动。otlp 这种输出格式从 4.1 起就有,之前只能写成本地文件,4.6 新增的是让 -f 直接接 http:// 或 https:// URL 发出去,这需要机器上有 libcurl。下文用到的 -t(按线程拆分调用栈)也是老选项,不是 4.6 新加的。

准备一个 Spring Boot 应用

环境是 Java 21.0.12.1 和 Spring Boot 4.1.1,async-profiler 用的是官方的 linux-x64 包,解压在项目的 tools/ 目录下:

$ tools/async-profiler-4.6-linux-x64/bin/asprof --version
Async-profiler 4.6 built on Oct 12 2026

应用里起两个后台线程。report-worker-1 一直在数质数,report-worker-2 每次睡 200 毫秒:

package com.example.apdemo;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;

import jakarta.annotation.PreDestroy;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

// 两个后台线程:report-worker-1 一直在算,report-worker-2 大部分时间在睡
@Component
public class ReportWorkers {

    private final AtomicInteger seq = new AtomicInteger();
    private final ThreadFactory factory = r -> new Thread(r, "report-worker-" + seq.incrementAndGet());
    private final ExecutorService pool = Executors.newFixedThreadPool(2, factory);
    private volatile boolean running = true;

    @EventListener(ApplicationReadyEvent.class)
    public void start() {
        pool.submit(this::crunch);
        pool.submit(this::waitForData);
    }

    void crunch() {
        long acc = 0;
        while (running) {
            acc += Primes.countUpTo(20_000);
        }
        System.out.println("crunch done " + acc);
    }

    void waitForData() {
        while (running) {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                return;
            }
        }
    }

    @PreDestroy
    public void stop() {
        running = false;
        pool.shutdownNow();
    }
}
package com.example.apdemo;

final class Primes {

    private Primes() {
    }

    static int countUpTo(int n) {
        int count = 0;
        for (int i = 2; i <= n; i++) {
            if (isPrime(i)) {
                count++;
            }
        }
        return count;
    }

    static boolean isPrime(int n) {
        for (int d = 2; (long) d * d <= n; d++) {
            if (n % d == 0) {
                return false;
            }
        }
        return true;
    }
}

再加一个会睡 300 毫秒的接口,profile 时用 curl 循环调它,让 Tomcat 的工作线程也忙起来:

package com.example.apdemo;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class SlowController {

    @GetMapping("/slow")
    public String slow() throws InterruptedException {
        Thread.sleep(300);
        return "ok\n";
    }
}

端口写在 src/main/resources/application.properties:

server.port=18082

应用启动日志(摘录):

 :: Spring Boot ::                (v4.1.1)
2026-10-20T08:12:04.569+08:00  INFO 362818 --- [           main] o.s.boot.tomcat.TomcatWebServer          : Tomcat started on port 18082 (http) with context path '/'
2026-10-20T08:12:04.585+08:00  INFO 362818 --- [           main] com.example.apdemo.ApDemoApplication     : Started ApDemoApplication in 1.507 seconds (process running for 2.008)

按线程名过滤 wall clock

先不加过滤,用 wall clock 模式采 5 秒。-t 让每个线程单独成栈,-o collapsed 输出折叠栈文本,方便用命令行数:

$ tools/async-profiler-4.6-linux-x64/bin/asprof -e wall -d 5 -t -o collapsed -f wall-all.collapsed 362818
Profiling for 5 seconds
Done

折叠栈每行的第一段是线程名。5 秒里一共出现了 45 个线程,其中 10 个是 Tomcat 的工作线程,剩下的还有编译线程、Catalina 的后台线程等(下面只列了前 8 个):

$ cut -d';' -f1 wall-all.collapsed | sed -E 's/ tid=[0-9]+//' | sort -u | wc -l
45
$ cut -d';' -f1 wall-all.collapsed | sed -E 's/ tid=[0-9]+//' | sort -u | grep -c 'http-nio-18082-exec'
10
$ cut -d';' -f1 wall-all.collapsed | sed -E 's/ tid=[0-9]+//' | sort -u | grep -v -e 'http-nio' -e 'report-worker' | head -8
[Attach Listener]
[C1 CompilerThre]
[C2 CompilerThre]
[Catalina-utility-1]
[Catalina-utility-2]
[Cleaner-0]
[Common-Cleaner]
[DestroyJavaVM]

加上 --ithread 'report-worker-*' 再采一次:

$ tools/async-profiler-4.6-linux-x64/bin/asprof -e wall -d 5 -t --ithread 'report-worker-*' -o collapsed -f wall-workers.collapsed 362818
Profiling for 5 seconds
Done

结果里只剩两个工作线程。一个一直在算,一个大部分时间在睡,两者的样本数都是 101。wall clock 模式下,线程在算还是在睡都会被采到。report-worker-1 的栈里有 Primes.isPrime,report-worker-2 的栈里有 Thread.sleep:

$ cut -d';' -f1 wall-workers.collapsed | sed -E 's/ tid=[0-9]+//' | sort -u
[report-worker-1]
[report-worker-2]
$ awk '{split($1,a," tid="); s[a[1]]+=$NF} END {for (t in s) print t"]", s[t]}' wall-workers.collapsed | sort
[report-worker-1] 101
[report-worker-2] 101
$ grep 'report-worker-1' wall-workers.collapsed | grep -c 'Primes.isPrime'
2
$ grep 'report-worker-2' wall-workers.collapsed | grep -c 'Thread.sleep'
1

直推 OTLP

没有接真的 Collector(Collector 的搭法可以参考《Spring Boot 4 不用 Java agent 也能推 OTLP》),只写了个几十行的 Python 接收端,把每个请求的路径、Content-Type、大小记下来,并把请求体存成文件。它在项目根目录下,叫 otlp-receiver.py:

# 极简 HTTP 接收端:只记录收到的请求,并把请求体存成文件
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer

count = 0

class Handler(BaseHTTPRequestHandler):
    def do_POST(self):
        global count
        count += 1
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length)
        name = f"otlp-body-{count}.bin"
        with open(name, "wb") as f:
            f.write(body)
        print(f"POST {self.path} content-type={self.headers.get('Content-Type')} "
              f"content-encoding={self.headers.get('Content-Encoding')} bytes={len(body)} saved={name}", flush=True)
        self.send_response(200)
        self.send_header("Content-Type", "application/x-protobuf")
        self.send_header("Content-Length", "0")
        self.end_headers()

    def log_message(self, fmt, *args):
        pass

port = int(sys.argv[1])
print(f"listening on {port}", flush=True)
HTTPServer(("127.0.0.1", port), Handler).serve_forever()

接收端监听 14318 端口。先发一次不带路径的 CPU profile,再发一次带自定义路径、按线程名过滤的 wall clock profile:

$ tools/async-profiler-4.6-linux-x64/bin/asprof -e cpu -d 5 -f http://localhost:14318 362818
Profiling for 5 seconds
Done
$ tools/async-profiler-4.6-linux-x64/bin/asprof -e wall -d 5 --ithread 'report-worker-*' -f http://localhost:14318/custom/path 362818
Profiling for 5 seconds
Done

接收端日志:

listening on 14318
POST /v1development/profiles content-type=application/x-protobuf content-encoding=None bytes=874 saved=otlp-body-1.bin
POST /custom/path content-type=application/x-protobuf content-encoding=None bytes=1243 saved=otlp-body-2.bin

不写路径时用的是文档里说的默认路径 /v1development/profiles,写了路径就按写的发。Content-Type 是 application/x-protobuf,没有压缩。5 秒的 CPU profile 只有 874 字节。

第二个请求体里能找到 thread.name 这个键,但搜不到 report-worker。加上 -t 再发一次:

$ tools/async-profiler-4.6-linux-x64/bin/asprof -e wall -d 5 -t --ithread 'report-worker-*' -f http://localhost:14318/custom/path 362818
Profiling for 5 seconds
Done
POST /custom/path content-type=application/x-protobuf content-encoding=None bytes=1402 saved=otlp-body-3.bin

对比两个请求体:

$ grep -c 'report-worker' otlp-body-2.bin
0
$ grep -ao 'report-worker-[0-9]' otlp-body-3.bin | sort | uniq -c
      1 report-worker-1
      1 report-worker-2
$ grep -ao 'http-nio-[0-9a-z-]*' otlp-body-3.bin | wc -l
0

不加 -t 的那份里一个 report-worker 都没有,加了以后两个线程名都在。第三条命令是确认过滤生效,Tomcat 的线程名没有出现在请求体里。这里我只是在二进制里搜字符串,没有按 OTLP 的 protobuf 定义完整解码。

最后看看端点不通时会怎样。把地址指向一个没人监听的端口:

$ tools/async-profiler-4.6-linux-x64/bin/asprof -e cpu -d 3 -f http://localhost:14319 362818; echo "exit=$?"
Profiling for 3 seconds
Done
[INFO] Failed to send profile to http://localhost:14319/v1development/profiles: Failed to connect to localhost port 14319 after 0 ms: Could not connect to server
[ERROR] Could not connect to server
exit=200

asprof 照常采完 3 秒,发送失败时打出 INFO 和 ERROR 两行,退出码是 200。因为 -f 给的是 URL,这份 profile 没有写到本地文件。我没有测试 --loop 定时发送,也没有验证 #1784 提到的 service.name 属性。

项目信息

async-profiler 用的是 Apache License 2.0,仓库在 github.com/async-profiler/async-profiler。本文的选项说明以 4.6 tag 下的 docs/ProfilerOptions.md 和 docs/OutputFormats.md 为准。

— 感谢阅读 —
广告

一起交流

分享你的思考,让讨论更进一步。