从JNI异常案例出发浅谈异常捕捉机制

28 min read,created at 2024-12-06
异常捕捉catchpanicexception

1 起因

最近写了一个java的native程序,然后程序中出现了未捕捉的异常(例如整数除0,数组越界等),竟然会导致整个jvm进程崩溃,一个简单的例子就是当前目录下的Demo.java Demo.c

Demo.java
public class Demo {
    public static native int divide(int a, int b);
    static {
        System.load("C:\\Users\\sunwu\\Desktop\\base\\gateway\\notebook\\24.12\\libDemo.dll");
    }
    public static void main(String[] args) {
        try {
            System.out.println(divide(10, 2));
            System.out.println(divide(10, 0));
        } catch (Throwable t) {
            System.out.println("Error " + t);
        }
    }    
}
Demo.c
#include <jni.h>
JNIEXPORT jint JNICALL Java_Demo_divide
  (JNIEnv *env, jclass clz, jint a, jint b) {
    return a /b;
}

Demo.javaDemo.c编译成Demo.class和libDemo.dll,然后运行java Demo,结果如下:

image

这里native程序没有处理好异常,java程序没有捕捉到异常,整个jvm进程崩溃,这是一个非常严重的问题,尤其是线上服务,当参数没有做好验证,调用这种native程序,程序内部又没有做检查和异常捕获的话,是非常危险的。

除了验证参数外,java程序员会觉得,还需要在native程序中做异常检查,但是c语言是没有try catch的,可以借助c++

Demo.cpp
#include <jni.h>
#include <stdexcept>
extern "C" JNIEXPORT jint JNICALL Java_Demo_divide
  (JNIEnv *env, jclass clz, jint a, jint b) {
    try {
        return a /b;
    } catch (const std::exception &e) {
        env->ThrowNew(env->FindClass("java/lang/ArithmeticException"), e.what());
        return 0;
    }
}

然后发现cpp用了try-catch然后向jvm抛出异常,结果并没到预期,而是和之前一样,进程崩溃了。这是因为即使cpp也有无法捕捉的异常,会导致程序崩溃,例如

  • 整数除零
  • 数组越界
  • 空指针解引用
  • 类型转换错误

所以,在native程序中,需要自己额外注意这些情况的发生,一旦出现可能都是致命的。

但是,我写的native程序用的并不是c/cpp,而是使用了rust,

Demo.rs
#[macro_use]
extern crate jni;

use jni::JNIEnv;
use jni::objects::{JClass};
use jni::sys::jint;
#[no_mangle]
pub extern "C" fn Java_Demo_divide(env: JNIEnv, _class: JClass, a: jint, b: jint) -> jint {
    return a / b;
}

报错其实长得不太一样,但是结局是一样的,就是jvm catch不到异常,最终进程崩溃:

img

c/cpp不同的是rust提供可以捕捉除零这些panic,并且提供了catch_unwind,一种类似try-catch的机制。

lib.rs
#[macro_use]
extern crate jni;

use jni::JNIEnv;
use jni::objects::{JClass};
use jni::sys::jint;
use std::panic;

#[no_mangle]
pub extern "C" fn Java_Demo_divide(mut env: JNIEnv, _class: JClass, a: jint, b: jint) -> jint {
    let res = panic::catch_unwind(|| {
        return a / b;
    });

    match res {
        Ok(x) => x,
        Err(_) => {
            env.throw_new("java/lang/ArithmeticException", "Divide by zero").unwrap();
            return 0;
        },
    }
}

img

当然这里还是有一些rust的日志,我们可以把panic的hook设置为空函数,这样日志就纯净多了。

lib.rs
pub extern "C" fn Java_Demo_divide(mut env: JNIEnv, _class: JClass, a: jint, b: jint) -> jint {
    // 设置回调为空函数
    panic::set_hook(Box::new(|_| {}));

    let res = panic::catch_unwind(|| {
        return a / b;
    });

    match res {
        Ok(x) => x,
        Err(_) => {
            env.throw_new("java/lang/ArithmeticException", "Divide by zero").unwrap();
            return 0;
        },
    }
}

img

2 错误处理

我们来讨论下错误处理,这个编程语言中都会涉及到的机制。

我们可以自己来想一下,如果要设计一组api,去执行一些功能,正确的处理可以返回正确的结果,比如要查询某些信息的接口,返回的结果就是查询的结果。但是如果执行失败,应该返回什么呢?这里有一些思路:

2.1 返回状态码

就像HTTP协议一样,200表示成功,其他状态码可能是失败,不同状态码会有不同的含义。在c语言的很多接口中都是用的int返回值,非0表示失败,0表示成功。但c语言是单返回值的,如果把状态码作为返回值,那查询的信息放到哪里呢?一般需要再入参中放置一个指针,把结果放到指针中。正常返回后,判断状态码,如果是成功,再去指针地址获取数据。例如标准库中的一些函数:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
// 返回值为0表示成功,非0表示失败
// 线程ID通过第一个指针参数返回

int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);
// 返回0表示成功(网络地址ip解析)
// 解析结果通过res指针返回

size_t fread(void *ptr, size_t size, size_t nmemb, FILE *stream);
// 返回实际读取的元素个数
// 数据通过ptr指针返回

这种设计,相比于把返回值再单独封装一个结构体来放置数据+状态码,省去一层封装效率更高,更符合c语言底层操作的特性。但是缺点就是可读性较差,容易出现指针使用错误,所以高级语言中大都不再使用这种设计。

我们可以着重讨论下指针用错的情况,例如上面的fread函数,传入的ptr指针是空指针或者ptr提前申请的空间比nmemb的大小小,则会导致读取失败,而这个失败可能会导致段错误,引发SIGSEGV信号,进而导致进程崩溃。是的,没错,段错误会导致进程崩溃。而引发段错误的,通常是内存访问错误,例如访问空指针,访问越界等等,这些异常不会反馈在函数的返回值的状态码中,因为是更严重的错误,是直接导致进程崩溃的。

2.2 抛出异常

高级语言例如java等,一般将返回值设计为要查询的数据,而至于发生异常的时候,则直接抛出一个异常,由上层去捕捉异常,使用try-catch语法。

private void write(int b, boolean append) throws IOException
// 写文件的函数,会抛出IO异常。可能导致异常的情况有,文件不存在,文件无权限、无法写入等等。

有一种异常比较特殊,RuntimeException运行时异常,是不需要强制处理的,例如NullPointerException空指针异常,ArrayIndexOutOfBoundsException数组越界异常,对应上面提到的c中段错误,在java中则是被运行时做了较好的处理,反馈为运行时的异常。这些异常不会导致进程崩溃,如果没有try-catch的话,最多就是导致线程结束。因为运行时异常时异常随时都有可能发生,每次都要捕捉就导致程序到处都是捕捉异常,非常麻烦,所以并不强制要求处理运行时异常。

try-catch-finally的异常捕捉方式从c++开始就有了,他的一个主要的优势就是可以把正常的逻辑代码和异常处理代码分开,如下在try中可以按照无异常发生顺序的写逻辑,而在catch中可以集中处理异常,并且对ExceptionThrowable做兜底的catch处理,再加上finally中控制资源释放,整体代码就非常有安全感。

try {
    /// 这里是正常的逻辑代码
    /// 读文件
    /// 写文件
    /// 查数据库
    /// 调用其他函数等等
} catch (FileNotFoundException e) {
    /// 文件不存在的异常处理
} catch (SQLException e) {
    /// 数据库异常处理
} catch (IOException e) {
    /// 其他的IO异常处理
} catch (Exception e) {
    /// 其他异常处理
} finally {
    /// 一些必须执行的逻辑,例如资源释放
}

2.3 多返回值

一些语言例如golang等,支持多返回值,一般把第一个返回值设计为当前函数正常执行的结果例如查询的信息,第二个返回值是错误err,这种方式和前面c的处理有点类似,或者说是对c的加强,都是把错误返回,只不过c是返回了一个int,要自己判断不同的code的含义,go则返回了错误err结构体,此外go的多返回值就类似于c封装了一个结构体,有正常结果和错误两部分,这样由函数运行时产生数据把结果都放到返回值中,可以避免指针使用错误。这种方式提高了阅读性,也减少了错误。

file, err := os.Open("filename.txt")
/// 打开文件


resp, err := http.Get("https://example.com")
/// http请求


t, err := time.Parse(time.RFC3339, "2023-12-07T12:00:00Z")
/// 时间解析

但是与throw抛出异常相比,多返回值可以更好的判断每一个错误的位置,例如上面try-catch的例子中,如果有对两个文件的操作,如果将两个文件操作放到一个try-catch中,抛出异常时,就不知道是哪个文件导致的。

try { 读文件A();  读文件B(); } 
catch(IOException e) { /*这里无法判断是A 还是B*/}

这可能就需要写两个try-catch分别处理文件A和B的读操作,代码就变长了,并且没有能够很好的把业务逻辑和异常处理分开。而如果是golang,则可以很好的识别出是哪个文件导致的。

contentA, errA := ioutil.ReadFile("A.txt")
if errA != nil {/*A读取失败*/}

contentB, errB := ioutil.ReadFile("B.txt")
if errB != nil {/*B读取失败*/}

当然了,golang这种方式的问题也是因为区分的太细,每个err都要做判断,判断错误的代码穿插在正常的逻辑中间,没办法很好的分离关注点。

2.4 Result/Either

还有一些语言采用Result/Either的方式,和golang的多返回值类似,只不过更适合单返回值的语言,例如RustResult<T, E>是一个枚举类型,他要么是OK(T)类型,里面会存放正常结果,要么是Err(E)类型,里面会存放错误信息。ScalaEither[L, R]也是类似的,要么是L要么是R。说到底都和golangcontent, err := xxx是类似的效果。这里不再展开说了。

3 不同类型异常的处理

了解了这些错误处理的形式,我们再来说一下,不同语言针对“段错误”的异常,严重的异常,异常的兜底的处理方式。

3.1 C语言

首先是c语言中,段错误也来自c,一旦发生段错误,C语言没有给开发者处理的机会,只能是提前避免,一旦发生了段错误,例如数组越界,当然也包含除0异常等,都会导致进程崩溃;这些异常在c中就是严重的异常,需要开发者提前避免,例如在数组越界之前就做判断,在除0之前就做判断;

int main() {
    int *q = NULL;
    *q = 10; // 会触发段错误
    printf("Program completed.\n");
    return 0;
}
// Segmentation fault 
// 段错误进程挂掉

不仅是当前线程崩溃,而是整个进程崩溃

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

// 线程函数
void* thread_function(void* arg) {
    sleep(1);
    int *p = NULL;
    *p = 10;
    return NULL;
}

int main() {
    pthread_t thread;
    
    // 创建线程
    if (pthread_create(&thread, NULL, thread_function, NULL) != 0) {
        printf("线程创建失败\n");
        return 1;
    }
    
    // 等待线程结束
    if (pthread_join(thread, NULL) != 0) {
        printf("等待线程失败\n");
        return 1;
    }
    
    // 这一行运行不到,因为线程执行的时候已经导致进程崩溃了
    printf("Program completed.\n");
    return 0;
}

而对于兜底处理,需要借助信号的处理机制和setjmp/longjmp,信号处理的函数只能在线程级别注册,如果多线程需要各自注册,下面是一个简单的setjmp的例子配合signal_handle注册可以捕捉到段错误异常。

#include <stdio.h>
#include <setjmp.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>

jmp_buf global_exception;
struct ErrorContext {
    int code;
    char msg[256];
};

void signal_handler(int signum) {
    longjmp(global_exception, signum);
}

int main() {
    int *q = NULL;
    *q = 10; // 会触发段错误

    struct ErrorContext ctx = {0};
    
    // 设置信号处理
    signal(SIGSEGV, signal_handler);
    
    // 设置异常捕获点
    int exception = setjmp(global_exception);
    
    if (exception == 0) {
        // 正常执行
        int *p = NULL;
        *p = 10; // 会触发段错误
        // 错误发生后,会再次跳到setjmp这一行,并且exception此时返回信号值
    } else {
        // 异常处理
        ctx.code = exception;
        snprintf(ctx.msg, sizeof(ctx.msg), 
                 "Caught signal: %d", exception);
        // 记录日志
        fprintf(stderr, "Error: %s\n", ctx.msg);
    }

    printf("Program completed.\n");
    return 0;
}
/**
$ gcc main.c && ./a.exe
Error: Caught signal: 11
Program completed.
**/

但是多线程的场景就比较复杂,上面signal函数是指给当前线程注册收到信号的处理函数,但是多线程场景下,每个线程需要各自注册,并且需要设置异常捕获点,否则无法捕获到段错误的异常。默认情况下,这个信号会被发送到引发段错误的线程上。

3.2 java

java中抛出的异常是随着堆栈一层层向上,直到有一个地方能catch这个异常,而如果没有任何地方能catch,最终会导致当前线程崩溃,但是如果当前线程不是主线程的话,是不会导致进程崩溃的,数组越界等“段错误”也是类似的,最多导致当前线程崩溃。

img

java中什么算是严重的错误呢,这与c中就有较大不同,c中整数除零、越界等都是严重的错误,但是java中就是一个RuntimeException,并不算是一种严重的错误,因为jre已经很好的处理过了这些错误,我们也可以自己构建自定义的RuntimeExceptionjava中的异常分类则是Throwable接口下有两个大的分类ErrorException,其中Error才是比较严重的错误,Error一般是无法处理的,不可恢复的错误,比如OOM、类不存在、方法不存在、栈溢出等,一般Error不太需要catch,因为catch到了,最多也就是打印个日志,开发者很难处理这类异常。当然即使是Error也只会导致当前线程崩溃,不会导致进程崩溃。而Exception则又分为RuntimeException和非运行时异常,前者不强制要求处理,后者需要开发者必须处理,要么继续往上抛出,要么在自己这里处理掉。

对于兜底处理,java中做的比较好,就是try-catch直接把Throwablecatch住,所有的异常,都逃不过这一层catch,也就不会往上抛了,同时还提供了finally来保证异常情况下一些释放和收尾操作的执行。

img

3.3 golang

golang中正常的函数返回的err,由开发者自行处理即可,而对于“段错误”或者其他的“运行时异常”,则是提供了panic机制,panic的传递与java的异常是一样的,同样是栈不断向上抛出,直到有能够处理它的地方,如果没有任何一层栈能处理panic,会导致进程退出,这一点与java不同,java的异常即使是Error也不会导致进程崩溃。而gopanic则是面向恢复的,需要有地方去“捕捉”panic,在go中是恢复,否则就会导致进程崩溃,好在很多框架都内置了恢复panic的机制,比如ginbeego等。原生代码如下,会导致进程崩溃,无法打印程序完成这一行

package main

func main() {
	done := make(chan bool)
	// 启动一个协程
	go func() {
		b := 0
		a := 100 / b
		println(a)
		done <- true
	}()
	
    // 等待协程完成
	<-done
    // 或者改成:time.Sleep(1 * time.Second)

	println("程序完成")
}

img

panic就是golang中的“严重错误”了,这个错误严重到,如果没有恢复机制,会导致进程退出。那就来看下该如何恢复recover:

package main

func main() {
	done := make(chan bool)
	// 启动一个协程
	go func() {
		defer func() {
			if r := recover(); r != nil {
				if errStr, ok := r.(string); ok {
					println("Recovered from panic with message:", errStr)
				} else {
					println("Recovered from panic with unknown type")
				}
				done <- false
			}
		}()
		b := 0
		a := 100 / b
		println(a)
		done <- true
	}()

	// 等待协程完成
	<-done
	println("程序完成")
}

img

这里还要说一下defer关键字,他类似的java中的finally,是一定会执行到的代码,但是它的调用顺序是和函数调用栈的顺序相反的,也就是说,如果函数中声明了多段defer他们会在函数执行结束后,按顺序反向执行,当然如果有panic发生,则会从panic这一行开始,往上依次执行defer。有了这个defer的机制,再来看recover函数,这个函数是指当前协程栈如果发生了panic,才能恢复,如果栈没有发生panic,则返回nil,否则返回引发panic的值,一般是个string。recover函数的返回值是interface,所以它不能直接打印,需要使用类型断言,如果断言成功,则打印出panic的值,否则打印出panic未知类型。

3.4 rust

rust中和golang的情况是类似的,一般执行过程中通过Result将错误返回,开发者自行处理,而对于没有办法预测的错误,会产生panic,同样可以用宏panic!自己产生错误,rustpanic会导致线程结束,但是不会导致进程崩溃。

use std::thread;

fn main() {   
    // 创建一个新线程
    let handle = thread::spawn(|| {
        panic!("子线程 panic");
    });

    // 等待子线程完成
    handle.join().unwrap_or(());

    println!("程序完成");
}

这段代码运行结果,会打印程序完成,即join函数unwrap会失败,但是unwrap_or处理了这个错误,不会导致主线程崩溃,也不会导致进程崩溃。

img

我们把unwrap_or修改一下,会看到主线程join能捕捉到子线程的panic

    match handle.join() {
        Ok(_) => println!("子线程完成"),
        Err(e) => println!("子线程 panic: {:?}", e),
    }

img

这里我们不展开讨论协程的库。

4 回到最初jni的场景

我们再回头思考,jni的场景下,在c中发生了除零的错误,接下来其实按照c语言的逻辑是会向进程的当前线程发送一个SIGFPE信号的,如果是段错误则是SIGSEGV,那是不是我在java中捕捉这个信号,就可以避免进程崩溃呢?如果是除零异常是不可以的,会在new Signal的时候就报错,因为这个信号已经被JVM内置处理,并且不允许用户处理了,但是我们自己jni函数中的信号,jvm又无法正常处理,导致了进程崩溃.

Demo.java
import sun.misc.Signal;
public class Demo {
    public static native int divide(int a, int b);
    static {
        // System.load("C:\\Users\\sunwu\\Desktop\\base\\gateway\\notebook\\24.12\\demo\\target\\release\\demo.dll");
        System.load("C:\\Users\\sunwu\\Desktop\\base\\gateway\\notebook\\24.12\\libDemo.dll");
    }
    public static void main(String[] args) {
        try {
            Signal.handle(new Signal("FPE"), sig -> System.out.println(sig));
            System.out.println(divide(10, 2));
            System.out.println(divide(10, 0));
        } catch (Throwable t) {
            System.out.println("Error " + t);
        }
    } 
}
// $ java Demo
// Error java.lang.IllegalArgumentException: Signal already used by VM or OS: SIGFPE

我们修改c代码,把signal的处理放到c的代码里是否可以呢?

Demo.c
#include <jni.h>
#include <stdio.h>
#include <setjmp.h>
#include <signal.h>
#include <pthread.h>
#include <unistd.h>

jmp_buf global_exception;
struct ErrorContext {
    int code;
    char msg[256];
};

void signal_handler(int signum) {
    longjmp(global_exception, signum);
}
JNIEXPORT jint JNICALL Java_Demo_divide
  (JNIEnv *env, jclass clz, jint a, jint b) {
    signal(SIGFPE, signal_handler);
    struct ErrorContext ctx = {0};
    // 设置异常捕获点
    int exception = setjmp(global_exception);
    if (exception == 0) {
        return a /b;
    } else {
        ctx.code = exception;
        snprintf(ctx.msg, sizeof(ctx.msg), 
                 "Caught signal: %d", exception);
        
        fprintf(stderr, "Error: %s\n", ctx.msg);
        return 0;
    }
}

答案是不行,c代码中的signal_handler函数并没有触发,猜测还是jvm做了一些提前的捕捉,但是又不能正常处理,导致了致命错误,这发生在信号能到达这个线程之前,虚拟机就崩溃了。

img

如果换成SEGV呢?结果是一样的,这里就不再贴图了。另外即使,java中能够捕捉这个信号,其实和没办法和出现错误的线程和代码对应起来,所以还是要写c的jni函数的时候,自己再代码中检查好,越界、除零、空指针等等的问题。

如果是rust呢?因为rustpanic上面例子中能看出,他并没有导致进程崩溃,但是用rust写的jni函数panic却能导致jvm进程退出。我们在第一个章节用了panic::catch_unwind来捕捉了所有的panic,如果不进行捕捉,直接return a/b;除以0的时候,会出现panic并导致进程的崩溃,如下。

img

虽然,纯rust代码中panic不会导致进程崩溃,但是jvm环境变得复杂,panic传递到栈的最上层,就到了jvm层了,这时候是无法处理panic,进而导致整个jvm挂掉了。所以如果用rustnative程序,建议套一层panic::catch_unwind

{
    let res = panic::catch_unwind(|| {
        return a / b; // 把要执行的逻辑放到这里。
    });

    match res {
        Ok(x) => x,
        Err(_) => {
            env.throw_new("java/lang/RuntimeException", "Error when invoke native method").unwrap();
            return 0;
        },
    }
}

当然这里还会遇到一个问题,就是如果要执行的逻辑中,需要用到env也就是JNIEnv这个变量,并且是需要用可变引用的话,这里会报错的。

img

因为可变引用在发生panic的时候,可能导致这个变量发生部分数据的变化,而另一部分没来得及变化,产生不一致性,这样在堆栈展开过程中使用这个变量可能会导致更严重的错误。但是显然这里的env并不会有类似的问题,他就是个jni的环境,get_string等函数并不会导致env变量出现问题。所以我们可以使用AssertSafeUnwindwrap一下闭包,这个函数的作用就是我确认不会有安全问题绕开编译器的报错用的,如下框架可以作为一个rustjni代码的一个代码模板。

pub extern "C" fn Java_Demo_toLowercase(mut env: JNIEnv, _class: JClass, s: JString) -> jstring {
    // panic时不打印stderr
    panic::set_hook(Box::new(|_| {}));
    // 把所有的逻辑放到catch_unwind中,类似于一个try操作
    let res = panic::catch_unwind(AssertUnwindSafe(|| {
        let result = env.get_string(&s).unwrap().to_str().unwrap().to_lowercase();
        let result = env.new_string(result).expect("Couldn't create java string");
        result.into_raw()
    }));
    // 处理结果,处理异常,类似于一个catch操作
    match res {
        Ok(x) => x,
        Err(_) => {
            let msg = "native method error. ".to_string();
            if let Some(s) = err.downcast_ref::<&str>() {
                msg.push_str(s);
            } else if let Some(s) = err.downcast_ref::<String>() {
                msg.push_str(s);
            }
            // 调用throw_new方法给jvm上下文抛出一个运行时异常。
            env.throw_new("java/lang/RuntimeException", msg).unwrap();
            return std::ptr::null_mut();
        },
    }
}

例如发生越界异常的时候,jvm能捕捉到一个运行时异常:

img