Node 서비스는 Non-Blocking 및 Event-Driven 기반으로 구축되었으며 메모리 소모가 적다는 장점이 있으며 대규모 네트워크 요청을 처리하는 데 매우 적합합니다. 대규모 요청을 전제로 '메모리 제어'와 관련된 문제를 고려해야 합니다.
Js는 가비지 수집 메커니즘을 사용하여 다른 언어처럼 코드를 작성하는 과정에서 메모리에 주의를 기울일 필요가 없습니다. (c/C++) 할당 및 릴리스 문제. 브라우저에서 가비지 수집 메커니즘은 애플리케이션 성능에 거의 영향을 미치지 않지만 성능에 민감한 서버측 프로그램의 경우 메모리 관리 품질과 가비지 수집 품질이 서비스에 영향을 미칩니다. [관련 튜토리얼 권장 사항: nodejs 비디오 튜토리얼, 프로그래밍 교육]
Node는 Chrome의 Js 런타임을 기반으로 구축된 플랫폼이고 V8은 Node의 Js 스크립트 엔진
In 일반적인 백엔드 언어에서는 기본 메모리 사용량에 제한이 없지만 Node에서 Js를 통해 메모리를 사용할 경우 메모리의 일부만 사용할 수 있습니다. 이러한 제한으로 인해 Node는 대용량 메모리 개체를 직접 작동할 수 없습니다.
문제의 주된 원인은 Node가 V8을 기반으로 구축되어 있고, Node에서 사용하는 Js 객체들은 기본적으로 V8만의 방식으로 할당 및 관리되기 때문입니다.
V8에서는 모든 Js 객체가 힙을 통해 할당됩니다.
v8에서 메모리 사용량 확인하기
heapTotal과 heapUsed는 V8의 힙 메모리 사용량이고, 전자가 적용된 힙 메모리 사용량이고, 후자는 현재 사용된 양입니다.
코드에서 변수를 선언하고 값을 할당하면 사용된 객체의 메모리가 힙에 할당됩니다. 적용된 힙 프리 메모리가 새 개체를 할당하기에 충분하지 않으면 힙 크기가 V8의 제한을 초과할 때까지 힙 메모리가 계속 적용됩니다.
V8이 힙 크기를 제한하는 이유는 V8이 원래 목적으로 설계되었기 때문입니다. 브라우저에서는 많은 메모리를 사용하는 시나리오가 발생할 가능성이 매우 높습니다. 웹페이지의 경우 V8의 제한 값이면 충분합니다. 근본적인 이유는 V8의 가비지 수집 메커니즘의 한계 때문입니다. 공식 성명에 따르면 1.5G의 가비지 수집 힙 메모리를 예로 들면 v8은 소규모 가비지 수집을 수행하는 데 50밀리초 이상이 걸리고 비증분 가비지 수집을 수행하는 데에도 1초 이상이 걸립니다. 이는 가비지 수집 중에 JS 스레드가 실행을 일시 중지하고 애플리케이션의 성능과 응답성이 급락하는 시간입니다. 현재 고려 사항에서는 힙 메모리를 직접 제한하는 것이 좋은 선택입니다.
이 제한은 노드가 시작될 때 --max-old-space-size
或--max-new-space-size
를 전달하여 메모리 제한의 크기를 조정할 수 있습니다. 일단 적용되면 동적으로 변경할 수 없습니다. 예:
node --max-old-space-size=1700 test.js // 单位为MB // 或者 node --max-new-space-size=1024 test.js // 单位为KB
v8에서 사용되는 다양한 가비지 수집 알고리즘
v8의 가비지 수집 전략은 주로 세대별 가비지 수집 메커니즘을 기반으로 합니다.
실제 응용에서는 객체의 수명주기가 다양합니다. 현재의 가비지 수집 알고리즘에서는 객체의 생존 시간에 따라 서로 다른 세대에서 메모리 가비지 수집이 수행되며, 서로 다른 세대의 메모리에는 보다 효율적인 알고리즘이 적용됩니다.
V8 메모리 세대
v8에서 메모리는 주로 신세대와 구세대의 두 세대로 나뉩니다. Young Generation의 객체는 생존 시간이 짧은 객체인 반면, Old Generation의 객체는 생존 시간이 길거나 메모리에 상주하는 객체입니다.
신세대의 기억공간 | 구세대의 기억공간 |
---|
v8 힙의 전체 크기 = 신세대가 사용하는 메모리 공간 + 구세대가 사용하는 메모리 공간
v8 힙 메모리의 최대값은 64비트 시스템에서 약 1.4GB의 메모리만 사용할 수 있으며, 32비트 시스템에서 약 0.7GB 메모리
Scavenge 알고리즘
세대를 기준으로 보면 새로운 세대의 개체는 주로 Scavenge 알고리즘을 통해 가비지 수집됩니다. Scavenge의 특정 구현은 주로 Cheney 알고리즘을 사용합니다. Cheney의 알고리즘은 복사를 통해 구현된 가비지 수집 알고리즘입니다. 힙 메모리를 두 부분으로 나누고, 공간의 각 부분을 세미스페이스(semispace)라고 합니다. 두 개의 반공간 중 하나만 사용 중이고 다른 하나는 유휴 상태입니다. 사용 중인 반공간 공간을 From 공간, 유휴 상태의 공간을 To 공간이라고 합니다. 객체를 할당할 때 먼저 From 공간에 할당됩니다. 가비지 수집이 시작되면 From 공간에서 살아남은 개체가 확인되고, 살아남지 않은 개체가 차지한 공간은 해제됩니다. 복사가 완료되면 From 공간과 To 공간의 역할이 바뀌게 됩니다. 즉, 가비지 수집 프로세스 중에 살아남은 객체가 두 개의 세미스페이스 사이에 복사됩니다. Scavenge의 단점은 분할 공간과 복사 메커니즘에 따라 결정되는 힙 메모리의 절반만 사용할 수 있다는 것입니다. 하지만 Scavenge는 살아남은 객체만 복사하기 때문에 시간 효율성 측면에서 뛰어난 성능을 보이며, 수명 주기가 짧은 시나리오에서는 살아남은 객체 중 극히 일부만 사용됩니다. Scavenge는 시간을 위해 공간을 희생하는 전형적인 알고리즘이기 때문에 대규모의 모든 가비지 컬렉션에 적용할 수는 없습니다. 그러나 Scavenge는 새로운 세대의 객체 수명주기가 짧고 이 알고리즘에 적합하기 때문에 새로운 세대에 적용하기에 매우 적합합니다. 실제 사용되는 힙 메모리는 신세대의 두 개의 반공간 공간과 구세대에서 사용된 메모리 크기의 합입니다.
객체가 여러 번 복사된 후에도 여전히 살아남는 경우 수명 주기가 긴 객체로 간주되어 Old Generation으로 이동되어 새로운 알고리즘을 사용하여 관리됩니다. 젊은 세대에서 노년층으로 물건을 옮기는 과정을 승격(Promotion)이라고 합니다.
간단한 Scavenge 프로세스에서는 From 공간에 남아 있는 객체가 To 공간으로 복사되고, 그런 다음 From 공간과 To 공간의 역할이 반전됩니다(flip). 그러나 세대별 가비지 수집을 전제로 To 공간에 복사되기 전에 From 공간에 남아 있는 개체를 확인해야 합니다. 특정 조건에서는 생존 기간이 긴 객체를 Old Generation으로 이동해야 합니다. 즉, 객체 승격이 완료됩니다.
객체 승격에는 두 가지 주요 조건이 있습니다. 하나는 객체가 Scavenge 재활용을 경험했는지 여부이고, 다른 하나는 To 공간의 메모리 사용 비율이 한도를 초과한다는 것입니다.
기본적으로 V8의 객체 할당은 주로 From 공간에 집중되어 있습니다. 개체가 From 공간에서 To 공간으로 복사되면 해당 메모리 주소를 검사하여 개체에 청소 재활용이 발생했는지 여부를 확인합니다. 경험된 객체는 From 공간에서 Old Generation 공간으로 복사됩니다. 그렇지 않은 경우 To 공간으로 복사됩니다. 승격 흐름도는 다음과 같습니다.
또 다른 판단 조건은 To 공간의 메모리 사용량 비율입니다. From 공간에서 To 공간으로 객체를 복사할 때 To 공간이 25% 사용된 경우 해당 객체는 Old Generation 공간으로 직접 승격됩니다. 승격 흐름도는 다음과 같습니다.
이유는 다음과 같습니다. 25% 제한 설정: 첫 번째 Scavenge 재활용이 완료된 후 To 공간은 From 공간이 되고 다음 메모리 할당은 이 공간에서 수행됩니다. 비율이 너무 높으면 후속 메모리 할당에 영향을 미칩니다.
객체가 승격된 후에는 Old Generation 공간에서 더 긴 생존 기간을 갖는 객체로 처리되며 새로운 재활용 알고리즘에 의해 처리됩니다.
Mark-Sweep & Mark-Compact
Old Generation의 객체의 경우 살아남은 객체의 비중이 크기 때문에 Scavenge를 사용하면 두 가지 문제가 있습니다. 하나는 살아남는 객체가 많고 효율성이 떨어진다는 점입니다. 살아남은 객체를 복사하는 것은 매우 낮습니다. 또 다른 문제는 공간의 절반을 낭비한다는 것입니다. 이를 위해 v8에서는 구세대의 가비지 수집을 위해 Mark-Sweep과 Mark-Compact의 조합을 주로 사용합니다.
Mark-Sweep은 표시 지우기를 의미하며 표시와 지우기의 두 단계로 나뉩니다. Scavenge와 비교하여 Mark-Sweep은 메모리 공간을 두 개로 나누지 않으므로 공간의 절반을 낭비하는 동작이 없습니다. 라이브 객체를 복사하는 Scavenge와 달리 Mark-Sweep은 마킹 단계에서 힙의 모든 객체를 순회하고 후속 지우기 단계에서는 표시되지 않은 객체만 지워집니다. Scavenge는 살아있는 객체만 복사하는 반면 Mark-Sweep은 죽은 객체만 정리하는 것을 알 수 있습니다. 살아있는 객체는 신세대의 작은 부분만을 차지하고, 죽은 객체는 구세대의 작은 부분만을 차지합니다. 이것이 두 가지 재활용 방법이 이를 효율적으로 처리할 수 있는 이유입니다. Old Generation 공간에서 Mark-Sweep을 표시한 후의 회로도는 다음과 같습니다. 검정색 부분은 죽은 개체로 표시됩니다
Mark-Sweep의 가장 큰 문제점은 공간을 표시하고 비운 후에 메모리 공간이 불연속적이 된다는 것입니다. 이러한 종류의 메모리 단편화는 후속 메모리 할당에 문제를 일으킬 수 있습니다. 대형 객체를 할당해야 하는 경우 단편화된 모든 공간이 할당을 완료할 수 없으며 사전에 가비지 수집이 트리거되므로 이러한 재활용이 필요하지 않습니다.
Mark-Compact는 Mark-Sweep의 메모리 조각화 문제를 해결하도록 설계되었습니다. Mark-Compact는 Mark-Sweep에서 발전된 마크 편집을 의미합니다. 차이점은 개체가 죽은 것으로 표시된 후 청소 과정에서 살아있는 개체가 한쪽 끝으로 이동한 후 경계 외부의 메모리가 직접 지워진다는 것입니다. 생명체를 표시하고 이동시킨 후의 모식도 흰색 그리드는 생명체, 어두운 그리드는 죽은 객체, 밝은 그리드는 생명체가 이동한 후 남은 구멍입니다.
이동을 완료한 후 가장 오른쪽에 살아남은 개체 뒤의 메모리 영역을 직접 지워 재활용을 완료할 수 있습니다.
V8의 재활용 전략에서는 Mark-Sweep과 Mark-Compact를 조합하여 사용합니다.
3대 가비지 수집 알고리즘의 간단한 비교
재활용 알고리즘 | Mark-Sweep | Mark-Compact | Scavenge |
---|---|---|---|
Speed | Medium | 가장 느림 | 가장 빠름 |
공간 오버헤드 | 적게(조각화 있음) | 적게(조각화 없음) | 이중 공간(조각화 없음) |
개체 이동 여부 | 아니요 | 예 | 예 |
由于Mark-Compact需要移动对象,所以它的执行速度不可能很快,所以在取舍上,v8主要使用Mark-Sweep,在空间不足以对从新生代中晋升过来的对象进行分配时才使用Mark-Compact
Incremental Marking
为了避免出现Js应用逻辑与垃圾回收器看到的不一致情况,垃圾回收的3种基本算法都需要将应用逻辑暂停下来,待执行完垃圾回收后再恢复执行应用逻辑,这种行为称为“全停顿”(stop-the-world).在v8的分代式垃圾回收中,一次小垃圾回收只收集新生代,由于新生代默认配置得较小,且其中存活对象通常较少,所以即便它是全停顿的影响也不大。但v8的老生代通常配置得较大,且存活对象较多,全堆垃圾回收(full垃圾回收)的标记、清理、整理等动作造成的停顿就会比较可怕,需要设法改善
为了降低全堆垃圾回收带来的停顿时间,v8先从标记阶段入手,将原本要一口气停顿完成的动作改为增量标记(incremental marking),也就是拆分为许多小“步进”,每做完一“步进”,就让Js应用逻辑执行一小会儿,垃圾回收与应用逻辑交替执行直到标记阶段完成。下图为:增量标记示意图
v8在经过增量标记的改进后,垃圾回收的最大停顿时间可以减少到原本的1/6左右。 v8后续还引入了延迟清理(lazy sweeping)与增量式整理(incremental compaction),让清理与整理动作也变成增量式的。同时还计划引入标记与并行清理,进一步利用多核性能降低每次停顿的时间。
在启动时添加--trace_gc
参数。在进行垃圾回收时,将会从标准输出中打印垃圾回收的日志信息。
node --trace_gc -e "var a = [];for (var i = 0; i < 1000000; i++) a.push(new Array(100));" > gc.log
在Node启动时使用--prof参数,可以得到v8执行时的性能分析数据,包含了垃圾回收执行时占用的时间。以下面的代码为例
// test.js for (var i = 0; i < 1000000; i++) { var a = {}; } node --prof test.js
会生成一个v8.log日志文件
如何让垃圾回收机制更高效地工作
在js中能形成作用域的有函数调用、with以及全局作用域
如下代码:
var foo = function(){ var local = {}; }
foo()函数在每次被调用时会创建对应的作用域,函数执行结束后,该作用域会被销毁。同时作用域中声明的局部变量分配在该作用域上,随作用域的销毁而销毁。只被局部变量引用的对象存活周期较短。在这个示例中,由于对象非常小,将会被分配在新生代中的From空间中。在作用域释放后,局部变量local失效,其引用的对象将会在下次垃圾回收时被释放
标识符,可以理解为变量名。下面的代码,执行bar()函数时,将会遇到local变量
var bar = function(){ console.log(local); }
js执行时会查找该变量定义在哪里。先查找的是当前作用域,如果在当前作用域无法找到该变量的声明,会向上级的作用域里查找,直到查到为止。
在下面的代码中
var foo = function(){ var local = 'local var'; var bar = function(){ var local = 'another var'; var baz = function(){ console.log(local) }; baz() } bar() } foo()
baz()函数中访问local变量时,由于作用域中的变量列表中没有local,所以会向上一个作用域中查找,接着会在bar()函数执行得到的变量列表中找到了一个local变量的定义,于是使用它。尽管在再上一层的作用域中也存在local的定义,但是不会继续查找了。如果查找一个不存在的变量,将会一直沿着作用域链查找到全局作用域,最后抛出未定义错误。
如果变量是全局变量(不通过var声明或定义在global变量上),由于全局作用域需要直到进程退出才能释放,此时将导致引用的对象常驻内存(常驻在老生代中)。如果需要释放常驻内存的对象,可以通过delete操作来删除引用关系。或者将变量重新赋值,让旧的对象脱离引用关系。在接下来的老生代内存清除和整理的过程中,会被回收释放。示例代码如下:
global.foo = "I am global object" console.log(global.foo);// => "I am global object" delete global.foo; // 或者重新赋值 global.foo = undefined; console.log(global.foo); // => undefined
虽然delete操作和重新赋值具有相同的效果,但是在V8中通过delete删除对象的属性有可能干扰v8的优化,所以通过赋值方式解除引用更好。
作用域链上的对象访问只能向上,外部无法向内部访问。
js实现外部作用域访问内部作用域中变量的方法叫做闭包。得益于高阶函数的特性:函数可以作为参数或者返回值。
var foo = function(){ var bar = function(){ var local = "局部变量"; return function(){ return local; } } var baz = bar() console.log(baz()) }
在bar()函数执行完成后,局部变量local将会随着作用域的销毁而被回收。但是这里返回值是一个匿名函数,且这个函数中具备了访问local的条件。虽然在后续的执行中,在外部作用域中还是无法直接访问local,但是若要访问它,只要通过这个中间函数稍作周转即可。
闭包是js的高级特性,利用它可以产生很多巧妙的效果。它的问题在于,一旦有变量引用这个中间函数,这个中间函数将不会释放,同时也会使原始的作用域不会得到释放,作用域中产生的内存占用也不会得到释放。
无法立即回收的内存有闭包和全局变量引用这两种情况。由于v8的内存限制,要注意此变量是否无限制地增加,会导致老生代中的对象增多。
会存在一些认为会回收但是却没有被回收的对象,会导致内存占用无限增长。一旦增长达到v8的内存限制,将会得到内存溢出错误,进而导致进程退出。
process.memoryUsage()可以查看内存使用情况。除此之外,os模块中的totalmem()和freemem()方法也可以查看内存使用情况
调用process.memoryUsage()可以看到Node进程的内存占用情况
rss是resident set size的缩写,即进程的常驻内存部分。进程的内存总共有几部分,一部分是rss,其余部分在交换区(swap)或者文件系统(filesystem)中。
除了rss外,heapTotal和heapUsed对应的是v8的堆内存信息。heapTotal是堆中总共申请的内存量,heapUsed表示目前堆中使用中的内存量。单位都是字节。示例如下:
var showMem = function () { var mem = process.memoryUsage() var format = function (bytes) { return (bytes / 1024 / 1024).toFixed(2) + 'MB'; } console.log('Process: heapTotal ' + format(mem.heapTotal) + ' heapUsed ' + format(mem.heapUsed) + ' rss ' + format(mem.rss)) console.log('---------------------') } var useMem = function () { var size = 50 * 1024 * 1024; var arr = new Array(size); for (var i = 0; i < size; i++) { arr[i] = 0 } return arr } var total = [] for (var j = 0; j < 15; j++) { showMem(); total.push(useMem()) } showMem();
在内存达到最大限制值的时候,无法继续分配内存,然后进程内存溢出了。
os模块中的totalmem()和freemem()这两个方法用于查看操作系统的内存使用情况,分别返回系统的总内存和闲置内存,以字节为单位
通过process.memoryUsage()的结果可以看到,堆中的内存用量总是小于进程的常驻内存用量,意味着Node中的内存使用并非都是通过v8进行分配的。将那些不是通过v8分配的内存称为堆外内存
将上面的代码里的Array变为Buffer,将size变大
var useMem = function () { var size = 200 * 1024 * 1024; var buffer = Buffer.alloc(size); // new Buffer(size)是旧语法 for (var i = 0; i < size; i++) { buffer[i] = 0 } return buffer }
输出结果如下:
内存没有溢出,改造后的输出结果中,heapTotal与heapUsed的变化极小,唯一变化的是rss的值,并且该值已经远远超过v8的限制值。原因是Buffer对象不同于其它对象,它不经过v8的内存分配机制,所以也不会有堆内存的大小限制。意味着利用堆外内存可以突破内存限制的问题
Node的内存主要由通过v8进行分配的部分和Node自行分配的部分构成。受v8的垃圾回收限制的只要是v8的堆内存。
Node对内存泄漏十分敏感,内存泄漏造成的堆积,垃圾回收过程中会耗费更多的时间进行对象扫描,应用响应缓慢,直到进程内存溢出,应用崩溃。
在v8的垃圾回收机制下,大部分情况是不会出现内存泄漏的,但是内存泄漏通常产生于无意间,排查困难。内存泄漏的情况不尽相同,但本质只有一个,那就是应当回收的对象出现意外而没有被回收,变成了常驻在老生代中的对象。通常原因有如下几个:
缓存在应用中的作用十分重要,可以十分有效地节省资源。因为它的访问效率要比 I/O 的效率高,一旦命中缓存,就可以节省一次 I/O时间。
对象被当作缓存来使用,意味着将会常驻在老生代中。缓存中存储的键越多,长期存活的对象也就越多,导致垃圾回收在进行扫描和整理时,对这些对象做无用功。
Js开发者喜欢用对象的键值对来缓存东西,但这与严格意义上的缓存又有着区别,严格意义的缓存有着完善的过期策略,而普通对象的键值对并没有。是一种以内存空间换CPU执行时间。示例代码如下:
var cache = {}; var get = function (key) { if (cache[key]) { return cache[key]; } else { // get from otherwise } }; var set = function (key, value) { cache[key] = value; };
所以在Node中,拿内存当缓存的行为应当被限制。当然,这种限制并不是不允许使用,而是要小心为之。
为了解决缓存中的对象永远无法释放的问题,需要加入一种策略来限制缓存的无限增长。可以实现对键值数量的限制。下面是其实现:
var LimitableMap = function (limit) { this.limit = limit || 10; this.map = {}; this.keys = []; }; var hasOwnProperty = Object.prototype.hasOwnProperty; LimitableMap.prototype.set = function (key, value) { var map = this.map; var keys = this.keys; if (!hasOwnProperty.call(map, key)) { if (keys.length === this.limit) { var firstKey = keys.shift(); delete map[firstKey]; } keys.push(key); } map[key] = value; }; LimitableMap.prototype.get = function (key) { return this.map[key]; }; module.exports = LimitableMap;
记录键在数组中,一旦超过数量,就以先进先出的方式进行淘汰。
直接将内存作为缓存的方案要十分慎重。除了限制缓存的大小外,另外要考虑的事情是,进程之间无法共享内存。如果在进程内使用缓存,这些缓存不可避免地有重复,对物理内存的使用是一种浪费。
如何使用大量缓存,目前比较好的解决方案是采用进程外的缓存,进程自身不存储状态。外部的缓存软件有着良好的缓存过期淘汰策略以及自有的内存管理,不影响Node进程的性能。它的好处多多,在Node中主要可以解决以下两个问题。
目前,市面上较好的缓存有Redis和Memcached。
队列在消费者-生产者模型中经常充当中间产物。这是一个容易忽略的情况,因为在大多数应用场景下,消费的速度远远大于生产的速度,内存泄漏不易产生。但是一旦消费速度低于生产速度,将会形成堆积, 导致Js中相关的作用域不会得到释放,内存占用不会回落,从而出现内存泄漏。
解决方案应该是监控队列的长度,一旦堆积,应当通过监控系统产生报警并通知相关人员。另一个解决方案是任意异步调用都应该包含超时机制,一旦在限定的时间内未完成响应,通过回调函数传递超时异常,使得任意异步调用的回调都具备可控的响应时间,给消费速度一个下限值。
常见的工具
由于Node的内存限制,操作大文件也需要小心,好在Node提供了stream模块用于处理大文件。
stream模块是Node的原生模块,直接引用即可。stream继承自EventEmitter,具备基本的自定义事件功能,同时抽象出标准的事件和方法。它分可读和可写两种。Node中的大多数模块都有stream的应用,比如fs的createReadStream()和createWriteStream()方法可以分别用于创建文件的可读流和可写流,process模块中的stdin和stdout则分别是可读流和可写流的示例。
由于V8的内存限制,我们无法通过fs.readFile()和fs.writeFile()直接进行大文件的操作,而改用fs.createReadStream()和fs.createWriteStream()方法通过流的方式实现对大文件的操作。下面的代码展示了如何读取一个文件,然后将数据写入到另一个文件的过程:
var reader = fs.createReadStream('in.txt'); var writer = fs.createWriteStream('out.txt'); reader.on('data', function (chunk) { writer.write(chunk); }); reader.on('end', function () { writer.end(); }); // 简洁的方式 var reader = fs.createReadStream('in.txt'); var writer = fs.createWriteStream('out.txt'); reader.pipe(writer);
可读流提供了管道方法pipe(),封装了data事件和写入操作。通过流的方式,上述代码不会受到V8内存限制的影响,有效地提高了程序的健壮性。
如果不需要进行字符串层面的操作,则不需要借助V8来处理,可以尝试进行纯粹的Buffer操作,这不会受到V8堆内存的限制。但是这种大片使用内存的情况依然要小心,即使V8不限制堆内存的大小,物理内存依然有限制。
更多node相关知识,请访问:nodejs 教程!
위 내용은 Node의 메모리 제어에 관한 기사의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!