关于v8垃圾回收机制以及与其相关联的知识点--还没整理版本

对于值类型b来说,就直接释放了其占用的内存,对于引用类型obj来说,销毁的只是变量obj对堆内存地址 1001 的引用,obj的值 { c: 3 } 依然存在于堆内存中。那么堆内存中的变量如何进行回收呢?

  • V8的垃圾回收策略主要是基于分代式垃圾回收机制 ,其根据对象的存活时间将内存的垃圾回收进行不同的分代,然后对不同的分代采用不同的垃圾回收算法。
  • 在新生代的垃圾回收过程中主要采用了Scavenge 算法;在老生代采用Mark-Sweep(标记清除) 和**Mark-Compact(标记整理)**算法。

V8的内存分代

在V8中,将内存分为了新生代(new space)和老生代(old space)。它们特点如下:

  • 新生代:对象的存活时间较短。新生对象或只经过一次垃圾回收的对象。
  • 老生代:对象存活时间较长。经历过一次或多次垃圾回收的对象。

V8堆的空间等于新生代空间加上老生代空间。我们可以通过 --max-old-space-size命令设置老生代空间的最大值,--max-new-space-size 命令设置新生代空间的最大值。老生代与新生代的空间大小在程序初始化时设置,一旦生效则不能动态改变。

weakMap

通过【垃圾回收机制】的角度认识【Map与WeakMap】的区别 - 知乎 (zhihu.com)

V8 垃圾回收机制与 WeakMap 之间有一些联系,主要涉及到垃圾回收对于弱引用的处理。以下是它们之间的关系:

1.弱引用和垃圾回收: WeakMap 中的键是弱引用的。这意味着,如果没有其他引用指向 WeakMap 中的键对象,这些键对象可以被垃圾回收。垃圾回收器在执行时会检测并处理弱引用,当检测到某个对象的引用计数为零时,可以安全地回收该对象。

2.避免内存泄漏: 由于 WeakMap 的键是弱引用,当键对象不再被其他部分引用时,它们可以被垃圾回收,相应的键值对也会从 WeakMap 中自动删除。这有助于防止一些潜在的内存泄漏问题,因为对象在 WeakMap 中的存在不会阻止它们被垃圾回收。

3.私有数据存储: WeakMap 通常被用于存储对象的私有数据,因为这样的数据不会影响对象的垃圾回收。这使得在不破坏封装性的情况下关联额外信息成为可能。

示例代码如下:

let weakMap = new WeakMap();

let obj = {};

weakMap.set(obj, "some private data");

// 当 obj 不再被引用时,垃圾回收可以回收 obj,并清理 weakMap 中对应的项。

obj = null;

总体而言,WeakMap 的设计与垃圾回收机制的协同工作有助于更有效地管理对象的生命周期,避免潜在的内存泄漏问题,同时提供一种安全地存储私有数据的机制。

Map和WeakMap都是JavaScript的内置数据结构,用于存储键值对。它们之间的主要区别在于以下几点:

  1. 键类型的限制:在Map中,键可以是任意类型的值(包括基本类型和对象引用),而在WeakMap中,键只能是对象引用。这是因为WeakMap的键是弱引用,不会阻止对象被垃圾回收,使得WeakMap更适合于存储对象之间的关联信息。
  2. 垃圾回收机制:在Map中,如果某个键不再被引用,它仍然会被Map引用,并且不会被垃圾回收。而在WeakMap中,如果某个键不再被引用,它会被自动从WeakMap中删除,这也是WeakMap的一个特性,可以避免内存泄漏。
  3. 迭代:在Map中,可以使用Map.prototype.keys()Map.prototype.values()Map.prototype.entries()等方法来迭代Map中的键、值或键值对,而在WeakMap中,由于键是对象引用,无法直接迭代键或值。
  4. 功能:Map相比WeakMap提供了更多的功能,比如可以获取Map的大小(使用Map.prototype.size属性),可以通过键获取值(使用Map.prototype.get()方法),可以遍历Map中的键值对等。而WeakMap相对简单,只提供了WeakMap.prototype.get()WeakMap.prototype.set()WeakMap.prototype.has()WeakMap.prototype.delete()等基本操作。

============================

众所周知,应用程序在运行过程中需要占用一定的内存空间,且在运行过后就必须将不再用到的内存释放掉,否则就会出现下图中内存的占用持续升高的情况,一方面会影响程序的运行速度,另一方面严重的话则会导致整个程序的崩溃。

JavaScript语言中也提供了垃圾回收机制(Garbage Collecation),简称GC机制

全停顿(Stop The World )

在介绍垃圾回收算法之前,先了解一下「全停顿 」。垃圾回收算法在执行前,需要将应用逻辑暂停,执行完垃圾回收后再执行应用逻辑,这种行为称为 「全停顿 」(Stop The World)。例如,如果一次GC需要50ms,应用逻辑就会暂停50ms。

全停顿的目的,是为了解决应用逻辑与垃圾回收器看到的情况不一致的问题。

JavaScript中会被判定为垃圾的情形如下:
  • 对象不再被引用;
  • 对象不能从根上访问到;

GC算法

常见的GC算法如下:

  • 引用计数
  • 标记清除
  • 标记整理
  • 分代回收

如果我们去外边餐馆吃饭,也是同样的流程。只不过不需要自己找餐桌,由引导服务员给分配,使用后,不需要关心留在餐桌上的餐盘,由回收餐盘服务员去回收。对于JS 来说,垃圾回收器(Garbage Collector)就在做类似于餐盘服务员垃圾回收的工作:将不再使用的内存进行释放回收,从而能够循环利用有限的内存空间。

浏览器的垃圾回收机制:

浏览器垃圾回收机制根据数据的存储方式分为栈垃圾回收和堆垃圾回收

栈垃圾回收, 当一个函数执行结束之后,JavaScript 引擎会通过向下移动 ESP 来销毁该函数保存在栈中的执行上下文,遵循先进后出的原则。

**堆垃圾回收,**当函数直接结束,栈空间处理完成了,但是堆空间的数据虽然没有被引用,但是还是存储在堆空间中,需要垃圾回收器将堆空间中的垃圾数据回收。

为了使垃圾回收达到更好的效果,根据对象的生命周期不一样,使用不同的垃圾回收的算法。

在 V8 中会把**堆分为新生代和老生代两个区域,**新生代中存放的是生存时间短的对象,老生代中存放的生存时间久的对象。新生区中使用Scavenge(清除)算法,老生区中使用标记-清除算法和标记-整理算法。

新生代垃圾回收(副垃圾回收器)

新生代的特点:

  • 通常把小的对象分配到新生代
  • 新生代的垃圾回收比较频繁
  • 通常存储容量在1~8M

新生代中垃圾回收算法:

  • Scavenge算法

Scavenge算法:标记->复制->角色反转

  1. 标记:对对象区域中的垃圾进行标记

  2. 清除垃圾数据和整理碎片化内存:副垃圾回收器会把这些存活的对象复制到空闲区域中,并且有序的排列起来,复制后空闲区域就没有内存碎片了

  3. 角色翻转:完成复制后,对象区域与空闲区域进行角色翻转,也就是原来的对象区域变成空闲区域,原来的空闲区域变成了对象区域,这样就完成了垃圾对象的回收操作,同时这种角色翻转的操作还能让新生代中的这两块区域无限重复使用下去

过程如下:

  1. 从 From 空间分配对象,若 semispace 被分配满,则执行 Scavenge 算法进行垃圾回收。
  2. 检查 From 空间的存活对象,若对象存活,则检查对象是否符合晋升条件,若符合条件则晋升到老生代,否则将对象从 From 空间复制到 To 空间。
  3. 若对象不存活,则释放不存活对象的空间。
  4. 完成复制后,将 From 空间与 To 空间进行角色翻转(flip)。
对象晋升:

当一个对象在经过多次复制之后依旧存活,那么它会被认为是一个生命周期较长的对象,在下一次进行垃圾回收时,该对象会被直接转移到老生代中,这种对象从新生代转移到老生代的过程我们称之为晋升

对象晋升的条件主要有以下两个(满足其一即可):

  • 对象是否经历过一次Scavenge算法
  • To空间的内存占比是否已经超过25%

默认情况下,我们创建的对象都会分配在From空间中,当进行垃圾回收时,在将对象从From空间复制到To空间之前,会先检查该对象的内存地址来判断是否已经经历过一次Scavenge算法,如果地址已经发生变动则会将该对象转移到老生代中,不会再被复制到To空间。

之所以有25%的内存限制是因为To空间在经历过一次Scavenge算法后会和From空间完成角色互换,会变为From空间,后续的内存分配都是在From空间中进行的,如果内存使用过高甚至溢出,则会影响后续对象的分配,因此超过这个限制之后对象会被直接转移到老生代来进行管理。

在讲解老生代Mark-Sweep(标记清除)Mark-Compact(标记整理) 算法之前,先来回顾一下引用计数法:对于对象A,任何一个对象引用了A的值,计数器+1,引用失效时计数器-1,当计数器为0时责备回收,但是会存在循环引用的情况,可能会导致内存泄漏,自2012年起,所有的现代浏览器均放弃了这种算法。

老生代垃圾回收(主垃圾收集器)

老生代的特点:

对象占用空间大

对象存活时间长

老生代垃圾回收的过程:

标记-清除

标记-整理

标记-清除算法:

标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

清除:将垃圾数据进行清除。

对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。此时需要对内存碎片进行整理。

标记-清除算法:

1. 标记:标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

  1. 清除:将垃圾数据进行清除。

  2. 产生内存碎片:对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存。

标记-整理算法:

  1. 标记:和标记 - 清除的标记过程一样,从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素标记为活动对象。

  2. 整理:让所有存活的对象都向内存的一端移动

  3. 清除:清理掉端边界以外的内存 V8 是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JS 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。这种行为叫做全停顿。为了降低老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JS 应用逻辑交替进行,直到标记阶段完成,这个算法称为增量标记(Incremental Marking)算法

Mark-Sweep(标记清除)分为标记和清除两个阶段,在标记阶段会遍历堆中的所有对象,然后标记活着的对象,在清除阶段中,会将死亡的对象进行清除。Mark-Sweep算法主要是通过判断某个对象是否可以被访问到,从而知道该对象是否应该被回收,具体步骤如下:

  1. 垃圾回收器会在内部构建一个根列表,用于从根节点出发去寻找那些可以被访问到的变量。比如在JavaScript中,window全局对象可以看成一个根节点。
  2. 垃圾回收器从所有根节点出发,遍历其可以访问到的子节点,并将其标记为活动的 ,根节点不能到达的地方即为非活动的,将会被视为垃圾。
  3. 垃圾回收器将会释放所有非活动的内存块,并将其归还给操作系统。

=======================================================

引用计数

早期的浏览器最常使用的垃圾回收方法叫做"引用计数":语言引擎有一张"引用表",保存了内存里面所有的资源(通常是各种值)的引用次数。如果一个值的引用次数是0,就表示这个值不再用到了,因此可以将这块内存释放。

引用计数有一个问题就是:循环引用

引用计数算法优点:

  • 引用计数为零时,发现垃圾立即回收;
  • 最大限度减少程序暂停;

引用计数算法缺点:

  • 无法回收循环引用的对象;
  • 空间开销比较大;

标记清除(Mark-Sweep)

核心思想:分标记和清除两个阶段完成。

  1. 遍历所有对象找标记活动对象;
  2. 遍历所有对象清除没有标记对象;
  3. 回收相应的空间。

标记清除算法的优点是:对比引用计数算法,标记清除算法最大的优点是能够回收循环引用的对象,它也是v8引擎使用最多的算法。

标记整理(Mark-Compact)

标记-整理(Mark-Compact):

标记整理对待未存活对象不是⽴即回收,⽽是将存活对象移动到⼀边,然后直接清掉端边界以外的内存。

这里为了便于理解,引用两个流程图。

  1. 假设在老生代中有A、B、C、D四个对象

为了解决内存碎片化的问题,提高对内存的利用,引入了标记整理算法。

标记整理可以看做是标记清除的增强。标记阶段的操作和标记清除一致。

清除阶段会先执行整理,移动对象位置,将存活的对象移动到一边,然后再清理端边界外的内存。

增量标记

全停顿

V8 是使用副垃圾回收器和主垃圾回收器处理垃圾回收的,不过由于 JavaScript 是运行在主线程之上的,一旦执行垃圾回收算法,都需要将正在执行的 JavaScript 脚本暂停下来,待垃圾回收完毕后再恢复脚本执行。我们把这种行为叫做全停顿(Stop-The-World)。

将原本需要一次性遍历堆内存的操作改为增量标记的方式,先标记堆内存中的一部分对象,然后暂停,将执行权重新交给JS主线程,待主线程任务执行完毕后再从原来暂停标记的地方继续标记,直到标记完整个堆内存。
即:把垃圾回收这个⼤的任务分成⼀个个⼩任务,穿插在 JavaScript任务中间执⾏

这个理念其实有点像React框架中的Fiber架构,只有在浏览器的空闲时间才会去遍历Fiber Tree执行对应的任务,否则延迟执行,尽可能少地影响主线程的任务,避免应用卡顿,提升应用性能。

得益于增量标记的好处,V8引擎后续继续引入了延迟清理(lazy sweeping)和增量式整理(incremental compaction),让清理和整理的过程也变成增量式的。同时为了充分利用多核CPU的性能,也将引入并行标记和并行清理,进一步地减少垃圾回收对主线程的影响,为应用提升更多的性能。

最后附上V8-GC的触发机制:

回收新生代对象

回收新生代对象主要采用复制算法Scavenge 算法)加标记整理算法。而Scavenge 算法的具体实现,主要采用了Cheney算法

Cheney算法将内存分为两个等大空间,使用空间为From,空闲空间为To

检查From空间内的存活对象,若对象存活,检查对象是否符合晋升条件,若符合条件则晋升到老生代,否则将对象从 From 空间复制到 To 空间。若对象不存活,则释放不存活对象的空间。完成复制后,将 From 空间与 To 空间进行角色翻转。

对象晋升机制

一轮GC还存活的新生代需要晋升。

当对象从From 空间复制到 To 空间时,若 To 空间使用超过 25%,则对象直接晋升到老生代中。设置为25%的比例的原因是,当完成 Scavenge 回收后,To 空间将翻转成From 空间,继续进行对象内存的分配。若占比过大,将影响后续内存分配。

缺点:由于只能使用堆内存的一半,所以不适用大规模的垃圾回收机制中,是典型的牺牲空间换时间的算法。

回收老生代对象

因为新生代存储容量小,很容易写满,所以经过两次垃圾回收之后依然活动的对象,就会被移动到老生代中,这个策略被称为对象晋升策略

2、Mark-sweep 和 Mark-compact 算法(用于老生代垃圾回收机制)

当一个对象经过多次复制依然存活时,它将会被认为是生命周期较长的对象。

这种较长生命周期的对象随后会被移动到老生代中,采用新的算法进行管理。

对象从新生代中移动到老生代中的过程称为晋升。

晋升条件:

对象晋升的条件主要有两个。

1、对象在新生代期间是否经历过Scavenge回收;

2、是To空间的内存占用比超过限制(To空间内存消耗是否超过25%,如果超过对象直接晋升)。

Mark-sweep 称为对象标记和清除,顾名思义该算法执行了两个步骤,标记和清除。

标记:

标记存活的对象

清除:

清除未被标记的对象,也就是死亡的对象

Mark-Sweep最大的问题是在进行一次标记清除回收后,内存空间会出现不连续的状态。

这种内存碎片会对后续的内存分配造成问题,因为很可能出现需要分配一个大对象的情况,这时所有的碎片空间都无法完成此次分配,就会提前触发垃圾回收,而这次回收是不必要的。

然而为了解决Mark-Sweep的内存碎片问题,Mark-Compact被提出来。

Mark-Compact是标记整理的意思,是在Mark-Sweep的基础上演变而来的。

它们的差别在于对象在标记为死亡后,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

Mark-Sweep与Mark-Compact两者为策略递进关系,当空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact,在整理的过程中,将活着的对象往一端移动,移动完成后,直接清理掉边界外的内存。

完成移动后,就可以直接清除最右边的存活对象后面的内存区域完成回收。


原文链接:https://blog.csdn.net/weixin_43832981/article/details/128803266

回收老生代对象主要采用标记清除标记整理增量标记 算法,主要使用标记清除 算法,只有在内存分配不足时,采用标记整理算法。

  1. 首先使用标记清除 完成垃圾空间的回收
  2. 采用标记整理 进行空间优化
  3. 采用增量标记 进行效率优化

新生代和老生代回收对比

新生代由于占用空间比较少,采用空间换时间机制。

老生代区域空间比较大,不太适合大量的复制算法和标记整理,所以最常用的是标记清除算法,为了就是让全停顿的时间尽量减少。

识别内存泄漏的方法

点击 检查的performance 然后点击录制 执行我们觉得比较消耗内存的操作 然后stop录制。

可以看到内存在短时间消耗的比较快,下降的小凹槽,就是浏览器在进行垃圾回收

如何避免内存泄漏

在我们编写代码的过程中,尽管浏览器和大部分的前端框架已经帮助我们处理了常见的内存泄漏问题,但我们仍然有必要了解一些常见的内存泄漏问题以及避免它们的方式。以下是几种常见的避免内存泄漏的方式:

尽可能减少全局变量的使用

在 JavaScript 中,全局变量会一直存在于内存中,直到应用程序退出。因此,过多的全局变量会导致内存占用增加。为了避免这个问题,尽量减少全局变量的使用,尽可能将变量限定在局部作用域中。如果确实需要使用全局变量,确保在使用完毕后将其设置为 null,以便垃圾回收机制可以及时释放内存。

手动清除定时器

在使用定时器时,一定要记得在适当的时机手动清除定时器。如果忘记清除定时器,定时器的回调函数将持续执行,可能导致内存泄漏。确保在不需要定时器时,使用 clearTimeout 或 clearInterval 主动清除定时器。

清除 DOM 引用

当操作 DOM 元素时,确保在不再需要使用它们时清除对 DOM 元素的引用。如果仍然保留对已移除或隐藏的 DOM 元素的引用,这些元素将无法被垃圾回收。

垃圾回收优化策略

延迟回收:由于垃圾回收会带来运行阻塞,因此可以选择在CPU空闲时或系统资源充足时执行垃圾回收,从而尽可能减少对应用程序运行的影响。

增量标记:由于全堆垃圾回收会导致JS应用暂停执行,为了减少全堆垃圾回收带来的卡顿,V8采用增量标记的策略。也就是将一次完整的垃圾回收分解为多个小的步骤,同时让垃圾回收和应用逻辑交替执行,以达到流畅的用户体验。

对象晋升:在新生代中存活下来的对象会被移动到老生代中,这就是对象晋升策略。在V8中通常采用两次垃圾回收后仍然存活的对象会被晋升到老生代。

当进行大规模的垃圾回收时,V8引擎使用增量标记来减少对应用程序的阻塞。

增量标记是一种垃圾回收的优化策略,它将一次完整的垃圾回收过程分解为多个小的步骤,使得垃圾回收和应用程序的逻辑可以交替执行。这样可以减少垃圾回收造成的长时间阻塞,提高应用程序的响应性和用户体验。

V8引擎的增量标记策略主要包括以下步骤:

初始标记(Initial Marking):在这个阶段,V8会标记出根对象和直接从根对象可达的对象,确定它们为活动对象。这个阶段需要阻塞应用程序的执行,但是尽量保持时间短暂。

并发标记(Concurrent Marking):在初始标记之后,V8引擎会启动增量标记线程,与应用程序的执行并发进行。增量标记线程会遍历剩余的对象图,标记出所有的活动对象。同时,应用程序的逻辑也在继续执行。

再标记(Remark):在并发标记过程中,应用程序可能会继续修改对象的引用关系,因此需要进行再标记。再标记阶段会对并发标记过程中发生变化的对象进行重新标记,以确保准确性。

清除阶段(Sweeping):在增量标记完成后,V8引擎会进行清除阶段,回收非活动对象所占用的内存。这个阶段通常会阻塞应用程序的执行,因为它需要遍历堆中的所有对象。

通过增量标记的方式,V8引擎可以在垃圾回收过程中与应用程序的逻辑交替执行,减少长时间的阻塞。这种方式可以有效降低垃圾回收对应用程序性能的影响,提高应用程序的响应速度和用户体验。


性能优化

1.避免使用全局变量

  • 全局变量会挂载在window下;
  • 全局变量至少有一个引用计数;
  • 全局变量存活更久,持续占用内存;
  • 在明确数据作用域的情况下,尽量使用局部变量;

2.减少判断层级

function doSomething(part, chapter) {
    const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
 
    if (part) {
        if (parts.includes(part)) {
            console.log('属于当前课程')
            if (chapter > 5) {
                console.log('您需要提供 VIP 身份')
            }
        }
    } else {
        console.log('请确认模块信息')
    }
}
 
doSomething('Vue', 6)
 
// 减少判断层级
function doSomething(part, chapter) {
    const parts = ['ES2016', '工程化', 'Vue', 'React', 'Node']
 
    if (!part) {
        console.log('请确认模块信息')
        return
    }
 
    if (!parts.includes(part)) return
    console.log('属于当前课程')
 
    if (chapter > 5) {
        console.log('您需要提供 VIP 身份')
    }
}
 
doSomething('Vue', 6)

3.减少数据读取次数

对于频繁使用的数据,我们要对数据进行缓存。

<div id="skip" class="skip"></div>
 
<script>
    var oBox = document.getElementById('skip')
 
    // function hasEle (ele, cls) {
    //     return ele.className === cls
    // }
 
    function hasEle (ele, cls) {
        const className = ele.className
        return className === cls
    }
 
    console.log(hasEle(oBox, 'skip'))
</script>

5.事件绑定优化

<ul class="ul">
    <li>Hello World!</li>
    <li>25</li>
    <li>岂曰无衣,与子同袍</li>
</ul>
 
<script>
    var list = document.querySelectorAll('li')
    function showTxt(ev) {
        console.log(ev.target.innerHTML)
    }
 
    for (item of list) {
        item.onclick = showTxt
    }
 
    // 优化后
    function showTxt(ev) {
        var target = ev.target
        if (target.nodeName.toLowerCase() === 'li') {
            console.log(ev.target.innerHTML)
        }
    }
 
    var ul = document.querySelector('.ul')
    ul.addEventListener('click', showTxt)
</script>

6.避开闭包陷阱

<button class="btn">点击</button>
 
<script>
    function foo() {
        let el = document.querySelector('.btn')
        el.onclick = function() {
            console.log(el.className)
        }
    }
    foo()
 
    // 优化后
    function foo1() {
        let el = document.querySelector('.btn')
        el.onclick = function() {
            console.log(el.className)
        }
        el = null // 将el置为 null 防止闭包中的引用使得不能被回收
    }
    foo1()
</script>
相关推荐
西猫雷婶14 分钟前
python学opencv|读取图像(十六)修改HSV图像HSV值
开发语言·python·opencv
weixin_5375904526 分钟前
《Java编程入门官方教程》第八章练习答案
java·开发语言·servlet
lsx20240630 分钟前
MVC 发布
开发语言
qincjun1 小时前
文件I/O操作:C++
开发语言·c++
CodeClimb1 小时前
【华为OD-E卷-最左侧冗余覆盖子串 100分(python、java、c++、js、c)】
java·python·华为od
小马超会养兔子1 小时前
如何写一个数字老虎机滚轮
开发语言·前端·javascript·vue
汉克老师1 小时前
2023年厦门市第30届小学生C++信息学竞赛复赛上机操作题(三、2023C. 太空旅行(travel))
开发语言·c++
Q_19284999061 小时前
基于Spring Boot的大学就业信息管理系统
java·spring boot·后端
HvrI11 小时前
JS使用random随机数实现简单的四则算数验证
开发语言·javascript