Java & Android未捕获异常处理机制

Java & Android未捕获异常处理机制,第1张

概述一、背景无论是Java还是Android项目,往往都会用到多线程。不管是主线程还是子线程,在运行过程中,都有可能出现未捕获异常。未捕获异常中含有详细的异常信息堆栈,可以很方便的去帮助我们排查问题。默认情况下,异常信息堆栈都会在输出设备显示,同时,Java&Android为我们提供了未捕获异 一、背景

无论是Java还是AndroID项目,往往都会用到多线程。不管是主线程还是子线程,在运行过程中,都有可能出现未捕获异常。未捕获异常中含有详细的异常信息堆栈,可以很方便的去帮助我们排查问题。

默认情况下,异常信息堆栈都会在输出设备显示,同时,Java & AndroID为我们提供了未捕获异常的处理接口,使得我们可以去自定义异常的处理,甚至可以改变在异常处理流程上的具体走向,如常见的将异常信息写到本地日志文件,甚至上报服务端等。

在未捕获异常的处理机制上,总体上,AndroID基本沿用了Java的整套流程,同时,针对AndroID自身的特点,进行了一些特别的处理,使得在表现上与Java默认的流程会有一些差异。


二、未捕获异常处理流程2.1 引子

我们先可以思考几个问题:
1,Java子线程中出现了未捕获的异常,是否会导致主进程退出?
2,AndroID子线程中出现了未捕获的异常,是否会导致App闪退?
3,AndroID项目中,当未作任何处理时,未捕获异常发生时,Logcat中的异常堆栈信息是如何输出的?
4,AndroID项目中,可能引入了多个质量监控的三方库,为何三方库之间,甚至与主工程之间都没有冲突?
5,AndroID中因未捕获异常导致闪退时,如何处理,从而可以将异常信息写到本地日志文件甚至上报服务端?
6,Java & AndroID对未捕获异常的处理流程有何异同?


先来看下第1个问题:

Java子线程中出现了未捕获的异常,是否会导致主进程退出?

可以做一个实验:

package com.corn.javalib;public class MyClass {    public static voID main(String[] args) {        System.out.println("thread name:" + Thread.currentThread().getname() + " begin...");        Thread thread = new Thread(new MyRunnable());        thread.start();        try {            Thread.currentThread().sleep(1000);        } catch (Exception e) {            e.printstacktrace();        }        System.out.println("thread name:" + Thread.currentThread().getname() + " end...");    }    static class MyRunnable implements Runnable {        @OverrIDe        public voID run() {            System.out.println("thread name:" + Thread.currentThread().getname() + " start run");            errorMethod();            System.out.println("thread name:" + Thread.currentThread().getname() + " end run");        }    }    public static int errorMethod() {        String name = null;        return name.length();    }}复制代码

执行Java程序,最后输出结果为:

thread name:main begin...thread name:Thread-0 start runException in thread "Thread-0" java.lang.NullPointerException	at com.corn.javalib.MyClass.errorMethod(MyClass.java:35)	at com.corn.javalib.MyClass$MyRunnable.run(MyClass.java:26)	at java.lang.Thread.run(Thread.java:748)thread name:main end...Process finished with exit code 0复制代码

我们发现,主线程中新起的子线程在运行时,出现了未捕获异常,但是,main主线程还是可以继续执行下去的,对整个进程而言,最终是Process finished with exit code 0,说明也没有异常终止。

因此,第一个问题的结果是:

Java子线程中出现了未捕获的异常,默认情况下不会导致主进程异常终止。复制代码

第2个问题:

AndroID子线程中出现了未捕获的异常,是否会导致App闪退?

同样的,新建AndroID工程后,模拟对应的场景,例如点击按钮,启动子线程,发现App直接闪退,AS Logcat中对应有如下日志输出:

2019-11-21 19:10:42.678 26259-26449/com.corn.crash I/System.out: thread name:Thread-2 start run2019-11-21 19:10:42.679 26259-26449/com.corn.crash E/AndroIDRuntime: FATAL EXCEPTION: Thread-2    Process: com.corn.crash, PID: 26259    java.lang.NullPointerException: Attempt to invoke virtual method 'int java.lang.String.length()' on a null object reference        at com.corn.crash.MainActivity.errorMethod(MainActivity.java:76)        at com.corn.crash.MainActivity$MyRunnable.run(MainActivity.java:67)        at java.lang.Thread.run(Thread.java:764)2019-11-21 19:10:42.703 26259-26449/com.corn.crash I/Process: Sending signal. PID: 26259 SIG: 9复制代码

从日志信息上看,SIG: 9,意味着App进程被kill掉,日志信息堆栈中给出了具体的异常位置,于是,我们得出如下结论:

默认情况下,AndroID子线程中出现了未捕获的异常,在是会导致App闪退的,且有异常信息堆栈输出。复制代码

我们发现,基于Java基础上的AndroID,默认情况下,对于子线程中的未捕获异常,在进程是否异常退出方面,却有着相反的结果。


2.2 未捕获异常处理流程

接下来看下第3个问题:

AndroID项目中,当未作任何处理时,未捕获异常发生时,Logcat中的异常堆栈信息是如何输出的?复制代码

当AndroID项目中出现未捕获异常时,Logcat中默认会自动有异常堆栈信息输出,且信息输出的前缀为: E/AndroIDRuntime: FATAL EXCEPTION:。我们很容易猜想到,这应该是系统层直接输出的,搜索framework源码,很快可以找到具体输出日志的位置:

 

RuntimeInit.java中,找到了对应的异常日志输出位置,从代码注释上,我们找到了关键的KillApplicationHandlerUncaughtExceptionHandler类,先看下KillApplicationHandler类。

显然,KillApplicationHandler是未捕获异常发生时,默认情况下最终杀死应用的最后处理类,通过调用其uncaughtException进行。 代码继续往下,可以找到设置loggingHandlerKillApplicationHandler的方法。

 

 

 

终于,我们可以得出第3个问题的答案:

默认情况下,未捕获异常发生时,Logcat中的异常堆栈信息,是从framework层,具体是RuntimeInit.java类中的loggingHandler异常处理处理对象中的uncaughtException输出。复制代码

loggingHandler异常处理处理对象中的uncaughtException调用,具体又是在何处触发的呢?

从上述源码,以及对应的方法及代码注释中,我们大概已经知道了,未捕获异常的处理,与UncaughtExceptionHandler类有着莫大的关系。

UncaughtExceptionHandler,实际上定义在Thread类中,并作为interface的形式存在,其内部,只有一个uncaughtException方法。

/** * Interface for handlers invoked when a <tt>Thread</tt> abruptly * terminates due to an uncaught exception. * <p>When a thread is about to terminate due to an uncaught exception * the Java Virtual Machine will query the thread for its * <tt>UncaughtExceptionHandler</tt> using * {@link #getUncaughtExceptionHandler} and will invoke the handler's * <tt>uncaughtException</tt> method, passing the thread and the * exception as arguments. * If a thread has not had its <tt>UncaughtExceptionHandler</tt> * explicitly set, then its <tt>ThreadGroup</tt> object acts as its * <tt>UncaughtExceptionHandler</tt>. If the <tt>ThreadGroup</tt> object * has no * special requirements for dealing with the exception, it can forward * the invocation to the {@linkplain #getDefaultUncaughtExceptionHandler * default uncaught exception handler}. * * @see #setDefaultUncaughtExceptionHandler * @see #setUncaughtExceptionHandler * @see ThreadGroup#uncaughtException * @since 1.5 */@Functionalinterfacepublic interface UncaughtExceptionHandler {    /**     * Method invoked when the given thread terminates due to the     * given uncaught exception.     * <p>Any exception thrown by this method will be ignored by the     * Java Virtual Machine.     * @param t the thread     * @param e the exception     */    voID uncaughtException(Thread t, Throwable e);}复制代码

接口的注释中,基本上已经说明了未捕获异常的处理流程。我们将Thread类中关于未捕获异常的逻辑都截取出来,如下:

public class Thread implements Runnable {    ....        @Functionalinterface    public interface UncaughtExceptionHandler {        /**         * Method invoked when the given thread terminates due to the         * given uncaught exception.         * <p>Any exception thrown by this method will be ignored by the         * Java Virtual Machine.         * @param t the thread         * @param e the exception         */        voID uncaughtException(Thread t, Throwable e);    }    // null unless explicitly set    private volatile UncaughtExceptionHandler uncaughtExceptionHandler;    // null unless explicitly set    private static volatile UncaughtExceptionHandler defaultUncaughtExceptionHandler;        /**     * Set the default handler invoked when a thread abruptly terminates     * due to an uncaught exception, and no other handler has been defined     * for that thread.     *     * <p>Uncaught exception handling is controlled first by the thread, then     * by the thread's {@link ThreadGroup} object and finally by the default     * uncaught exception handler. If the thread does not have an explicit     * uncaught exception handler set, and the thread's thread group     * (including parent thread groups)  does not specialize its     * <tt>uncaughtException</tt> method, then the default handler's     * <tt>uncaughtException</tt> method will be invoked.     * <p>By setting the default uncaught exception handler, an application     * can change the way in which uncaught exceptions are handled (such as     * logging to a specific device, or file) for those threads that would     * already accept whatever &quot;default&quot; behavior the system     * provIDed.     *     * <p>Note that the default uncaught exception handler should not usually     * defer to the thread's <tt>ThreadGroup</tt> object, as that Could cause     * infinite recursion.     *     * @param eh the object to use as the default uncaught exception handler.     * If <tt>null</tt> then there is no default handler.     *     * @throws SecurityException if a security manager is present and it     *         denIEs <tt>{@link RuntimePermission}     *         (&quot;setDefaultUncaughtExceptionHandler&quot;)</tt>     *     * @see #setUncaughtExceptionHandler     * @see #getUncaughtExceptionHandler     * @see ThreadGroup#uncaughtException     * @since 1.5     */    public static voID setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh) {         defaultUncaughtExceptionHandler = eh;     }    /**     * Returns the default handler invoked when a thread abruptly terminates     * due to an uncaught exception. If the returned value is <tt>null</tt>,     * there is no default.     * @since 1.5     * @see #setDefaultUncaughtExceptionHandler     * @return the default uncaught exception handler for all threads     */    public static UncaughtExceptionHandler getDefaultUncaughtExceptionHandler(){        return defaultUncaughtExceptionHandler;    }    // BEGIN AndroID-added: uncaughtExceptionPreHandler for use by platform.    // See http://b/29624607 for background information.    // null unless explicitly set    private static volatile UncaughtExceptionHandler uncaughtExceptionPreHandler;    /**     * Sets an {@link UncaughtExceptionHandler} that will be called before any     * returned by {@link #getUncaughtExceptionHandler()}. To allow the standard     * handlers to run, this handler should never terminate this process. Any     * throwables thrown by the handler will be ignored by     * {@link #dispatchUncaughtException(Throwable)}.     *     * @hIDe used when configuring the runtime for exception logging; see     *     {@link dalvik.system.RuntimeHooks} b/29624607     */    public static voID setUncaughtExceptionPreHandler(UncaughtExceptionHandler eh) {        uncaughtExceptionPreHandler = eh;    }    /** @hIDe */    public static UncaughtExceptionHandler getUncaughtExceptionPreHandler() {        return uncaughtExceptionPreHandler;    }    // END AndroID-added: uncaughtExceptionPreHandler for use by platform.    /**     * Returns the handler invoked when this thread abruptly terminates     * due to an uncaught exception. If this thread has not had an     * uncaught exception handler explicitly set then this thread's     * <tt>ThreadGroup</tt> object is returned, unless this thread     * has terminated, in which case <tt>null</tt> is returned.     * @since 1.5     * @return the uncaught exception handler for this thread     */    public UncaughtExceptionHandler getUncaughtExceptionHandler() {        return uncaughtExceptionHandler != null ?            uncaughtExceptionHandler : group;    }    /**     * Set the handler invoked when this thread abruptly terminates     * due to an uncaught exception.     * <p>A thread can take full control of how it responds to uncaught     * exceptions by having its uncaught exception handler explicitly set.     * If no such handler is set then the thread's <tt>ThreadGroup</tt>     * object acts as its handler.     * @param eh the object to use as this thread's uncaught exception     * handler. If <tt>null</tt> then this thread has no explicit handler.     * @throws  SecurityException  if the current thread is not allowed to     *          modify this thread.     * @see #setDefaultUncaughtExceptionHandler     * @see ThreadGroup#uncaughtException     * @since 1.5     */    public voID setUncaughtExceptionHandler(UncaughtExceptionHandler eh) {        checkAccess();        uncaughtExceptionHandler = eh;    }    /**     * dispatch an uncaught exception to the handler. This method is     * intended to be called only by the runtime and by tests.     *     * @hIDe     */    // AndroID-changed: Make dispatchUncaughtException() public, for use by tests.    public final voID dispatchUncaughtException(Throwable e) {        // BEGIN AndroID-added: uncaughtExceptionPreHandler for use by platform.        Thread.UncaughtExceptionHandler initialUeh =                Thread.getUncaughtExceptionPreHandler();        if (initialUeh != null) {            try {                initialUeh.uncaughtException(this, e);            } catch (RuntimeException | Error ignored) {                // Throwables thrown by the initial handler are ignored            }        }        // END AndroID-added: uncaughtExceptionPreHandler for use by platform.        getUncaughtExceptionHandler().uncaughtException(this, e);    }        ....}复制代码

从源码及注释整个分析下来,对于未捕获异常,得出如下处理流程:
1,运行时发生异常时,系统会调用dispatchUncaughtException,开始执行异常的分发处理流程;
2,dispatchUncaughtException中,先判断有无异常预处理器,即uncaughtExceptionPreHandler,有的话,将会先调用异常预处理器uncaughtException方法;
3,接下来获取异常处理器,并调用其uncaughtException方法。至此,整个异常分发处理流程完毕。

异常预处理器在前述RuntimeInit.java类的loggingHandler中,我们已经有所接触,在App进程启动时,系统会自动注入loggingHandler对象,作为异常预处理器。当有未捕获异常发生时,以此会自动调用loggingHandler对象的uncaughtException方法,以完成默认的日志输出。

至此,第3个问题的完整回答是:

未捕获异常发生时,系统会调用Thread类的dispatchUncaughtException方法,方法中取到异常预处理器,并执行对应uncaughtException方法。由于App进程启动时,系统已经在RuntimeInit.java类中注册了一个默认的异常预处理器loggingHandler。因此,loggingHandler得以回调,并执行了其uncaughtException方法,输出了异常的堆栈信息。复制代码

当然,系统为我们提供了异常预处理器的设置接口,如果我们通过setUncaughtExceptionPreHandler(ncaughtExceptionHandler eh)方法设置了异常预处理器,那默认的loggingHandler将会失效。因为静态变量uncaughtExceptionPreHandler被重新赋值了嘛,但此方法被设置成了@hIDe,当前可以通过反射去设置。

这里,我们也应该认识到,正因为uncaughtExceptionPreHandler为静态变量,因此,同一进程中的所有线程的异常预处理器都是相同的。

下面,我们开始着重看下异常处理器的异常处理流程。对应代码为:

getUncaughtExceptionHandler().uncaughtException(this, e);复制代码

getUncaughtExceptionHandler(),返回的一个异常处理器,具体对应方法定义如下:

/** * Returns the handler invoked when this thread abruptly terminates * due to an uncaught exception. If this thread has not had an * uncaught exception handler explicitly set then this thread's * <tt>ThreadGroup</tt> object is returned, unless this thread * has terminated, in which case <tt>null</tt> is returned. * @since 1.5 * @return the uncaught exception handler for this thread */public UncaughtExceptionHandler getUncaughtExceptionHandler() {    return uncaughtExceptionHandler != null ?        uncaughtExceptionHandler : group;}复制代码

首先判断uncaughtExceptionHandler变量是否赋值,如果有值将直接返回此异常处理器,否则返回的是groupuncaughtExceptionHandler是一个对象类型的属性变量,并非static的静态变量,这也意味着,每个线程,都可以通过setUncaughtExceptionHandler(UncaughtExceptionHandler eh)方法设置线程私有的异常处理器,并且,一旦设置,如果有未捕获异常,此异常处理器将被调用,异常处理流程结束。

group具体类型是ThreadGroup,并实现了Thread.UncaughtExceptionHandler接口。ThreadGroup中关于未捕获异常处理的逻辑截取如下:

public class ThreadGroup implements Thread.UncaughtExceptionHandler {    ....        /**     * Called by the Java Virtual Machine when a thread in this     * thread group stops because of an uncaught exception, and the thread     * does not have a specific {@link Thread.UncaughtExceptionHandler}     * installed.     * <p>     * The <code>uncaughtException</code> method of     * <code>ThreadGroup</code> does the following:     * <ul>     * <li>If this thread group has a parent thread group, the     *     <code>uncaughtException</code> method of that parent is called     *     with the same two arguments.     * <li>Otherwise, this method checks to see if there is a     *     {@linkplain Thread#getDefaultUncaughtExceptionHandler default     *     uncaught exception handler} installed, and if so, its     *     <code>uncaughtException</code> method is called with the same     *     two arguments.     * <li>Otherwise, this method determines if the <code>Throwable</code>     *     argument is an instance of {@link ThreadDeath}. If so, nothing     *     special is done. Otherwise, a message containing the     *     thread's name, as returned from the thread's {@link     *     Thread#getname getname} method, and a stack backtrace,     *     using the <code>Throwable</code>'s {@link     *     Throwable#printstacktrace printstacktrace} method, is     *     printed to the {@linkplain System#err standard error stream}.     * </ul>     * <p>     * Applications can overrIDe this method in subclasses of     * <code>ThreadGroup</code> to provIDe alternative handling of     * uncaught exceptions.     *     * @param   t   the thread that is about to exit.     * @param   e   the uncaught exception.     * @since   JDK1.0     */    public voID uncaughtException(Thread t, Throwable e) {        if (parent != null) {            parent.uncaughtException(t, e);        } else {            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);            }        }    }        ....}复制代码

当线程私有的uncaughtExceptionHandler变量为空时,此时调用到。ThreadGroupuncaughtException方法。这个方法内部逻辑稍显复杂,具体流程如下:
1,先判断是否有父线程组,只要存在父线程组,都将会先调用父线程组的uncaughtException方法;
2,直到父线程组为null时,此时已经是根线程组了,将会通过Thread.getDefaultUncaughtExceptionHandler()获取线程默认的异常处理器
3,如果线程默认的异常处理器存在,将直接调用线程默认异常处理器uncaughtException方法,流程结束;
4,否则,将会通过e.printstacktrace,输出异常信息。

同样的,我们需要注意的是,线程默认的异常处理器也是一个static定义在Thread类中的静态变量,跟异常预处理器一样,也就意味着这是所有线程共享的。在前述的RuntimeInit.java类中KillApplicationHandler类的对象,就是通过setDefaultUncaughtExceptionHandler(UncaughtExceptionHandler eh)设置进去的。也就是说,App启动时,系统会默认为其设置一个线程默认的异常处理器,当未捕获异常发生时,默认情况下的闪退就是这个线程默认的异常处理器,即KillApplicationHandler去具体触发的。

当然了,我们也可以人为的设置线程线程默认的异常处理器,此时,如果流程执行到这,将会按照我们设置的异常处理器去处理。

总体上,我们可以画一个流程图,总结下上述的整个流程。

 


 

通过设置异常预处理器线程默认的异常处理器或者线程私有的异常处理器,都可以实现对未捕获异常的自定义异常的处理,或者改变其默认的执行流程。更有甚者,我们可以将线程归组,同时自定义线程组,并重写其uncaughtException方法,以实现对特定线程组的异常处理的自定义。凡此种种,处理起来可以依据实际需要,非常灵活。

很自然的,我们可以很容易地回答第4个问题:

AndroID项目中,可能引入了多个质量监控的三方库,为何三方库之间,甚至与主工程之间都没有冲突?复制代码

例如项目中接入了腾讯的BUGly,同时又接入了友盟或firebase,且项目自身,往往还自定义了异常处理器。这在实际项目开发中是非常常见的。当有未捕获异常出现时,多个质量监控的后台,都能有效收集到对应的错误信息。这也是实际上都知道的“常识”。之所以彼此之间没有互相冲突,也没有相互影响,原因在于大家都是遵循同样的一套原则去处理未捕获的异常,而未实际去阻断或不可逆的直接改变未捕获异常的流程。例如:各自自定义异常处理时,先获取线程默认的异常处理器,暂存起来,然后各自设置自定义的异常处理器,但在实现的uncaughtException方法中,处理完自己的逻辑后,适时的去调用原有的线程默认的异常处理。如此,表面上看,是static静态变量(线程默认的异常处理器)每次被重新覆盖,实际上却达到了彼此间的自定义的异常处理逻辑都能实现,互不影响。

如:

public class CrashReport implements UncaughtExceptionHandler {    private final static String TAG = "CrashReport";    private final static CrashReport INSTANCE = new CrashReport();    private Thread.UncaughtExceptionHandler mDefaultHandler;    private CrashReport() {    }    public static CrashReport getInstance() {        return INSTANCE;    }    /**     * 初始化,注册Context对象,     * 获取系统默认的UncaughtException处理器,     * 设置该CrashHandler为程序的默认处理器     */    public voID init() {        if (Thread.getDefaultUncaughtExceptionHandler() != this) {            mDefaultHandler = Thread.getDefaultUncaughtExceptionHandler();            Thread.setDefaultUncaughtExceptionHandler(this);        }    }    /**     * 当UncaughtException发生时会转入该函数来处理     */    @OverrIDe    public voID uncaughtException(Thread thread, Throwable ex) {        // 实现自定义的未捕获异常处理逻辑,例如上报自己的服务器等。        .....        .....                // 调用原有的线程默认的异常处理器处理异常        if (mDefaultHandler != null && mDefaultHandler != this) {            mDefaultHandler.uncaughtException(thread, ex);        }    }}复制代码

自然的,实际上,第5个问题也已经回答完了。


2.3 Java & AndroID 未捕获异常处理流程的异同

接下来开始回答第6个问题。

从上述分析的流程及源码中可以看出,未捕获异常的处理流程上,最核心的涉及到的是java.lang.Threadjava.lang.ThreadGroup以及com.androID.internal.os.RuntimeInit类。但是RuntimeInit是AndroID中特有的类,这也就意味着,单纯的Java环境下,是没有默认被系统注入的uncaughtExceptionPreHandlerdefaultUncaughtExceptionHandler异常处理器的。

同时,在源码中,发现针对setUncaughtExceptionPreHandler方法有如下注释部分:

/** * Sets an {@link UncaughtExceptionHandler} that will be called before any * returned by {@link #getUncaughtExceptionHandler()}. To allow the standard * handlers to run, this handler should never terminate this process. Any * throwables thrown by the handler will be ignored by * {@link #dispatchUncaughtException(Throwable)}. * * @hIDe only for use by the AndroID framework (RuntimeInit) b/29624607 */public static voID setUncaughtExceptionPreHandler(UncaughtExceptionHandler eh) {    uncaughtExceptionPreHandler = eh;}/** @hIDe */public static UncaughtExceptionHandler getUncaughtExceptionPreHandler() {    return uncaughtExceptionPreHandler;}复制代码

显然,从注释中可以看出,uncaughtExceptionPreHandler只是AndroID中才特有的概念,Java中是没有的。

因为AndroID中用到的,是基于OpendJDK版本的Java,并非Oracle的Java版本。在OpendJDK版本的Java中,针对AndroID系统特有的需求,增加了线程预处理器的概念,并让其在其他异常处理器之前执行。

再次用流程图表示下,其中浅红色区域,是Java & AndroID 未捕获异常处理流程的差异部分。

 


三、结语

Java & AndroID 未捕获异常处理流程总体上是类似的,除了AndroID特有的线程异常预处理器和默认设置的uncaughtExceptionPreHandlerdefaultUncaughtExceptionHandler。AndroID项目开发中,可以依据实际的情况,去增加特有的异常处理逻辑,甚至去改变异常处理的流程走向。只要你愿意,甚至当未捕获异常发生时,App不闪退都是完全可以的。

Just do it

end ~


作者:HappyCorn
链接:https://juejin.im/post/5dd52e156fb9a05a7523778e
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 总结

以上是内存溢出为你收集整理的Java & Android未捕获异常处理机制全部内容,希望文章能够帮你解决Java & Android未捕获异常处理机制所遇到的程序开发问题。

如果觉得内存溢出网站内容还不错,欢迎将内存溢出网站推荐给程序员好友。

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/web/1070079.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-05-26
下一篇 2022-05-26

发表评论

登录后才能评论

评论列表(0条)

保存