Java#异常

简介

Java语言中Throwable是所有异常的根类,Throwable 派生了两个直接子类Error 和 Exception。Error 表示应用程序本身无法克服和恢复的一种严重问题,触发Error时会终止线程甚至是虚拟机。Exception 表示程序还能够克服和恢复的问题,Exception按照处理时机可以分为编译时异常和运行时异常。

编译时异常都是可以被修复的异常,代码编译期间Java程序必须显式处理编译时异常,否则无法编译通过。运行时异常通常是软件开发人员考虑不周所导致的问题,软件使用者无法克服和恢复这种问题,但在这种问题下软件系统可能会继续运行,严重情况下软件系统才会死掉。

请添加图片描述

异常的处理

异常处理方案

Java中异常处理有两种方案,捕获处理异常和抛出异常。

对编译时异常处理方案有两种,当前方法知道如何处理该异常则捕获处理。当前方法不知道如何处理则在定义该方法时声明抛出该异常。

运行时异常只有当代码在运行时才发现的异常,编译时不需要捕获处理。如除数是0、数组下标越界等等,其产生频繁,处理麻烦,若显示声明或者捕获将会对程序的可读性和运行效率影响很大。所以由虚拟机自动检测抛出。当然也可以主动显示捕获处理。

异常相关关键字

Java的异常机制主要依赖于try、catch、finally、throw和throws五个关键字。一般try,catch,finally结合使用,用于捕获异常。throws,throw单独使用,用于抛出异常。

      // try catch finally
       try {
    
    
             // 可能触发异常的代码
        } catch (XXXException e) {
    
     // XXXException  :代表异常类型 
            // 这里进行处理异常
        } finally {
    
    
           //这里进行资源释放
       }

try后紧跟一个花括号扩起来的代码块简称try块,try块它里面放置可能引发异常的代码。catch后定义一个异常类型和一个代码块。当try块某段代码触发了异常并匹配上catch定义的异常类型,这时便走catch块处理逻辑。

try 代码块后面可以跟着多个 catch 代码块,用于捕获不同类型的异常。Java 虚拟机会从上到下匹配异常处理器。因此,前面的 catch 代码块所捕获的异常类型不能覆盖后面的,否则编译器会报错。

finally块跟catch块之后,finally块用于回收在try块里打开的物理资源,异常机制会保证finally块总被执行。


    //throws 抛出异常,方法签名处抛出。
    private static void test() throws XXXException {
    
    }
    
    //throw 作为语句使用,代码中直接抛出一个异常。 throw new XXXException();
    private static void getCode(String type) {
    
    
       if (type == null) throw new IllegalArgumentException("参数不能为空");
    } 

throws关键字主要在方法签名中使用,用于声明该方法可能抛出的异常。throw用于抛出一个实际的异常,throw可以单独作为语句使用,抛出一个具体的异常对象。

异常处理栗子

(1)编译时异常

    public static void main(String[] args) {
    
    
        File file = new File("F://a.txt");
        if (!file.exists()) {
    
    
            file.createNewFile(); // 这段代码直接运行,这里编译不通过,报编译时异常。
        }
    }

编译时异常在代码编译期间就会报错(注意编译期间报错的不一定都是编译时异常),这种异常在编码期间需要手动捕获或者抛出处理~

/**
 * Create by SunnyDay on 2022/04/21 18:26
 */
public class ExceptionDemo {
    
    
    public static void main(String[] args) throws IOException {
    
    
        tryCatch();
        throwsException(); // 这里选择继续抛出给main
    }

    /**
     * 捕获异常栗子
     */
    private static void tryCatch() {
    
    
        try {
    
    
            File file = new File("F://a.txt");
            if (!file.exists()) {
    
    
                file.createNewFile();
            }
        } catch (IOException e) {
    
    
            e.printStackTrace();
        } finally {
    
    
            System.out.println("finally 块");
        }

    }

    /**
     * 方法签名处抛出异常栗子。
     * 注意若是此方法被其他方法A调用,那么A需要捕获或者抛出处理。
     */
    private static void throwsException() throws IOException {
    
    
        File file = new File("F://a.txt");
        if (!file.exists()) {
    
    
            file.createNewFile();
        }
    }
}

(2)运行时异常

    private static void runtimeException(String name) {
    
    
        name.length(); //name 为空时java.lang.NullPointerException.直接crash。
    }

运行时异常一般为开发人员代码考虑不周引起的,一般不需要主动来捕获或者抛出的~ 不过若是需要也可以主动捕获处理~如下。

    /**
     * 不过一般不会建议采取捕获处理的方式,完全可通过name的判空处理。
     */
    private static void runtimeException(String name) {
    
    
        // 捕获,出现异常也不会导致crash,不影响try catch 块之外的逻辑。
        try {
    
    
            name.length(); //java.lang.NullPointerException
        } catch (Exception e) {
    
    
            e.printStackTrace();
        }
    }

(3)访问异常信息

如果需要在catch块中访问异常对象的相关信息,则可以通过访问catch块后的异常形参来获得。当JVM决定调用某个catch块来处理该异常对象时,会将异常对象赋给catch块后的异常参数,这时我们可通过该参数来获得异常的相关信息。常用方法如下:

  • getMessage():返回该异常的详细描述字符串。
  • printStackTrace():将该异常的跟踪栈信息输出到标准错误输出。
  • printStackTrace(PrintStream s):将该异常的跟踪栈信息输出到指定输出流。
  • getStackTrace():返回该异常的跟踪栈信息。
注意点

看似两三个栗子吧异常过了一遍,其实异常相关的东西还是很多的~

(1)不管程序代码块是否处于try块中,甚至包括catch块中的代码,只要执行该代码块时出现了异常,系统总会自动生成一个异常对象。如果程序没有为这段代码定义任何的catch块,则Java运行时环境无法找到处理该异常的catch块,程序就在此异常退出。

(2)触发异常时虚拟机会生成对应的异常,并会自上而下遍历catch中定义的异常条目,寻找匹配的异常条目。catch 中定义异常条目时要遵循只能扩大或者不相关的原则,否则编译失败。

(3)在Java 7以前,每个catch块只能捕获一种类型的异常;但从Java 7开始,一个catch块可以捕获多种类型的异常。多异常捕获需要注意:

  • 捕获多种类型的异常时,多种异常类型之间用竖线"|"隔开。

  • 捕获多种类型的异常时,异常变量有隐式的final修饰,因此程序不能对异常变量重新赋值。

    private static void mutipleException(){
    
    
        // 多异常捕获,如下catch块可以捕获处理2种异常。
        try {
    
    
        
        }catch (ArrayIndexOutOfBoundsException|NumberFormatException e){
    
    
            e = new IllegalArgumentException("") // 编译报错,多异常不能重新赋值。
        }
        
        // 单个异常捕获
        try {
    
    
            
        }catch (Exception e){
    
    
            e = new IllegalArgumentException(""); // 编译通过,单个异常可以赋值。
        }
    }

(4)在通常情况下,不要在finally块中使用如return或throw等导致方法终止的语句,一旦在finally块中使用了return或throw语句,将会导致try块、catch块中的return、throw语句失效。

(5)try、catch中的return语句、异常触发等导致方法结束的case不会影响finally代码块的执行。


    public static void main(String[] args) {
    
    
        System.out.println("test return value:"+test());
    }
    
    private static int test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
        } catch (Exception e) {
    
    
            return 0;
        } finally {
    
    
            System.out.println("finally");
        }
        System.out.println("test finish");
        return 1;
    }
log:

finally
test return value:0

可见finally最终打印出来了,证明了我们的观点,那么为啥方法最终打印的返回值是0,而不是1呢?其实流程是这样的~

首先代码执行到try块触发ArithmeticException异常,然后catch块捕获住处理,不过异常机制有这么一个原则如果在 catch 中遇到了 return 或者异常等能使该函数终止的话,那么finally就必须先执行完finally代码块里面的代码然后再返回到catch中抛出或者return处。最终执行catch return语句方法结束。后续的代码不会再执行了。

不妨可以修改代码验证下,如下catch 代码块执行完后会继续走try catch finally 之外的代码~

    private static int test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
        } catch (Exception e) {
    
    
            System.out.println("catch");
        } finally {
    
    
            System.out.println("finally");
        }
        System.out.println("test finish");
        return 1;
    }
    
log:

catch
finally
test finish
test return value:1   

来个栗子再让我们更好巩固下,彻底理解他 emmm~ 如下方法的返回值是几?

    private static int test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
            return 1;
        } catch (Exception e) {
    
    
            return 2;
        } finally {
    
        
            return 3;
        }
    }

代码执行到try的 int a这里会触发ArithmeticException,这时由异常处理器捕获,走catch中return 2,但是由于java的异常执行机制此时会先执行finally中的return3。finally这里正好碰到了return语句,正常结束方法。

若是finally只是处理一些资源关闭的代码,这里未return 3,那么本方法的返回值就是2喽~

(6)除非在try块、catch块中调用了退出虚拟机的方法,否则不管在try块、catch块中执行怎样的代码,出现怎样的情况,异常处理的finally块总会被执行。

    private static int test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
        } catch (Exception e) {
    
    
            System.out.println("catch");
            System.exit(0);
        } finally {
    
    
            System.out.println("finally");
        }
        return 0;
    }
log:

catch
Process finished with exit code 0

如上,首先触发ArithmeticException异常,此时会走到catch代码块,执行了打印语句后执行System.exit(0) 直接退出JVM。

(7)try catch finally 执行机制存在异常丢失的情况

    /**
     * try中捕获异常A,catch中又触发异常B,这时finally执行完后系统只会抛出异常B。
     * 这种case也可以看做try catch的弊端,丢失了try中的异常。
     * */
    private static void test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
        } catch (Exception e) {
    
    
            String a = null;
            a.length(); // finally 执行完毕后这里最终由系统抛出NullPointerException
        } finally {
    
    
            System.out.println("finally");
        }
    }
    /**
     * try中捕获异常A,catch中又触发异常B,这时finally执行又触发异常C系统只会抛出异常C。
     * 这种case也可以看做try catch的弊端,丢失了try,catch中的异常。
     */
    private static void test() {
    
    
        try {
    
    
            int a = 10 / 0; //  ArithmeticException: / by zero
        } catch (Exception e) {
    
    
            String a = null;
            a.length(); // NullPointerException
        } finally {
    
    
           Integer.parseInt("aaa"); //代码执行到这里只会抛出NumberFormatException。上述两异常忽略。
           System.out.println("finally");
        }
    }

Java 7 Supressed 异常以及语法糖

前面了解到try catch 中的异常存在丢失的情况,为了解决这个问题,java7引入了Supressed 异常来解决这个问题。这个新特性允许开发人员将一个异常附于另一个异常之上。因此,抛出的异常可以附带多个异常的信息。

Java 7 专门构造了一个名为 try-with-resources 的语法糖,在字节码层面自动使用 Supressed 异常。当然,该语法糖的主要目的并不是使用 Supressed 异常,而是精简资源打开关闭。因为在 Java 7 之前,对于打开的资源,我们需要定义一个 finally 代码块,来确保该资源在正常或者异常执行状态情况下都能关闭。这种做法使代码太臃肿了~

Java 7 的 try-with-resources 语法糖,极大的简化了try catch finally代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close 操作。在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于try catch finally手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Supressed 异常的功能,来避免原异常 “被消失”。

(1)自动关闭资源

系统提供了一些类实现了AutoCloseable接口 ,若直接使用try-with-resources 语法糖则不需要再使用finally做繁琐的关闭处理的工作~

    public static void main(String[] args) throws Exception {
    
    

        /**
         * 1、try()中进行变量定义(创建、赋值),类必须实现了AutoCloseable接口(或者是AutoCloseable实现类)。
         * 2、try后的代码块中可进行逻辑的操作。
         * 3、自动关闭资源的try语句相当于包含了隐式的finally块,执行了close回调,因此这个try语句可以既没有catch块,
         *    也没有finally块。
         * 4、注意AutoCloseable#close()方法抛出了Exception
         * */
        try (
                BufferedReader br = new BufferedReader(new FileReader("F://a.txt"));
                PrintStream pr = new PrintStream(new FileOutputStream("F://b.txt"))
        ) {
    
    
            br.readLine();
            pr.write("emmm".getBytes());
        }
    }

BufferedReader、PrintStream都间接实现了AutoCloseable 接口,把它们放在try语句中声明、初始化,try语句会自动关闭它们。当然我们也可以自定义类实现接口即可,在接口中实现资源的处理工作。接下来验证下异常的捕获~

(2)避免异常的丢失

/**
 * Create by SunnyDay on 2022/04/22 17:37
 */
public class Demo implements AutoCloseable {
    
    

    private String desc;

    public Demo(String name) {
    
    
        this.desc = name;
    }

    public static void main(String[] args) throws Exception {
    
    
        try (
                Demo demo1 = new Demo("1");
                Demo demo2 = new Demo("2")) {
    
    

                int a = 10/0; // 执行代码 触发异常
        }
    }


    @Override
    public void close() throws Exception {
    
    
        // 这里直接抛出一个异常,验证 finally中触发了异常工作。
        throw new IllegalArgumentException();
    }
}

log: 打印所有的异常信息

Exception in thread "main" java.lang.ArithmeticException: / by zero
	at Demo.main(Demo.java:21)
	Suppressed: java.lang.IllegalArgumentException
		at Demo.close(Demo.java:29)
		at Demo.main(Demo.java:22)
	Suppressed: java.lang.IllegalArgumentException
		at Demo.close(Demo.java:29)
		at Demo.main(Demo.java:22)

异常实现原理

异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常。

在生成栈轨迹时,Java 虚拟机会忽略掉异常构造器以及填充栈帧的 Java 方法,直接从新建异常位置开始算起。此外,Java 虚拟机还会忽略标记为不可见的 Java 方法栈帧。

既然异常实例的构造十分昂贵,我们是否可以缓存异常实例,在需要用到的时候直接抛出呢?

从语法角度上来看,这是允许的。然而,该异常对应的栈轨迹并非 throw 语句的位置,而是新建异常的位置。因此,这种做法可能会误导开发人员,使其定位到错误的位置。这也是为什么在实践中,我们往往选择抛出新建异常实例的原因。

jvm是如何实现异常的?

class 文件被编译成字节码时,每个方法都附带一张异常表。异常表中的每一个条目代表一个异常处理器。该处理器由from指针、to指针、target指针、所捕获的异常类型组成。这些指针的值是字节码索引,用以定位字节码。

  • from 、to 表示表示异常处理器监控范围,即用try代码块监控的范围。
  • target表示异常处理器的起始位置,即catch起始位置。
  • 异常类型即为xxxException。
/**
 * Create by SunnyDay on 2022/04/22 18:45
 */
public class Test {
    
    
    public static void main(String[] args) {
    
    
        // 异常条目1(try catch finally块就是一个异常处理器)
        try {
    
    // from
            File file = new File("F://a.txt");
            if (!file.exists()) {
    
    
                file.createNewFile();
            }
        } catch (IOException e) {
    
    //to(不包括to 可以这样记住范围“包左不包右”也即[from,to))。  target,这里也是异常处理器
                                // 开始位置
            e.printStackTrace();
        } finally {
    
    
            System.out.println("finally1");
        }

        // 异常条目2
        try {
    
    // from
             int a =  1/0;
        } catch (Exception e) {
    
    // to ,target
            e.printStackTrace();
        } finally {
    
    
            System.out.println("finally2");
        }

    }
}
//javap 命令 查看class文件:javap -c -l Test.class  main方法中生成的异常表如下:

    Exception table:
       from    to  target type
           0    22    33   Class java/io/IOException // 异常条目1
           0    22    49   any
          33    38    49   any
          60    64    75   Class java/lang/Exception // 异常条目2
          60    64    91   any
          75    80    91   any

当程序触发异常时,Java 虚拟机会生成一个要抛出的异常实例,然后自上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断要抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。

如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的Java 栈帧,并且在调用者中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java栈上所有方法的异常表。最终把异常抛出。

finally 代码块的编译比较复杂,当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。

在这里插入图片描述
针对异常执行路径,Java 编译器会生成一个或多个异常表条目,监控整个 try-catch 代码块,并且捕获所有种类的异常。这些异常表条目的 target 指针将指向另一份复制的 finally 代码块。并且,在这个 finally 代码块的最后,Java 编译器会重新抛出所捕获的异常。

如果 catch 代码块捕获了异常,并且触发了另外一个异常,那么 finally 捕获并重抛的异常是哪个呢?答案是后者,也就是说原本的异常便会被忽略掉,这对于代码调试来说十分不利。

UncaughtExceptionHandler

介绍

Java中,当一个线程如果没有显式处理异常而抛出时Jvm会将该异常事件报告给该线程对象的 UncaughtExceptionHandler 进行处理,如果线程没有设置 UncaughtExceptionHandler,则默认会把异常栈信息输出到终端而使程序直接崩溃。所以如果我们想在线程意外崩溃时做一些处理就可以通过实现 UncaughtExceptionHandler 来满足需求。

栗子
/**
 * Create by SunnyDay on 2022/04/24 11:07
 */
public class CrashHandler implements Thread.UncaughtExceptionHandler {
    
    

    private volatile static CrashHandler INSTANCE;

    private CrashHandler() {
    
    
    }

    public static CrashHandler getINSTANCE() {
    
    
        if (INSTANCE == null) {
    
    
            synchronized (CrashHandler.class) {
    
    
                if (INSTANCE == null) {
    
    
                    INSTANCE = new CrashHandler();
                }
            }
        }
        return INSTANCE;
    }

    @Override
    public void uncaughtException(Thread t, Throwable e) {
    
    
        printInfo(t, e);
        collectDeviceInfo();
        saveCatchInfo2File(e);
    }

    private void printInfo(Thread t, Throwable e) {
    
    
        System.out.println("异常线程:" + t.getName() + " 异常信息:" + e.getMessage());
    }

    private void collectDeviceInfo() {
    
    
        System.out.println("收集用户设备信息");
    }

    private void saveCatchInfo2File(Throwable ex) {
    
    
        System.out.println("异常信息保存到文件");
    }
}


/**
 * Create by SunnyDay on 2022/04/24 11:06
 */
public class Test {
    
    
    public static void main(String[] args) {
    
    
        getException();
        new Thread(() -> createAnException1(), "工作线程1").start();
        new Thread(() -> createAnException2(), "工作线程2").start();
    }

    /**
     * 捕获所有线程未捕获异常。
     */
    private static void getException() {
    
    
        Thread.setDefaultUncaughtExceptionHandler(CrashHandler.getINSTANCE());
    }

    /**
     * 模拟一个异常 "除0异常"
     */
    private static void createAnException1() {
    
    
        int a = 10 / 0;
    }

    /**
     * 模拟一个异常 "NumberFormatException"
     */
    private static void createAnException2() {
    
    
        Integer.parseInt("sss");
    }
}

上述Test#main方法运行后最终会走CrashHandler#uncaughtException log如下:

异常线程:工作线程1异常信息:/ by zero
收集用户设备信息
异常信息保存到文件

异常线程:工作线程2异常信息:For input string: "sss"
收集用户设备信息
异常信息保存到文件
源码分析

上面的栗子中使用到了Thread.setDefaultUncaughtExceptionHandler(),其实Thread还有一个方法setUncaughtExceptionHandler那么二者有啥区别呢? 先来个结论再看源码~

  • Thread.setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh):用于设置一个默认的全局异常处理器,也就是给所有的线程都设置这个处理器。
  • Thread.setUncaughtExceptionHandler(UncaughtExceptionHandler eh):给指定线程设置,用于对特定的线程进行未捕获的异常处理。
class Thread implements Runnable {
    
    
...
    /* The group of this thread */
    private ThreadGroup group;
    
    @FunctionalInterface
    public interface UncaughtExceptionHandler {
    
    
        void uncaughtException(Thread t, Throwable e);
    }
    
    // 单独线程的UncaughtExceptionHandler 对象
    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;
    // 全局的UncaughtExceptionHandler 对象
    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;

    //设置全局的UncaughtExceptionHandler 对象
    public static void setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
    
    
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
    
    
            sm.checkPermission(
                new RuntimePermission("setDefaultUncaughtExceptionHandler")
                    );
        }

         defaultUncaughtExceptionHandler = eh;
     }

    public static UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(){
    
    
        return defaultUncaughtExceptionHandler;
    }

    //为单独线程指定UncaughtExceptionHandler 对象
    public void setUncaughtExceptionHandler(UncaughtExceptionHandler eh) {
    
    
        checkAccess();
        uncaughtExceptionHandler = eh;
    }

    /**
      uncaughtExceptionHandler对象为null则返回group。ThreadGroup 类对象在Thread构造中初始化。

      这里不妨猜测下ThreadGroup必定实现了UncaughtExceptionHandler接口。
     */
    public UncaughtExceptionHandler getUncaughtExceptionHandler() {
    
    
        return uncaughtExceptionHandler != null ?
            uncaughtExceptionHandler : group;
    }
...
}

到这就能明白了 setDefaultUncaughtExceptionHandler 和 setUncaughtExceptionHandler 的区别了。线程崩溃时异常抛出的顺序是先调用 Thread 的 getUncaughtExceptionHandler 查看UncaughtExceptionHandler 对象是否有值,如果有就直接处理,没有就调用 ThreadGroup的逻辑处理~

ThreadGroup是UncaughtExceptionHandler 在 JDK 的默认实现类。内部调用 Thread 的 getDefaultUncaughtExceptionHandler() 获取 handler 进行处理,如果默认 handler 也没有处理就直接执行正常的异常流程使程序崩溃~

//这个类实现了UncaughtExceptionHandler 接口。
public class ThreadGroup implements Thread.UncaughtExceptionHandler {
    
    

    private final ThreadGroup parent;
···
    public void uncaughtException(Thread t, Throwable e) {
    
    
        if (parent != null) {
    
    //ThreadGroup 无参构造中直接parent == null。这个值一般为null。
            parent.uncaughtException(t, e);
        } else {
    
    
        /**
           ThreadGroup 其实也是调用 Thread.getDefaultUncaughtExceptionHandler()来获取UncaughtExceptionHandler 对象的。

            当我们通过setDefaultUncaughtExceptionHandler设置过UncaughtExceptionHandler对象时则调用
            UncaughtExceptionHandler#uncaughtException
            当未设置过UncaughtExceptionHandler对象时打印异常信息,后续就是jvm的crash了~
         */
            Thread.UncaughtExceptionHandler ueh =
                Thread.getDefaultUncaughtExceptionHandler();
            if (ueh != null) {
    
    
                ueh.uncaughtException(t, e);
            } else if (!(e instanceof ThreadDeath)) {
    
    
                System.err.print("Exception in thread \""
                                 + t.getName() + "\" ");
                e.printStackTrace(System.err);
            }
        }
    }
···
}

End

参考:深入拆解 Java 虚拟机

参考:UncaughtExceptionHandler 相关问题解析

猜你喜欢

转载自blog.csdn.net/qq_38350635/article/details/124325091