kotlin协程硬核解读(5. Java异常本质&协程异常传播取消和异常处理机制)

Posted open-Xu

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了kotlin协程硬核解读(5. Java异常本质&协程异常传播取消和异常处理机制)相关的知识,希望对你有一定的参考价值。

版权声明:本文为openXu原创文章【openXu的博客】,未经博主允许不得以任何形式转载

本文承接《kotlin协程硬核解读(2. 协程基础使用&源码浅析)》中的第8章节

1. 异常的本质

平时在开发中当程序throw抛出了一个异常对象而没有被try-catch处理掉的话,会在控制台看到一大串红色的异常堆栈信息,然后程序就崩溃了,这是大家的噩梦。大家有没有想过抛出异常为什么会导致程序崩溃?控制台打印红色异常信息和程序崩溃的根源是什么?想要搞懂这些问题,先要简单了解一下JVM、java程序、进程、线程之间的关系。

1.1 操作系统、程序、JVM、进程、线程

操作系统

我们平时使用的电脑、ipad、手机等各种智能设备在关机的情况下,它们就是一堆塑料废铜烂铁,我们把着对废铜烂铁称为硬件,它们是看得见摸得着的内存条、硬盘、鼠标、键盘、摄像头等等,只有开机后运行了各种应用程序(应用程序就是软件,软件就是应用程序)后才智能。应用程序为用户提供了各种功能,但对于这堆硬件来说就是去操纵这些硬件来为用户提供功能。比如当我看到了美好的风景想拍一张照片,我拿出手机打开相机按下快门就可以记录下美好的画面,对于手机硬件来说,相机程序就是操纵了手机的摄像头。作为普通开发人员开发的各种应用程序是不可以直接操纵这些硬件的,只有操作系统(operation system,简称OS)可以管理和操纵硬件。操作系统是管理计算机硬件、运行与管理各种软件(应用程序)的计算机程序。它内嵌了各种驱动程序来操纵硬件设备,并提供了接口库供普通开发人员调用开发出各种应用程序,这些应用程序就是间接的通过操作系统提供的api来操纵硬件的。操作系统提供了管理配置内存、决定系统资源供需优先次序、控制输出输出设备、操作网络和管理文件系统等基本功能,并提供了可以让用户和系统交互的操作界面。为什么会出现各种不同的操作系统呢(Windows、Linux、Unix、macOS、iosandroid)?一款苹果手机、华为手机、小米手机它们在某些硬件上可能使用的是同一个品牌甚至同一个性号的硬件(比如都使用了索尼的某个型号的摄像头),但是却运行着不同的操作系统。出现不同操作系统的原因除了满足不同计算机终端(电脑和手机)需求,终极原因就是:“我觉得你开发的这个操作系统程序不行呐,不能充分的利用管理硬件和其他软件”。我们经常会听到别人对比ios和android系统的手机:“苹果就是比小米流畅”,其实就是在比较操作系统。虽然苹果公司和Google公司没有在公开场合打嘴仗,但是他们各自就是觉得自家的操作系统更好,不好的地方不说而是不停的去优化系统程序做到超越对方后再在发布会自诩一番。

程序

程序是可执行的静态可执行文件,程序就是软件,软件就是程序,软件是相对于硬件来说的。操作系统就是一个程序,普通开发人员开发的各种应用程序也是程序,我们编写的一个带main函数的java文件编译后也是一个程序。应用程序和操作系统的区别在于应用两个字上,应用程序偏向为普通用户提供应用功能满足用户使用需求,而操作系统更多是面向开发人员。普通开发者基于不同操作系统提供的api开发出各种应用程序并运行在对应的操作系统上,由于不同操作系统使用不同的语言编写并提供不同的api接口,所以基于windows开发的应用程序不能运行在Linux系统上。

JVM

程序是用编程语言编写的,不同操作系统可能需要使用不同的语言和不同的系统api来编写。老板让我开发一款windows上的抢票软件,费了九牛二虎之力写完了,这时候老板说你再写一个linux上的吧,我怼他:你是故意整劳资?linux用户安装一个虚拟机装上windows系统不就可以安装软件抢票了。老板二话不说给了我一大逼斗,你知道一个大逼斗对一个二十几岁的孩子会造成多大的心理伤害吗?Sun公司知道后投入了大量人力物力开发了一种可以跨平台运行的Java语言,然后安慰我说:宝宝不哭,以后你用Java语言开发程序就可以运行在不同的操作系统上了。并不是Java语言本身具有跨平台的能力,而是Sun公司提出了Java虚拟机(JVM)的概念和规范,并基于这个规范为不同的操作系统实现了虚拟机程序(比如windows上的java.exe)。虚拟机也是一个程序,它运行在各种操作系统之上,同时它又类似与一个虚拟的小操作系统(操作系统有本地方法栈和堆,jvm也有栈、堆、方法区等),可以运行和管理使用Java语言编写的程序,不管你是什么操作系统,只要安装了java虚拟机,就可以运行Java程序,这就实现了Java程序的跨平台运行,JVM屏蔽了不同操作系统的差异性,兼容这些差异性的工作(针对不同操作系统开发的虚拟机程序)都由Sun公司完成了。

进程

当我们使用java命令运行一个java程序时,首先会启动jvm虚拟机程序,这时候操作系统就会启动一个进程来运行jvm程序。一个程序是一个静态的可执行的文件,而一个进程则是一个动态执行中的程序实例,多次使用java命令就会开启多个进程运行多个虚拟机程序实例。jvm程序被启动后就会开启一个线程去运行main()方法,这个线程称为主线程,只要main()方法执行完毕,主线程就没有可运行的字节码了,jvm进程也随即退出(即使存在其他线程还在执行)。java程序是运行在jvm实例之上的,一个jvm实例就是一个正在运行jvm程序的进程

线程

线程(Thread)是操作系统进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。现在的电脑或者手机都是多核CPU,就是为了同时并发的执行多个线程从而提高程序的执行效率。每个进程至少会运行一个线程,这个线程会找到程序的main()方法并执行,我们可以在main方法中开启多个线程来并发的运行不同的代码分支。多线程间共享堆内存和方法区内存,但使用各自的栈内存。需要注意的是我们平时说的线程都是指Java中的Thread类,它是线程这个概念在java中的实现,Thread类的实例对象是运行在jvm进程之上的。其他的编程语言中也会有线程的概念,其实现的名称可能是亦或不是Thread,与java中的Thread只是概念相同但是被运行在不同的环境中。

1.2 异常方法调用栈

jvm就是一个可以运行在各种操作系统上的程序,jvm程序在运行后会仿照操作系统开辟各种内存区,用于运行和管理普通java程序,所以我们可以将jvm看作是一个简单的操作系统,线程就是运行在jvm进程实例之上的。主线程是为了运行java程序的main()函数,在主线程上开启的其他子线程是为了运行Threadrun()方法。一个线程的退出只有一种可能那就是它的工作完成了,对于主线程来说就是main()函数执行完毕,对于字线程来说就是run()方法执行完毕,当然对于子线程还有一种特殊情况,当main()函数执行完毕主线程退出会导致jvm进程退出,如果子线程还没有执行完毕也会被强制退出,因为线程是运行在进程之上的,jvm进程都退出了还怎么运行线程?

代码中通过throw抛出一个异常对象(Throwable及子类对象)会在控制台打印异常堆栈信息,并导致对应的线程崩溃,其实这个崩溃的原理就是线程的工作完成了(main()或者run()执行完毕)。throw是java中的关键字,它被编译为class后就是一个特殊的jvm指令,当线程执行到throw指令,就会终止抛异常的方法执行,并尝试将异常对象传递(抛)给调用它的外层方法,就像break会跳出循环、continue会跳出本次循环一样。根据方法调用栈一层一层往外抛的过程就有了异常堆栈信息。在将异常对象往外层方法抛的过程中,如果在某一层使用try-catch(一定要catch处理异常否则还会往外抛)捕获处理了异常,异常对象就停止在被捕获的那一层不再继续往外抛了;如果调用栈的所有方法中都没有捕获处理异常,这个异常对象最终会抛给main()或者run()方法,使main()或者run()方法跳出终止,这两个方法终止就是执行完毕会导致对应的线程退出。

// ★ 示例1:异常未捕获导致main()立刻终止执行,jvm退出
fun main()
    a()
    //c方法抛出异常在调用栈中没有被try-catch,会使main()终止执行,sleep()不会被执行
    Thread.sleep(1000000000)  

fun a() b() 
fun b() c() 
fun c() throw Exception("c抛异常") 
运行结果:
	//根线程组ThreadGroup默认将异常堆栈信息打印到控制台
	Exception in thread "main" java.lang.Exception: c抛异常
	at runable.ZhengtiKt.c(zhengti.kt:37)
	at runable.ZhengtiKt.b(zhengti.kt:35)
	at runable.ZhengtiKt.a(zhengti.kt:31)
	at runable.ZhengtiKt.main(zhengti.kt:21)
	at runable.ZhengtiKt.main(zhengti.kt)
	Process finished with exit code 1   //异常导致main()函数跳出运行,主线程执行完毕导致jvm退出


// ★ 示例2:异常在调用栈中被try-catch捕获处理
fun main()
    a()     //a()中try-catch处理了异常,异常对象不会抛到main()函数中,不会导致main()终止
    Thread.sleep(1000000000)  //jvm延迟退出

fun a()
    try   //在异常调用栈中的某一环使用try-catch捕获并处理异常
        b()
    catch (e:Exception)
        println("a()捕获了c()抛出的异常:$e.message")
    

fun b() c() 
fun c() throw Exception("c抛异常") 
运行结果:
    a()捕获了c()抛出的异常:c抛异常

1.3 java异常处理机制

如果异常调用方法栈中都没有try-catch处理异常,在线程退出之前JVM会调用当前线程ThreaddispatchUncaughtException(Throwable e)方法并将异常对象传递给它,这个方法的作用是分发当前线程上未被捕获处理的异常,也就是将异常对象交给线程自己处理掉。Thread类中有3个Thread.UncaughtExceptionHandler类型的成员变量uncaughtExceptionHandlergroupdefaultUncaughtExceptionHandler,异常对象最终会交给它们中的一个来处理,具体交给哪一个我们通过源码来一探究竟:

//线程类
public class Thread implements Runnable 
    //未捕获的异常处理器 接口
    public interface UncaughtExceptionHandler 
        void uncaughtException(Thread t, Throwable e);
    

    // ①. 当前线程异常处理器,如果不显式设置默认是null
    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;
    // ②. jvm进程上所有线程的(static)默认异常处理器,如果不显式设置默认是null
    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;
    // ③. 线程组,UncaughtExceptionHandler的子类对象
    private ThreadGroup group;
    
    //1. 当线程throw了异常没有被try-catch处理时,jvm就会调用此方法分发处理这个未捕获的异常对象,此方法只会被jvm调用
    private void dispatchUncaughtException(Throwable e) 
        //2. 获取异常处理器对象,并调用它的uncaughtException()处理异常
    	getUncaughtExceptionHandler().uncaughtException(this, e);
	
    public UncaughtExceptionHandler getUncaughtExceptionHandler() 
        //★3. 如果当前线程设置了异常处理器则返回,否则返回线程组group对象
        return uncaughtExceptionHandler != null ?
            uncaughtExceptionHandler : group;
    


//线程组表示一组线程,线程组还可以包括其他线程组形成一个树
public class ThreadGroup implements Thread.UncaughtExceptionHandler 
    private final ThreadGroup parent;   //父线程组
    ThreadGroup groups[]; //子线程组
    Thread threads[];     //当前线程组的线程
    //线程组处理未捕获的异常
	public void uncaughtException(Thread t, Throwable e) 
        if (parent != null)  
             //4. 如果存在父线程组,交给父线程组处理
            parent.uncaughtException(t, e);
         else 
            //★5. 如果设置defaultUncaughtExceptionHandler默认异常处理器则交给它处理
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) 
                ueh.uncaughtException(t, e);
             else if (!(e instanceof ThreadDeath)) 
                //★6. 没有设置任何异常处理器,则默认由根线程组将线程异常堆栈信息输出到控制台
                System.err.print("Exception in thread \\""
                                 + t.getName() + "\\" ");
                e.printStackTrace(System.err);
            
        
    

根据源码可以看出,当线程执行到throw指令后,异常对象最终会被下面某一环处理掉:

  • 异常方法调用栈中的try-catch处理
  • 否则,交给异常对应线程的异常处理器uncaughtExceptionHandler处理(默认为null)
  • 否则,交给根线程组ThreadGroup处理,根线程组又会委托线程默认异常处理器defaultUncaughtExceptionHandler处理(默认为null)
  • 否则,根线程组将异常堆栈信息输出到控制台

1.4 小结

  • 方法throw异常会终止方法的执行,如果方法调用栈中使用了try-catch处理了异常,异常对象会停止在被处理的地方不再往外层抛;如果调用栈中没有try-catch,则会终止线程对应的main()或者run()方法使线程退出执行,同时将异常对象交给线程的某个未捕获异常处理器处理
  • 线程throw的异常只能在对应线程的异常调用栈上被try-catch,线程无法捕获子线程中抛出的异常
  • 线程throw了异常如果没被try-catch处理,只会使对应线程退出执行,并不会影响其他线程;但是当主线程退出时jvm进程会退出导致其他子线程被迫退出。
  • 线程因为异常”崩溃“退出并不是通过某种方式将线程杀死,而是线程执行到throw指令退出了对应方法的执行从而使线程任务结束而退出。与正常的将main()或者run()方法执行完而退出,前者会在控制台打印红色的异常堆栈信息,对于开发者来说看到红色日志并且代码没有按照预期的步骤执行完毕所以就出现了“崩溃”这个词,其实崩溃的并不是线程而是开发者自己

2. Android异常处理机制

最初Android是使用java语言进行开发的,后来出现了kotlin,其实不管是java还是kotlin最终都会被编译为dex(可看作是很多class的压缩包)运行在dalvik或者art虚拟机上。Android系统内核是基于Linux实现的,可以将Android操作系统看作是一个小型的Linux系统,而dalvik或者art虚拟机都是JVM在Android平台的虚拟机实现,dalvik和art是运行在Android操作系统上的jvm实例,每启动一个android app就会启动一个jvm实例进程,然后让app运行在jvm进程中。不同的jvm实现对异常的处理机制有细微的差别,比如使用普通的java编写一个main()函数,在main()函数中开启一个子线程,如果子线程抛出异常,并不会导致main()函数所在的主线程因为异常退出(jvm不会退出);但是在Android中,子线程抛出异常会导致整个应用程序进程退出,也就是jvm进程退出,根本原因就是Android重写了java.lang.Thread类,修改了dispatchUncaughtException()方法,当线程出现未捕获的异常时,首先会将异常对象交给异常预处理器将异常堆栈信息打印到logcat,然后再走原java的异常处理逻辑,而RuntimeInit中为线程设置了默认异常处理器,该处理器会杀死应用进程

# 源码路径 sdk\\platforms\\android-xx\\android.jar\\ java.lang.Thread
//1. android上的线程类
public class Thread implements Runnable 
    //在原有3个未捕获异常处理器的基础上增加了uncaughtExceptionPreHandler异常预处理器(注意是static的所有线程共用)
    //如果没有显式设置默认为null,但是当app启动时framwork层设置了异常预处理器
    private static volatile UncaughtExceptionHandler uncaughtExceptionPreHandler;
    
    //dalvik或者art虚拟机会调用此方法分发未捕获的异常**
    public final void dispatchUncaughtException(Throwable e) 
        //★ 首先将异常对象交给异常预处理器处理(打印异常堆栈信息到logcat)
        Thread.UncaughtExceptionHandler initialUeh = Thread.getUncaughtExceptionPreHandler();
        if (initialUeh != null) 
            try 
                initialUeh.uncaughtException(this, e);
             catch (RuntimeException | Error ignored) 
            
        
        //★ 然后调用原java的异常处理逻辑(设置的默认异常处理器会杀死应用进程)
        getUncaughtExceptionHandler().uncaughtException(this, e);
    


//2. RuntimeInit是hide隐藏api,不能直接通过Android studio查看源码,但可以在sdk源码包中找到对应文件
package com.android.internal.os;
//@hide 运行时初始化的主要入口点
public class RuntimeInit 
    //在logcat中打印的android异常堆栈信息都会显示这个tag
    final static String TAG = "AndroidRuntime"; 
    //应用程序启动时会调用此函数初始化Android运行时
    public static final void main(String[] argv) 
        commonInit();
        ...
    
    protected static final void commonInit() 
        if (DEBUG) Slog.d(TAG, "Entered RuntimeInit!");
        LoggingHandler loggingHandler = new LoggingHandler();
        //★ 设置异常预处理器,主要负责打印android程序的异常堆栈信息
        Thread.setUncaughtExceptionPreHandler(loggingHandler);
        //★ 为所有线程设置默认异常处理器,用于杀死应用进程
        Thread.setDefaultUncaughtExceptionHandler(new KillApplicationHandler(loggingHandler));
	   ...
    
    
    //异常预处理器用于在应用程序被杀死之前将异常堆栈信息打印到控制台
    private static class LoggingHandler implements Thread.UncaughtExceptionHandler 
        @Override
        public void uncaughtException(Thread t, Throwable e) 
            ...
            StringBuilder message = new StringBuilder();
            message.append("FATAL EXCEPTION: ").append(t.getName()).append("\\n");
            final String processName = ActivityThread.currentProcessName();
            ...
            message.append("PID: ").append(Process.myPid());
            //★ 将异常堆栈信息打印到logcat
            Clog_e(TAG, message.toString(), e);
        
    
    
    //android默认的异常处理器,当未捕获的异常对象交给它处理最终会在finally中杀死应用进程
    private static class KillApplicationHandler implements Thread.UncaughtExceptionHandler 
        @Override
        public void uncaughtException(Thread t, Throwable e) 
            try 
                ...
             catch (Throwable t2) 
            	...
             finally 
                //★ 杀死应用进程,退出应用
                Process.killProcess(Process.myPid());
                System.exit(10);
            
        
    

Android上的异常处理机制如下:

  • 异常方法调用栈中的try-catch处理
  • 否则,交给android异常预处理器uncaughtExceptionPreHandler(LoggingHandler类型)将异常堆栈信息输出到logcat
  • 然后将异常对象交给对应线程的异常处理器uncaughtExceptionHandler处理(默认为null)
  • 否则,交给所有线程默认异常处理器defaultUncaughtExceptionHandler(KillApplicationHandler类型)杀死应用进程

android提示:应用程序中可以重新设置默认异常处理器defaultUncaughtExceptionHandler,但不要重置异常预处理器uncaughtExceptionPreHandler。下图是Android异常的处理机制,黄色部分是与java异常机制的区别之处:

Android应用程序进程的主线程就是执行ActivityThread.main()方法的线程,main()中通过Looper.loop()启动了一个死循环来轮询Handler消息,使得jvm进程不会因为main()执行完毕而退出。当主线程或者子线程抛出异常而未被try-catch时,异常默认最终会传递给KillApplicationHandler,通过Process.killProcess(Process.myPid())杀死应用进程,jvm进程都被杀死了,运行在它上面的主线程、子线程也就都被杀死了。这就Android相对于普通java程序在异常处理机制方面的变化。

3. 协程异常

3.1 挂起函数的异常

interface Callback 
    fun onSuccess(value: Int)

fun getResult(callback: Callback)
    Thread   //函数中开启子线程
        throw Exception("抛异常了")  //子线程中抛出异常
        callback.onSuccess(1)
    .start()

fun main()
    try  //主线程试图捕获getResult()中抛出的异常,但是throw是在子线程发生的,主线程无法捕获子线程抛出的异常
        getResult(object :Callback
            override fun onSuccess(value: Int) 
                println("函数返回结果$value")
            
        )
    catch (e:Exception)
        println("捕获异常,避免程序意外退出 $e")
    
    Thread.sleep(10000)  //主线程延迟10s退出

运行结果:
//根ThreadGroup将异常堆栈信息输出到控制台
Exception in thread "Thread-0" java.lang.Exception: 抛异常了   
	at runable.ZhengtiKt$getResult$1.run(zhengti.kt:25)
	at java.lang.Thread.run(Thread.java:748)
//子线程抛出的异常虽然没有捕获处理,但并不影响主线程,主线程会在10s之后才退出
Process finished with exit code 0 

上面的示例中getResult()异步函数通过CallBack回调函数计算结果,但是在回调之前throw了一个异常,会导致子线程的run()方法立即终止运行,虽然主线程中的try-catch没有捕获到子线程抛出的异常,但并没有导致主线程main()函数也提前退出。现在的问题是,我希望主线程知道哪个函数的子线程抛了什么异常然后给用户相应的提示,这时候大家可能会想到通过Thread.setDefaultUncaughtExceptionHandler()设置默认异常处理器接受处理所有未捕获的异常:

fun main()
    //设置默认未捕获异常处理器
    Thread.setDefaultUncaughtExceptionHandler t: Thread, e: Throwable ->
        println("默认异常处理器捕获线程 '$t.name' 抛出的异常 '$e'")
    
    getResult(object :Callback
        override fun onSuccess(value: Int) 
            println("函数返回结果$value")
        
    )
    Thread.sleep(10000)

运行结果:
默认异常处理器捕获线程 'Thread-0' 抛出的异常 'java.lang.Exception: 抛异常了'
Process finished with exit code 0  //主线程10s后退出

这确实可以实现捕获所有的异常,但并不能满足开发中的需求。比如我希望在getResult()函数子线程抛异常后提示用户:获取数据失败;在另一个函数getUserInfo()子线程抛出异常后提示用户:获取用户信息失败。默认异常处理器不能对抛异常的函数进行区分,通过它只能知道是哪个线程抛出了什么类型的异常对象,在Android中设置默认异常处理器通常是为了跟踪异常解决bug,或者修改Android异常处理机制避免app因为未捕获异常而被杀死。要实现对每个方法子线程抛出的异常进行精确处理和提示,只能在每个子线程中通过try-catch捕获处理异常,然后通过回调的方式将异常对象回调到主线程(注意:回调方法也是在子线程,需要手动切换到主线程,回调的目的只是让主线程有机会拿到子线程抛出的异常对象):

interface Callback 
    fun onSuccess(value: Int)
    fun onError(t: Throwable)   //★ 增加一个异常回调

fun getResult(callback: Callback)
    Thread
        try   //子线程中try-catch捕获处理异常
            throw Exception("抛异常了")  
        catch (e:Exception)
            callback.onError(e)     //★ 通过回调将异常对象返回
        
    .start()


fun main()
    getResult(object :Callback
        override fun onSuccess(value: Int) 
        override fun onError(t: Throwable) 
            //★ 异常对象被回调到这里,但是这里的代码还是在子线程中,如果在android中还需切到主线程后Toast
            println("$Thread.currentThread() 子线程抛出异常被回调到这里,提示用户 $t")
        
    )
    Thread.sleep(10000)

协程中的续体从某种意义上来说就是一个CallBack,通过续体的resume系列方法来回调函数的结果值或者异常给协程:

//将getResult()定义为挂起函数
suspend fun getResult() = suspendCancellableCoroutine<Int> 
    Thread
        it.resumeWithException(Exception("抛异常了"))  //用一个异常对象恢复协程执行(协程回调)
    .start()

fun main()
    runBlocking 
        try kotlin协程硬核解读(5. Java异常本质&协程异常传播取消和异常处理机制)

kotlin协程硬核解读(5. Java异常本质&协程异常传播取消和异常处理机制)

kotlin协程硬核解读(1. 协程初体验)

kotlin协程硬核解读(1. 协程初体验)

kotlin协程硬核解读(4. 协程的创建和启动流程分析)

kotlin协程硬核解读(4. 协程的创建和启动流程分析)