1、简述
在日常开发中,崩溃日志的捕获,至关重要。有一个好的日志,有利于开发者快速定位问题并解决。
对于Android平台,我们可以使用现成的产品来捕获崩溃日志,这些产品包括Bugly、Firebase、友盟等,这些产品经过多年的迭代,对于日志捕获得比较全,也有很好的兼容性。
但是作为开发者,我们不能仅仅满足于使用,最好还是知道其中背后的原理。要知道原理,可以在网络上搜索崩溃捕获的相关文章,虽然网络上的文章绝大部分都是相互转载的,但是我们还是能从其中捋出一个大概的崩溃捕获的原理。注意,但是来了,但是我们仅仅通过读他人的文章来了解原理,而不是自己动手实现一遍,和纸上谈兵并无太大差异。
目前的网络上,讲解崩溃原理捕获的文章不少,但是这些文章仅仅是讲大致的原理,对于设计到的代码也只是仅仅展示片段,完全找不到一个能运行起来的并能捕获到Native崩溃栈的Demo。这就导致我们看文章理解到的原理不是串联起来的,是断断续续的。基于这个状况,我会写篇文章,首先主要讲解奔溃栈捕获的相关原理,然后主要是分析崩溃捕获的代码实现,将原理落在实处,在文章的末尾会提供我实现的能运行的并且能捕获到崩溃日志的Demo工程,希望能帮助大家对崩溃捕获原理有一个连贯认识。
接下来就先讲讲崩溃捕获的相关原理,先分析Java层的崩溃捕获,再分析Native层的崩溃捕获原理,有不对的地方,望不吝指正。
2、Java层崩溃捕获
2、1 异常发生时方法调用栈的捕获
在Java层的奔溃日志捕获比较简单,接下来看看如何操作。
首先设置默认的未捕获异常处理器:
1 Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() { 2 @Override 3 public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { 4 StringBuilder builder = new StringBuilder(); 5 for (StackTraceElement stackTraceElement : e.getStackTrace()) { 6 builder.append("at ") 7 .append(stackTraceElement.getClassName()) 8 .append(".") 9 .append(stackTraceElement.getMethodName()) 10 .append("(") 11 .append(stackTraceElement.getFileName()).append(":").append(stackTraceElement.getLineNumber()) 12 .append(")") 13 .append("\n"); 14 } 15 Log.e(TAG, "堆栈:\n 线程:" + t.getName() + " 原因" + e.getMessage() + builder.toString()); 16 } 17 })
在上面的代码中,调用Thread的静态方法,设置了默认的未捕获异常处理程序。当一个线程发生未捕获的异常,并且没有设置过异常处理器,那么就会调用这里设置的默认的异常处理程序。
然后写一段触发崩溃的代码并调用,代码如下:
1 private void crash() { 2 int n = 100; 3 System.out.println(n / 0); 4 }
在上面的代码中,会产生异常的地方就是使用100除以了0。当代码执行到这里,会在控制台看到我们捕获并打印出来的方法调用栈:
<br/>可以看到,当异常发生的时候,我们的确是捕获到了异常发生线程的方法调用栈,然后很快就可以定位到出问题的代码。
上面介绍了当Java层崩溃日志的捕获,接下来作为补充介绍一下Java的异常机制。
2、2 Java异常机制
在目前网络上的文章,对于Java异常这一块,大部分的都是简单的讲讲Java中异常的分类,有哪些异常等。这里现提出几个问题,作为这一小节的大纲,下面会对每一个问题进行分析:
- Java中异常存在的意义,也就是为什么要设计异常体系。
- 对于不同的异常应该怎么处理?是全部
catch就好了吗? - 日常开发中如何使用异常?
- 当异常发生后JVM怎么处理的?
下面将一一分析。
1、Java中异常存在的意义
首先,我将通过两个简单程序,一个是有异常机制的Java程序,一个是没有异常机制的C程序,分别看看对于程序中的非正常情况(异常情况)都是怎么处理的。
这里以打开文件并且文件不存在的情况看看两种语言的处理:
Java程序:
1 void main() { 2 File f = new File("文件路劲"); 3 try { 4 FileOutputStream fos = new FileOutputStream(f); 5 //文件打开成功,继续执行后续逻辑 6 ... 7 } catch (FileNotFoundException e) { 8 //文件打开失败,异常处理 9 } 10 }
C程序:
1void main() { 2 FILE *f = fopen("文件路劲", "rb"); 3 if (f == NULL) { 4 //文件打开失败,处理文件打开失败的情况 5 //这里输出错误的原因 6 printf("%s\n", strerror(errno)); 7 } else { 8 //这里文件打开成功,继续执行 9 ... 10 } 11}
通过对比可以看到,Java语言对于文件不存在的异常,在编码期间编译器就要求处理这种情况,不然编译不通过,而C语言则不会强制要求,通过库函数的返回值来判断文件打开是否异常。Java语言将异常的情况的处理代码放在了catch块中,和正常的逻辑的程序代码分开了,而C语言中是正常逻辑代码和异常情况代码是混在一起的。
C是一门伟大的语言,咱得客观看待不同语言的优劣,语言只是工具。
通过上面简单的对比,这里总结一下Java异常机制的意义,就会有一个比较直观的感受。
总的来说,Java异常机制能增强程序的稳定性,具体些,使用异常机制能:
- 将正常做事的代码和出了问题处理的代码分离,便于阅读和测试等;
- 要求检查和处理异常,增强了程序稳定性;
- 降低了开发者对库函数的了解要求;
- 异常一般携带方法调用栈,便于调试;
- ...
Java异常存在的意义大致分析完了,接下来分析Java对于不同的异常应该怎么处理。
2、对于不同的异常应该怎么处理
要知道对应不同的异常应该怎么处理,那么首先就得知道有哪异常,并且这些异常JDK设计出来是为了什么。首先来看看Java异常类的继承关系图,对异常的体系有一个整体的认知。
<br/>这里讲的异常不仅仅指
Exception及其子类,还包括Error。
可以看到,我们平常泛指的异常其实有两个直接子类:Error(错误)和Exception(异常)。
Error(错误): 当错误发生时,表示程序遇到了灾难性的错误,是程序无法控制和处理的问题。合理的程序是不应该捕获和处理错误的。
Exception(异常): 它是指阻止当前方法或作用域继续执行的问题(----Think in java),其实白话一些的说,那就是当前程序出问题了,无法正常的运行下去了。
对于Exception类型的异常,程序是可以处理的,我们应当尽量避免或尽量处理这些异常,这句话看起来矛盾,接下来会有说明。Exception下有一个直接子类RuntimeException,这个子类下面的异常都是非检查异常,这类异常程序员往往都是能够预见或避免的,这类异常发生,往往是代码逻辑存在问题,更多的是应该排查代码的逻辑,而不是依赖于try catch来保证程序的运行。对于不能够预见或避免除外,这类使用catch处理,比如NumberFormatException;Exception下除了RuntimeException的其他子类几乎都是受检查异常,这些异常程序员一般是不能预见和避免的,这类异常一般和代码逻辑无关,我们只有尽量的去检查并做好异常处理。
很好区分受检查异常和非检查异常,只要写代码阶段,编译器制要求我们处理的异常就是检查异常,不要求的就是非检查异常。
上面理论讲得很空泛,下面将针对每个问题用一个实际的情况来充实一下内容。
内存溢出错误(OutOfMemoryError),它是Error的子类,当Java虚拟机由于内存不足而无法分配对象,并且垃圾回收器无法再腾出更多内存时,就会抛出该错误。对于Android程序来说,出现这个错误的原因可能很多,比如内存逐渐泄漏导致堆空间逐渐减小;比如加载过大数据,像我14年工作的时候,那时手机内存相对于现在比较小,加载图片的时候就容易出现内存溢出的错误;等等。
当出现这个错误的时候,尽管我们程序catch了它,还是会崩溃的。所以我们不应该去捕获和处理错误,而是应该排查和处理程序中可能导致该错误的代码,同时检查程序运行的环境。
空指针异常(NullPointerException),这个是我们可能经常遇到的异常,它是RuntimeException的子类,也就是运行时异常(非检查异常)。这个异常时可以预见和避免的,当这个异常出现,表示程序代码有逻辑上的错误,应该去排查代码逻辑,而不是使用try catch将可能出现空指针的代码包裹起来。
FileNotFoundException(文件找不到异常),这个异常属于检查异常,在编写文件读写代码的时候,编译器是明确要求处理该异常的,这种异常一般都是外部产生的,程序逻辑没问题。因为文件的存在与否,是和程序的运行环境有关的,程序的运行环境各种各样,这个是无法预见和避免的,所以就只有尽量的去做好异常的检查和处理。
上面讲了对于不同的异常应该怎么处理,除了处理异常,那么我们在开发中能使用异常机制吗?必须能,接下来分析日常开发中应该如何使用异常。
3、日常开发中如何使用异常
在我们日常开发中,很少会自己设计和抛出异常,几乎都是在处理异常。但是Android系统很多方法会抛出异常,JDK很多方法也会抛出异常,就说明我们这种只会处理异常,而不会使用(狭隘点可以理解为抛出)异常是不太合理的。那么在日常的开发中,应该如何较好的使用异常呢?
对于异常的如何使用,很大程度取决于开发者对异常机制的理解。下面将会阐述一下我的理解,读者可以作为一个参考。
对于这个话题,我们分两步来讨论:
- 什么情况下该抛异常?
- 应该抛出什么异常?
对于问题1,在我们编写方法的时候,首先应该明确方法的职责,如果发生了方法职责之外的情况并且不知道如何处理的时候才抛出异常。这里还需要注意的点是,不能使用异常来参与流程的控制。
下面通过两个例子来加深印象,首先看看Android 中的RemoteViews源码中的一个方法
1 private Action getActionFromParcel(Parcel parcel, int depth) { 2 int tag = parcel.readInt(); 3 switch (tag) { 4 //case ... 5 case SET_RIPPLE_DRAWABLE_COLOR_TAG: 6 return new SetRippleDrawableColor(parcel); 7 case SET_INT_TAG_TAG: 8 return new SetIntTagAction(parcel); 9 default: 10 throw new RemoteViews.ActionException("Tag " + tag + " not found"); 11 } 12 }
可以看到,这个方法对于很多的case都能处理,但当走到default流程的时候,表示这种情况不能处理,也不知道如何处理,然后就抛出了异常。
接下来再看一个Fragment中的方法:
1public class Fragment implements ComponentCallbacks2, View.OnCreateContextMenuListener { 2 //... 3 public static Fragment instantiate(Context context, String fname, @Nullable Bundle args) { 4 try { 5 Class<?> clazz = sClassMap.get(fname); 6 if (clazz == null) { 7 // Class not found in the cache, see if it's real, and try to add it 8 clazz = context.getClassLoader().loadClass(fname); 9 if (!android.app.Fragment.class.isAssignableFrom(clazz)) { 10 throw new android.app.Fragment.InstantiationException("Trying to instantiate a class " + fname 11 + " that is not a Fragment", new ClassCastException()); 12 } 13 sClassMap.put(fname, clazz); 14 } 15 android.app.Fragment f = (android.app.Fragment) clazz.getConstructor().newInstance(); 16 if (args != null) { 17 args.setClassLoader(f.getClass().getClassLoader()); 18 f.setArguments(args); 19 } 20 return f; 21 } catch (ClassNotFoundException e) { ...} 22 //一系列的catch 23 } 24 ... 25 26}
这个方法的职责,就是据传入的类名创建对应的Fragment实例,当传入的类名不是Fragment的子类类名时,方法的设计者认为该方法无法处理这种情况,就抛出了异常。
对于日常开发中,一定不要随便的抛异常,比如方法中会有一个主流程,如果遇到一些分支流程就抛出异常是不可取的。要记住,当抛出异常的时候情况已经是比较严重的时候了。
对于问题2,如果要抛异常,应该抛什么类型的异常?这个问题使用《Effective Java》的一句话来回答:
对于可恢复的情况就抛出受检查异常,对于程序错误就抛出运行时异常。
Use checked exceptions for recoverable conditions and runtime exceptions for programming errors
要能很好的使用异常机制,需要在实践中逐渐加深体会和理解。
上面介绍了日常开发中如何较好的使用异常机制的理解,接下来介绍当异常发生后JVM怎么处理的。
4、当异常发生后JVM怎么处理的
这一小节将会简单描述当异常发生后,Jvm处理异常的大致过程。
首先看一个具有try catch的简单方法:
1public class Test { 2 public void exceptionMethod() { 3 try { 4 System.out.println("try代码块"); 5 } catch (Exception e) { 6 //...异常的处理 7 e.printStackTrace(); 8 } 9 } 10 11}
接下来使用javap -c查看对应的字节码,这里只列出exceptionMethod方法对应的字节码命令:
1public class com.example.jnicalljavatest.Test { 2 //... 3 4 public void exceptionMethod(); 5 Code: 6 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 7 3: ldc #3 // String try代码块 8 5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 9 8: goto 16 10 11: astore_1 11 12: aload_1 12 13: invokevirtual #6 // Method java/lang/Exception.printStackTrace:()V 13 16: return 14 Exception table: 15 from to target type 16 0 8 11 Class java/lang/Exception 17}
可以看到,下方有一个异常表( Exception table):
from: 代表异常处理器所监控范围的起始点。
to: 代表异常处理器所监控范围的结束点(该行不被包括在监控范围内,一般是 goto 指令)
target: 指向异常处理器的起始位置,在这里就是 catch 代码块的起始位置
type: 代表异常处理器所捕获的异常类型
建议先补充字节码指令相关的知识。
如果在在字节命令的执行过程中,JVM检测到会产生异常,那么Jvm会在堆上构造一个异常对象(这个对象会有异常的原因、位置和栈帧的快照等),停止当前的执行流程,将异常对象抛出,并且异常处理机制接收代码的执行,寻找合适的异常处理程序。
当程序触发了异常,如果当前方法有异常表,那么Java 虚拟机会遍历异常表,当触发的异常在这条异常处理器的监控范围内(from 和 to),且异常类型与异常表中type一致时,Java 虚拟机就会跳转到该异常处理器的起始位置(target)开始执行字节码。如果当前方法没有异常表或者没有找到匹配的异常处理程序,那么向上去调用当前方法的方法中(弹栈处理),重复前面的查找操作。如果所有的栈帧被弹出,仍然没有被处理,则抛给当前的Thread,Thread则会终止。
到这里,Java的异常机制介绍得差不多了,接下来介绍如何捕获Native层的崩溃堆栈。
3、Native层崩溃捕获
这一节可以参考这篇文章:Android 平台 Native 代码的崩溃捕获机制及实现
我们在接下来的代码中,将会使用libunwind库来解析native层的调用栈,如何编译libunwind库可以参考我这篇文章:Mac使用NDK21编译libunwind。
4、崩溃捕获代码解释
在这一小节,主要是讲解Demo中的崩溃捕获代码,尽量争取让读者能看明白代码执行的大致流程。
demo地址:https://github.com/ITFlyPig/crash_sdk
这一小节主要分为几个步骤介绍源码:
- 崩溃捕获代码注册
- 发生崩溃时调用栈的解析
- 将Native解析到的调用栈回传到Java层
- Java层解析Java崩溃调用栈
1、崩溃捕获代码注册
1extern "C" 2JNIEXPORT jint JNICALL 3Java_com_example_crashsdk_MainActivity_initCrashSDK(JNIEnv *env, jobject thiz, jobject crash_log) { 4 //创建一个全局的引用 5 g_obj = env->NewGlobalRef(crash_log); 6 //开启子线程,目的是在Native调用Java层的方法,将Naive层的崩溃回传到Java层 7 pthread_t tid; 8 int ret = pthread_create(&tid, nullptr, dumpStack, nullptr); 9 if (ret) { 10 return ret; 11 } 12 //注册崩溃处理程序 13 ret = register_crash_handler(); 14 return ret; 15}
调用Java层的initCrashSDK方法会调到上面对应的Jni方法,在该方法中,首先创建了一个全局的jobject引用,这个对象是Java层的CrashLog的实例,目的是为了后面在新的线程中,调用该对象对应的类的方法,将Native层的崩溃日志传递到Java层。
接着开启了一个新线程阻塞等待,当堆栈解析完成之后会唤醒该线程继续执行,在该线程中将Naive层的崩溃回传到Java层。为什么要开启一个新的线程来传递Naive层的崩溃日志呢?因为在实践的时候,当前线程无法成功回调Java层的方法,原因暂时不清楚。
最后注册了崩溃处理程序,进去看看:
1//注册信号的处理 2static int register_crash_handler() { 3 //为信号处理函数注册一个栈 4 stack_t stack; 5 memset(&stack, 0, sizeof(stack)); 6 stack.ss_size = SIGSTKSZ; 7 stack.ss_sp = malloc(stack.ss_size); 8 stack.ss_flags = 0; 9 if (stack.ss_sp == NULL || sigaltstack(&stack, NULL) != 0) { 10 return ERROR; 11 } 12 //需要捕获的信号 13 //定义信号对应的处理结构体sigaction 14 struct sigaction sa; 15 memset(&sa, 0, sizeof(sa)); 16 sigemptyset(&sa.sa_mask); 17 sa.sa_flags = SA_SIGINFO | SA_ONSTACK; 18 sa.sa_sigaction = sig_handler;//注册信号处理函数 19 20 //分配保存之前的信号处理结构体的内存 21 p_sa_old = static_cast<struct sigaction *>(calloc(sizeof(struct sigaction), SIG_NUMBER_MAX)); 22 23 for (int i = 0; sig_arr[i] != 0; i++) { 24 int sig = sig_arr[i]; 25 if (sigaction(sig, &sa, &p_sa_old[sig]) != 0) { 26 LOGE("信号处理注册失败"); 27 return ERROR; 28 } 29 } 30 LOGE("信号处理注册成功"); 31 return SUCCESS; 32 33}
该方法中,首先为信号处理函数设置了额外的栈空间。在实践中,如果不设置,那么信号处理函数将会无限被调用,直至再次崩溃。
接着在for循环中,通过sigaction注册了我们需要处理的信号。
当系统发出我们注册了的感兴趣的信号量之后,信号处理函数就会被调用:
1static void sig_handler(const int code, siginfo *siginfo, void *context) { 2 3 //要返回的堆栈日志 4 char log[1024]; 5 snprintf(log, sizeof(log), "signal %d (%s)", code, sig_desc(code, siginfo->si_code)); 6 append(stack_log, log); 7 8 //开始解堆栈 9 slow_backtrace(); 10 pid_t tid = gettid(); 11 pthread_t t = pthread_self(); 12 LOGE("pid_t = %d, pthread_self = %d", tid, t); 13 //获取线程名称 14 thread_name = get_thread_name(tid); 15 16 //通知等待信号的线程 17 pthread_mutex_lock(&mtx); 18 pthread_cond_signal(&cond); 19 pthread_mutex_unlock(&mtx); 20 21 //据code找到之前的信号处理 22 struct sigaction old_sig_act = p_sa_old[code]; 23 //调用之前的处理 24 old_sig_act.sa_sigaction(code, siginfo, context); 25 26}
在该方法中主要的逻辑就是:首先通过slow_backtrace()解native层的调用栈,然后唤醒等待的线程,最后时调用之前的信号处理程序。
接下里去看看如何解native层的调用栈:
2、发生崩溃时调用栈的解析
1static int slow_backtrace() { 2 unw_context_t uc; 3 unw_getcontext (&uc); 4 5 unw_cursor_t cursor; 6 unw_word_t pc; 7 8 if (unw_init_local(&cursor, &uc) < 0) 9 return ERROR; 10 11 while (unw_step(&cursor) > 0) { 12 13 if (unw_get_reg(&cursor, UNW_REG_IP, &pc) < 0) 14 return ERROR; 15 //尝试获取动态库的信息 16 Dl_info info; 17 if (dladdr((void *) pc, &info) != 0) { 18 void *const nearest = info.dli_saddr; 19 //相对偏移地址 20 const uintptr_t addr_relative = 21 ((uintptr_t) pc - (uintptr_t) info.dli_fbase); 22 char log[1024]; 23 snprintf(log, sizeof(log), "at %s(%s)", info.dli_fname, info.dli_sname); 24 append(stack_log, log); 25// addr2line() 26 } 27 } 28 return SUCCESS; 29}
在该方法中,使用libunwind库来解native层的调用栈,每一次while循环对应解一层方法。在循环中,先通过unw_get_reg获取到寄存器pc的值,然后通过dladdr库函数,据pc值获取当前方法所在的动态库相关信息。这里获取到的信息就是native层崩溃的主要信息。
3、将Native解析到的调用栈回传到Java层
native层的崩溃信息获取到之后,会唤醒等待的线程回传到Java层,接下来去看看如何回传:
1void *dumpStack(void *argv) { 2 //将当前线程attach到虚拟机 3 JNIEnv *env; 4 if (g_jvm->AttachCurrentThread(&env, nullptr) != JNI_OK) { 5 LOGE("当前线程AttachCurrentThread失败"); 6 return nullptr; 7 } 8 9 while (!b_stop) { 10 //等待信号 11 char *stack_temp; 12 pthread_mutex_lock(&mtx); 13 pthread_cond_wait(&cond, &mtx); 14 //将全局的数据拷贝到局部变量中 15 stack_temp = static_cast<char *>(malloc(sizeof(stack_log))); 16 strcpy(stack_temp, stack_log); 17 pthread_mutex_unlock(&mtx); 18 19 //将堆栈发送到Java层 20 jclass clz = env->GetObjectClass(g_obj); 21 jmethodID jmethodId = env->GetStaticMethodID(clz, "onNativeLog", "(Ljava/lang/String;Ljava/lang/String;)V"); 22 jstring jstack = env->NewStringUTF(stack_temp); 23 24 //释放资源 25 free(stack_temp); 26 if (thread_name == nullptr) { 27 thread_name = ""; 28 } 29 env->CallStaticVoidMethod(clz, jmethodId, jstack, env->NewStringUTF(thread_name)); 30 env->DeleteLocalRef(jstack); 31 } 32}
该dumpStack方法会在新线程中执行,刚开始因为native堆栈数据没准备好,会一直阻塞等待,当native调用栈数据准备好后,会唤醒它继续执行。首先将准备好的调用栈数据拷贝到变本地量中,然后调用Java层的onNativeLog方法,将native的调用栈传到Java层,最后释放资源。
4、Java层解析Java崩溃调用栈
接着去Java层对应方法看看:
1public class CrashLog { 2 public static final String TAG = CrashLog.class.getSimpleName(); 3 4 public static void onNativeLog(String nativeLog, String threadName) { 5 if (nativeLog == null) { 6 nativeLog = ""; 7 } 8 String finalStackTrace = nativeLog + dumpJavaStack(threadName); 9 Log.e(TAG, "程序的崩溃堆栈:\n" + finalStackTrace); 10 } 11 ... 12 13}
在Java层的代码就比较简单,通过dumpJavaStack获取对应线程的方法调用栈,然后和native层传递过来的栈信息合并,最后打印出来。
4、总结
这篇文章,首先介绍了Java层异常相关知识,然后介绍了Native层堆栈捕获的原理,最后写了一个demo,将他们串联起来,能简单的捕获到崩溃时的Native层调用栈和Java层的调用栈。这样我们的知识不至于只会纸上谈兵,整个原理通过demo完全串联起来了。
最后希望读者看完本片文章能有所收获,写得不正确的地方望指正,共同进步。
