栈和堆何时被使用,为何被使用,以及如何运作

  • 程序运行时,操作系统会把整个程序加载到内存中,同时分配大量物理内存
  • 栈和堆是内存中实际存在的两个区域
    • 栈具有预定义的大小,通常约2兆字节
    • 堆也有预定义默认值,随着应用程序运行,它会增长变化
    • 我们需要一个地方存储运行程序所需的数据,如局部变量、或者从文件读取数据
  • 请求内存,也称内存分配

接下来看看在栈上分配一个整数,和在栈上分配其他数据的差异。与实际c++代码的堆相比

代码#

#include <iostream>
#include <string>

struct Vector3
{
	float x, y, z;
	Vector3()
		:x(10), y(11), z(12)
	{

	}
};

int main()
{
	{
		//在栈上分配内存
		int value = 5;
		int array[5];
		array[0] = 1;
		array[1] = 2;
		array[2] = 3;
		array[3] = 4;
		array[4] = 5;
		Vector3 vector;

		std::cout << &value << std::endl;
		std::cout << array << std::endl;
		std::cout << &vector << std::endl;
		
	}//作用域结束后,该作用域内栈上分配所有内容都被弹出(内存被释放)
	//在栈上分配内存和释放内存都只是移动栈指针,几乎没有性能损耗

	//new关键字分配堆内存
	int* hvalue = new int;
	*hvalue = 5;
	int* harray = new int[5];
	harray[0] = 1;
	harray[1] = 2;
	harray[2] = 3;
	harray[3] = 4;
	harray[4] = 5;
	//()可以省略
	Vector3* hvector = new Vector3();

	//必须手动释放内存
	delete hvalue;
	delete[] harray;
	delete hvector;

	std::cin.get();
}

#


调试模式下,变量之间有一些字节,因为所有变量周围会添加安全防护,以确保不让他们溢出或在错误内存地址访问他们

  • 00 00 20 41 $\rightarrow$ 对应 10.0
  • 00 00 30 41 $\rightarrow$ 对应 11.0
  • 00 00 40 41 $\rightarrow$ 对应 12.0 以上是在x64平台调试时的地址,函数中声明的变量是先放在低地址,再放在高地址

以下是x86平台调试时,函数中声明的变量是先放在高地址,再放在低地址

之前在《深入理解计算机系统》一书中提到栈中先声明的变量是先放在高地址的,这里不知道为啥x86会相反,ai的回答是为了优化调整了顺序

  • x86 的逻辑是“推(Push)”:局部变量是一个个压进去的,所以声明顺序 = 压栈顺序 = 地址递减顺序。
  • x64 的逻辑是“填(Fill)”:函数进来先执行 sub rsp, 0x50 挖个大坑。在这个坑里,编译器像是个装修工,它会根据内存对齐需求,从这个坑的“天花板”往“地板”填,或者反过来。

解释#

假设以下是先分配高地址,再分配低地址

当我们在栈中分配变量时,发生变化的是栈指针,栈顶指针会移动相应字节

  • 如果要分配一个int,那么就会把栈顶指针移动4字节
  • 如果要分配一个数组,这里是5x4=20,那么就会把栈顶指针移动20字节
  • 像栈一样依次堆叠,大多数栈实现中栈是向后增长。所以首个值存储在更高的内存地址
  • 比如我要分配一个整数,那就把栈指针向后移动4个字节(-4),然后返回该内存地址
sub rsp, 4    ; 将栈指针减去 4 (栈向低地址增长,减法代表分配)
mov [rsp], 10 ; 将数值 10 放入刚刚“圈出来”的那块地

#

不同变量在内存中地址是无序的,没有先后,更没有相连

当然,同一个数组不同元素间是有序的

//new关键字分配堆内存
	int* hvalue = new int;
	*hvalue = 5;
	int* harray = new int[5];
	harray[0] = 1;
	harray[1] = 2;
	harray[2] = 3;
	harray[3] = 4;
	harray[4] = 5;
	//()可以省略
	Vector3* hvector = new Vector3();

	//必须手动释放内存
	delete hvalue;
	delete[] harray;
	delete hvector;
  • new实际上只是调用了malloc的函数,转而调用底层操作系统的平台特定函数,并会在堆上分配内存
  • 它实现这一点的方式是,当你启动应用程序时,会给你分配一定量的物理内存,你的程序会维护一个空闲列表,它主要用于跟踪哪些内存块空闲及其位置,以便当你实际使用malloc请求动态内存(堆内存)时,它能遍历空闲列表,然后找到一个空闲内存块切大小符合需求,然后返回指向该块的指针。之后还会记录一些信息,比如分配的大小、以及它已被分配,并且不能再使用那块内存了 说明这是个相当繁重的函数 ,之后程序员就可以拿回这个指针使用
    • 如果申请的内存比最初分配的还多,那么程序就要向操作系统申请多些内存,所以存在巨大的潜在成本

对比#

  • 分配方式:在栈上分配内存差不多如一条cpu指令,而在堆上相比有些繁重
  • 栈上分配,内存中彼此相邻,基本可以放入同一条CPU缓存行中
    • 缓存行: 当 CPU 需要内存中的某个数据时,它会一次性把该地址周围的一大块数据(在现代 CPU 上通常是 64 字节)全部加载到自己的 L1/L2 缓存中。这一块 64 字节的数据就叫一个“缓存行”。
    • 情景 A(栈): 因为变量 $A, B, C, D$ 彼此相邻,当你读取 $A$ 时,CPU 顺便把紧跟在后面的 $B, C, D$ 也一起抓进缓存里了。当你接下来要用 $B$ 时,它已经躺在 CPU 缓存里了,访问速度提升了 10 到 100 倍。这叫 空间局部性(Spatial Locality)。所以说也许请求第一个栈变量之后就不会有缓存未命中
    • 情景 B(堆): 堆分配的对象往往散落在内存的各个角落。读取对象 $A$ 加载了一个缓存行,但里面全是没用的杂质;当你需要对象 $B$ 时,CPU 发现缓存里没有 缓存未命中 ,必须再次去缓慢的内存里“跑一趟”。这被称为 缓存缺失(Cache Miss)

查看汇编代码#

查看E:\cppStudyTemp\ChernoCpp\HelloWorld52\HelloWorld52\Debug\Main54_1.asm文件

#


; 16   : 	{
; 17   : 		//在栈上分配内存
; 18   : 		int value = 5;

	mov	DWORD PTR _value$11[ebp], 5 //5移动到具有特定偏移量的栈指针中

; 19   : 		int array[5];
; 20   : 		array[0] = 1;

	mov	eax, 4
	imul	ecx, eax, 0
	mov	DWORD PTR _array$10[ebp+ecx], 1

; 21   : 		array[1] = 2;

	mov	eax, 4
	shl	eax, 0
	mov	DWORD PTR _array$10[ebp+eax], 2

; 22   : 		array[2] = 3;

	mov	eax, 4
	shl	eax, 1
	mov	DWORD PTR _array$10[ebp+eax], 3

; 23   : 		array[3] = 4;

	mov	eax, 4
	imul	ecx, eax, 3
	mov	DWORD PTR _array$10[ebp+ecx], 4

; 24   : 		array[4] = 5;

	mov	eax, 4
	shl	eax, 2
	mov	DWORD PTR _array$10[ebp+eax], 5

; 25   : 		Vector3 vector;

	lea	ecx, DWORD PTR _vector$9[ebp]
	//调用了构造函数
	call	??0Vector3@@QAE@XZ			; Vector3::Vector3
	npad	1

#

; 34   : 	//new关键字分配堆内存
; 35   : 	int* hvalue = new int;

	push	4
	#这里调用了new运算符
	call	??2@YAPAXI@Z				; operator new 
	add	esp, 4
	mov	DWORD PTR $T8[ebp], eax
	mov	eax, DWORD PTR $T8[ebp]
	mov	DWORD PTR _hvalue$[ebp], eax

; 36   : 	*hvalue = 5;

	mov	eax, DWORD PTR _hvalue$[ebp]
	mov	DWORD PTR [eax], 5

; 37   : 	int* harray = new int[5];

	push	20					; 00000014H
	call	??_U@YAPAXI@Z				; operator new[]
	add	esp, 4
	mov	DWORD PTR $T7[ebp], eax
	mov	eax, DWORD PTR $T7[ebp]
	mov	DWORD PTR _harray$[ebp], eax

; 38   : 	harray[0] = 1;

	mov	eax, 4
	imul	ecx, eax, 0
	mov	edx, DWORD PTR _harray$[ebp]
	mov	DWORD PTR [edx+ecx], 1

; 39   : 	harray[1] = 2;

	mov	eax, 4
	shl	eax, 0
	mov	ecx, DWORD PTR _harray$[ebp]
	mov	DWORD PTR [ecx+eax], 2

; 40   : 	harray[2] = 3;

	mov	eax, 4
	shl	eax, 1
	mov	ecx, DWORD PTR _harray$[ebp]
	mov	DWORD PTR [ecx+eax], 3

; 41   : 	harray[3] = 4;

	mov	eax, 4
	imul	ecx, eax, 3
	mov	edx, DWORD PTR _harray$[ebp]
	mov	DWORD PTR [edx+ecx], 4

; 42   : 	harray[4] = 5;

	mov	eax, 4
	shl	eax, 2
	mov	ecx, DWORD PTR _harray$[ebp]
	mov	DWORD PTR [ecx+eax], 5

; 43   : 	//()可以省略
; 44   : 	Vector3* hvector = new Vector3();

	push	12					; 0000000cH
	call	??2@YAPAXI@Z				; operator new
	add	esp, 4
	mov	DWORD PTR $T5[ebp], eax
	mov	DWORD PTR __$EHRec$[ebp+8], 0
	cmp	DWORD PTR $T5[ebp], 0
	je	SHORT $LN3@main
	mov	ecx, DWORD PTR $T5[ebp]
	call	??0Vector3@@QAE@XZ			; Vector3::Vector3
	mov	DWORD PTR tv153[ebp], eax
	jmp	SHORT $LN4@main
$LN3@main:
	mov	DWORD PTR tv153[ebp], 0
$LN4@main:
	mov	eax, DWORD PTR tv153[ebp]
	mov	DWORD PTR $T6[ebp], eax
	mov	DWORD PTR __$EHRec$[ebp+8], -1
	mov	ecx, DWORD PTR $T6[ebp]
	mov	DWORD PTR _hvector$[ebp], ecx

; 45   : 
; 46   : 	//必须手动释放内存
; 47   : 	delete hvalue;

	mov	eax, DWORD PTR _hvalue$[ebp]
	mov	DWORD PTR $T4[ebp], eax
	push	4
	mov	ecx, DWORD PTR $T4[ebp]
	push	ecx
	call	??3@YAXPAXI@Z				; operator delete
	add	esp, 8

; 48   : 	delete[] harray;

	mov	eax, DWORD PTR _harray$[ebp]
	mov	DWORD PTR $T3[ebp], eax
	mov	ecx, DWORD PTR $T3[ebp]
	push	ecx
	call	??_V@YAXPAX@Z				; operator delete[]
	add	esp, 4

; 49   : 	delete hvector;

	mov	eax, DWORD PTR _hvector$[ebp]
	mov	DWORD PTR $T2[ebp], eax
	push	12					; 0000000cH
	mov	ecx, DWORD PTR $T2[ebp]
	push	ecx
	call	??3@YAXPAXI@Z				; operator delete
	add	esp, 8

小结#

  • 尽量尝试在栈上分配,除非放不下栈
    • 栈:内容连续存储
    • 堆:碎片化存储