在面试、并发编程、一些开源框架中老是会遇到 volatile
与 synchronized
。synchronized
如何保证并发安全?volatile
语义的内存可见性指的是什么?这其中又跟 JMM 有什么关系,在并发编程中 JMM 的做用是什么,为何须要 JMM?与 JVM 内存结构有什么区别?java
「码哥字节」 总结出里面的核心知识点以及面试重点,图文并茂无畏面试与并发编程,全面提高并发编程内功!git
「码哥字节」会分别图解下 JVM 内存结构和 JMM 内存模型,这里不会讲太多 JVM 相关的,将来会有专门讲解 JVM 以及垃圾回收、内存调优的文章。敬请期待……程序员
接下来咱们经过图文的方式分别认识 JVM 内存结构和 JMM 内存模型,DJ, trop the beat, lets’go!github
JVM 内存结构这么骚,须要和虚拟机运行时数据一块儿唠叨,由于程序运行的数据区域须要他来划分各领风骚。面试
Java 内存模型也很妖娆,不能被 JVM 内存结构来搞混淆,实际他是一种抽象定义,主要为了并发编程安全访问数据。编程
总结下就是:数组
Java 代码是运行在虚拟机上的,咱们写的 .java 文件首先会被编译成 .class 文件,接着被 JVM 虚拟机加载,而且根据不一样操做系统平台翻译成对应平台的机器码运行,以下如所示:缓存
从图中能够看到,有了 JVM 这个抽象层以后,Java 就能够实现跨平台了。JVM 只须要保证可以正确加载 .class 文件,就能够运行在诸如 Linux、Windows、MacOS 等平台上了。安全
JVM 经过 Java 类加载器加载 javac 编译出来的 class 文件,经过执行引擎解释执行或者 JIT 即时编译调用才调用系统接口实现程序的运行。多线程
而虚拟机在运行程序的时候会把内存划分为不一样的数据区域,不一样区域负责不一样功能,随着 Java 的发展,内存布局也在调整之中,以下是 Java 8 以后的布局状况,移除了永久代,使用 Mataspace 代替,因此 -XX:PermSize -XX:MaxPermSize
等参数变没有意义。 JVM 内存结构以下图所示:
执行字节码的模块叫作执行引擎,执行引擎依靠程序计数器恢复线程切换。本地内存包含元数据区域以及一些直接内存。
数据共享区域存储实例对象以及数组,一般是占用内存最大的一块也是数据共享的,好比 new Object() 就会生成一个实例;而数组也是保存在堆上面的,由于在 Java 中,数组也是对象。垃圾收集器的主要做用区域。
那一个对象建立的时候,究竟是在堆上分配,仍是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。
Java 的对象能够分为基本数据类型和普通对象。
对于普通对象来讲,JVM 会首先在堆上建立对象,而后在其余地方使用的实际上是它的引用。好比,把这个引用保存在虚拟机栈的局部变量表中。
对于基本数据类型来讲(byte、short、int、long、float、double、char),有两种状况。
咱们上面提到,每一个线程拥有一个虚拟机栈。当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其余状况,一般在在堆上分配,逃逸分析的状况下可能会在栈分配。
注意,像 int[] 数组这样的内容,是在堆上分配的。数组并非基本数据类型。
Java 虚拟机栈基于线程,即便只有一个 main 方法,都是以线程的方式运行,在运行的生命周期中,参与计算的数据会出栈与入栈,而「虚拟机栈」里面的每条数据就是「栈帧」,在 Java 方法执行的时候则建立一个「栈帧」并入栈「虚拟机栈」。调用结束则「栈帧」出栈,随之对应的线程也结束。
public int add() { int a = 1, b = 2; return a + b; }
add 方法会被抽象成一个「栈帧」的结构,当方法执行过程当中则对应着操做数 1 与 2 的操做数栈入栈,而且赋值给局部变量 a 、b ,遇到 add 指令则将操做数 一、2 出栈相加结果入栈。方法结束后「栈帧」出栈,返回结果结束。
每一个栈帧包含四个区域:
这里有一个重要的地方,敲黑板了:
每一个线程拥有一个「虚拟机栈」,每一个「虚拟机栈」拥有多个「栈帧」,而栈帧则对应着一个方法。每一个「栈帧」包含局部变量表、操做数栈、动态连接、方法返回地址。方法运行结束则意味着该「栈帧」出栈。
以下图所示:
存储每一个 class 类的元数据信息,好比类的结构、运行时的常量池、字段、方法数据、方法构造函数以及接口初始化等特殊方法。
元空间是在堆上么?
答:不是在堆上分配的,而是在堆外空间分配,方法区就是在元空间中。
字符串常量池在那个区域中?
答:这个跟 JDK 不一样版本不一样区别,JDK 1.8 以前,元空间尚未出道成团,方法区被放在一个叫永久代的空间,而字符串常量就在此间。
JDK 1.7 以前,字符串常量池也放在叫做永久带的空间。 JDK 1.7 以后,字符串常量池从永久带挪到了堆上凑。
因此,从 1.7 版本开始,字符串常量池就一直存在于堆上。
跟虚拟机栈相似,区别在于前者是为 Java 方法服务,而本地方法栈是为 native 方法服务。
保存当前正在执行的 JVM 指令地址。咱们的程序在线程切换中运行,那凭啥指导这个线程已经执行到什么地方呢?
程序计数器是一块较小的内存空间,它的做用能够看做是当前线程所执行的字节码的行号指示器。这里面存的,就是当前线程执行的进度。
DJ, drop the beats!有请“码哥字节”,拨弄 Java 内存模型这根动人心弦。
首先他不是“真实存在”,而是和多线程相关的一组“规范”,须要每一个 JVM 的实现都要遵照这样的“规范”,有了 JMM 的规范保障,并发程序运行在不一样的虚拟机获得出的程序结果才是安全可靠可信赖。
若是没有 JMM 内存模型来规范,就可能会出现通过不一样 JVM “翻译”以后,运行的结果都不相同也不正确。
JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等致使的结果不可预期的问题数据,保证不一样的并发语义关键字获得相应的并发安全的数据资源保护。
主要目的就是让 Java 程序员在各类平台下达到一致性访问效果。
是 JUC 包工具类和并发关键字的原理保障
volatile、synchronized、Lock
等,它们的实现原理都涉及 JMM。有了 JMM 的参与,才让各个同步工具和关键字可以发挥做用同步语义才能生效,使得咱们开发出并发安全的程序。
JMM 最重要的的三点内容:重排序、原子性、内存可见性。
咱们写的 bug 代码,当我觉得这些代码的运行顺序按照我神来之笔的书写的顺序执行的时候,我发现我错的。实际上,编译器、JVM、甚至 CPU 都有可能出于优化性能的目的,并不能保证各个语句执行的前后顺序与输入的代码顺序一致,而是调整了顺序,这就是指令重排序。
重排序优点
可能咱们会疑问:为何要指令重排序?有啥用?
以下图:
通过重排序以后,状况以下图所示:
重排序后,对 a 操做的指令发生了改变,节省了一次 Load a 和一次 Store a,减小了指令执行,提高了速度改变了运行,这就是重排序带来的好处。
重排序的三种状况
好比当前唐伯虎爱慕 “秋香”,那就把对“秋香”的爱慕、约会放到一块儿执行效率就高得多。避免在撩“冬香”的时候又跑去约会“秋香”,减小了这部分的时间开销,此刻咱们须要必定的顺序重排。不太重排序并不意味着能够任意排序,它须要须要保证重排序后,不改变单线程内的语义,不能把对“秋香”说的话传到“冬香”的耳朵里,不然能任意排序的话,后果不堪设想,“时间管理大师”非你莫属。
这里的优化跟编译器相似,目的都是经过打乱顺序提升总体运行效率,这就是为了更快而执行的秘密武器。
我不是真正意义的重排序,可是结果跟重排序有相似的成绩。由于仍是有区别因此我加了双引号做为不同的定义。
因为内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,因此这也会致使程序表现出乱序的行为。
每一个线程只可以直接接触到工做内存,没法直接操做主内存,而工做内存中所保存的数据正是主内存的共享变量的副本,主内存和工做内存之间的通讯是由 JMM 控制的。
举个例子:
线程 1 修改了 a 的值,可是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,因此线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 仍是等于初始值。可是线程 2 却可能看到线程 1 修改 a 以后的代码执行效果,表面上看起来像是发生了重顺序。
先来看为什么会有内存可见性问题
public class Visibility { int x = 0; public void write() { x = 1; } public void read() { int y = x; } }
内存可见性问题:当 x 的值已经被第一个线程修改了,可是其余线程却看不到被修改后的值。
假设两个线程执行的上面的代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。下面咱们来分析一下,代码在实际运行过程当中的情景是怎么样的,以下图所示:
它们均可以从主内存中去获取到这个信息,对两个线程来讲 x 都是 0。但是此时咱们假设第 1 个线程先去执行 write 方法,它就把 x 的值从 0 改成了 1,可是它改动的动做并非直接发生在主内存中的,而是会发生在第 1 个线程的工做内存中,以下图所示。
那么,假设线程 1 的工做内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,可是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。
volatile、synchronized、final、和锁
都能保证可见性。要注意的是 volatile,每当变量的值改变的时候,都会立马刷新到主内存中,因此其余线程想要读取这个数据,则须要从主内存中刷新到工做内存上。
而锁和同步关键字就比较好理解一些,它是把更多个操做强制转化为原子化的过程。因为只有一把锁,变量的可见性就更容易保证。
咱们大体能够认为基本数据类型变量、引用类型变量、声明为 volatile 的任何类型变量的访问读写是具有原子性的(long 和 double 的非原子性协定:对于 64 位的数据,如 long 和 double,Java 内存模型规范容许虚拟机将没有被 volatile 修饰的 64 位数据的读写操做划分为两次 32 位的操做来进行,即容许虚拟机实现选择能够不保证 64 位数据类型的 load、store、read 和 write 这四个操做的原子性,即若是有多个线程共享一个并未声明为 volatile 的 long 或 double 类型的变量,而且同时对它们进行读取和修改操做,那么某些线程可能会读取到一个既非原值,也不是其余线程修改值的表明了“半个变量”的数值。
但因为目前各类平台下的商用虚拟机几乎都选择把 64 位数据的读写操做做为原子操做来对待,所以在编写代码时通常也不须要将用到的 long 和 double 变量专门声明为 volatile)。这些类型变量的读、写自然具备原子性,但相似于 “基本变量++” / “volatile++” 这种复合操做并无原子性。好比 i++;
JMM 最重要的的三点内容:重排序、原子性、内存可见性。那么 JMM 又是如何解决这些问题的呢?
JMM 抽象出主存储器(Main Memory)和工做存储器(Working Memory)两种。
线程是没法直接对主内存进行操做的,以下图所示,线程 A 想要和线程 B 通讯,只能经过主存进行交换。
经历下面 2 个步骤:
1)线程 A 把本地内存 A 中更新过的共享变量刷新到主内存中去。
2)线程 B 到主内存中去读取线程 A 以前已更新过的共享变量。
从抽象角度看,JMM 定义了线程与主内存之间的抽象关系:
八个操做
为了支持 JMM,Java 定义了 8 种原子操做(Action),用来控制主存与工做内存之间的交互:
int i = 1;
如上图所示,把一个变量数据从主内存复制到工做内存,要顺序执行 read 和 load;而把变量数据从工做内存同步回主内存,就要顺序执行 store 和 write 操做。
因为重排序、原子性、内存可见性,带来的不一致问题,JMM 经过 八个原子动做,内存屏障保证了并发语义关键字的代码可以实现对应的安全并发访问。
原子性保障
JMM 保证了 read、load、assign、use、store 和 write 六个操做具备原子性,能够认为除了 long 和 double 类型之外,对其余基本数据类型所对应的内存单元的访问读写都是原子的。
可是当你想要更大范围的的原子性保证就须要使用 ,就可使用 lock 和 unlock 这两个操做。
内存屏障:内存可见性与指令重排序
那 JMM 如何保障指令重排序排序,内存可见性带来并发访问问题?
内存屏障(Memory Barrier)用于控制在特定条件下的重排序和内存可见性问题。JMM 内存屏障可分为读屏障和写屏障,Java 的内存屏障实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。Java 编译器在生成字节码时,会在执行指令序列的适当位置插入内存屏障来限制处理器的重排序。
组合以下:
JMM 是一个抽象概念,因为 CPU 多核多级缓存、为了优化代码会发生指令重排的缘由,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工做内存于主内存的概念,而且经过八个原子操做以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排致使的线程安全问题,JMM 是并发编程的基础。
而且 JMM 为程序中全部的操做定义了一个关系,称之为 「Happens-Before」原则,要保证执行操做 B 的线程看到操做 A 的结果,那么 A、B 之间必须知足「Happens-Before」关系,若是这两个操做缺少这个关系,那么 JVM 能够任意重排序。
Happens-Before
它是 Java 中的一个关键字,当一个变量是共享变量,同时被 volatile
修饰当值被更改的时候,其余线程再读取该变量的时候能够保证能获取到修改后的值,经过 JMM 屏蔽掉各类硬件和操做系统的内存访问差别 以及 CPU 多级缓存等致使的数据不一致问题。
须要注意的是,volatile 修饰的变量对全部线程是当即可见的,关键字自己就包含了禁止指令重排的语意,可是在非原子操做的并发读写中是不安全的,好比 i++ 操做一共分三步操做。
相比 synchronised
Lock
volatile
更加轻量级,不会发生上下文切换等开销,接着跟着「码哥字节」来分析下他的适用场景,以及错误使用场景。
volatile 的做用
这就表明了若是变量被 volatile 修饰,那么每次修改以后,接下来在读取这个变量的时候必定能读取到该变量最新的值。
boolean 标志位
共享变量只有被赋值和读取,没有其余的多个复合操做(好比先读数据再修改的复合运算 i++),咱们就可使用 volatile 代替 synchronized 或者代替原子类,由于赋值操做是原子性操做,而 volatile 同时保证了 可见性,因此是线程安全的。
以下经典场景 volatile boolean flag
,一旦 flag 发生变化,全部的线程当即可见。
volatile boolean shutdownRequested; ... public void shutdown() { shutdownRequested = true; } public void doWork() { while (!shutdownRequested) { // do stuff } }
线程 1 执行 doWork() 的过程当中,可能有另外的线程 2 调用了 shutdown,线程 1 里吗读区到修改的值并中止执行。
这种类型的状态标记的一个公共特性是:一般只有一种状态转换;shutdownRequested
标志从false
转换为true
,而后程序中止。
双重检查(单例模式)
class Singleton{ private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { // 1 synchronized (Singleton.class) { if(instance==null) instance = new Singleton(); //2 } } return instance; } }
在双重检查锁模式中为何须要使用 volatile 关键字?
假如 Instance 类变量是没有用 volatile 关键字修饰的,会致使这样一个问题:
在线程执行到第 1 行的时候,代码读取到 instance 不为 null 时,instance 引用的对象有可能尚未完成初始化。
形成这种现象主要的缘由是建立对象不是原子操做以及指令重排序。
第二行代码能够分解成如下几步:
memory = allocate(); // 1:分配对象的内存空间 ctorInstance(memory); // 2:初始化对象 instance = memory; // 3:设置instance指向刚分配的内存地址
根源在于代码中的 2 和 3 之间,可能会被重排序。例如:
memory = allocate(); // 1:分配对象的内存空间 instance = memory; // 3:设置instance指向刚分配的内存地址 // 注意,此时对象尚未被初始化! ctorInstance(memory); // 2:初始化对象
这种重排序可能就会致使一个线程拿到的 instance 是非空的可是还没初始化彻底。
面试官可能会问你,“为何要 double-check?去掉任何一次的 check 行不行?”
咱们先来看第二次的 check,这时你须要考虑这样一种状况,有两个线程同时调用 getInstance 方法,因为 singleton 是空的 ,所以两个线程均可以经过第一重的 if 判断;而后因为锁机制的存在,会有一个线程先进入同步语句,并进入第二重 if 判断 ,而另外的一个线程就会在外面等待。
不过,当第一个线程执行完 new Singleton() 语句后,就会退出 synchronized 保护的区域,这时若是没有第二重 if (singleton == null) 判断的话,那么第二个线程也会建立一个实例,此时就破坏了单例,这确定是不行的。
而对于第一个 check 而言,若是去掉它,那么全部线程都会串行执行,效率低下,因此两个 check 都是须要保留的。
volatile 不适合运用于须要保证原子性的场景,好比更新的时候须要依赖原来的值,而最典型的就是 a++ 的场景,咱们仅靠 volatile 是不能保证 a++ 的线程安全的。代码以下所示:
public class DontVolatile implements Runnable { volatile int a; public static void main(String[] args) throws InterruptedException { Runnable r = new DontVolatile(); Thread thread1 = new Thread(r); Thread thread2 = new Thread(r); thread1.start(); thread2.start(); thread1.join(); thread2.join(); System.out.println(((DontVolatile) r).a); } @Override public void run() { for (int i = 0; i < 1000; i++) { a++; } } }
最终的结果 a < 2000。
互斥同步是常见的并发正确性保障方式。同步就好像在公司上班,厕所只有一个,如今一帮人同时想去「带薪拉屎」占用厕所,为了保证厕所同一时刻只能一个员工使用,经过排队互斥实现。
互斥是实现同步的一种手段,临界区、互斥量(Mutex)和信号量(Semaphore)都是主要互斥方式。互斥是因,同步是果。
监视器锁(Monitor 另外一个名字叫管程)本质是依赖于底层的操做系统的 Mutex Lock(互斥锁)来实现的。每一个对象都存在着一个 monitor 与之关联,对象与其 monitor 之间的关系有存在多种实现方式,如 monitor 能够与对象一块儿建立销毁或当线程试图获取对象锁时自动生成,但当一个 monitor 被某个线程持有后,它便处于锁定状态。
mutex 的工做方式
在 Java 虚拟机 (HotSpot) 中,Monitor 是基于 C++ 实现的,由 ObjectMonitor 实现的, 几个关键属性:
ObjectMonitor 中有两个队列,\_WaitSet 和 \_EntryList,用来保存 ObjectWaiter 对象列表( 每一个等待锁的线程都会被封装成 ObjectWaiter 对象),\_owner 指向持有 ObjectMonitor 对象的线程,当多个线程同时访问一段同步代码时,首先会进入 \_EntryList 集合,当线程获取到对象的 monitor 后进入 \_Owner 区域并把 monitor 中的 owner 变量设置为当前线程同时 monitor 中的计数器 count 加 1。
若线程调用 wait() 方法,将释放当前持有的 monitor,owner 变量恢复为 null,count 自减 1,同时该线程进入 WaitSet 集合中等待被唤醒。若当前线程执行完毕也将释放 monitor(锁)并复位变量的值,以便其余线程进入获取 monitor(锁)。
在 Java 中,最基本的互斥同步手段就是 synchronised,通过编译以后会在同步块先后分别插入 monitorenter
, monitorexit
这两个字节码指令,而这两个字节码指令都须要提供一个 reference 类型的参数来指定要锁定和解锁的对象,具体表现以下所示:
synchronised 用的锁也存在 Java 对象头里,在 JVM 中,对象在内存的布局分为三块区域:对象头、实例数据、对其填充。
对象头是 synchronised 实现的关键,使用的锁对象是存储在 Java 对象头里的,jvm 中采用 2 个字宽(一个字宽表明 4 个字节,一个字节 8bit)来存储对象头(若是对象是数组则会分配 3 个字宽,多出来的 1 个字宽记录的是数组长度)。其主要结构是由 Mark Word 和 Class Metadata Address 组成。
Mark word 记录了对象和锁有关的信息,当某个对象被 synchronized 关键字当成同步锁时,那么围绕这个锁的一系列操做都和 Mark word 有关系。
虚拟机位数 | 对象结构 | 说明 |
---|---|---|
32/64bit | Mark Word | 存储对象的 hashCode、锁信息或分代年龄或 GC 标志等信息 |
32/64bit | Class Metadata Address | 类型指针指向对象的类元数据,JVM 经过这个指针肯定该对象是哪一个类的实例。 |
32/64bit | Array length | 数组的长度(若是当前对象是数组) |
其中 Mark Word 在默认状况下存储着对象的 HashCode、分代年龄、锁标记位等。Mark Word 在不一样的锁状态下存储的内容不一样,在 32 位 JVM 中默认状态为下:
锁状态 | 25 bit | 4 bit | 1 bit 是不是偏向锁 | 2 bit 锁标志位 |
---|---|---|---|---|
无锁 | 对象 HashCode | 对象分代年龄 | 0 | 01 |
在运行过程当中,Mark Word 存储的数据会随着锁标志位的变化而变化,可能出现以下 4 种数据:
锁标志位的表示意义:
到目前为止,咱们再总结一下前面的内容,synchronized(lock) 中的 lock 能够用 Java 中任何一个对象来表示,而锁标识的存储实际上就是在 lock 这个对象中的对象头内。
Monitor(监视器锁)本质是依赖于底层的操做系统的 Mutex Lock(互斥锁)来实现的。Mutex Lock 的切换须要从用户态转换到核心态中,所以状态转换须要耗费不少的处理器时间。因此 synchronized 是 Java 语言中的一个重量级操做。
为何任意一个 Java 对象都能成为锁对象呢?
Java 中的每一个对象都派生自 Object 类,而每一个 Java Object 在 JVM 内部都有一个 native 的 C++对象 oop/oopDesc 进行对应。
其次,线程在获取锁的时候,实际上就是得到一个监视器对象(monitor) ,monitor 能够认为是一个同步对象,全部的 Java 对象是天生携带 monitor。
多个线程访问同步代码块时,至关于去争抢对象监视器修改对象中的锁标识, ObjectMonitor 这个对象和线程争抢锁的逻辑有密切的关系。
JMM 总结
JVM 内存结构和 Java 虚拟机的运行时区域有关;
Java 内存模型和 Java 的并发编程有关。JMM 是并发编程的基础,它屏蔽了硬件于系统形成的内存访问差别,保证了 一致性、原子性、并禁止指令重排保证了安全访问。经过总线嗅探机制使得缓存数据失效, 保证 volatile 内存可见性。
JMM 是一个抽象概念,因为 CPU 多核多级缓存、为了优化代码会发生指令重排的缘由,JMM 为了屏蔽细节,定义了一套规范,保证最终的并发安全。它抽象出了工做内存于主内存的概念,而且经过八个原子操做以及内存屏障保证了原子性、内存可见性、防止指令重排,使得 volatile 能保证内存可见性并防止指令重排、synchronised 保证了内存可见性、原子性、防止指令重排致使的线程安全问题,JMM 是并发编程的基础。
synchronized 原理
提到了锁的几个概念,偏向锁、轻量级锁、重量级锁。在 JDK1.6 以前,synchronized 是一个重量级锁,性能比较差。从 JDK1.6 开始,为了减小得到锁和释放锁带来的性能消耗,synchronized 进行了优化,引入了偏向锁和轻量级锁的概念。
因此从 JDK1.6 开始,锁一共会有四种状态,锁的状态根据竞争激烈程度从低到高分别是: 无锁状态->偏向锁状态->轻量级锁状态->重量级锁状态。这几个状态会随着锁竞争的状况逐步升级。为了提升得到锁和释放锁的效率,锁能够升级可是不能降级。
同时为了提高性能,还带来了锁消除、锁粗化、自旋锁和自适应自旋锁…...
鉴于篇幅缘由关于线程状态、锁的同步过程「码哥字节」下回分解,分别介绍加锁、解锁以及锁升级过程当中 Mark Word 如何变化。如何正确使用 wait()、 notify() 实现生产-消费模式,讲解如何避免常见的易错知识点,防止掉坑。
敬请期待......
后台回复 “加群” 进入专属技术群一块儿成长
往期推荐