图解 Google V8 # 11:堆和栈:函数调用是如何影响到内存布局的?

简介: 图解 Google V8 # 11:堆和栈:函数调用是如何影响到内存布局的?

说明

图解 Google V8 学习笔记



在编译流水线中的位置

8bfdc5eaa9fa45a68b7837846a450945.png



先看三个例子:


1、在同一个任务中重复调用嵌套的 kaimo 函数。

function kaimo() {
  kaimo()
}
kaimo()


V8 会报栈溢出的错误:

677cbc5ddcff481cbdbbe35ada4ec4a1.png


2、使用 setTimeout 让 kaimo 函数在不同的任务中执行。

function kaimo() {
    console.log(1)
  setTimeout(kaimo, 0)
}
kaimo()


V8 能够正确执行。

4e19cb65b9ee44ce9722e6f7623464b9.png


3、在同一个任务中执行 kaimo 函数,但是却不是嵌套执行。

function kaimo() {
    console.log(1)
  return Promise.resolve().then(kaimo)
}
kaimo()


没有栈溢出的错误,页面却出现了卡死的情况。

这相当于在当前这一轮任务里不停地创建微任务,执行,创建,执行,创建……虽然不会爆栈,但也无法去执行下一个任务,主线程被卡在这里了,所以页面会卡死。


06ba56a51a3e4804918f9ef12cf92dde.png


有同学说在执行5-10分钟后,Chrome会报错:paused before potential out-of-memory crash,然后当前宏任务继续处于被挂起状态。大家可以试一下,我试了好几次都是浏览器崩溃了,就很离谱。


V8 执行这三种不同代码时,它们的内存布局是不同的,而不同的内存布局又会影响到代码的执行逻辑。



为什么使用栈结构来管理函数调用?


通常函数有两个主要的特性:


  1. 函数可以被调用
  2. 函数具有作用域机制:作用域机制通常表现在函数执行时,会在内存中分配函数内部的变量、上下文等数据,在函数执行完成之后,这些内部数据会被销毁掉。

例子:

int getZ()
{
    return 4;
}
int add(int x, int y)
{
    int z = getZ();
    return x + y + z;
}
int main()
{
    int x = 5;
    int y = 6;
    int ret = add(x, y);
}


上面这段 C 代码执行流程:


   当 main 函数调用 add 函数时,需要将代码执行控制权交给 add 函数;

   然后 add 函数又调用了 getZ 函数,于是又将代码控制权转交给 getZ 函数;

   接下来 getZ 函数执行完成,需要将控制权返回给 add 函数;

   同样当 add 函数执行结束之后,需要将控制权返还给 main 函数;

   然后 main 函数继续向下执行。


函数调用示意图如下:

9a3c74b396484b5faf1644496840570a.png


嵌套调用时函数的生命周期:


ad5b9fadc1ec4016bf72102bed1e5035.png


函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出)。

函数资源分配流程:

b3d4a3c390da4fbab9014bcacb3bdcc9.png

因为函数是有作用域机制的,站在函数资源分配和回收角度来看,被调用函数的资源分配总是晚于调用函数 (后进),而函数资源的释放则总是先于调用函数 (先出)。



b2e575cb72d2423ca8da7ee8418bd29c.png


函数的生命周期和函数的资源分配情况,它们都符合后进先出 (LIFO) 的策略,而栈结构正好满足这种后进先出 (LIFO) 的需求,所以选择栈来管理函数调用关系是一种很自然的选择。



栈如何管理函数调用?


栈的变化情况是怎样的?


当执行一个函数的时候,栈怎么变化?

例子:

int main()
{
  int x = 5;
  int y = 6;
  x = 100;
  int z = x + y;
  return z;
}


函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中。



e78a63bc53b949d59dcea9f4685a18e2.png


当一个函数调用另外一个函数时,栈的变化情况是怎样的?

int add(num1,num2){
    int x = num1;
    int y = num2;
    int ret = x + y;
    return ret;
}
int main()
{
    int x = 5;
    int y = 6;
    x = 100;
    int z = add(x,y);
    return z;
}


快调用 add 函数时:

4f325a52024942d694bd36bf39408a53.png


调用 add 函数的过程:

db344b71c23645f0b79be486dacafece.png


当 add 函数执行完成之后,需要将执行代码的控制权转交给 main 函数,这意味着需要将栈的状态恢复到 main 函数上次执行时的状态,我们把这个过程叫恢复现场



怎么恢复 main 函数的执行现场呢?


   栈顶指针是在栈操作过程中,有一个专门的栈指针(习惯上称它为TOP),指出栈顶元素所在的位置。


在寄存器中保存一个永远指向当前栈顶的指针即可。


   栈顶指针的作用就是告诉你应该往哪个位置添加新元素,这个指针通常存放在 esp 寄存器中。如果你想往栈中添加一个元素,那么你需要先根据 esp 寄存器找到当前栈顶的位置,然后在栈顶上方添加新元素,新元素添加之后,还需要将新元素的地址更新到 esp 寄存器中。


所以当 add 函数执行结束时,只需要将栈顶指针向下移动,就可以恢复 main 函数的执行现场了。


add 函数即将执行结束的状态:


4a9df5ddbee14247963cee8aba7d9fcc.png


恢复 mian 函数执行现场:

755bd8f6500a45a6ac8a0e78e65ac3a4.png




CPU 是怎么知道要移动到这个地址呢?

CPU 的解决方法是增加了另外一个 ebp 寄存器,用来保存当前函数的起始位置,我们把一个函数的起始位置也称为栈帧指针ebp 寄存器中保存的就是当前函数的栈帧指针


ebp 寄存器保存了栈帧指针:


fdb51d0d4f7d46b19adf7022055094ef.png



在 main 函数调用 add 函数的时候:


   main 函数的栈顶指针就变成了 add 函数的栈帧指针


   需要将 main 函数的栈顶指针保存到 ebp 中


当 add 函数执行结束之后:只需要取出 main 函数的栈顶指针写到 esp 中即可

   需要销毁 add 函数的栈帧


   并恢复 main 函数的栈帧


这就相当于将栈顶指针移动到 main 函数的区域。



如何恢复 main 函数的栈帧指针呢?


每个栈帧对应着一个未运行完的函数,栈帧中保存了该函数的返回地址和局部变量。栈帧也叫过程活动记录。


通常的方法是在 main 函数中调用 add 函数时,CPU 会将当前 main 函数的栈帧指针保存在栈中:

  1. 首先取出 ebp 中的指针,写入 esp 中
  2. 然后从栈中取出之前保留的 main 的栈帧地址,将其写入 ebp 中



cecb8f775db04600b053ebf6dcbe9ab8.png


这样 ebp 和 esp 就都恢复了,可以继续执行 main 函数了。



和栈空间不同,存放在堆空间中的数据是不要求连续存放的,从堆上分配内存块没有固定模式的,你可以在任何时候分配和释放它。


下面代码中有 new int、new Point 这种语句,当执行这些语句时,表示要在堆中分配一块数据,然后返回指针,通常返回的指针会被保存到栈中。


struct Point
{
    int x;
    int y;
};
int main()
{
    int x = 5;
    int y = 6;
    int *z = new int;
    *z = 20;
    Point p;
    p.x = 100;
    p.y = 200;
    Point *pp = new Point();
    pp->y = 400;
    pp->x = 500;
    delete z;
    delete pp;
    return 0;
}



当 main 函数快执行结束时,堆和栈的状态:


9c045229cc6c4c1da0e536f491138b9e.png



上图中 p 和 pp 都是地址,它们保存在栈中,指向了在堆中分配的空间。


  • C/C++ 这种手动管理内存的语言,如果没有手动销毁堆中的数据,那么就会造成内存泄漏
  • JavaScript,Java 使用了自动垃圾回收策略,可以实现垃圾自动回收,但也会带来一些性能问题







目录
相关文章
|
2月前
|
缓存 Prometheus 监控
Elasticsearch集群JVM调优设置合适的堆内存大小
Elasticsearch集群JVM调优设置合适的堆内存大小
515 1
|
1月前
|
存储 编译器 程序员
【C语言】内存布局大揭秘 ! -《堆、栈和你从未听说过的内存角落》
在C语言中,内存布局是程序运行时非常重要的概念。内存布局直接影响程序的性能、稳定性和安全性。理解C程序的内存布局,有助于编写更高效和可靠的代码。本文将详细介绍C程序的内存布局,包括代码段、数据段、堆、栈等部分,并提供相关的示例和应用。
60 5
【C语言】内存布局大揭秘 ! -《堆、栈和你从未听说过的内存角落》
|
29天前
|
算法 Java
堆内存分配策略解密
本文深入探讨了Java虚拟机中堆内存的分配策略,包括新生代(Eden区和Survivor区)与老年代的分配机制。新生代对象优先分配在Eden区,当空间不足时执行Minor GC并将存活对象移至Survivor区;老年代则用于存放长期存活或大对象,避免频繁内存拷贝。通过动态对象年龄判定优化晋升策略,并介绍Full GC触发条件。理解这些策略有助于提高程序性能和稳定性。
|
1月前
|
机器学习/深度学习 人工智能 缓存
【AI系统】推理内存布局
本文介绍了CPU和GPU的基础内存知识,NCHWX内存排布格式,以及MNN推理引擎如何通过数据内存重新排布进行内核优化,特别是针对WinoGrad卷积计算的优化方法,通过NC4HW4数据格式重排,有效利用了SIMD指令集特性,减少了cache miss,提高了计算效率。
57 3
|
1月前
|
存储 算法 Java
Java 内存管理与优化:掌控堆与栈,雕琢高效代码
Java内存管理与优化是提升程序性能的关键。掌握堆与栈的运作机制,学习如何有效管理内存资源,雕琢出更加高效的代码,是每个Java开发者必备的技能。
75 5
|
2月前
|
存储
栈内存
栈内存归属于单个线程,也就是每创建一个线程都会分配一块栈内存,而栈中存储的东西只有本线程可见,属于线程私有。 栈的生命周期与线程一致,一旦线程结束,栈内存也就被回收。 栈中存放的内容主要包括:8大基本类型 + 对象的引用 + 实例的方法
28 1
|
3月前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
141 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
3月前
|
存储 Java
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
这篇文章详细地介绍了Java对象的创建过程、内存布局、对象头的MarkWord、对象的定位方式以及对象的分配策略,并深入探讨了happens-before原则以确保多线程环境下的正确同步。
72 0
JVM知识体系学习四:排序规范(happens-before原则)、对象创建过程、对象的内存中存储布局、对象的大小、对象头内容、对象如何定位、对象如何分配
|
3月前
|
C++
析构造函数就是为了释放内存,就是在局部指针消失前释放内存,拷贝构造函数就是以构造函数为模块,在堆里面新开一块,同一个变量在堆里面的地址
本文讨论了C++中构造函数和析构函数的作用,特别是它们在管理动态内存分配和释放中的重要性,以及如何正确地实现拷贝构造函数以避免内存泄漏。
51 2
|
3月前
|
存储 Java
深入理解java对象的内存布局
这篇文章深入探讨了Java对象在HotSpot虚拟机中的内存布局,包括对象头、实例数据和对齐填充三个部分,以及对象头中包含的运行时数据和类型指针等详细信息。
36 0
深入理解java对象的内存布局

热门文章

最新文章