做为目前最流行的JavaScript引擎,V8引擎从出现的那一刻起便普遍受到人们的关注,咱们知道,JavaScript能够高效地运行在浏览器和Nodejs这两大宿主环境中,也是由于背后有强大的V8引擎在为其保驾护航,甚至成就了Chrome在浏览器中的霸主地位。不得不说,V8引擎为了追求极致的性能和更好的用户体验,为咱们作了太多太多,从原始的Full-codegen
和Crankshaft
编译器升级为Ignition
解释器和TurboFan
编译器的强强组合,到隐藏类,内联缓存和HotSpot
热点代码收集等一系列强有力的优化策略,V8引擎正在努力下降总体的内存占用和提高到更高的运行性能。javascript
本篇主要是从V8引擎的垃圾回收机制入手,讲解一下在JavaScript代码执行的整个生命周期中V8引擎是采起怎样的垃圾回收策略来减小内存占比的,固然这部分的知识并不太影响咱们写代码的流程,毕竟在通常状况下咱们不多会遇到浏览器端出现内存溢出而致使程序崩溃的状况,可是至少咱们对这方面有必定的了解以后,能加强咱们在写代码过程当中对减小内存占用,避免内存泄漏的主观意识,也许可以帮助你写出更加健壮和对V8引擎更加友好的代码。本文也是笔者在查阅资料巩固复习的过程当中慢慢总结和整理出来的,若文中有错误的地方,还请指正。前端
咱们知道,在V8引擎逐行执行JavaScript代码的过程当中,当遇到函数的状况时,会为其建立一个函数执行上下文(Context)环境并添加到调用堆栈的栈顶,函数的做用域(handleScope)中包含了该函数中声明的全部变量,当该函数执行完毕后,对应的执行上下文从栈顶弹出,函数的做用域会随之销毁,其包含的全部变量也会统一释放并被自动回收。试想若是在这个做用域被销毁的过程当中,其中的变量不被回收,即持久占用内存,那么必然会致使内存暴增,从而引起内存泄漏致使程序的性能直线降低甚至崩溃,所以内存在使用完毕以后理当归还给操做系统以保证内存的重复利用。java
这个过程就比如你向亲戚朋友借钱,借得多了却不按时归还,那么你再下次借钱的时候确定没有那么顺利了,或者说你的亲戚朋友不肯意再借你了,致使你的手头有点儿紧(内存泄漏,性能降低),因此说有借有还,再借不难嘛,毕竟出来混都是要还的。node
可是JavaScript做为一门高级编程语言,并不像C语言或C++语言中须要手动地申请分配和释放内存,V8引擎已经帮咱们自动进行了内存的分配和管理,好让咱们有更多的精力去专一于业务层面的复杂逻辑,这对于咱们前端开发人员来讲是一项福利,可是随之带来的问题也是显而易见的,那就是因为不用去手动管理内存,致使写代码的过程当中不够严谨从而容易引起内存泄漏(毕竟这是别人对你的好,你没有付出过,又怎能体会获得?)。git
虽然V8引擎帮助咱们实现了自动的垃圾回收管理,解放了咱们勤劳的双手,但V8引擎中的内存使用也并非无限制的。具体来讲,默认状况下,V8引擎在64
位系统下最多只能使用约1.4GB
的内存,在32
位系统下最多只能使用约0.7GB
的内存,在这样的限制下,必然会致使在node中没法直接操做大内存对象,好比将一个2GB
大小的文件所有读入内存进行字符串分析处理,即便物理内存高达32GB
也没法充分利用计算机的内存资源,那么为何会有这种限制呢?这个要回到V8引擎的设计之初,起初只是做为浏览器端JavaScript的执行环境,在浏览器端咱们其实不多会遇到使用大量内存的场景,所以也就没有必要将最大内存设置得太高。但这只是一方面,其实还有另外两个主要的缘由:github
JS单线程机制
:做为浏览器的脚本语言,JS的主要用途是与用户交互以及操做DOM,那么这也决定了其做为单线程的本质,单线程意味着执行的代码必须按顺序执行,在同一时间只能处理一个任务。试想若是JS是多线程的,一个线程在删除DOM元素的同时,另外一个线程对该元素进行修改操做,那么必然会致使复杂的同步问题。既然JS是单线程的,那么也就意味着在V8执行垃圾回收时,程序中的其余各类逻辑都要进入暂停等待阶段,直到垃圾回收结束后才会再次从新执行JS逻辑。所以,因为JS的单线程机制,垃圾回收的过程阻碍了主线程逻辑的执行。虽然JS是单线程的,可是为了可以充分利用操做系统的多核CPU计算能力,在HTML5中引入了新的Web Worker标准,其做用就是为JS创造多线程环境,容许主线程建立Worker线程,将一些任务分配给后者运行。在主线程运行的同时,Worker在后台运行,二者互不干扰。等到Worker线程完成计算任务,再把结果返回给主线程。这样的好处是, 一些计算密集型或高延迟的任务,被Worker线程负担,主线程(一般负责UI交互)就会很流畅,不会被阻塞或者拖慢。Web Worker不是JS的一部分,而是经过JS访问的浏览器特性,其虽然创造了一个多线程的执行环境,可是子线程彻底受主线程控制,不能访问浏览器特定的API,例如操做DOM,所以这个新标准并无改变JS单线程的本质。面试
垃圾回收机制
:垃圾回收自己也是一件很是耗时的操做,假设V8的堆内存为1.5G
,那么V8作一次小的垃圾回收须要50ms以上,而作一次非增量式回收甚至须要1s以上,可见其耗时之久,而在这1s的时间内,浏览器一直处于等待的状态,同时会失去对用户的响应,若是有动画正在运行,也会形成动画卡顿掉帧的状况,严重影响应用程序的性能。所以若是内存使用太高,那么必然会致使垃圾回收的过程缓慢,也就会致使主线程的等待时间越长,浏览器也就越长时间得不到响应。基于以上两点,V8引擎为了减小对应用的性能形成的影响,采用了一种比较粗暴的手段,那就是直接限制堆内存的大小,毕竟在浏览器端通常也不会遇到须要操做几个G内存这样的场景。可是在node端,涉及到的I/O
操做可能会比浏览器端更加复杂多样,所以更有可能出现内存溢出的状况。不过也不要紧,V8为咱们提供了可配置项来让咱们手动地调整内存大小,可是须要在node初始化的时候进行配置,咱们能够经过以下方式来手动设置。算法
咱们尝试在node命令行中输入如下命令:编程
笔者本地安装的node版本为
v10.14.2
,可经过node -v
查看本地node的版本号,不一样版本可能会致使下面的命令会有所差别。数组
// 该命令能够用来查看node中可用的V8引擎的选项及其含义
node --v8-options
复制代码
而后咱们会在命令行窗口中看到大量关于V8的选项,这里咱们暂且只关注图中红色选框中的几个选项:
// 设置新生代内存中单个半空间的内存最小值,单位MB
node --min-semi-space-size=1024 xxx.js
// 设置新生代内存中单个半空间的内存最大值,单位MB
node --max-semi-space-size=1024 xxx.js
// 设置老生代内存最大值,单位MB
node --max-old-space-size=2048 xxx.js
复制代码
经过以上方法即可以手动放宽V8引擎所使用的内存限制,同时node也为咱们提供了process.memoryUsage()
方法来让咱们能够查看当前node进程所占用的实际内存大小。
heapTotal
:表示V8当前申请到的堆内存总大小。heapUsed
:表示当前内存使用量。external
:表示V8内部的C++对象所占用的内存。rss(resident set size)
:表示驻留集大小,是给这个node进程分配了多少物理内存,这些物理内存中包含堆,栈和代码片断。对象,闭包等存于堆内存,变量存于栈内存,实际的JavaScript源代码存于代码段内存。使用Worker线程时,rss
将会是一个对整个进程有效的值,而其余字段则只针对当前线程。在JS中声明对象时,该对象的内存就分配在堆中,若是当前已申请的堆内存已经不够分配新的对象,则会继续申请堆内存直到堆的大小超过V8的限制为止。
V8的垃圾回收策略主要是基于分代式垃圾回收机制
,其根据对象的存活时间将内存的垃圾回收进行不一样的分代,而后对不一样的分代采用不一样的垃圾回收算法。
在V8引擎的堆结构组成中,其实除了新生代
和老生代
外,还包含其余几个部分,可是垃圾回收的过程主要出如今新生代和老生代,因此对于其余的部分咱们不必作太多的深刻,有兴趣的小伙伴儿能够查阅下相关资料,V8的内存结构主要由如下几个部分组成:
新生代(new_space)
:大多数的对象开始都会被分配在这里,这个区域相对较小可是垃圾回收特别频繁,该区域被分为两半,一半用来分配内存,另外一半用于在垃圾回收时将须要保留的对象复制过来。老生代(old_space)
:新生代中的对象在存活一段时间后就会被转移到老生代内存区,相对于新生代该内存区域的垃圾回收频率较低。老生代又分为老生代指针区
和老生代数据区
,前者包含大多数可能存在指向其余对象的指针的对象,后者只保存原始数据对象,这些对象没有指向其余对象的指针。大对象区(large_object_space)
:存放体积超越其余区域大小的对象,每一个对象都会有本身的内存,垃圾回收不会移动大对象区。代码区(code_space)
:代码对象,会被分配在这里,惟一拥有执行权限的内存区域。map区(map_space)
:存放Cell和Map,每一个区域都是存放相同大小的元素,结构简单(这里没有作具体深刻的了解,有清楚的小伙伴儿还麻烦解释下)。内存结构图以下所示:
在V8引擎的内存结构中,新生代主要用于存放存活时间较短的对象。新生代内存是由两个semispace(半空间)
构成的,内存最大值在64
位系统和32
位系统上分别为32MB
和16MB
,在新生代的垃圾回收过程当中主要采用了Scavenge
算法。
Scavenge
算法是一种典型的牺牲空间换取时间的算法,对于老生代内存来讲,可能会存储大量对象,若是在老生代中使用这种算法,势必会形成内存资源的浪费,可是在新生代内存中,大部分对象的生命周期较短,在时间效率上表现可观,因此仍是比较适合这种算法。
在
Scavenge
算法的具体实现中,主要采用了Cheney
算法,它将新生代内存一分为二,每个部分的空间称为semispace
,也就是咱们在上图中看见的new_space中划分的两个区域,其中处于激活状态的区域咱们称为From
空间,未激活(inactive new space)的区域咱们称为To
空间。这两个空间中,始终只有一个处于使用状态,另外一个处于闲置状态。咱们的程序中声明的对象首先会被分配到From
空间,当进行垃圾回收时,若是From
空间中尚有存活对象,则会被复制到To
空间进行保存,非存活的对象会被自动回收。当复制完成后,From
空间和To
空间完成一次角色互换,To
空间会变为新的From
空间,原来的From
空间则变为To
空间。
基于以上算法,咱们能够画出以下的流程图:
From
空间中分配了三个对象A、B、CTo
空间中进行保存From
空间中的全部非存活对象所有清除From
空间中的内存已经清空,开始和To
空间完成一次角色互换From
空间中分配了一个新对象DTo
空间中进行保存From
空间中的全部非存活对象所有清除From
空间和To
空间继续完成一次角色互换Scavenge
算法的垃圾回收过程主要就是将存活对象在
From
空间和
To
空间之间进行复制,同时完成两个空间之间的角色互换,所以该算法的缺点也比较明显,浪费了一半的内存用于复制。
当一个对象在通过屡次复制以后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象重新生代转移到老生代的过程咱们称之为晋升
。
对象晋升的条件主要有如下两个:
Scavenge
算法To
空间的内存占比是否已经超过25%
默认状况下,咱们建立的对象都会分配在From
空间中,当进行垃圾回收时,在将对象从From
空间复制到To
空间以前,会先检查该对象的内存地址来判断是否已经经历过一次Scavenge
算法,若是地址已经发生变更则会将该对象转移到老生代中,不会再被复制到To
空间,能够用如下的流程图来表示:
Scavenge
算法,会被复制到
To
空间,可是若是此时
To
空间的内存占比已经超过
25%
,则该对象依旧会被转移到老生代,以下图所示:
25%
的内存限制是由于
To
空间在经历过一次
Scavenge
算法后会和
From
空间完成角色互换,会变为
From
空间,后续的内存分配都是在
From
空间中进行的,若是内存使用太高甚至溢出,则会影响后续对象的分配,所以超过这个限制以后对象会被直接转移到老生代来进行管理。
在老生代中,由于管理着大量的存活对象,若是依旧使用Scavenge
算法的话,很明显会浪费一半的内存,所以已经再也不使用Scavenge
算法,而是采用新的算法Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
来进行管理。
在早前咱们可能据说过一种算法叫作引用计数
,该算法的原理比较简单,就是看对象是否还有其余引用指向它,若是没有指向该对象的引用,则该对象会被视为垃圾并被垃圾回收器回收,示例以下:
// 建立了两个对象obj1和obj2,其中obj2做为obj1的属性被obj1引用,所以不会被垃圾回收
let obj1 = {
obj2: {
a: 1
}
}
// 建立obj3并将obj1赋值给obj3,让两个对象指向同一个内存地址
let obj3 = obj1;
// 将obj1从新赋值,此时原来obj1指向的对象如今只由obj3来表示
obj1 = null;
// 建立obj4并将obj3.obj2赋值给obj4
// 此时obj2所指向的对象有两个引用:一个是做为obj3的属性,另外一个是变量obj4
let obj4 = obj3.obj2;
// 将obj3从新赋值,此时本能够对obj3指向的对象进行回收,可是由于obj3.obj2被obj4所引用,所以依旧不能被回收
obj3 = null;
// 此时obj3.obj2已经没有指向它的引用,所以obj3指向的对象在此时能够被回收
obj4 = null;
复制代码
上述例子在通过一系列操做后最终对象会被垃圾回收,可是一旦咱们碰到循环引用
的场景,就会出现问题,咱们看下面的例子:
function foo() {
let a = {};
let b = {};
a.a1 = b;
b.b1 = a;
}
foo();
复制代码
这个例子中咱们将对象a
的a1
属性指向对象b
,将对象b
的b1
属性指向对象a
,造成两个对象相互引用,在foo
函数执行完毕后,函数的做用域已经被销毁,做用域中包含的变量a
和b
本应该能够被回收,可是由于采用了引用计数
的算法,两个变量均存在指向自身的引用,所以依旧没法被回收,致使内存泄漏。
所以为了不循环引用致使的内存泄漏问题,截至2012年全部的现代浏览器均放弃了这种算法,转而采用新的Mark-Sweep(标记清除)
和Mark-Compact(标记整理)
算法。在上面循环引用的例子中,由于变量a
和变量b
没法从window
全局对象访问到,所以没法对其进行标记,因此最终会被回收。
Mark-Sweep(标记清除)
分为标记
和清除
两个阶段,在标记阶段会遍历堆中的全部对象,而后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep
算法主要是经过判断某个对象是否能够被访问到,从而知道该对象是否应该被回收,具体步骤以下:
根列表
,用于从根节点出发去寻找那些能够被访问到的变量。好比在JavaScript中,window
全局对象能够当作一个根节点。如下几种状况均可以做为根节点:
- 全局对象
- 本地函数的局部变量和参数
- 当前嵌套调用链上的其余函数的变量和参数
Mark-Sweep
算法存在一个问题,就是在经历过一次标记清除后,内存空间可能会出现不连续的状态,由于咱们所清理的对象的内存地址可能不是连续的,因此就会出现内存碎片的问题,致使后面若是须要分配一个大对象而空闲内存不足以分配,就会提早触发垃圾回收,而此次垃圾回收实际上是不必的,由于咱们确实有不少空闲内存,只不过是不连续的。
为了解决这种内存碎片的问题,Mark-Compact(标记整理)
算法被提了出来,该算法主要就是用来解决内存的碎片化问题的,回收过程当中将死亡对象清除后,在整理的过程当中,会将活动的对象往堆内存的一端进行移动,移动完成后再清理掉边界外的所有内存,咱们能够用以下流程图来表示:
标记
阶段,将对象A和对象C标记为活动的整理
阶段,将活动的对象往堆内存的一端移动清除
阶段,将活动对象左侧的内存所有回收至此就完成了一次老生代垃圾回收的所有过程,咱们在前文中说过,因为JS的单线程机制,垃圾回收的过程会阻碍主线程同步任务的执行,待执行完垃圾回收后才会再次恢复执行主任务的逻辑,这种行为被称为全停顿(stop-the-world)
。在标记阶段一样会阻碍主线程的执行,通常来讲,老生代会保存大量存活的对象,若是在标记阶段将整个堆内存遍历一遍,那么势必会形成严重的卡顿。
所以,为了减小垃圾回收带来的停顿时间,V8引擎又引入了Incremental Marking(增量标记)
的概念,即将本来须要一次性遍历堆内存的操做改成增量标记的方式,先标记堆内存中的一部分对象,而后暂停,将执行权从新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。这个理念其实有点像React
框架中的Fiber
架构,只有在浏览器的空闲时间才会去遍历Fiber Tree
执行对应的任务,不然延迟执行,尽量少地影响主线程的任务,避免应用卡顿,提高应用性能。
得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)
和增量式整理(incremental compaction)
,让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记
和并行清理
,进一步地减小垃圾回收对主线程的影响,为应用提高更多的性能。
在咱们写代码的过程当中,基本上都不太会关注写出怎样的代码才能有效地避免内存泄漏,或者说浏览器和大部分的前端框架在底层已经帮助咱们处理了常见的内存泄漏问题,可是咱们仍是有必要了解一下常见的几种避免内存泄漏的方式,毕竟在面试过程当中也是常常考察的要点。
在ES5中以var
声明的方式在全局做用域中建立一个变量时,或者在函数做用域中不以任何声明的方式建立一个变量时,都会无形地挂载到window
全局对象上,以下所示:
var a = 1; // 等价于 window.a = 1;
复制代码
function foo() {
a = 1;
}
复制代码
等价于
function foo() {
window.a = 1;
}
复制代码
咱们在foo
函数中建立了一个变量a
可是忘记使用var
来声明,此时会意想不到地建立一个全局变量并挂载到window对象上,另外还有一种比较隐蔽的方式来建立全局变量:
function foo() {
this.a = 1;
}
foo(); // 至关于 window.foo()
复制代码
当foo
函数在调用时,它所指向的运行上下文环境为window
全局对象,所以函数中的this
指向的实际上是window
,也就无心建立了一个全局变量。当进行垃圾回收时,在标记阶段由于window
对象能够做为根节点,在window
上挂载的属性都可以被访问到,并将其标记为活动的从而常驻内存,所以也就不会被垃圾回收,只有在整个进程退出时全局做用域才会被销毁。若是你遇到须要必须使用全局变量的场景,那么请保证必定要在全局变量使用完毕后将其设置为null
从而触发回收机制。
在咱们的应用中常常会有使用setTimeout
或者setInterval
等定时器的场景,定时器自己是一个很是有用的功能,可是若是咱们稍不注意,忘记在适当的时间手动清除定时器,那么颇有可能就会致使内存泄漏,示例以下:
const numbers = [];
const foo = function() {
for(let i = 0;i < 100000;i++) {
numbers.push(i);
}
};
window.setInterval(foo, 1000);
复制代码
在这个示例中,因为咱们没有手动清除定时器,致使回调任务会不断地执行下去,回调中所引用的numbers
变量也不会被垃圾回收,最终致使numbers
数组长度无限递增,从而引起内存泄漏。
闭包是JS中的一个高级特性,巧妙地利用闭包能够帮助咱们实现不少高级功能。通常来讲,咱们在查找变量时,在本地做用域中查找不到就会沿着做用域链从内向外单向查找,可是闭包的特性可让咱们在外部做用域访问内部做用域中的变量,示例以下:
function foo() {
let local = 123;
return function() {
return local;
}
}
const bar = foo();
console.log(bar()); // -> 123
复制代码
在这个示例中,foo
函数执行完毕后会返回一个匿名函数,该函数内部引用了foo
函数中的局部变量local
,而且经过变量bar
来引用这个匿名的函数定义,经过这种闭包的方式咱们就能够在foo
函数的外部做用域中访问到它的局部变量local
。通常状况下,当foo
函数执行完毕后,它的做用域会被销毁,可是因为存在变量引用其返回的匿名函数,致使做用域没法获得释放,也就致使local
变量没法回收,只有当咱们取消掉对匿名函数的引用才会进入垃圾回收阶段。
以往咱们在操做DOM元素时,为了不屡次获取DOM元素,咱们会将DOM元素存储在一个数据字典中,示例以下:
const elements = {
button: document.getElementById('button')
};
function removeButton() {
document.body.removeChild(document.getElementById('button'));
}
复制代码
在这个示例中,咱们想调用removeButton
方法来清除button
元素,可是因为在elements
字典中存在对button
元素的引用,因此即便咱们经过removeChild
方法移除了button
元素,它其实仍是依旧存储在内存中没法获得释放,只有咱们手动清除对button
元素的引用才会被垃圾回收。
经过前几个示例咱们会发现若是咱们一旦疏忽,就会容易地引起内存泄漏的问题,为此,在ES6中为咱们新增了两个有效的数据结构WeakMap
和WeakSet
,就是为了解决内存泄漏的问题而诞生的。其表示弱引用
,它的键名所引用的对象均是弱引用,弱引用是指垃圾回收的过程当中不会将键名对该对象的引用考虑进去,只要所引用的对象没有其余的引用了,垃圾回收机制就会释放该对象所占用的内存。这也就意味着咱们不须要关心WeakMap
中键名对其余对象的引用,也不须要手动地进行引用清除,咱们尝试在node中演示一下过程(参考阮一峰ES6标准入门中的示例,本身手动实现了一遍)。
首先打开node命令行,输入如下命令:
node --expose-gc // --expose-gc 表示容许手动执行垃圾回收机制
复制代码
而后咱们执行下面的代码。
// 手动执行一次垃圾回收保证内存数据准确
> global.gc();
undefined
// 查看当前占用的内存,主要关心heapUsed字段,大小约为4.4MB
> process.memoryUsage();
{ rss: 21626880,
heapTotal: 7585792,
heapUsed: 4708440,
external: 8710 }
// 建立一个WeakMap
> let wm = new WeakMap();
undefined
// 建立一个数组并赋值给变量key
> let key = new Array(1000000);
undefined
// 将WeakMap的键名指向该数组
// 此时该数组存在两个引用,一个是key,一个是WeakMap的键名
// 注意WeakMap是弱引用
> wm.set(key, 1);
WeakMap { [items unknown] }
// 手动执行一次垃圾回收
> global.gc();
undefined
// 再次查看内存占用大小,heapUsed已经增长到约12MB
> process.memoryUsage();
{ rss: 30232576,
heapTotal: 17694720,
heapUsed: 13068464,
external: 8688 }
// 手动清除变量key对数组的引用
// 注意这里并无清除WeakMap中键名对数组的引用
> key = null;
null
// 再次执行垃圾回收
> global.gc()
undefined
// 查看内存占用大小,发现heapUsed已经回到了以前的大小(这里约为4.8M,原来为4.4M,稍微有些浮动)
> process.memoryUsage();
{ rss: 22110208,
heapTotal: 9158656,
heapUsed: 5089752,
external: 8698 }
复制代码
在上述示例中,咱们发现虽然咱们没有手动清除WeakMap
中的键名对数组的引用,可是内存依旧已经回到原始的大小,说明该数组已经被回收,那么这个也就是弱引用的具体含义了。
本文中主要讲解了一下V8引擎的垃圾回收机制,并分别重新生代和老生代讲述了不一样分代中的垃圾回收策略以及对应的回收算法,以后列出了几种常见的避免内存泄漏的方式来帮助咱们写出更加优雅的代码。若是你已经了解过垃圾回收相关的内容,那么这篇文章能够帮助你简单复习加深印象,若是没有了解过,那么笔者也但愿这篇文章可以帮助到你了解一些代码层面以外的底层知识点,因为V8引擎的源码是用C++实现的,因此笔者也就没有作这方面的深刻了,有兴趣的小伙伴儿能够自行探究,文中有错误的地方,还但愿可以在评论区指正。
若是你以为这篇文章的内容对你有帮助,可否帮个忙关注一下笔者的公众号[前端之境],每周都会努力原创一些前端技术干货,关注公众号后能够邀你加入前端技术交流群,咱们能够一块儿互相交流,共同进步。
文章已同步更新至Github博客,若觉文章尚可,欢迎前往star!
你的一个点赞,值得让我付出更多的努力!
逆境中成长,只有不断地学习,才能成为更好的本身,与君共勉!