Async-profiler:低开销Java性能分析利器

项目标题与描述

Async-profiler 是一个针对Java的低开销采样性能分析器,它克服了传统分析器的“安全点偏差”(Safepoint bias)问题。项目利用了HotSpot JVM特有的API来收集堆栈踪迹和跟踪内存分配,能够分析非Java线程(例如GC和JIT编译线程),并在堆栈跟踪中显示本地和内核帧。

它支持多种分析模式,包括CPU时间、Java堆内存分配、本地内存分配与泄漏、竞争锁、硬件和软件性能计数器(如缓存未命中、页面错误、上下文切换等)。

功能特性

  • 无安全点偏差采样:采用异步采样机制,避免传统Java分析器的固有缺陷。
  • 多维度性能分析
    • CPU时间分析
    • Java堆内存分配分析
    • 本地内存分配与泄漏跟踪
    • 锁竞争分析
    • 硬件/软件性能计数器分析(如缓存未命中、页面错误等)
  • 线程与栈帧分析
    • 监控非Java线程(如GC、JIT线程)
    • 在堆栈跟踪中展示本地(Native)和内核(Kernel)帧
  • 丰富的输出格式
    • 交互式火焰图(Flame Graph)
    • Java飞行记录器(JFR)格式
    • OpenTelemetry格式
    • 文本格式报告
  • 灵活的集成方式
    • 命令行工具(asprof
    • Java API
    • 原生API(C API)
    • 可通过代理方式集成
  • 平台支持
    • 官方支持Linux(x64, arm64)和macOS(x64, arm64)平台
    • 社区支持其他架构端口(如x86, arm32, ppc64le, riscv64, loongarch64)
  • 多种采样引擎
    • Perf Events(Linux)
    • 时钟定时器(CTimer, ITimer)
    • Java方法追踪与延迟分析(Instrumentation)

安装指南

下载稳定版本

可直接从GitHub Releases页面下载最新稳定版本(如v4.2.1)的预编译二进制包:

  • Linux x64: async-profiler-4.2.1-linux-x64.tar.gz
  • Linux arm64: async-profiler-4.2.1-linux-arm64.tar.gz
  • macOS arm64/x64: async-profiler-4.2.1-macos.zip

从源码构建

最低要求

  • GNU Make
  • GCC 7.5.0+ 或 Clang 7.0.0+
  • 静态版libstdc++(例如在Amazon Linux 2023上:yum install libstdc++-static
  • JDK 11+

构建步骤

  1. 确保 gccg++javaPATH 环境变量中。
  2. 导航到async-profiler源码根目录。
  3. 运行 make 命令。构建完成后,启动器 asprof 将位于 build/bin/asprof 目录下。
  4. (可选)运行 make test 进行单元和集成测试,或运行 make release 打包二进制文件。

使用说明

快速开始

分析一个正在运行的Java应用通常只需使用 asprof 命令并指定Java进程的PID。

# 分析PID为1234的进程,持续30秒,并将结果保存为交互式火焰图
$ asprof -d 30 -f flamegraph.html 1234

基础使用示例

CPU性能分析

$ asprof -e cpu -d 60 -o cpu_profile.html <PID>

内存分配分析(每分配512KB采样一次):

$ asprof -e alloc -d 30 -f alloc_flame.html <PID>

锁竞争分析

$ asprof -e lock -d 30 -f lock_profile.html <PID>

输出为JFR格式

$ asprof -d 30 -o profile.jfr <PID>

Java API集成

Async-profiler提供了Java API,可以直接在Java代码中调用。

import one.profiler.AsyncProfiler;

public class ProfilerDemo {
    public static void main(String[] args) throws Exception {
        AsyncProfiler profiler = AsyncProfiler.getInstance();
        // 启动CPU分析
        profiler.start("cpu", 10000000); // 10ms间隔
        Thread.sleep(30000); // 运行30秒
        // 停止并保存结果
        String output = profiler.stop();
        System.out.println(output);
    }
}

原生API (C API) 集成

对于非Java应用或需要更精细控制的场景,可以使用原生C API。

#include "asprof.h"

int main() {
    asprof_init();
    asprof_error_t err = asprof_execute("start,event=cpu,interval=10ms", NULL);
    if (err != NULL) {
        fprintf(stderr, "Profiler error: %s\n", asprof_error_str(err));
    }
    // ... 运行被分析的代码 ...
    err = asprof_execute("stop,file=profile.jfr", NULL);
    return 0;
}

核心代码

1. CPU采样引擎信号处理核心逻辑 (cpuEngine.cpp)

此代码段展示了CPU采样引擎如何处理采样信号,记录执行样本。

/*
 * Copyright The async-profiler authors
 * SPDX-License-Identifier: Apache-2.0
 */
#include "cpuEngine.h"
#include "profiler.h"
#include "tsc.h"

void CpuEngine::signalHandler(int signo, siginfo_t* siginfo, void* ucontext) {
    if (!_enabled) return;

    ExecutionEvent event(TSC::ticks());
    // 当估算总CPU时间时,计算错过的样本数
    u64 total_cpu_time = _count_overrun ? u64(_interval) * (1 + OS::overrun(siginfo)) : u64(_interval);
    Profiler::instance()->recordSample(ucontext, total_cpu_time, EXECUTION_SAMPLE, &event);
}

代码注释

  • signalHandler:当配置的采样信号(如SIGPROF)触发时被调用。
  • _enabled:静态标志,指示分析器是否处于活动状态。
  • ExecutionEvent:封装采样时间戳的简单事件对象。
  • TSC::ticks():使用时间戳计数器(TSC)获取高精度纳秒级时间。
  • OS::overrun(siginfo):在支持的情况下,估算因信号队列满而丢失的样本数量,用于更准确地计算总CPU时间。
  • Profiler::instance()->recordSample:将采样事件(包含上下文、时间、事件类型)传递给核心分析器进行记录和处理。

2. 内存分配跟踪引擎 (allocTracer.cpp)

此代码展示了如何通过设置断点来拦截JVM内部的内存分配方法,实现堆内存分配的采样。

/*
 * Copyright The async-profiler authors
 * SPDX-License-Identifier: Apache-2.0
 */
#include "allocTracer.h"
#include "profiler.h"
#include "stackFrame.h"
#include "tsc.h"
#include "vmStructs.h"

// 当我们的断点陷阱被触发时调用
void AllocTracer::trapHandler(int signo, siginfo_t* siginfo, void* ucontext) {
    StackFrame frame(ucontext);
    EventType event_type;
    uintptr_t total_size;
    uintptr_t instance_size;

    // PC指向BREAKPOINT指令或下一条指令
    if (_in_new_tlab.covers(frame.pc())) {
        // send_allocation_in_new_tlab(...)
        event_type = ALLOC_SAMPLE;
        total_size = _trap_kind == 1 ? frame.arg2() : frame.arg1();
        instance_size = _trap_kind == 1 ? frame.arg3() : frame.arg2();
    } else if (_outside_tlab.covers(frame.pc())) {
        // send_allocation_outside_tlab(...)
        event_type = ALLOC_OUTSIDE_TLAB;
        total_size = _trap_kind == 1 ? frame.arg2() : frame.arg1();
        instance_size = 0;
    } else {
        // 不是我们的陷阱,交给其他处理程序
        Profiler::instance()->trapHandler(signo, siginfo, ucontext);
        return;
    }

    // 通过模拟“ret”指令离开被跟踪的函数
    uintptr_t klass = frame.arg0();
    frame.ret();

    if (_enabled && updateCounter(_allocated_bytes, total_size, _interval)) {
        recordAllocation(ucontext, event_type, klass, total_size, instance_size);
    }
}

代码注释

  • trapHandler:处理由分配断点触发的信号。
  • StackFrame frame(ucontext):从信号上下文(ucontext)中解析出栈帧信息。
  • _in_new_tlab, _outside_tlabTrap对象,代表在JVM的AllocTracer::send_allocation_in_new_tlabsend_allocation_outside_tlab方法中设置的断点。
  • covers(frame.pc()):检查程序计数器(PC)是否位于特定断点的地址范围内。
  • frame.arg0(), arg1(), arg2(), arg3():根据调用约定(因JDK版本_trap_kind而异)从栈帧或寄存器中提取函数参数(如类指针、分配大小)。
  • frame.ret():修改上下文,模拟从被拦截函数返回,使执行流程继续。
  • updateCounter:基于配置的采样间隔(_interval),原子地更新已分配字节计数器,并决定是否记录当前分配样本。
  • recordAllocation:创建并记录分配事件,包含类信息、大小和时间戳。

3. 栈帧存储与哈希管理 (callTraceStorage.cpp)

这段代码是分析器的核心数据结构,负责高效地存储和检索调用栈踪迹。

/*
 * Copyright The async-profiler authors
 * SPDX-License-Identifier: Apache-2.0
 */
#include "callTraceStorage.h"
#include "os.h"

u64 CallTraceStorage::calcHash(int num_frames, ASGCT_CallFrame* frames) {
    u64 h = 0;
    for (int i = 0; i < num_frames; i++) {
        // 组合方法ID和行号(BCI)来生成哈希
        h = h * 31 + (uintptr_t)frames[i].method_id;
        h = h * 31 + frames[i].bci;
    }
    return h;
}

CallTrace* CallTraceStorage::storeCallTrace(int num_frames, ASGCT_CallFrame* frames) {
    u64 hash = calcHash(num_frames, frames);
    CallTrace* trace = findCallTrace(_current_table, hash);
    if (trace != NULL) {
        return trace;
    }

    // 在分配器中为新调用踪迹分配内存
    size_t size = sizeof(CallTrace) + (num_frames - 1) * sizeof(ASGCT_CallFrame);
    trace = (CallTrace*)_allocator.alloc(size);
    if (trace == NULL) {
        // 内存不足,返回溢出标识
        _overflow++;
        return &_overflow_trace;
    }

    trace->num_frames = num_frames;
    memcpy(trace->frames, frames, num_frames * sizeof(ASGCT_CallFrame));

    // 将新踪迹插入哈希表
    u32 call_trace_id = _current_table->incSize();
    if (call_trace_id >= _current_table->capacity()) {
        // 哈希表已满,分配新的更大容量的表
        LongHashTable* new_table = LongHashTable::allocate(_current_table, _current_table->capacity() * 2);
        if (new_table != NULL) {
            _current_table = new_table;
            call_trace_id = 0;
        } else {
            _overflow++;
            return &_overflow_trace;
        }
    }

    u64* keys = _current_table->keys();
    CallTraceSample* values = _current_table->values();
    keys[call_trace_id] = hash;
    values[call_trace_id].setTrace(trace);
    values[call_trace_id].samples = 0;
    values[call_trace_id].counter = 0;

    return trace;
}

代码注释

  • CallTraceStorage:管理所有唯一调用栈踪迹的存储。
  • calcHash:根据调用栈中所有帧的方法ID和行号(BCI)计算一个哈希值,用于快速查找。
  • storeCallTrace:存储一个新的调用栈踪迹。
    • 首先通过findCallTrace在哈希表中查找是否已存在相同栈。
    • 如果不存在,使用LinearAllocator (_allocator) 分配内存。这是一个高性能的自定义分配器,用于快速分配小对象。
    • 如果分配失败(或哈希表扩容失败),递增溢出计数器并返回一个预定义的“溢出”踪迹。
  • LongHashTable:一个两级哈希表结构,支持并发插入和动态扩容。
  • incSize():原子地增加哈希表大小并返回新条目的索引。
  • setTrace(trace):使用原子存储将踪迹指针设置到哈希表的值槽中,确保多线程环境下的内存可见性。
  • 该设计实现了去重:相同的调用栈只在内存中存储一次,后续采样只增加该栈对应的计数器,极大节省了内存空间。
    更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)
    对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)

公众号二维码

公众号二维码

posted @ 2025-12-18 14:14  qife  阅读(3)  评论(0)    收藏  举报