JVM系列(五) - JVM类加载机制详解

前言

本文将由浅及深,介绍 Java 类加载 的过程和原理,进一步对类加载器的进行 源码分析,完成一个 自定义类加载器

正文

1. 类加载器是什么

类加载器简言之,就是用于把 .class 文件中的 字节码信息,转化为具体的 java.lang.Class 对象 的工具。具体过程如下:

  1. 在实际的 类加载过程 中,JVM 会将所有的 .class 字节码文件 中的 二进制数据 读入内存中,导入运行时 数据区方法区 中。

  2. 当一个类首次被 主动加载被动加载 时,类加载器会对此类执行类加载的流程:加载连接验证准备解析)、初始化

  3. 如果类加载成功,堆内存 中会产生一个新的 Class 对象,Class 对象封装了类在 方法区 内的数据结构

Class 对象的创建过程描述:

2. 类加载的过程

类加载的过程分为三个步骤(五个阶段) :加载 -> 连接验证准备解析)-> 初始化

加载验证准备初始化 这四个阶段发生的顺序是 确定的解析阶段 可以在 初始化阶段之后 发生,也称为 动态绑定晚期绑定

2.1. 加载

加载 是查找并加载类的 二进制数据 的过程,具体的描述如下:

  1. 通过类的 全限定名 定位 .class 文件,并获取其 二进制字节流
  2. 把字节流所代表的 静态存储结构 转换为 方法区运行时数据结构
  3. Java 中生成一个此类的 java.lang.Class 对象,作为方法区中这些数据的 访问入口

2.2. 连接

连接 包括 验证准备解析 三个步骤。

2.2.1. 验证

验证 是为了保证 被加载的类正确性。验证是 连接阶段 的第一步,用于确保 Class 字节流 中的信息是否符合 虚拟机 的要求。具体验证形式如下:

  1. 文件格式验证:验证 字节流 是否符合 Class 文件格式的规范。例如:是否以 0xCAFEBABE 开头、主次版本号 是否在当前虚拟机的处理范围之内、常量池 中的常量是否有不被支持的类型。

  2. 元数据验证:对 字节码描述的信息 进行语义分析(注意:对比 javac 编译阶段的语义分析),以保证其描述的信息符合 Java 语言规范的要求;例如:这个类是否有 父类,除了 java.lang.Object 之外。

  3. 字节码验证:通过 数据流控制流 分析,确定程序语义是合法的、符合逻辑的。

  4. 符号引用验证:确保 解析动作 能正确执行。

2.2.2. 准备

为类的 静态变量 分配内存,并将其初始化为 默认值。准备过程通常会分配一个结构用来存储 类信息,这个结构中包含了类中定义的 成员变量方法接口信息 等。准备过程完成了以下操作:

  1. 这时候进行 内存分配 的仅包括 类变量 (static),而不包括 实例变量实例变量 会在对象 实例化 时随着对象一块分配在Java 中。

  2. 这里所设置的 初始值 通常情况下是数据类型 默认的零值 (比如 00Lnullfalse 等),而不是被在 Java 代码中被显式赋值

2.2.3. 解析

解析过程 把类中对 常量池 内的 符号引用 转换为 直接引用。解析动作主要针对 类或接口字段类方法接口方法方法类型方法句柄调用点限定符7 类符号引用进行。

2.3. 初始化

初始化 会对 类静态变量 赋予正确的 初始值,注意和 连接 时的 解析过程 区分开来。

2.3.1. 初始化的目标

  1. 实现对声明 类静态变量 时指定的初始值的初始化;

  2. 实现对使用 静态代码块 设置的初始值的初始化。

2.3.2. 初始化的步骤

  1. 如果此类没被 加载连接,则先 加载连接 此类;
  2. 如果此类的 直接父类 还未被初始化,则先 初始化 其直接父类;
  3. 如果类中有 初始化语句,则按照顺序依次执行 初始化语句

2.3.3. 初始化的时机

  1. 创建类的 对象实例 (new 关键字);
  2. java.lang.reflect 包中的方法 (比如:Class.forName(“xxx”));
  3. 对类的 静态变量 进行访问或赋值;
  4. 访问调用类的 静态方法
  5. 初始化一个类的 子类父类 本身也会被初始化;
  6. 作为程序的 启动入口,包含 main 方法(比如:SpringBoot入口类)。

3. 类的主动引用和被动引用

3.1. 主动引用

主动引用 是指在 类加载阶段,只执行 加载连接 操作,不执行 初始化 操作。

3.1.1. 主动引用的几种形式

  1. 创建类的实例(new关键字);

  2. 调用 java.lang.reflect 包中的方法 (比如:Class.forName(“xxx”));

  3. 对类的 静态变量 进行访问或赋值;

  4. 访问调用类的 静态方法

  5. 初始化一个类的 子类父类 本身也会被初始化;

  6. 作为程序的 启动入口,包含 main 方法(比如:SpringBoot 入口类)。

3.1.2. 主方法在初始类中

  • 代码示例:
1
2
3
4
5
6
7
8
9
public class OptimisticReference0 {
static {
System.out.println(OptimisticReference0.class.getSimpleName() + " is referred!");
}

public static void main(String[] args) {
System.out.println();
}
}
  • 运行结果:

OptimisticReference0 is referred!

3.1.3. 创建子类会触发父类的初始化

  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class OptimisticReference1 {
public static class Parent {
static {
System.out.println(Parent.class.getSimpleName() + " is referred!");
}
}

public static class Child extends Parent {
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
}
}

public static void main(String[] args) {
new Child();
}
}
  • 运行结果:

Parent is referred!
Child is referred!

3.1.4. 访问一个类静态变量

  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class OptimisticReference2 {
public static class Child {
protected static String name;
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
name = "Child";
}
}

public static void main(String[] args) {
System.out.println(Child.name);
}
}
  • 运行结果:

Child is referred!
Child

3.1.5. 对类的静态变量进行赋值

  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
public class OptimisticReference3 {
public static class Child {
protected static String name;
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
}
}

public static void main(String[] args) {
Child.name = "Child";
}
}
  • 运行结果:

Child is referred!

3.1.6. 使用java.lang.reflect包提供的反射机制

  • 代码示例:
1
2
3
4
5
public class OptimisticReference4 {
public static void main(String[] args) throws ClassNotFoundException {
Class.forName("org.ostenant.jdk8.learning.examples.reference.optimistic.Child");
}
}
  • 运行结果:

Child is referred!

3.2. 被动引用

被动引用 指的是在 类加载阶段,会执行 加载连接初始化 操作。被动引用 的几种形式如下:

  1. 通过子类引用 父类静态字段,不会导致 子类 初始化;

  2. 定义类的 数组引用不赋值,不会触发此类的初始化;

  3. 访问类定义的 常量,不会触发此类的初始化。

3.2.1. 子类引用父类的的静态字段,不会导致子类初始化

  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class NegativeReference0 {
public static class Parent {
public static String name = "Parent";
static {
System.out.println(Parent.class.getSimpleName() + " is referred!");
}
}

public static class Child extends Parent {
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
}
}

public static void main(String[] args) {
System.out.println(Child.name);
}
}
  • 运行结果:

Parent is referred!
Parent

3.2.2. 定义类的数组引用而不赋值,不会触发类的初始化

  • 代码示例:
1
2
3
4
5
6
7
8
9
10
11
public class NegativeReference1 {
public static class Child {
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
}
}

public static void main(String[] args) {
Child[] childs = new Child[10];
}
}
  • 运行结果:

无输出

3.2.3. 访问类定义的常量,不会触发此类的初始化

  • 示例代码:
1
2
3
4
5
6
7
8
9
10
11
12
public class NegativeReference2 {
public static class Child {
public static final String name = "Child";
static {
System.out.println(Child.class.getSimpleName() + " is referred!");
}
}

public static void main(String[] args) {
System.out.println(Child.name);
}
}
  • 运行结果:

Child

4. 三种类加载器

类加载器 负责加载程序中的 类型接口),并赋予唯一的名字予以标识。

4.1. 类加载器的组织结构

4.2. 类加载器的关系

  1. Bootstrap ClassLoader 是在 Java 虚拟机启动后 初始化的。

  2. Bootstrap ClassLoader 负责加载 ExtClassLoader,并且将 ExtClassLoader的父加载器设置为 Bootstrap ClassLoader

  3. Bootstrap ClassLoader 加载完 ExtClassLoader 后,就会加载 AppClassLoader,并且将 AppClassLoader 的父加载器指定为 ExtClassLoader

4.3. 类加载器的作用

Class Loader 实现方式 具体实现类 负责加载的目标
Bootstrap Loader C++ 由C++实现 %JAVA_HOME%/jre/lib/rt.jar 以及 -Xbootclasspath 参数指定的路径以及中的类库
Extension ClassLoader Java sun.misc.Launcher$ExtClassLoader %JAVA_HOME%/jre/lib/ext 路径下以及 java.ext.dirs 系统变量指定的路径中类库
Application ClassLoader Java sun.misc.Launcher$AppClassLoader Classpath 以及-classpath-cp 指定目录所指定的位置的类或者是 jar 文档,它也是 Java 程序默认的类加载器

4.4. 类加载器的特点

  • 层级结构Java 里的 类装载器 被组织成了有 父子关系层级结构Bootstrap 类装载器 是所有 装载器 的父亲。

  • 代理模式:基于 层级结构类的代理 可以在装载器之间进行 代理。当装载器装载一个类时,首先会检查它在 父装载器 中是否进行了装载。如果 上层装载器 已经装载了这个类,这个类会被 直接使用。反之,类装载器会 请求装载 这个类。

  • 可见性限制:一个 子装载器 可以 查找父装载器 中的类,但是一个 父装载器 不能查找 子装载器 里的类。

  • 不允许卸载类装载器 可以装载一个类但是 不可以卸载,不过可以 删除 当前的 类装载器,然后创建 一个新的类装载器。

4.5. 类加载器的隔离问题

每个 类装载器 都有一个自己的 命名空间 用来保存 已装载的类。当一个类装载器装载一个类时,它会通过保存在 命名空间 里的 类全局限定名 (Fully Qualified Class Name) 进行搜索,来检测这个类是否 已经被加载

JVMDalvik 对类唯一的识别是 ClassLoader id + PackageName + ClassName。所以一个运行程序中,是有可能存在两个 包名类名 完全一致的 。这两个 不是由一个 ClassLoader 所加载,因此无法将一个类的实例 强转 为另外一个类的实例,这就是 ClassLoader隔离性

5. 双亲委托机制

为了解决 类加载器隔离问题JVM 引入了 双亲委托机制。它的核心思想是:

  • 自底向上 检查类是否 已加载

  • 自顶向下 尝试去 加载类

5.1. 具体加载过程

  1. AppClassLoader 加载一个 class 时,它首先不会自己去尝试加载这个类,而是把类加载请求 委派父类加载器 ExtClassLoader 去完成。

  2. ExtClassLoader 加载一个 class 时,它首先也不会自己去尝试加载这个类,而是把类加载请求 委派BootStrapClassLoader 去完成。

  3. 如果 BootStrapClassLoader 加载失败(例如在 %JAVA_HOME%/jre/lib 里未查找到该 class),会使用 ExtClassLoader 来尝试加载。

  4. 如果 ExtClassLoader 也加载失败,则会使用 AppClassLoader 来加载,如果 AppClassLoader加载失败,则会报出异常 ClassNotFoundException

5.1. ClassLoader源码分析

  • loadClass()

通过指定类的 全限定名称,由类加载器 检测装载创建 并返回该类的 java.lang.Class 对象。

ClassLoader 通过 loadClass() 方法实现了 双亲委托机制,用于类的 动态加载loadClass() 本身是一个 递归向上调用 的过程。

自底向上检查类是否已加载

  1. 先通过 findLoadedClass() 方法从最 底端类加载器 开始检查类是否已经加载。
  2. 如果 已经加载,则根据 resolve 参数,决定是否要执行 连接 过程,并返回 Class 对象。
  3. 如果 没有加载,则通过 parent.loadClass() 委托其 父类加载器 执行相同的 检查操作 (默认 不做连接处理)。
  4. 直到 顶级类加载器,即 parent 为空时,由 findBootstrapClassOrNull() 方法尝试到 Bootstrap ClassLoader 中去检查 目标类

自顶向下尝试去加载类

  1. 如果仍然没有找到目标类,则从 Bootstrap ClassLoader 开始,通过 findClass() 方法尝试到对应的 类目录 下去加载目标类。
  2. 如果 加载成功,则根据 resolve 参数决定是否要执行 连接 过程,并返回 Class 对象。
  3. 如果 加载失败,则由其 子类加载器 尝试去加载,直到最 底端类加载器 也加载失败,最终抛出 ClassNotFoundException 异常。
  • findLoadedClass()

通过 findLoadedClass() 方法,查找当前 类加载器的缓存中 是否已经加载目标类。findLoadedClass() 实际调用了底层的 native 方法 findLoadedClass0()

  • findBootstrapClassOrNull()

查找最顶端的 Bootstrap 类加载器 的是否已经加载目标类。同样,findBootstrapClassOrNull() 实际调用了底层的 native 方法 findBootstrapClass()

  • findClass()

ClassLoaderjava.lang 包下的 抽象类,也是所有类加载器 (除了 Bootstrap) 的 基类findClass()ClassLoader子类 提供的用于 加载目标类抽象方法

注意Bootstrap ClassLoader并不属于JVM的层次,它不遵守ClassLoader的加载规则,Bootstrap classLoader并没有子类。

  • defineClass()

defineClass()ClassLoader子类 提供的方法,它可以将 .class 文件的 二进制数据 转换为合法的 java.lang.Class 对象。

6. 类的动态加载

6.1. 类的几种加载方式

  • 通过 命令行 启动时由 JVM 初始化加载;
  • 通过 Class.forName() 方法动态加载;
  • 通过 ClassLoader.loadClass() 方法动态加载。

6.2. forName()

Class.forName() 方法会把类的 .class 文件加载到 JVM 虚拟机中,对类进行 解释 的同时,执行类中的 static 静态代码块

6.3. loadClass()

ClassLoader.loadClass() 只是把 .class 文件加载到 JVM 虚拟机中,不会执行 static 代码块 中的内容,只有在 newInstance 才会去执行。

7. 对象的初始化

7.1. 对象的初始化顺序

静态变量/静态代码块 -> 普通代码块 -> 构造函数

  1. 父类的 静态变量静态代码块(先声明的先执行);
  2. 子类的 静态变量静态代码块(先声明的先执行);
  3. 父类的 普通成员变量普通代码块(先声明的先执行);
  4. 父类的 构造函数
  5. 子类 普通成员变量普通代码块(先声明的先执行);
  6. 子类的 构造函数

7.2. 对象的初始化示例

Parent.java

Children.java

Tester.java

  • 测试结果:JVM 在创建对象时,遵守以上对象的 初始化顺序

8. 自定义类加载器

8.1. 自定义类加载器

在源码分析中提到如何实现 自定义类加载器,下面按步骤实现一个 自定义的类加载器

  • 定义待加载的 目标类 Parent.javaChildren.java

Parent.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package org.ostenant.jdk8.learning.examples.classloader.custom;

public class Parent {
protected static String CLASS_NAME;
protected static String CLASS_LOADER_NAME;
protected String instanceID;

// 1.先执行静态变量和静态代码块(只在类加载期间执行一次)
static {
CLASS_NAME = Parent.class.getName();
CLASS_LOADER_NAME = Parent.class.getClassLoader().toString();
System.out.println("Step a: " + CLASS_NAME + " is loaded by " + CLASS_LOADER_NAME);
}

// 2.然后执行变量和普通代码块(每次创建实例都会执行)
{
instanceID = this.toString();
System.out.println("Step c: Parent instance is created: " + CLASS_LOADER_NAME + " -> " + instanceID);
}

// 3.然后执行构造方法
public Parent() {
System.out.println("Step d: Parent instance:" + instanceID + ", constructor is invoked");
}

public void say() {
System.out.println("My first class loader...");
}
}

Children.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package org.ostenant.jdk8.learning.examples.classloader.custom;

public class Children extends Parent {
static {
CLASS_NAME = Children.class.getName();
CLASS_LOADER_NAME = Children.class.getClassLoader().toString();
System.out.println("Step b: " + CLASS_NAME + " is loaded by " + CLASS_LOADER_NAME);
}

{
instanceID = this.toString();
System.out.println("Step e: Children instance is created: " + CLASS_LOADER_NAME + " -> " + instanceID);
}

public Children() {
System.out.println("Step f: Children instance:" + instanceID + ", constructor is invoked");
}

public void say() {
System.out.println("My first class loader...");
}
}
  • 实现 自定义类加载器 CustomClassLoader

CustomClassLoader.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
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;
}
}
  • 测试类加载器的 加载过程

CustomerClassLoaderTester.java

测试程序启动时,逐一 拷贝加载 待加载的 目标类源文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private static final String CHILDREN_SOURCE_CODE_NAME = SOURCE_CODE_LOCATION + "Children.java";
private static final String PARENT_SOURCE_CODE_NAME = SOURCE_CODE_LOCATION + "Parent.java";
private static final List<String> SOURCE_CODE = Arrays.asList(CHILDREN_SOURCE_CODE_NAME, PARENT_SOURCE_CODE_NAME);

static {
SOURCE_CODE.stream().map(path -> new File(path))
// 路径转文件对象
.filter(f -> !f.isDirectory())
// 文件遍历
.forEach(f -> {
// 拷贝后源代码
File targetFile = copySourceFile(f);
// 编译源代码
compileSourceFile(targetFile);
});
}

拷贝 单一源文件 到自定义类加载器的 类加载目录 下面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
    protected static File copySourceFile(File f) {
BufferedReader reader = null;
BufferedWriter writer = null;
try {
reader = new BufferedReader(new FileReader(f));
// package ...;
String firstLine = reader.readLine();

StringTokenizer tokenizer = new StringTokenizer(firstLine, " ");
String packageName = "";
while (tokenizer.hasMoreElements()) {
String e = tokenizer.nextToken();
if (e.contains("package")) {
continue;
} else {
packageName = e.trim().substring(0, e.trim().length() - 1);
}
}

// package -> path
String packagePath = packageName.replace(".", "//");
// java file path
String targetFileLocation = TARGET_CODE_LOCALTION + "//" + packagePath + "//";

String sourceFilePath = f.getPath();
String fileName = sourceFilePath.substring(sourceFilePath.lastIndexOf("\\") + 1);

File targetFile = new File(targetFileLocation, fileName);
File targetFileLocationDir = new File(targetFileLocation);
if (!targetFileLocationDir.exists()) {
targetFileLocationDir.mkdirs();
}
// writer
writer = new BufferedWriter(new FileWriter(targetFile));
// 写入第一行
writer.write(firstLine);
writer.newLine();
writer.newLine();

String input = "";
while ((input = reader.readLine()) != null) {
writer.write(input);
writer.newLine();
}

return targetFile;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
reader.close();
writer.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}

对拷贝后的 .java 源文件 执行 手动编译,在 同级目录 下生成 .class 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
protected static void compileSourceFile(File f) {
try {
JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
StandardJavaFileManager standardFileManager = javaCompiler.getStandardFileManager(null, null, null);
Iterable<? extends JavaFileObject> javaFileObjects = standardFileManager.getJavaFileObjects(f);

// 执行编译任务
CompilationTask task = javaCompiler.getTask(null, standardFileManager, null, null, null, javaFileObjects);
task.call();
standardFileManager.close();

} catch (Exception e) {
e.printStackTrace();
}
}

通过 自定义类加载器 加载 Childrenjava.lang.Class<?> 对象,然后通过 反射机制 创建 Children 的实例对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
public void test() throws Exception {
// 创建自定义类加载器
CustomClassLoader classLoader = new CustomClassLoader(TARGET_CODE_LOCALTION); // E://myclassloader//classpath
// 动态加载class文件到内存中(无连接)
Class<?> c = classLoader.loadClass("org.ostenant.jdk8.learning.examples.classloader.custom.Children");
// 通过反射拿到所有的方法
Method[] declaredMethods = c.getDeclaredMethods();
for (Method method : declaredMethods) {
if ("say".equals(method.getName())) {
// 通过反射拿到children对象
Object children = c.newInstance();
// 调用children的say()方法
method.invoke(children);
break;
}
}
}

8.2. 自定义类加载器测试

8.2.1. 测试场景一

  1. 保留 static 代码块,把目标类 Children.javaParent.java 拷贝 到类加载的目录,然后进行 手动编译

  2. 保留 测试项目目录中的目标类 Children.javaParent.java

经过测试,Children 对象被成功 创建,并通过 反射 调用了它的 say() 方法。

然而查看 控制台日志,可以发现 类加载过程 使用的仍然是 AppClassLoader自定义类加载器 CustomClassLoader 并没有生效。

查看 CustomClassLoader类加载目录,发现 拷贝编译 后的 Parent.classChidren.class 文件存在。

分析原因:由于项目空间中的 Parent.javaChildren.java,在拷贝后并 没有移除。导致 AppClassLoader 优先在自身的 Classpath 下面 找到并加载 目标类。

8.2.2. 测试场景二

  1. 类目录下有 已编译目标类.class 文件存在,注释掉 static 代码块。

  2. 删除 代码目录中的 源文件 Children.javaParent.java

测试结果输出如下:

这里利用双亲委托机制,绕开 AppClassLoader,通过 自定义类加载器 CustomClassLoader 成功加载 目标类 ParentChildren,创建了Children对象,并通过 反射 调用了它的 say() 方法。

参考书籍

  • 周志明,深入理解Java虚拟机

欢迎关注技术公众号: 零壹技术栈

零壹技术栈

本帐号将持续分享后端技术干货,包括虚拟机基础,多线程编程,高性能框架,异步、缓存和消息中间件,分布式和微服务,架构学习和进阶等学习资料和文章。

文章作者: Chen Vainlgory
文章链接: https://geek.vainlgory.top/2017/05/10/JVM系列(五) - JVM类加载机制详解/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 零壹技術棧 | VainlgoryのBlog
微信打赏
支付宝打赏