前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >面试官:说说类加载的几个阶段

面试官:说说类加载的几个阶段

作者头像
Java极客技术
发布2024-02-22 12:43:02
3210
发布2024-02-22 12:43:02
举报
文章被收录于专栏:Java极客技术

一、摘要

我们知道 Java 是先通过编译器将.java类文件转成.class字节码文件,然后再通过虚拟机将.class字节码文件加载到内存中来实现应用程序的运行。

那么虚拟机是什么时候加载class文件?如何加载class文件?class文件进入到虚拟机后发生了哪些变化?

今天我们就一起来了解一下,虚拟机是如何加载类文件的。

二、类加载的时机

经常有面试官问,“类什么时候加载”和“类什么时候初始化”,从内容上来说,似乎都在问同一个问题:class文件是什么时候被虚拟机加载到内存中,并进入可以使用的状态?

从虚拟机角度来说,加载初始化是类的加载过程中的两个阶段。

对于“什么时候加载”,Java 虚拟机规范中并没有约束,每个虚拟机实例都可以按自身需要来自由实现。但基本上都遵循类在进行初始化之前,需要先进行加载class文件。

对于“什么时候初始化”,Java 虚拟机规范有明确的规定,当符合以下条件时(包括但不限),并且虚拟机在内存中没有找到对应的类信息,必须对类进行“初始化”操作:

  • 使用new实例化对象时,读取或者设置一个类的静态字段或方法时
  • 反射调用时,例如Class.forName("com.xxx.Test")
  • 初始化一个类的子类,会首先初始化子类的父类
  • Java 虚拟机启动时标明的启动类,比如main方法所在的类
  • JDK8 之后,接口中存在default方法,这个接口的实现类初始化时,接口会在它之前进行初始化

类在初始化开始之前,需要先经历加载、验证、准备、解析这四个阶段的操作。

下面我们一起来看看类的加载过程。

三、类的加载过程

当一个类需要被加载到虚拟机中执行时,虚拟机会通过类加载器,将其.class文件中的字节码信息在内存中转化成一个具体的java.lang.Class对象,以便被调用执行。

类从被加载到虚拟机内存中开始,到卸载出内存,整个生命周期包括七个阶段:加载、验证、准备、解析、初始化、使用和卸载,可以用如下图来简要概括。

其中类加载的过程,可以用三个步骤(五个阶段)来简要描述:加载 -> 连接(验证、准备、解析)-> 初始化。(验证、准备、解析这3个阶段统称为连接

其次加载、验证、准备和初始化这四个阶段发生的顺序是确定的,必须按照这种顺序按部就班的开始,而解析阶段则不一定。在某些情况下解析阶段可以在初始化阶段之后开始,这是为了支持 Java 语言的运行时绑定,也称为动态绑定晚期绑定

同时,这五个阶段并不是严格意义上的按顺序完成,在类加载的过程中,这些阶段会互相混合,可能有些阶段完成了,有些阶段没有完成,会交叉运行,最终完成类的加载和初始化。

接下来依此分解一下加载、验证、准备、解析、初始化这五个步骤,这五个步骤组成了一个完整的类加载过程。使用没什么好说的,卸载通常属于 GC 的工作,当一个类没有被任何地方引用并且类加载器已被 GC 回收,GC 会将当前类进行卸载,在后续的文章我们会介绍 GC 的工作机制。

3.1、加载

加载是类加载的过程的第一个阶段,这个阶段的主要工作是查找并加载类的二进制数据,在虚拟机中,类的加载有两种触发方式:

  • 预先加载:指的是虚拟机启动时加载,例如JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面包含了程序运行时常用的文件内容,例如java.lang.*java.util.*java.io.*等等,因此会随着虚拟机启动时一起加载到内存中。要证明这一点很简单,自己可以写一个空的main函数,设置虚拟机参数为-XX:+TraceClassLoading,运行程序就可以获取类加载的全部信息
  • 运行时加载:虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有,就会按照类的全限定名来加载这个类;如果有,就不会加载。

无论是哪种触发方式,虚拟机在加载.class文件时,都会做以下三件事情:

  • 1.通过类的全限定名定位.class文件,并获取其二进制字节流
  • 2.将类信息、静态变量、字节码、常量这些.class文件中的内容放入运行时数据区的方法区
  • 3.在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口,一般这个java.lang.Class对象会存在 Java 堆中

虚拟机规范对这三点的要求并不具体,因此具体虚拟机实现的灵活度都很大。比如第一条,没有指明二进制字节流要从哪里来,单单就这一条,就能变出许多花样来,比如下面几种加载方式:

  • 从 zip、jar、ear、war 等归档文件中加载.class文件
  • 通过网络下载并加载.class文件,典型应用就是 Applet
  • Java源文件动态编译为.class文件,典型应用就是动态代理技术
  • 从数据库中提取.class文件并进行加载

总的来说,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)对于开发者来说是可控性最强的一个阶段。因为开发者既可以使用系统提供的类加载器来完成加载,也可以自定义类加载器来完成加载。

3.2、验证

验证是连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

Java 语言本身是比较安全的语言,但是正如上面说到的.class文件未必是从 Java 源码编译而来,可以使用任何途径来生成并加载。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

验证阶段大致会完成 4 项检验工作:

  • 文件格式验证:验证字节流是否符合Class文件格式的规范,例如:是否以0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型等
  • 元数据验证:对字节码描述的元数据信息进行语义分析,要符合 Java 语言规范,例如:是否继承了不允许被继承的类(例如 final 修饰过的)、类中的字段、方法是否和父类产生矛盾等等
  • 字节码验证:对类的方法体进行校验分析,确保这些方法在运行时是合法的、符合逻辑的
  • 符号引用验证:确保解析动作能正确执行,例如:确保符号引用的全限定名能找到对应的类,符号引用中的类、字段、方法允许被当前类所访问等等

验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.3、准备

准备是连接阶段的第二步,这个阶段的主要工作是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配

不过这个阶段,有几个知识点需要注意一下:

  • 1.这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在 Java 堆中
  • 2.这个阶段会设置变量的初始值,值为数据类型默认的零值(如 0、0L、null、false 等),不是在代码中被显式地赋予的值;但是当字段被final修饰时,这个初始值就是代码中显式地赋予的值
  • 3.在 JDK1.8 取消永久代后,方法区变成了一个逻辑上的区域,这些类变量的内存实际上是分配在 Java 堆中的,跟 JDK1.7 及以前的版本稍有不同

关于第二个知识点,我们举个简单的例子进行讲解,比如public static int value = 123value在准备阶段过后是0而不是123

因为这时候尚未开始执行任何 Java 方法,把value赋值为123public static指令是在程序编译后存放于类构造器<clinit>()方法之中的,因此把value赋值为123的动作将在初始化阶段才会执行。

假如被final修饰,比如public static final int value = 123就不一样了,编译时Javac将会为value生成ConstantValue属性,在准备阶段,虚拟机就会给value赋值为123,因为这个变量无法被修改,会存入类的常量池中。

各个数据类型的零值如下图:

数据类型

零值

byte

0

short

0

int

0

long

0L

float

0.0f

double

0.0d

boolean

false

char

\u0000

reference

null

3.4、解析

解析是连接阶段的第三步,这个阶段的主要工作是虚拟机会把这个.class文件中常量池内的符号引用转换为直接引用

主要解析的是类或接口、字段、方法等符号引用,我们可以把解析阶段中符号引用转换为直接引用的过程,理解为当前加载的这个类和它所引用的类,正式进行“连接“的过程。

我们先来了解一下符号引用直接引用有什么区别:

  • 符号引用:这个其实是属于编译原理方面的概念,Java 代码在编译期间,是不知道最终引用的类型,具体指向内存中哪个位置的,这时候会使用一个符号引用来表示具体引用的目标是"谁",符号引用和虚拟机的内存布局是没有关系的
  • 直接引用:指的是可以直接或间接指向目标内存位置的指针或句柄,直接引用和虚拟机实现的内存布局是有关系的

符号引用转换为直接引用,可以理解成将某个符号与虚拟机中的内存位置建立连接,通过指针或句柄来直接访问目标。

与此同时,同一个符号引用在不同的虚拟机实现上翻译出来的直接引用一般不会相同。

3.5、初始化

初始化是类加载的过程的最后一步,这个阶段的主要工作是执行类构造器 <clinit>()方法的过程

简单的说,初始化阶段做的事就是给static变量赋予用户指定的值,同时类中如果存在static代码块,也会执行这个静态代码块里面的代码。

初始化阶段,虚拟机大致依此会进行如下几个步骤的操作:

  • 1.检查这个类是否被加载和连接,如果没有,则程序先加载并连接该类
  • 2.检查该类的直接父类有没有被初始化,如果没有,则先初始化其直接父类
  • 3.类中如果有多个初始化语句,比如多个static代码块,则依次执行这些初始化语句

有个地方需要注意的是:虚拟机会保证类的初始化在多线程环境中被正确地加锁、同步执行,所以无需担心是否会出现变量初始化时线程不安全的问题

如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都会阻塞等待,直到<clinit>()方法执行完毕。同时,同一个类加载器下,一个类只会初始化一次,如果检查到当前类没有初始化,执行初始化;反之,不会执行初始化。

与此同时,只有当对类的主动使用的时候才会触发类的初始化,触发时机主要有以下几种场景:

  • 1.创建类的实例对象,比如new一个对象操作
  • 2.访问某个类或接口的静态变量,或者对该静态变量赋值
  • 3.调用类的静态方法
  • 4.反射操作,比如Class.forName("xxx")
  • 5.初始化某个类的子类,则其父类也会被初始化,并且父类具有优先被初始化的优势
  • 6.Java 虚拟机启动时被标明为启动类的类,比如SpringBootApplication入口类

最后,<clinit>()方法和<init>()方法是不同的,一个是类构造器初始化,一个是实例构造器初始化,千万别搞混淆了啊。

3.6、小结

当一个符合 Java 虚拟机规范的.class字节码文件,经历加载、验证、准备、解析、初始化这些 5 个阶段相互协作执行完成之后,虚拟机会将此文件的二进制数据导入运行时数据区的方法区内,然后在堆内存中,创建一个java.lang.Class类的对象,这个对象描述了这个类所有的信息,同时提供了这个类在方法区的访问入口。

可以用如下图来简要描述。

与此同时,在方法区中,使用同一加载器的情况下,每个类只会有一份Class字节流信息;在堆内存中,使用同一加载器的情况下,每个类也只会有一份java.lang.Class类的对象。

四、类加载器

在上文类的加载过程中,我们有提到在加载阶段,通过一个类的全限定名来获取此类的二进制字节流操作,其实类加载器就是用来实现这个操作的。

在虚拟机中,任何一个类,都需要由加载它的类加载器和这个类本身一同确立其唯一性,每一个类加载器,都拥有一个独立的类名称空间,对于类也同样如此

简单的说,在虚拟机中看两个类是否相同,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个.class文件,被同一个虚拟机加载,但是它们的类加载器不同,这两个类必定不相等。

当年为了满足浏览器上 Java Applet 的需求,Java 的开发团队设计了类加载器,它独立于 Java 虚拟机外部,同时也允许用户按自身需要自行实现类加载器。通过类加载器,可以让同一个类可以实现访问隔离、OSGi、程序热部署等等场景。发展至今,类加载器已经是 Java 技术体系的一块重要基石。

4.1、类加载器介绍

如果要查找类加载器,通过Thread.currentThread().getContextClassLoader()方法可以获取。

简单示例如下:

代码语言:javascript
复制
public class ClassLoaderTest {

    public static void main(String[] args) {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        System.out.println("current loader:" +  loader);
        System.out.println("parent loader:" +  loader.getParent());
        System.out.println("parent parent loader:" +  loader.getParent().getParent());
    }
}

输出结果如下:

代码语言:javascript
复制
current loader:sun.misc.Launcher$AppClassLoader@18b4aac2
parent loader:sun.misc.Launcher$ExtClassLoader@511d50c0
parent parent loader:null

从运行结果可以看到,当前的类加载器是AppClassLoader,它的上一级是ExtClassLoader,再上一级是null

其实ExtClassLoader的上一级是有类加载器的,它叫Bootstrap ClassLoader,是一个启动类加载器,由 C++ 实现,不是 ClassLoader 子类,因此以 null 作为结果返回。

这几种类加载器的层次关系,可以用如下图来描述。

它们之间的启动流程,可以通过以下内容来简单描述:

  • 1.在虚拟机启动后,会优先初始化Bootstrap Classloader
  • 2.接着Bootstrap Classloader负责加载ExtClassLoader,并且将 ExtClassLoader的父加载器设置为Bootstrap Classloader
  • 3Bootstrap Classloader加载完ExtClassLoader后,就会加载AppClassLoader,并且将AppClassLoader的父加载器指定为 ExtClassLoader

因此,在加载 Java 应用程序中的class文件时,这里的父类加载器并不是通过继承关系来实现的,而是互相配合进行加载。

站在虚拟机的角度,只存在两种不同的类加载器:

  • 启动类加载器:它由 C++ 实现(这里仅限于 Hotspot,不同的虚拟机可能实现不太一样),是虚拟机自身的一部分
  • 其它类加载器:这些类加载器都由 Java 实现,独立于虚拟机之外,并且全部继承自抽象类java.lang.ClassLoader,比如ExtClassLoaderAppClassLoader等,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类

站在开发者的角度,类加载器大致可以划分为三类:

  • 启动类加载器:比如Bootstrap ClassLoader,负责加载<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数制定的路径,例如jre/lib/rt.jar里所有的class文件。同时,启动类加载器是无法被 Java 程序直接引用的
  • 拓展类加载器:比如Extension ClassLoader,负责加载 Java 平台中扩展功能的一些 jar 包,包括<JAVA_HOME>\lib\ext目录中或java.ext.dirs指定目录下的 jar 包。同时,开发者可以直接使用扩展类加载器
  • 应用程序类加载器:比如Application ClassLoader,负责加载ClassPath路径下所有 jar 包,如果应用程序中没有自定义过自己的类加载器,一般情况下它就是程序中默认的类加载器

当然,如果有必要,也可以自定义类加载器,因为 JVM 自带的 ClassLoader 只懂得从本地文件系统中加载标准的class文件,如果要从特定的场所取得class文件,例如数据库中和网络中,此时可以自己编写对应的 ClassLoader 类加载器。

4.2、双亲委派模型

在上文中我们提到,在虚拟机中,任何一个类由加载它的类加载器和这个类一同来确立其唯一性

也就是说,JVM 对类的唯一标识,可以简单的理解为由ClassLoader id + PackageName + ClassName组成,因此在一个运行程序中有可能存在两个包名和类名完全一致的类,但是如果这两个类不是由一个 ClassLoader 加载,会被视为两个不同的类,此时就无法将一个类的实例强转为另外一个类,这就是类加载器的隔离性。

为了解决类加载器的隔离问题,JVM 引入了双亲委派模型

双亲委派模式,可以用一句话来说表达:任何一个类加载器在接到一个类的加载请求时,都会先让其父类进行加载,只有父类无法加载(或者没有父类)的情况下,才尝试自己加载

大致流程图如下:

使用双亲委派模式,可以保证,每一个类只会有一个类加载器。例如 Java 最基础的 Object 类,它存放在 rt.jar 之中,这是 Bootstrap 的职责范围,当向上委派到 Bootstrap 时就会被加载。

但如果没有使用双亲委派模式,可以任由自定义加载器进行加载的话,Java 这些核心类的 API 就会被随意篡改,无法做到一致性加载效果。

JDK 中ClassLoader.loadClass()类加载器中的加载类的方法,部分核心源码如下:

代码语言:javascript
复制
public Class<?> loadClass(String name) throws ClassNotFoundException {
    return loadClass(name, false);
}
代码语言:javascript
复制
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    // 1.首先要保证线程安全
    synchronized (getClassLoadingLock(name)) {
        // 2.先判断这个类是否被加载过,如果加载过,直接跳过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 3.有父类,优先交给父类尝试加载;如果为空,使用BootstrapClassLoader类加载器
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父类加载失败,这里捕获异常,但不需要做任何处理
            }

            // 4.没有父类,或者父类无法加载,尝试自己加载
            if (c == null) {
                long t1 = System.nanoTime();
                c = findClass(name);

                // this is the defining class loader; record the stats
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
4.3、自定义类加载器

在上文中我们提及过,针对某些特定场景,比如通过网络来传输 Java 类的字节码文件,为保证安全性,这些字节码经过了加密处理,这时系统提供的类加载器就无法对其进行加载,此时我们可以自定义一个类加载器来完成文件的加载。

自定义类加载器也需要继承ClassLoader类,简单示例如下:

代码语言:javascript
复制
public class CustomClassLoader extends ClassLoader {

    private String classPath;

    public CustomClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            byte[] data = loadClassData(name);
            if (data == null) {
                throw new ClassNotFoundException();
            }
            return defineClass(name, data, 0, data.length);
        }
        return null;
    }

    protected byte[] loadClassData(String name) {
        try {
            // package -> file folder
            name = name.replace(".", "//");
            FileInputStream fis = new FileInputStream(new File(classPath + "//" + name + ".class"));
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int len = -1;
            byte[] b = new byte[2048];
            while ((len = fis.read(b)) != -1) {
                baos.write(b, 0, len);
            }
            fis.close();
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

相关的测试类如下:

代码语言:javascript
复制
package com.example;

public class ClassLoaderTest {

    public static void main(String[] args) {
        ClassLoader loader = Thread.currentThread().getContextClassLoader();
        System.out.println("current loader:" +  loader);
    }
}

ClassLoaderTest.java源文件放在指定目录下,并通过javac命令编译成ClassLoaderTest.class,最后进行测试。

代码语言:javascript
复制
public class CustomClassLoaderTest {

    public static void main(String[] args) throws Exception {
        String classPath = "/Downloads";
        CustomClassLoader customClassLoader = new CustomClassLoader(classPath);
        Class<?> testClass = customClassLoader.loadClass("com.example.ClassLoaderTest");
        Object obj = testClass.newInstance();
        System.out.println(obj.getClass().getClassLoader());
    }
}

输出结果:

代码语言:javascript
复制
com.example.CustomClassLoader@60e53b93

在实际使用过程中,最好不要重写loadClass方法,避免破坏双亲委派模型。

4.4、加载类的几种方式

在类加载器中,有三种方式可以实现类的加载。

  • 1.通过命令行启动应用时由 JVM 初始化加载,在上文已提及过
  • 2.通过Class.forName()方法动态加载
  • 3.通过ClassLoader.loadClass()方法动态加载

其中Class.forName()ClassLoader.loadClass()加载方法,稍有区别:

  • Class.forName():表示将类的.class文件加载到 JVM 中之后,还会对类进行解释,执行类中的static方法块;
  • Class.forName(name, initialize, loader):支持通过参数来控制是否执行类中的static方法块;
  • ClassLoader.loadClass():它只将类的.class文件加载到 JVM,但是不执行类中的static方法块,只有在newInstance()才会去执行static方法块;

我们可以看一个简单的例子!

代码语言:javascript
复制
public class ClassTest {

    static {
        System.out.println("初始化静态代码块!");
    }
}
代码语言:javascript
复制
public class CustomClassLoaderTest {

    public static void main(String[] args) throws Exception {
        // 获取当前系统类加载器
        ClassLoader classLoader = Thread.currentThread().getContextClassLoader();

        // 1.使用Class.forName()来加载类,默认会执行初始化静态代码块
        Class.forName(ClassTest.class.getName());

        // 2.使用Class.forName()来加载类,指定false,不会执行初始化静态代码块
//        Class.forName(ClassTest.class.getName(), false, classLoader);

        // 3.使用ClassLoader.loadClass()来加载类,不会执行初始化静态代码块
//        classLoader.loadClass(ClassTest.class.getName());
    }
}

运行结果如下:

代码语言:javascript
复制
初始化静态代码块!

切换不同的加载方式,会有不同的输出结果!

4.5、小结

从以上的介绍中,针对类加载器的机制,我们可以总结出以下几点:

  • 全盘负责:当一个类加载器负责加载某个Class文件时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来加载
  • 双亲委派:在接受类加载请求时,会让父类加载器试图加载该类,只有在父类加载器无法加载该类或者没有父类时,才尝试从自己的类路径中加载该类
  • 按需加载:用户创建的类,通常加载是按需进行的,只有使用了才会被类加载器加载
  • 缓存机制:有被加载过的Class文件都会被缓存,当要使用某个Class时,会先去缓存查找,如果缓存中没有才会读取Class文件进行加载。这就是为什么修改了Class文件后,必须重启 JVM,程序的修改才会生效的原因

五、小结

本文从类的加载过程到类加载器,做了一次知识内容讲解,内容比较多,如果有描述不对的地方,欢迎大家留言指出,不胜感激!

六、参考

1.https://zhuanlan.zhihu.com/p/25228545

2.http://www.ityouknow.com/jvm/2017/08/19/class-loading-principle.html

3.https://www.cnblogs.com/xrq730/p/4844915.html

4.https://www.cnblogs.com/xrq730/p/4845144.html

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2024-02-19,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java极客技术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、摘要
  • 二、类加载的时机
  • 三、类的加载过程
    • 3.1、加载
      • 3.2、验证
        • 3.3、准备
          • 3.4、解析
            • 3.5、初始化
              • 3.6、小结
              • 四、类加载器
                • 4.1、类加载器介绍
                  • 4.2、双亲委派模型
                    • 4.3、自定义类加载器
                      • 4.4、加载类的几种方式
                        • 4.5、小结
                        • 五、小结
                        • 六、参考
                        领券
                        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档