bpftrace 探测 Java 运行时栈-实践

开发Java应用的时候, 有时候我们想知道某个函数到底在哪里被调用的. 我们可以采取的方法有:

  1. 若是本地开发, 可以在函数体上加断点, 每当函数被调用, 都会暂停.
  2. 若是我们可以改动的代码, 我们可以加日志打印栈, 这样就能发现在哪里被调用.
  3. 不论是不是我们自己的Java代码, 我们都可以通过 Btrace 进行注入脚本, 在脚本打印运行时栈.

但是有时候, 我们想知道我们的 Java 代码是哪里调用了系统的 native 代码, 比如某些系统调用(syscall), 那么该如何获取这些栈呢?

这时候, 使用Java 提供的这些方法, 都无法达到目的, 所以, 我们要借用系统层级的 tracing 方式. 如今最流行且最简单的方式就是使用 bpftrace. 本文接下来将用一个例子来说明, 如何使用 bpftrace 来查找我们的Java应用是如何调用 recvfrom 这个系统调用的.

Java 代码

下面是我们用来演示的代码, 它的意图就是不断的循环去获得某个网页. 不断循环的目的是为了给我们的手工操作留有足够的时间. 这段代码有网络操作, 所以会调用系统调用 recvfrom 来拿到响应(response).(感谢 chatGPT 给我们演示代码)

import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.net.HttpURLConnection;
import java.net.URL;

public class URLTest {
    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1000; i++) {
            try {
                URL url = new URL("http://www.tianxiaohui.com");
                HttpURLConnection con = (HttpURLConnection) url.openConnection();
                con.setRequestMethod("GET");
                    con.setConnectTimeout(5000); // 连接超时时间 5000ms
                    con.setReadTimeout(10000); // 读取超时时间 10000ms
                System.out.println(i + "Response code: " + con.getResponseCode());
                BufferedReader in = new BufferedReader(new InputStreamReader(con.getInputStream()));
                String line;
                StringBuilder response = new StringBuilder();
    
                while ((line = in.readLine()) != null) {
                    response.append(line);
                }
                in.close();
                //System.out.println(response.toString());
            } catch (Exception e) {
                e.printStackTrace();
                System.out.println(e.getMessage());
            }
            Thread.sleep(2000);
        }
    }
}

本地编译并运行

使用 javac 编译源代码, 生成 URLTest.class. 然后启动这个带有 main 函数的类. 这里添加参数 -XX:+PreserveFramePointer 是为了在运行方法的时候保留栈指针寄存器, 这样就能使 bpftrace 获得运行时的栈. 运行开始, 输出返回的 response code.

$ javac URLTest.java
$ java -XX:+PreserveFramePointer URLTest
Response code: 301

安装编译 perf-map-agent

为了使用 bpftrace 能获得Java JIT 编译后的代码的符号, 需要通过 perf-map-agent Java agent 去获取运行时 Java 应用的符号表, 更确切来说, 是获得通过 JIT 编译后的的代码的符号表.

perf-map-agent 是一个Java agent, 它在运行时attach到目标Java进程, 然后获取JVM运行时内部JIT编译后代码的区域内存, 然后通过这个区域获取符号表, 然后把这些符号表以 Linux perf 能认识的格式放到 /tmp/perf-<pid>.map 文件中. bpftrace 底层ye shi

下载 perf-map-agent

可以直接克隆这个 git repo:

$ git clone https://github.com/jvm-profiling-tools/perf-map-agent.git

或者直接下载最新的代码

$ curl https://github.com/jvm-profiling-tools/perf-map-agent/archive/refs/heads/master.zip --output perf-map-agent.zip
$ unzip perf-map-agent.zip

编译

这个项目里面包含一些 C 代码, 需要先编译成 binary. 并且这个项目提供了一些脚本帮助我们快速生成符号表.

$ cd perf-map-agent
$ cmake .
$ make 

生成JIT编译后符号表

准备工作已经完成, 那么我们现在可以生成符号表了. 首先获取目标Java 进程的进程号, 然后设置 JAVA_HOME 环境变量, 因为 perf-map-agent 需要这个环境变量. 最后运行 bin/create-java-perf-map.sh 生成符号表.

# 获取 java 进程号
$ jcmd 
3408161 jdk.jcmd/sun.tools.jcmd.JCmd
3406489 URLTest

# 设置 JAVA_HOME
$ export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64

# 生成符号表
$ bin/create-java-perf-map.sh 3406489

# 查看符号表
ls -lah /tmp/perf-3406489.map
-rw-rw-r-- 1 root root 42K Nov 15 05:08 /tmp/perf-3406489.map

bpftrace 获取调用栈

万事俱备, 现在我们就可以通过 bpftrace 获取目标进程是如何调用 recvfrom 找个系统调用的了.
我们使用的probe event 是 tracepoint:syscalls:sys_enter_recvfrom, 设置过滤条件是我们的目标进程pid==3406489, 然后统计用户栈的出现的次数. 这里我们只截取用户栈的20行.

sudo bpftrace -e 'tracepoint:syscalls:sys_enter_recvfrom /pid==3406489/{ @[ustack(20)] = count(); }'
Attaching 1 probe...
^C

@[
    recvfrom+116
]: 2
@[
    __GI___recv+110
    Java_java_net_SocketInputStream_socketRead0+434
    Interpreter+28336
    Interpreter+4352
    Interpreter+4352
    Interpreter+4352
    Interpreter+4352
    Interpreter+5875
    Interpreter+4352
    Interpreter+4352
    Interpreter+3728
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+3828
    call_stub+138
    JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, Thread*)+883
    jni_invoke_static(JNIEnv_*, JavaValue*, _jobject*, JNICallType, _jmethodID*, JNI_ArgumentPusher*, Thread*) [clone .constprop.1]+682
    jni_CallStaticVoidMethod+352
    JavaMain+3441
    ThreadJavaMain+13
    start_thread+755
]: 3

上面的结果里面, 我们可以看到2处不同的调用栈, 第一个调用栈只有一行, 不是我们代码调用的. 第二个栈是从我们的代码发出的, 共被调用了 3次.

最上面的一行 __GI___recv+110 找个代码是 glibc 里面的. __GI__ 表示这是 Linux 上的标准C库 GNU C Library (glibc)里面的代码. “GI”前缀用于由 glibc 的动态链接器内部重定向到其在库中实现的函数。 这些函数称为“global indirect”或“indirect”函数,它们的实际实现驻留在运行时加载的动态链接库中。所以__GI___recv 是glibc 里面的一个函数, 它调用了系统调用 recvfrom.

接着一行Java_java_net_SocketInputStream_socketRead0 是JDK 中的C 代码, 我们可以在 JDK 原代码中找到它.

然后接着是一些 Java 的代码, 只不过这些都是运行时翻译的, 所以没有符号给我们看, 只能看到关键字 Interpreter.

然后 Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0 这是Java 代码通过JIT 编译后产生的代码, 然后程序通过 /tmp/perf-3406489.map 获得了符号表, 然后展示在这.

为了搞清楚到底是怎么一层层到 Java_java_net_SocketInputStream_socketRead0的, 我们需要把翻译的这部份也通过 JIT 编译成native 代码.

把翻译代码变成编译代码

在 Java 运行时, 当一个方法在一个滑动时间窗口内, 达到了足够的执行次数之后, 就会被编译成native 代码. 所以, 为了让我们看到的被翻译的代码也被编译, 需要关掉分层编译 -XX:-TieredCompilation, 并且把需要编译的最低次数设置的足够低 -XX:CompileThreshold=1, 这里我们设置成1次.

再次运行找个 Java 应用, 并且再次执行 bpftrace, 我们获得了如下的代码栈.

$ java -XX:+PreserveFramePointer -XX:-TieredCompilation -XX:CompileThreshold=1 URLTest

$ sudo bpftrace -e 'tracepoint:syscalls:sys_enter_recvfrom /pid==<pid>/{ @[ustack(20)] = count(); }'
    __GI___recv+110
    Java_java_net_SocketInputStream_socketRead0+434
    Ljava/net/SocketInputStream;::socketRead0+244
    Ljava/net/SocketInputStream;::read+224
    Ljava/io/BufferedInputStream;::fill+784
    Ljava/io/BufferedInputStream;::read1+176
    Ljava/io/BufferedInputStream;::read+252
    Lsun/net/www/http/HttpClient;::parseHTTPHeader+444
    Lsun/net/www/http/HttpClient;::parseHTTP+1004
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream0+1420
    Lsun/net/www/protocol/http/HttpURLConnection;::getInputStream+196
    Ljava/net/HttpURLConnection;::getResponseCode+96

调大code cache

在Java中,您可以通过设置-XX:ReservedCodeCacheSize标志来调整JIT代码缓存的大小。该标志控制JIT编译器可用来存储生成的代码的本机内存的最大数量。默认情况下,代码高速缓存的大小由平台确定 - 32位JVM通常为240MB,而64位JVM通常为480MB。

要调整JIT代码缓存区的大小,可以将-XX:ReservedCodeCacheSize标志设置为自定义值。该值应以字节为单位指定,并且可以是2的幂次方或2048(2KB)的倍数。以下是将标志设置为512MB的示例:

java -XX:ReservedCodeCacheSize=536870912 <your_program>
这将将JIT代码缓存的最大大小设置为512MB。

请注意,增加JIT代码缓存的大小可以通过允许JIT编译器在内存中存储更多的生成代码来提高性能。但是,这也会增加应用程序的内存使用量。你应仔细调整代码缓存的大小,并监视应用程序的内存使用情况,以确保它不超过系统上的可用内存。

Linux cgroup freezer subsystem 与 SIGSTOP/SIGCONT

在 Linux 上, 使用 cgroup freezer subsystemSIGSTOP/SIGCONT 都能暂停和继续进程的执行. 那么它的区别与联系是什么呢?

为什么需要 SIGSTOP/SIGCONT

暂停与继续进程.
SIGSTOP tells LINUX to pause a process to be resumed later.
SIGCONT tells LINUX to resume the processed paused earlier.

什么是 cgroup freezer subsystem

https://www.kernel.org/doc/Documentation/cgroup-v1/freezer-subsystem.txt
首先它是 cgroup 控制的一个子系统, 所以它有cgroup的层级结构. 它能把一个或者多个进程冻住, 然后就获得了该进程的一个snapshot(checkpoint), docker 的 pause 子命令就是使用 cgroup freezer subsystem 实现的. 当冻住该进程之后, 你能check 找个进程的 /proc 的状态, 内存的状态, 去收集它的运行时信息. 之后解冻之后, 就可以继续运行.

相比来说, 被 SIGSTOP/SIGCONT 的进程自己或者父进程能感知到自己收到了这些 signal.

cgroup freezer subsystem 的一个简单例子

先创建 cgroup freezer 目录, 然后挂载cgroup 文件系统, 然后创建一个文件夹, 然后把进程扔进去, 然后冻结, 解冻.
三种状态:

  1. FROZEN — tasks in the cgroup are suspended. 冻结.
  2. FREEZING — the system is in the process of suspending tasks in the cgroup. 过度, 瞬间状态.
  3. THAWED — tasks in the cgroup have resumed. 解冻.

    # mkdir /sys/fs/cgroup/freezer
    # mount -t cgroup -ofreezer freezer /sys/fs/cgroup/freezer
    # mkdir /sys/fs/cgroup/freezer/0
    # echo $some_pid > /sys/fs/cgroup/freezer/0/tasks
    # echo FROZEN > /sys/fs/cgroup/freezer/0/freezer.state
    # ps aux | grep $some_pid
    # echo THAWED > /sys/fs/cgroup/freezer/0/freezer.state

    为什么需要 cgroup freezer subsystem

进程的跨服务器迁移

整个服务器有可能, 也有可用的商业方案.
单进程, 不可行.

  1. /proc, debugFS 这些伪文件系统不可复制.
  2. 进程号冲突.
  3. 在用的文件描述符.
  4. IP 地址.
  5. socket buff 未读/未发的数据.

D - Uninterruptible Sleep Process

认识 Linux ps 命令的状态列:

$ ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.0 169156 12824 ?        Ss   Nov03   6:05 /lib/systemd/systemd 
root           2  0.0  0.0      0     0 ?        S    Nov03   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        I<   Nov03   0:00 [rcu_gp]
root          14  0.0  0.0      0     0 ?        I    Nov03  10:34 [rcu_sched]
root          68  0.0  0.0      0     0 ?        SN   Nov03   0:00 [ksmd]
root         868  0.0  0.0   2812  1048 ?        Ss   Nov03   0:00 /usr/sbin/acpid
root         875  0.0  0.0 484180 15888 ?        Ssl  Nov03   5:39 /usr/sbin/NetworkManager
root         894  0.0  0.1 917300 28164 pts/0    Ssl+ Nov03   1:52 /usr/local/qualys/cloud-agent 
rtkit       1415  0.0  0.0 155920  2584 ?        SNsl Nov03   0:08 /usr/libexec/rtkit-daemon
supra       2445  0.0  0.0  41472  3876 ?        S<sl Nov03   0:00 /usr/bin/pipewire
supra    2176300  0.0  0.0  15472  4596 pts/2    R+   16:58   0:00 ps aux

从上面的输出可以看到 STAT (state) 列有各种字母组合的状态, 分别代表什么意思呢?

State 含义

https://man7.org/linux/man-pages/man1/ps.1.html

    the state of a process:
               D    uninterruptible sleep (usually IO)
               I    Idle kernel thread
               R    running or runnable (on run queue)
               S    interruptible sleep (waiting for an event to
                    complete)
               T    stopped by job control signal
               t    stopped by debugger during the tracing
               W    paging (not valid since the 2.6.xx kernel)
               X    dead (should never be seen)
               Z    defunct ("zombie") process, terminated but not
                    reaped by its parent

       For BSD formats and when the stat keyword is used, additional
       characters may be displayed:

               <    high-priority (not nice to other users)
               N    low-priority (nice to other users)
               L    has pages locked into memory (for real-time and
                    custom IO)
               s    is a session leader
               l    is multi-threaded (using CLONE_THREAD, like NPTL
                    pthreads do)
               +    is in the foreground process group

虽然上面的各种状态比较详细, 简化一下, 其实主要有: R, S, D, T, Z.
LinuxProcessStateChange.png

关于 T & t Stopped 的状态

从运行或可运行状态,我们可以使用SIGSTOP或SIGTSTP信号将进程置于停止状态(T)。两种信号之间的区别在于,我们使用编程方式发送SIGSTOP,例如运行 kill -STOP {pid} 命令。进程不能忽略此信号,并将进入停止状态。当我们使用键盘CTRL + Z发送SIGTSTP信号, 也可以把它置于T状态。与SIGSTOP不同,进程可以选择忽略此信号并在接收SIGTSTP后继续执行。

在此状态下,我们可以通过发送SIGCONT信号将进程带回运行或可运行状态。

关于 D Uninterruptible 的状态

An uninterruptible sleep state is a sleep state that will not handle a signal right away. It will wake only as a result of a waited-upon resource becoming available or after a time-out occurs during that wait (if specified when put to sleep). It is mostly used by device drivers waiting for disk or network IO (input/output).

When the process is sleeping uninterruptibly, signals accumulated during the sleep will be noticed when the process returns from the system call or trap.

You cannot kill "D" state processes, even with SIGKILL or kill -9. As the name implies, they are uninterruptible. You can only clear them by rebooting the server or waiting for the I/O to respond.

为什么需要 Uninterruptible 状态

对于某些必须在系统可能出现故障或意外断电的情况下确保数据完整性和一致性的系统进程。 例如,当文件系统正在将数据写入磁盘时,如果系统接收到信号或其他事件,则它不能简单地在操作中途停止。 数据必须完整写入,以确保不丢失或损坏。

可以使用不间断睡眠的进程的其他示例包括一些设备驱动程序、备份进程和虚拟内存操作。

简而言之,不间断睡眠用于确保关键系统进程能够完成其任务,而不会出现数据损坏或丢失的风险,即使在发生断电或系统崩溃等意外事件时也是如此。

TASK_KILLABLE state.

这是可中断睡眠进程和不可中断睡眠进程之间的折衷方案。 处于 TASK_KILLABLE 状态的进程仍然不能被通常意义上的中断(即不能强制系统调用返回 EINTR); 然而,处于这种状态的进程可以被终止。 这意味着,例如,通过 NFS 执行 I/O 的进程如果进入楔入状态,则可能会被终止。 并非所有系统调用都实现这种状态,因此某些系统调用仍然有可能卡住不可杀死的进程,但这肯定比以前的情况有所改进。

Cgroup freezer subsystem -> D

https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/6/html/resource_management_guide/sec-freezer
this subsystem suspends or resumes tasks in a cgroup.
什么情况下需要 cgroup freezer 子系统

  1. 暂停出问题的进程/线程, 查看它内存状态
  2. 搬家 checkpoint
  3. 暂停消耗资源(CPU/内存/网络)的进程/线程

使用cgroup 挂起进程的例子: https://www.kernel.org/doc/Documentation/cgroup-v1/freezer-subsystem.txt
与 SIGSTOP/SIGCONT 的区别:

  1. 进入的状态不同, SIGSTOP/SIGCONT 进入 stopped (T)状态, freezer 进入 Unineteruptiable sleep D.
  2. 被 SIGSTOP/SIGCONT 的进程能感应到信号, 被 freezer 的进程感应不到自己收到这个信号.

cgroup freezer 子系统和 SIGSTOP/SIGCONT 的区别?

进程的生命周期(from wikipidia):

pLifeCycle.webp

eBPF - 例子 进程在 CPU 的运行时间

probe 点: tracepoint:sched:sched_switch
查看参数:

supra@suprabox:~/work/ebpf/bpftrace$ sudo bpftrace -lv tracepoint:sched:sched_switch
tracepoint:sched:sched_switch
    char prev_comm[16]
    pid_t prev_pid
    int prev_prio
    long prev_state
    char next_comm[16]
    pid_t next_pid
    int next_prio

代码如下:

BEGIN
{
    printf("     usecs         count |      distribution                                  |");
}

tracepoint:sched:sched_switch { 
    $prev = args.prev_pid;
    if (@pid[$prev]) {
        @ns = hist((nsecs - @pid[$prev])/1000);
        delete(@pid[$prev]);
    }
    @pid[args.next_pid] = nsecs;
}

END
{
    clear(@pid);
}

BPF 例子 - 观测某个 tcp 连接的状态变化

给2个参数, 分别是 IP 和端口, 观察符合找个条件的tcp 连接的状态变化.

这个例子中本想同时对比 IP 和 port, 但是对于IP 遇到一个问题.

常见的 IPv4 是这么写的(字符串): 145.23.45.23
$sk->__sk_common.skc_daddr 拿到的地址 (unsigned int32): 0X91172d17.
使用 ntop 函数转换后是一个 inet_t, 虽然文档死硬说是 Sting 表示形式: inet_t = “145.23.45.23”

那么为了比较2个 IP 地址, 你想把 0X91172d17 转成 inet_t = “145.23.45.23”, 但是还不能比, 不能用 String 和 inet_t 比较.
要么把 145.23.45.23 通过 pton 转成 uint8[], 然后再通过强转 (uint32)pton("145.23.45.23")) 再和 0X91172d17 比较.

关键的关键是 pton 的参数一定要是 字符串常量, 变量不行, 因为它要在编译时知道类型. TMD.

#!/usr/bin/env bpftrace

#ifndef BPFTRACE_HAVE_BTF
#include <linux/socket.h>
#include <net/sock.h>
#else
#include <sys/socket.h>
#endif

BEGIN
{
    @host = "127.0.0.1";
    @port = (uint16)80;
    if ("" != str($1)) {
        @host = str($1);
    }
    if ("" != str($2)) {
        @port = (uint16)$2;
    }

    printf("looking for tcp connection related to : %s:%d\n", @host, @port);
    printf("%39s:%-6s %39s:%-6s %-10s -> %-10s\n", "src IP", "src port", "dest ip", "dest port", "old state", "new state");
    @states[1] = "ESTABLISHED";
    @states[2] = "SYN_SENT";
    @states[3] = "SYN_RECV";
    @states[4] = "FIN_WAIT1";
    @states[5] = "FIN_WAIT2";
    @states[6] = "TIME_WAIT";
    @states[7] = "CLOSE";
    @states[8] = "CLOSE_WAIT";
    @states[9] = "LAST_ACK";
    @states[10] = "LISTEN";
    @states[11] = "CLOSING";
    @states[12] = "NEW_SYN_RECV";
}

kfunc:vmlinux:tcp_set_state {
    $sk = ((struct sock *) args->sk);
    $inet_family = $sk->__sk_common.skc_family;
    if ($inet_family == AF_INET) {
      $daddr = ntop($sk->__sk_common.skc_daddr);
      $saddr = ntop($sk->__sk_common.skc_rcv_saddr);
    } else {
      $daddr = ntop($sk->__sk_common.skc_v6_daddr.in6_u.u6_addr8);
      $saddr = ntop($sk->__sk_common.skc_v6_rcv_saddr.in6_u.u6_addr8);
    }
    $lport = $sk->__sk_common.skc_num;
    $dport = $sk->__sk_common.skc_dport;

    $dport = bswap($dport);

    if (($dport == @port) || ( $lport == @port)) {
        $curState = @states[args.state];
        $key = str($dport);
        $oldState = @keyMap[$daddr, $key];
        if ($oldState == "") {
            $oldState = "NONE";
        }
        @keyMap[$daddr, $key] = $curState;

        printf("%39s:%-6d %39s:%-6d %-10s -> %-10s\n", $saddr, $lport, $daddr, $dport, $oldState, $curState);
    }
}

END {
    clear(@states);
    clear(@keyMap);
    clear(@host);
    clear(@port);
}