Java 使用 JNA 调用 C++ 动态库问题诊断

在 Java 应用程序通过 JNA 调用 C++ 动态库时,C++ 代码运行在与 Java 同一进程中,当 C++ 代码 Crash 的时候,将会导致整个 Java 应用程序崩溃。 对于一个 Web 应用,这不是我们期望的结果,由于某一个请求输入的数据导致 C++ 代码崩溃了当前 Java 进程,从而造成该 Web 服务已接受到的所有请求全部失败, 这是非常糟糕的用户体验。如果是 Java 代码本身的异常我们可用 try/catch 进行保护,影响只限制在当前请求。如果是 C++ 代码崩溃的话,Java 应用程序无法捕获到这个异常,以致于整个 Java 应用程序崩溃,甚至发生这种情况时连 hs_err.log 文件都来不及生成,更别说生成 HeapDump, 或 CoreDump 了。

如果是用原始 JNI 的方式来调用动态库,我们还能在 JNI 相关的 C++ 代码中捕获到异常,并抛给 Java 去处理。而用 JNA, 我们贪图了它的方便, 比如一个 Java 进程中同时加载同一接口的不同动态库版本(JNI 要同样的实现必须用自定义的 ClassLoader),但在 C++ 代码崩溃时, Java 就显得无能为力了, 只能跟随着立即死亡, 并且在控制台下找不到关于 C++ 因何失败的线索。比如 C++ 中内存被多次释放,或地址越界访问破坏了内存数据等。

下面我们来用 JNA 的方式来调用 C++ 动态库,演示当 C++ 代码崩溃时会发生什么,并试图找到好的诊断办法。以下演示在 Linux 下进行, 并且 Linux 发行版是 Amazon Linux 2023.

刚刚完成的一篇日志 C++ 调用 C++ 动态库时问题诊断 算是个开味菜,现在开始要与 Java/JNA 的结合了。

先回顾一下上篇的总结部分,两个要点

  1. ulimit -c unlimited 或某个足够大的值才会产生 core dump 文件, 对应的配置文件是 /etc/security/limits.conf
  2. sysctl -w kernel.core_pattern=/crash_logs/core.%p.%t 配置直接生成 core dump 文件到某个具体的目录,而不是用 coredumpctl 来管理, 它对应的配置文件是 /etc/sysctl.conf,运行时文件是 /proc/sys/kernel/core_pattern

有了这两个知识储备后,以后如果应用程序运行在 Docker 容器中的话,比如 ECS Container,就可以通过配置 Docker 容器的参数来实现, 比如 --ulimit core=-1 来开启 core dump,--sysctl kernel.core_pattern=/crash_logs/core.%p.%

准备参触发 Crash 的动态库

C++ 动态还是用上篇一样的例子,当输入为 devil 时产生重复释放内存从而导致 Crash。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#include <stdio.h>
#include <string.h>

extern "C" {
    void sayHello(char* name) {
        printf("hello %s\n", name);

        if (strcmp(name, "devil") == 0) {
            char* buf = new char[64];
            strcpy(buf, "you are devil!");

            printf("buf: %s\n", buf);

            delete[] buf;
            delete[] buf;
        }
    }
}

编译生成动态库 libhello.so,带上 -g 参数让代码行信息保留在二进制文件中,命令如下

1g++ -g -shared -fPIC -o libhello.so hello.cpp

创建使用 C++ 动态库的 Java 代码

我们将采用 JNA, 只依赖于单一文件,当前版本为 5.18.1, 可从 5.18.1 下载. JDK 版本将使用版本 21, 应用 Java 18 的 Simple Web Server API 来搭建一个简单的 Web Server。

Test.java

 1import com.sun.jna.*;
 2import com.sun.net.httpserver.*;
 3
 4import java.net.InetSocketAddress;
 5
 6public class Test {
 7    interface HelloLibrary extends Library {
 8        void sayHello(String name);
 9    }
10
11    public static void main(String[] args) throws Exception {
12        var library = Native.load("./libhello.so", HelloLibrary.class);
13        System.out.println("loaded shared library");
14
15        HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
16
17        server.createContext("/ok", exchange -> {
18            library.sayHello("world");
19            exchange.sendResponseHeaders(200, 0);
20            exchange.close();
21        });
22        server.createContext("/fail", exchange -> {
23            library.sayHello("devil");
24            exchange.sendResponseHeaders(500, 0);
25            exchange.close();
26        });
27
28        server.start();
29        System.out.println("Server started on port 8080");
30    }
31}

测试 C++ 崩溃时是否生成 core dump

确保 ulimit -c 和 kernel.core_pattern 的值可以产生 coredump 并直接生成 core dump 文件,所以我们先执行

1ulimit -c unlimited
2sysctl -w kernel.core_pattern=core.%p.%t

注:切记, 如果写在某个目录中一定要确保该目录存在,并且有写入权限,比如 sysctl -w kernel.core_pattern=/crash_logs/core.%p.%t, 若目录 /crash_logs 不存在,或无写入权限,可触发错误时,看不到 (core dumped), 会错认为是别的原因导致 core dump 文件未能生成。 我本人就深受其苦, 写完本文的大部分内容,一直未能生成 core dump 文件而四处找原因,也使原本写成的后面大部分内容进行了重写。

运行 Java 程序

1java -cp jna-5.18.1.jar  Test.java
2loaded shared library
3Server started on port 8080

在另一终端中访问

1curl http://localhost:8080/ok

服务端控制台输出

hello world

调用

1url http://localhost:8080/fail

服务端输出

1hello devil
2buf: you are devil!
3free(): double free detected in tcache 2
4Aborted (core dumped)

看到 (core dumped), 非常好,现在可以开始分析该 core dump 文件的内容进行 C++ 错误代码定位。

C++ 错误代码分析

上一步在当前目录中生成了文件 core.91607.1771819640, 上 gdb, 然后 bt

 1gdb java core.91607.1771819640
 2.......
 3(gdb) bt
 4#0  0x00007f4ac588d02c in __pthread_kill_implementation () from /lib64/libc.so.6
 5#1  0x00007f4ac583fb86 in raise () from /lib64/libc.so.6
 6#2  0x00007f4ac5829873 in abort () from /lib64/libc.so.6
 7#3  0x00007f4ac582a1b2 in __libc_message.cold () from /lib64/libc.so.6
 8#4  0x00007f4ac58970d7 in malloc_printerr () from /lib64/libc.so.6
 9#5  0x00007f4ac58993ab in _int_free () from /lib64/libc.so.6
10#6  0x00007f4ac589b923 in free () from /lib64/libc.so.6
11#7  0x00007f4ac57431eb in sayHello (name=0x7f49ec018430 "devil") at hello.cpp:15
12#8  0x00007f4a95c16052 in ?? ()
13#9  0x0000000000000000 in ?? ()

问题出在

sayHello (name=0x7f49ec018430 "devil") at hello.cpp:15

其实都不需要虚拟机参数 -XX:+CreateCoredumpOnCrash 默认就会生成 core dump 文件. 不过没见到 hs_err.log 文件。尝试下面的命令

1java -XX:+CreateCoredumpOnCrash \
2     -XX:ErrorFile=hs_err_%p.log \
3     -cp jna-5.18.1.jar Test.java

仍然没见到 hs_err_.log Hot Spot 错误文件,不过不重要了,本文件想要知道的是 Java 通过 JNA 调用 C++ 动态库出错时哪段 C++ 代码出问题了。

另一定位 C++ 地址相关问题

Claude 介绍了针对 double free 的 ASAN, 即在编译动态库时用

1g++ -shared -fPIC -fsanitize=address -g -o libhello.so hello.cpp

然后执行

1LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
2java -cp jna-5.18.1.jar Test.java

如果加了 -fsantitize=address 参数编译生成的 libhello.so, 不指定 LD_PRELOAD 的话,Test.java 将找不到 libhello.so 文件。

现在访问

1curl http://localhost:8080/fail

在服务端的控制台会出现一大片的信息

 1hello devil
 2buf: you are devil!
 3=================================================================
 4==86041==ERROR: AddressSanitizer: attempting double-free on 0x606000062000 in thread T25:
 5    #0 0x7f740fcb1ac7 in operator delete[](void*) (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0xb1ac7)
 6    #1 0x7f740c201233 in sayHello /root/hello.cpp:15
 7    #2 0x7f731e016051  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0x16051)
 8    #3 0x7f731e0151cb  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0x151cb)
 9    #4 0x7f731e015507  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0x15507)
10    #5 0x7f731e00aa59  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0xaa59)
11    #6 0x7f731e00af20  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0xaf20)
12    #7 0x7f73fa5439bf  (<unknown module>)
13
140x606000062000 is located 0 bytes inside of 64-byte region [0x606000062000,0x606000062040)
15freed by thread T25 here:
16    #0 0x7f740fcb1ac7 in operator delete[](void*) (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0xb1ac7)
17    #1 0x7f740c201220 in sayHello /root/hello.cpp:14
18    #2 0x7f731e016051  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0x16051)
19    #3 0x7f731d0a5f0f  (<unknown module>)
20
21previously allocated by thread T25 here:
22    #0 0x7f740fcb1107 in operator new[](unsigned long) (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0xb1107)
23    #1 0x7f740c2011d3 in sayHello /root/hello.cpp:9
24    #2 0x7f731e016051  (/root/.cache/JNA/temp/jna4011246694615712467.tmp+0x16051)
25    #3 0x7f731d0a5f0f  (<unknown module>)
26
27Thread T25 created by T1 here:
28    #0 0x7f740fc57736 in pthread_create (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0x57736)
29    #1 0x7f740b50452d in os::create_thread(Thread*, os::ThreadType, unsigned long) (/usr/lib/jvm/java-21-amazon-corretto.x86_64/lib/server/libjvm.so+0xd0452d)
30    #2 0x7f740b1f2bee in JVM_StartThread (/usr/lib/jvm/java-21-amazon-corretto.x86_64/lib/server/libjvm.so+0x9f2bee)
31    #3 0x7f73fa5439bf  (<unknown module>)
32    #4 0x7f73fa53f17f  (<unknown module>)
33    #5 0x7f73fa53f17f  (<unknown module>)
34    #6 0x7f73fa53f17f  (<unknown module>)
35    #7 0x7f73fa53f17f  (<unknown module>)
36    #8 0x7f73fa53f17f  (<unknown module>)
37    #9 0x7f73fa53f17f  (<unknown module>)
38    #10 0x7f73fa53f2f1  (<unknown module>)
39    #11 0x7f73fa53f2f1  (<unknown module>)
40    #12 0x7f73fa53f2f1  (<unknown module>)
41    #13 0x7f73fa53f797  (<unknown module>)
42    #14 0x7f73fa53f2f1  (<unknown module>)
43    #15 0x7f73fa53f17f  (<unknown module>)
44    #16 0x7f73fa53f17f  (<unknown module>)
45    #17 0x7f73fa537cc5  (<unknown module>)
46    #18 0x7f740b114834 in JavaCalls::call_helper(JavaValue*, methodHandle const&, JavaCallArguments*, JavaThread*) (/usr/lib/jvm/java-21-amazon-corretto.x86_64/lib/server/libjvm.so+0x914834)
47    #19 0x7f740b1baebc in jni_invoke_static(JNIEnv_*, JavaValue*, _jobject*, JNICallType, _jmethodID*, JNI_ArgumentPusher*, JavaThread*) [clone .constprop.1] (/usr/lib/jvm/java-21-amazon-corretto.x86_64/lib/server/libjvm.so+0x9baebc)
48    #20 0x7f740b1bd43a in jni_CallStaticVoidMethod (/usr/lib/jvm/java-21-amazon-corretto.x86_64/lib/server/libjvm.so+0x9bd43a)
49    #21 0x7f741079f163 in JavaMain (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/../lib/libjli.so+0x5163)
50    #22 0x7f74107a1bb8 in ThreadJavaMain (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/../lib/libjli.so+0x7bb8)
51    #23 0x7f740f88b2e9 in start_thread (/lib64/libc.so.6+0x8b2e9)
52
53Thread T1 created by T0 here:
54    #0 0x7f740fc57736 in pthread_create (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0x57736)
55    #1 0x7f74107a27ad in CallJavaMainInNewThread (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/../lib/libjli.so+0x87ad)
56    #2 0x7f741079fa7c in ContinueInNewThread (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/../lib/libjli.so+0x5a7c)
57    #3 0x7f74107a04cf in JLI_Launch (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/../lib/libjli.so+0x64cf)
58    #4 0x55f716db61fe in main (/usr/lib/jvm/java-21-amazon-corretto.x86_64/bin/java+0x11fe)
59    #5 0x7f740f82a60f in __libc_start_call_main (/lib64/libc.so.6+0x2a60f)
60
61SUMMARY: AddressSanitizer: double-free (/usr/lib/gcc/x86_64-amazon-linux/11/libasan.so+0xb1ac7) in operator delete[](void*)
62==86041==ABORTING

最前面就提示了出错的代码行在

1 0x7f740c201233 in sayHello /root/hello.cpp:15

即第二个

delete[] buf;

不想信息输出来控制台,可以输出到文件,命令是

1LD_PRELOAD=$(gcc -print-file-name=libasan.so) \
2ASAN_OPTIONS=abort_on_error=0:log_path=/crash_logs/asan.log \
3java -cp jna-5.18.1.jar Test.java

但是其他非地址的问题该如何解决呢?加了 -fsanitize=address 编译参数对性能影响有多大呢?Claude 的答案是

CPU 速度慢 2x 左右, 内存占用增加 2~3x, 二进制体积增大(含调试符号)

看来生产环境中是不可接受的。

尝试用 JNI 的方式调用动态库

创建带 native 方法的 Greeter 类

 1public class Greeter {
 2
 3    static {
 4        System.load("/root/libgreeter.so"); // 或用 System.loadLibrary("greeter"); 会依 LD_LIBRARY_PATH 寻找 libgreeter.so
 5    }
 6
 7    public native void sayHello(String name);
 8
 9    public static void main(String[] args) {
10        Greeter greeter = new Greeter();
11        String name = args.length > 0 ? args[0] : "world";
12        greeter.sayHello(name);
13    }
14}

编译并生成相应的 C++ 头文件 Greeter.h

1javac -h ./ Greeter.java

生成 Greeter.class 和 Greeter.h

实现 Greeter.cpp

 1#include <jni.h>
 2#include <stdio.h>
 3#include <string.h>
 4
 5extern "C" {
 6
 7JNIEXPORT void JNICALL
 8Java_Greeter_sayHello(JNIEnv* env, jobject thiz, jstring jname) {
 9
10    const char* name = env->GetStringUTFChars(jname, nullptr);
11    if (name == nullptr) return;
12
13    printf("hello %s\n", name);
14
15    if (strcmp(name, "devil") == 0) {
16        char* buf = new char[64];
17        strcpy(buf, "you are devil!");
18
19        printf("buf: %s\n", buf);
20
21        delete[] buf;
22        delete[] buf;
23    }
24
25    env->ReleaseStringUTFChars(jname, name);
26}
27}

编译生成 C++ 动态库 libgreeter.so

1export JAVA_HOME=/usr/lib/jvm/java-21-amazon-corretto
2g++ -shared -fPIC -I $JAVA_HOME/include -I $JAVA_HOME/include/linux -o libgreeter.so Greeter.cpp

执行 Greeter

1[root@lin-0aff3de6 ~]# java Greeter
2hello world
3[root@lin-0aff3de6 ~]# java Greeter devil
4hello devil
5buf: you are devil!
6free(): double free detected in tcache 2
7Aborted (core dumped)

同样会产生 core dump 文件,诊断办法同上。

试下 Python

 1import ctypes
 2import os
 3
 4lib = ctypes.CDLL("./libhello.so")
 5
 6lib.sayHello.argtypes = [ctypes.c_char_p]
 7lib.sayHello.restype = None
 8
 9name = "world"
10lib.sayHello(name.encode("utf-8"))
11lib.sayHello(b"devil")

执行

1python3 test.py
2hello world
3hello devil
4buf: you are devil!
5free(): double free detected in tcache 2
6Aborted (core dumped)

诊断办法用 gdb python3 core.92636.1771820629, 然后输入 bt 就可以看到出错的 C++ 代码行了。

看来也要收住篇幅了,后来的研究任务是假如 Java 运行在 Docker 容器中, 以及作为 ECS 部署时,它所调用的 C++ 代码崩溃时应如何获得 core dump 进行问题分析。

永久链接 https://yanbin.blog/java-jna-cpp-trouble-shooting/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。