JVM的艺术-对象创建与内存分配机制深度剖析

JVM的艺术-对象创建与内存分配机制深度剖析

引言

本章将介绍jvm的对象创建与内存分配。彻底带你了解jvm的创建过程以及内存分配的原理和区域,以及包含的内容。

对象的创建

类加载的过程

固定的类加载执行顺序: 加载 验证 准备 初始化 卸载 的执行顺序是一定的 为什么解析过程没有在这个执行顺序中?(接下来分析)

什么时候触发类加载不一定,但是类的初始化如下四种情况就要求一定初始化。 但是初始化之前 就一定会执行 加载 验证 准备 三个阶段

触发类加载的过程(由初始化过程引起的类加载)

1):使用new 关键字 获取一个静态属性 设置一个静态属性 调用一个静态方法。

​ int myValue = SuperClass.value;会导致父类初始化,但是不会导致子类初始化

​ SuperClass.Value = 3 ; 会导致父类初始化,不会导致子类初始化。

​ SubClass.staticMethod(); 先初始化父类 再初始化子类

​ SubClass sc = new SubClass(); 先初始化父类 子类初始化子类

2):使用反射的时候,若发现类还没有初始化,就会进行初始化

​ Class clazz = Class.forName("com.hnnd.classloader.SubClass");

3):在初始化一个类的时,若发现其父类没有初始化,就会先初始化父类

​ SubClass.staticMethod(); 先初始化父类 在初始化子类

4):启动虚拟机的时候,需要加载包含main方法的类.

class SuperClass{
    public static int value = 5;

    static {
        System.out.println("Superclass ...... init........");
    }
}
    
class SubClass extends SuperClass {

    static {
        System.out.println("subClass********************init");
    }

    public static void staticMethod(){
        System.out.println("superclass value"+SubClass.value);
    }
}

1:加载

1.1)根据全类名获取到对应类的字节码流(字节流的来源 class 文件,网络文件,还有反射的Proxygeneraotor.generaotorProxyClass)

1.2)把字节流中的静态数据结构加载到方法区中的运行时数据结构

1.3)在内存中生成java.lang.Class对象,可以通过该对象来操作方法区中的数据结构(通过反射)

2:验证

文件格式的验证: 验证class文件开头的0XCAFFBASE 开头

​ 验证主次版本号是否在当前的虚拟机的范围之类

​ 检测jvm不支持的常量类型

元数据的校验:

​ 验证本类是否有父类

​ 验证是否继承了不允许继承的类(final)修饰的类

​ 验证本类不是抽象类的时候,是否实现了所有的接口和父类的接口

字节码验证:验证跳转指令跳转到 方法以外的指令.

​ 验证类型转换是否为有效的, 比如子类对象赋值父类的引用是可以的,但是把父类对象赋值给子类引用是危险的

​ 总而言之:字节码验证通过,并不能说明该字节码一定没有问题,但是字节码验证不通过。那么该字节码文件一定是有问题:。

符号引用的验证(发生在解析的过程中):

通过字符串描述的全类名是否能找到对应的类。

指定类中是否包含字段描述符,以及简单的字段和方法名称。

3:准备:为类变量分配内存以及设置初始值。

​ 比如public static int value = 123;

​ 在准备的过程中 value=0 而不是123 ,当执行类的初始化的方法的时候,value=123

​ 若是一个静态常量

​ public static final int value = 9; 那么在准备的过程中value为9.

4:解析 :把符号引用替换成直接引用

​ 符号引用分类:

​ CONSTANT_Class_info 类或者接口的符号引用

​ CONSTANT_Fieldref_info 字段的符号引用

​ CONSTANT_Methodref_info 方法的符号引用

​ CONSTANT_intfaceMethodref_info- 接口中方法的符号引用

​ CONSTANT_NameAndType_info 子类或者方法的符号引用.

​ CONSTANT_MethodHandle_Info 方法句柄

​ CONSTANT_InvokeDynamic_Info 动态调用

直接引用:

​ 指向对象的指针

​ 相对偏移量

​ 操作句柄

5:初始化:类的初始化时类加载的最后一步:执行类的构造器,为所有的类变量进行赋值(编译器生成CLInit<>)

​ 类构造器是什么?: 类构造器是编译器按照Java源文件总类变量和静态代码块出现的顺序来决定

​ 静态语句只能访问定义在静态语句之前的类变量,在其后的静态变量能赋值 但是不能访问。

​ 父类中的静态代码块优先于子类静态代码块执行。

​ 若类中没有静态代码块也没有静态类变量的话,那么编译器就不会生成 Clint<>类构造器的方法。

public class TestClassInit {
	public static void main(String[] args) {
		System.out.println(SubClass.sub_before_v);
	}
}

class SubClass extends SuperClass{
	public static int sub_before_v = 5;
	static {
		sub_before_v = 10;
		System.out.println("subclass init.......");
		sub_after_v=0;
		//抛错,static代码块中的代码只能赋值后面的类变量 但是不能访问。
		sub_before_v = sub_after_v;
	}
	public static int sub_after_v = 10;
}

class SuperClass {
	public static int super_before_v = 5;
	static{
		System.out.println("superclass init......");
	}
	public static int super_after_v = 10;
}

6:使用

7:卸载

1.****类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个

符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。

2.****分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为

对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。

这个步骤有两个问题:

1.如何划分内存。

2.在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的

情况。

划分内存的方法:

内存的方法:

“指针碰撞”(Bump the Pointer)(默认用指针碰撞)

假设Java堆中内存时完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,

仅仅需要把指针往空闲的一端移动与对象大小相等的距离。使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。

“空闲列表”(Free List)

事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个列表,记录可用的内存块信息,当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。

解决并发问题的方法:

CAS(compare and swap)

虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。

本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存。通过­XX:+/­

UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启­XX:+****UseTLAB),­XX:TLABSize 指定TLAB大小。

3.****初始化

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也

可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问

到这些字段的数据类型所对应的零值。

什么是 TLAB

TLAB (Thread Local Allocation Buffer,线程本地分配缓冲区)是 Java 中内存分配的一个概念,它是在 Java 堆中划分出来的针对每个线程的内存区域,专门在该区域为该线程创建的对象分配内存。它的主要目的是在多线程并发环境下需要进行内存分配的时候,减少线程之间对于内存分配区域的竞争,加速内存分配的速度。TLAB 本质上还是在 Java 堆中的,因此在 TLAB 区域的对象,也可以被其他线程访问。

如果没有启用 TLAB,多个并发执行的线程需要创建对象、申请分配内存的时候,有可能在 Java 堆的同一个位置申请,这时就需要对拟分配的内存区域进行加锁或者采用 CAS 等操作,保证这个区域只能分配给一个线程。

启用了 TLAB 之后(-XX:+UseTLAB, 默认是开启的),JVM 会针对每一个线程在 Java 堆中预留一个内存区域,在预留这个动作发生的时候,需要进行加锁或者采用 CAS 等操作进行保护,避免多个线程预留同一个区域。一旦某个区域确定划分给某个线程,之后该线程需要分配内存的时候,会优先在这片区域中申请。这个区域针对分配内存这个动作而言是该线程私有的,因此在分配的时候不用进行加锁等保护性的操作。

4.****设置对象头

初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对

象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。

在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)

和对齐填充(Padding)。 HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据, 如哈

希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等。对象头的另外一部分

是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

对象头在hotspot的C++源码里的注释如下:

1 Bit‐format of an object header (most significant first, big endian layout below): 
2 // 
3 // 32 bits: 
4 // ‐‐‐‐‐‐‐‐ 
5 // hash:25 ‐‐‐‐‐‐‐‐‐‐‐‐>| age:4 biased_lock:1 lock:2 (normal object) 
6 // JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object) 
7 // size:32 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (CMS free block) 
8 // PromotedObject*:29 ‐‐‐‐‐‐‐‐‐‐>| promo_bits:3 ‐‐‐‐‐>| (CMS promoted object) 
9 // 
10 // 64 bits: 
11 // ‐‐‐‐‐‐‐‐ 
12 // unused:25 hash:31 ‐‐>| unused:1 age:4 biased_lock:1 lock:2 (normal object) 
13 // JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object) 
14 // PromotedObject*:61 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| promo_bits:3 ‐‐‐‐‐>| (CMS promoted object) 
15 // size:64 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (CMS free block) 
16 // 
17 // unused:25 hash:31 ‐‐>| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object) 
18 // JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object) 
19 // narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ‐‐‐‐‐>| (COOPs && CMS promoted object) 
20 // unused:21 size:35 ‐‐>| cms_free:1 unused:7 ‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐‐>| (COOPs && CMS free block)

5.执行****方法

执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋

零值不同,这是由程序员赋的值),和执行构造方法。

对象大小与指针压缩

对象大小可以用jol­core包查看,引入依赖

 <dependency> 
     <groupId>org.openjdk.jol</groupId> 
     <artifactId>jol‐core</artifactId>
 <version>0.9</version> 5 </dependency>
1 import org.openjdk.jol.info.ClassLayout; 
2
3 /** 
4 * 计算对象大小 
5 */ 
6 public class JOLSample { 
7
8 	public static void main(String[] args) { 
9 		ClassLayout layout = ClassLayout.parseInstance(new Object()); 
10 		System.out.println(layout.toPrintable()); 
11
12 		System.out.println(); 
13 		ClassLayout layout1 = ClassLayout.parseInstance(new int[]{}); 
14 		System.out.println(layout1.toPrintable()); 
15
16 		System.out.println(); 
17 		ClassLayout layout2 = ClassLayout.parseInstance(new A()); 
18 		System.out.println(layout2.toPrintable()); 
19 	} 
20
21 	// ‐XX:+UseCompressedOops 默认开启的压缩所有指针 
22 	// ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer 
23 	// Oops : Ordinary Object Pointers 
24 	public static class A { 
25 		//8B mark word 
26 		//4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或‐XX:‐UseCompressedOops,则占用8B 
27 		int id; //4B 
28 		String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B 
29 		byte b; //1B 
30 		Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B 
31 	} 
32 } 
33
34
35 运行结果: 
36 java.lang.Object object internals: 
37 OFFSET SIZE TYPE DESCRIPTION VALUE 
38 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) //mark word 
39 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) //mark word 
40 8 4 (object header) e5 01 00 f8 (11100101 00000001 00000000 11111000) (‐134217243) //Klass Pointer 
41 12 4 (loss due to the next object alignment) 
42 Instance size: 16 bytes 
43 Space losses: 0 bytes internal + 4 bytes external = 4 bytes total 
44
45
46 [I object internals: 
                                                                                     
47 OFFSET SIZE TYPE DESCRIPTION VALUE 
48 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) 
49 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 
50 8 4 (object header) 6d 01 00 f8 (01101101 00000001 00000000 11111000) (‐134217363) 
51 12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) 
52 16 0 int [I.<elements> N/A 
53 Instance size: 16 bytes 
54 Space losses: 0 bytes internal + 0 bytes external = 0 bytes total 
55
56
57 com.tuling.jvm.JOLSample$A object internals: 58 OFFSET SIZE TYPE DESCRIPTION VALUE
58 OFFSET SIZE TYPE DESCRIPTION VALUE
59 0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000
60 4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
61 8 4 (object header) 61 cc 00 f8 (01100001 11001100 00000000 11111000) (‐134165407)
62 12 4 int A.id 0 
63 16 1 byte A.b 0 
64 17 3 (alignment/padding gap) 
65 20 4 java.lang.String A.name null
66 24 4 java.lang.Object A.o null
67 28 4 (loss due to the next object alignment) 
68 Instance size: 32 bytes 69 Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

什么是java对象的指针压缩

1.jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩

2.jvm配置参数:UseCompressedOops,compressed­­压缩、oop(ordinary object pointer)­­对象指针

3.启用指针压缩:­XX:+UseCompressedOops(默认开启),禁止指针压缩:­XX:­UseCompressedOops

为什么要进行指针压缩?

1.在64位平台的HotSpot中使用32位指针,内存使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据,

占用较大宽带,同时GC也会承受较大压力

2.为了减少64位平台下内存的消耗,启用指针压缩功能

3.在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm

只用32位地址就可以支持更大的内存配置(小于等于32G)

4.堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间

5.堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,这就会出现1的问题,所以堆内

存不要大于32G为好 .

对象内存分配

对象内存分配流程图

对象栈上分配

我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内

存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的

内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参

数传递到其他地方中。

很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结

束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内

存一起被回收掉。

JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换

先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

标量替换:通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该

对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就

不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认

开启。

标量与聚合量:标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及

reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一

步分解的聚合量。

栈上分配示例:

结论:****栈上分配依赖于逃逸分析和标量替换

对象在Eden区分配

大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。我

们来进行实际测试一下。

在测试之前我们先来看看 Minor GC和Full GC 有什么不同呢?

Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。

Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢

10倍以上。

Eden与Survivor区默认8:1:1

大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活

的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回

收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所

以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,

JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变

化可以设置参数-XX:-UseAdaptiveSizePolicy

示例:

我们可以看出eden区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用至少几M内存)。假如我们再为

allocation2分配内存会出现什么情况呢?

1 //添加运行JVM参数: ‐XX:+PrintGCDetails

2 public class GCTest {

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

4 byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;

5 allocation1 = new byte[60000*1024];

6

7 allocation2 = new byte[8000*1024];

8

9 /*allocation3 = new byte[1000*1024];

10 allocation4 = new byte[1000*1024];

11 allocation5 = new byte[1000*1024];

12 allocation6 = new byte[1000*1024];*/

13 }

14 }

15

16 运行结果:

17 [GC (Allocation Failure) [PSYoungGen: 65253K‐>936K(76288K)] 65253K‐>60944K(251392K), 0.0279083 secs] [Times:

user=0.13 sys=0.02, real=0.03 secs]

18 Heap

19 PSYoungGen total 76288K, used 9591K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)

20 eden space 65536K, 13% used [0x000000076b400000,0x000000076bc73ef8,0x000000076f400000)

21 from space 10752K, 8% used [0x000000076f400000,0x000000076f4ea020,0x000000076fe80000)

22 to space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)

23 ParOldGen total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)

24 object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)

25 Metaspace used 3342K, capacity 4496K, committed 4864K, reserved 1056768K

26 class space used 361K, capacity 388K, committed 512K, reserved 1048576K

简单解释一下为什么会出现这种情况: 因为给allocation2分配内存的时候eden区内存几乎已经被分配完了,我们刚刚讲

了当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现allocation1无法存入

Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放allocation1,所以不会出现

Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。可以执行如下代码

验证:

1  public class GCTest {
2 public static void main(String[] args) throws InterruptedException {
3 byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6;
4 allocation1 = new byte[60000*1024];
5
6 allocation2 = new byte[8000*1024];
7
8 allocation3 = new byte[1000*1024];
9 allocation4 = new byte[1000*1024];
10 allocation5 = new byte[1000*1024];
11 allocation6 = new byte[1000*1024];
12 }
13 }
14
15 运行结果:
16 [GC (Allocation Failure) [PSYoungGen: 65253K‐>952K(76288K)] 65253K‐>60960K(251392K), 0.0311467 secs] [Times:
user=0.08 sys=0.02, real=0.03 secs]
17 Heap
18 PSYoungGen total 76288K, used 13878K [0x000000076b400000, 0x0000000774900000, 0x00000007c0000000)
19 eden space 65536K, 19% used [0x000000076b400000,0x000000076c09fb68,0x000000076f400000)
20 from space 10752K, 8% used [0x000000076f400000,0x000000076f4ee030,0x000000076fe80000)
21 to space 10752K, 0% used [0x0000000773e80000,0x0000000773e80000,0x0000000774900000)
22 ParOldGen total 175104K, used 60008K [0x00000006c1c00000, 0x00000006cc700000, 0x000000076b400000)
23 object space 175104K, 34% used [0x00000006c1c00000,0x00000006c569a010,0x00000006cc700000)
24 Metaspace used 3343K, capacity 4496K, committed 4864K, reserved 1056768K
25 class space used 361K, capacity 388K, committed 512K, reserved 1048576K

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大

对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下

有效。

最后在赠送一张图:

posted @ 2020-12-31 14:46  雕爷的架构之路  阅读(812)  评论(0编辑  收藏  举报