栈,堆 && GC(垃圾回收)、ARC(自动引用计数)

6 阅读8分钟

我们知道,栈是自顶向下增长的,先进后出,这个过程就好比人早上起床先穿内衣内裤,再穿秋衣秋裤,再穿卫衣卫裤,再套羽绒服,这一层层的穿脱衣服就是帧(frame),等我们出门办完事了回家睡觉,那么一层层脱了,这穿衣和脱衣的过程如栈的执行过程,

在调用的过程中,一个新的分配足够的空间存储寄存器的上下文。在函数使用到的 通用寄存器会在栈 保存一个副本,当这个函数调用结束通过副本,可以恢复原本寄 存器的上下文,就像什么都没有经历一样。此外,函数所需要使用到局部变量,也都会 在帧分配的时候被预留出来

那一个函数运行时,怎么确定究竟需要多大的帧呢?这块由编译阶段编译器来分配,编译器得知道要用到哪些寄存器、栈上要放哪些局部变量,编译器就需要明确每个局部变量的大小,以便于预留空间;

由此可以知道在编译时,一切无法确定大小或者大小可以改变数据,都无法安全 地放在栈上,最好放在堆上

放栈上的问题

栈上内存分配是非常高效的需要改动指针 (stack pointer),可以预留相应的空间;把栈指针改动回来,预留的空间又释放 掉预留释放只是动动寄存器,不涉及额外计算、不涉及系统调用,因而效率很高,

栈是系统对其有最大栈空间的,一旦超出就溢出了,无法创建新的帧,这时程序会被系统终止,产生崩溃信息,

如:广为人知的递归函数没有妥善终 止。一个递归函数会不断调用自己,每次调用都会形成一个新的帧,如果递归函数无法终止,最终就会导致栈溢出。

栈虽然使用起来很高效,但它的局限也显而易见。当我们需要动态大小内存时,只能使 用,比如可变长度的数组、列表、哈希表、字典,它们都分配在堆上。

let mut arr = Vec::new();
arr.push(1);
arr.push(2);

堆上内存分配会使用 libc 提 供的 malloc() 函数,其内部会请求操作系统的系统调用,来分配内存。系统调用的代价是 昂贵的,所以我们要避免频繁地 malloc(),所以预留的空间大小 4 会大于需要的实际大小 2;

除了动态大小内存需要被分配上外,动态生命周期内存也需要分配上。

上的内存函数调用结束之后,所使用的帧被回收,相关变量对应的 内存也都被回收待用。所以栈上内存生命周期不受开发者控制的,并且局限在当前调 用栈

分配出来的每一块内存需要显式地释放,这就使堆上内存有更加灵活的生命周期, 可以不同的调用栈之间共享数据。(将栈之间的数据同步,堆相当于栈的状态同步管理器)。

放堆上的问题

手工管理堆内存的话,堆上内存分配后忘记释放,就会造成内存泄漏。一旦有内存泄 漏,程序运行得越久,就越吃内存,最终会因为占满内存被操作系统终止运行

堆越界

如果堆上内存多个线程调用栈引用,该内存的改动要特别小心,需要加锁独占访 问,来避免潜在的问题。比如说,一个线程遍历列表,而另一个线程释放列表中的某 一项,就可能访问野指针,导致堆越界(heap out of bounds)。而堆越界是第一大内存 安全问题。

使用已释放内存错误

如果堆上内存被释放,但栈上指向堆上内存的相应指针没有被清空,就有可能发生使用已 释放内存(use after free)的情况,程序轻则崩溃,重则隐含安全隐患;

解决堆内存管理

  • 追踪式垃圾回收 GC : 这种方式通过定期标记(mark) 找出不再被引用的对象,然后将其清理(sweep)掉,来自动管理内存,减轻开发者的负 担。
  • 自动引用计数 ARC 在编译时,它为每个函数插入 retain/release 语句来自动维护堆上对象的引 用计数,当引用计数为零的时候,release 语句就释放对象。

从效率上来说,GC 在内存分配和释放上无需额外操作,而 ARC 添加了大量的额外代码处 理引用计数,所以 GC 效率更高,吞吐量(throughput)更大。

但是,GC 释放内存的时机是不确定的,释放时引发的 STW(Stop The World),也会导 致代码执行的延迟(latency)不确定。所以一般携带 GC 的编程语言,不适于做嵌入式系 统或者实时系统。

一 核心思想对比

方法原理优点缺点
追踪式垃圾回收(如 Java、Go、Python)从“根对象”出发,标记所有可达对象,剩下的就是垃圾,统一清除自动处理循环引用,内存管理彻底停顿时间(Stop-the-world),不可预测的延迟
自动引用计数(如 Swift、Objective-C)每个对象记录被多少地方引用,引用为 0 时立即销毁实时释放内存,无停顿无法处理循环引用,需程序员手动干预

二、追踪式垃圾回收(Tracing GC)—— “警察扫街”

比喻:

像城市里的清洁工,每隔一段时间开着垃圾车,从“主干道”(根对象)出发,能开车到达的房子(对象)就是有人住的;到不了的,就推平回收。

工作流程(标记-清除算法为例):
  1. 根对象(Roots) :全局变量、栈上的局部变量、寄存器中的对象等。
  2. 标记阶段:从根出发,遍历所有能访问到的对象,打上“存活”标记。
  3. 清除阶段:扫描整个堆,回收所有未标记的对象。
  4. (可选)压缩内存,避免碎片。
  • 即使有循环引用,也能回收!

✅ 优点:程序员不用操心循环引用,系统自动清理。

⚠️ 缺点:GC运行时,程序可能“卡顿”一下(比如手机游戏突然卡住半秒)。

三、自动引用计数(ARC)—— “计数器管家”

比喻:

每个对象有个“人气计数器”,每被一个人喜欢(引用),+1;不喜欢了(释放引用),-1。人气为 0 时,对象立即“去世”。

工作方式:
  • 每个对象有一个 retainCount(引用计数)。
  • 当你用一个变量指向对象 → retainCount += 1
  • 当你让变量不再指向它(如设为 nil)→ retainCount -= 1
  • 当 retainCount == 0 → 立即释放内存

四、关键区别总结(用例子说话)

场景追踪式GC(如Python)引用计数(如Swift)
对象A被B引用,B被A引用,外部无引用✅ 自动回收(不可达)❌ 内存泄漏(除非用 weak)
对象释放时机不确定(GC运行时才回收)立即(引用为0时马上释放)
性能开销周期性暂停(GC停顿)每次赋值都增减计数(分散开销)
是否需程序员干预很少(GC智能)较多(需注意循环引用)

五、生活化类比

🧹 追踪式GC 像“社区普查”:
  • 每季度,社区派人从所有住户(根)出发,走访能联系到的人。
  • 住别墅但与世隔绝、没人能联系到你?→ 判定为“空置房”,拆了重建。
  • 即使你和邻居互相留电话(循环引用),但没人从“社区名单”能联系到你们 → 照样拆。
🔢 引用计数 像“每栋房子的访客计数器”:
  • 房子门口有个计数器:每有人来拜访 +1,客人走了 -1。
  • 如果两家人只互相串门,从不接待外人 → 计数器永远 ≥1 → 房子永远不拆!
  • 所以你必须让其中一家说:“我和你是普通朋友(weak),不算正式拜访” → 计数器归零 → 房子拆。

六、实际应用建议

技术栈推荐方法注意事项
Java / Python / Go追踪式GC注意大对象和GC停顿,可调优
Swift / Objective-CARC时刻警惕循环引用,善用 weak / unowned
Rust无GC也无ARC(所有权)编译期解决,更安全但学习曲线陡

总结一句话:

追踪式GC: “你活得连根都够不着?再见!” —— 智能但偶尔卡顿
引用计数(ARC) : “没人理你?立马抬走!” —— 及时但怕“死循环抱团”

理解这两种机制,能帮你写出更高效、更少内存泄漏的程序。

如开发 iOS App,记住口诀:

强引用要谨慎,循环引用用 weak,ARC虽好别大意。

而用 Python/Java 时,尽情创建对象互指吧,GC 会默默帮你收拾残局 😄


如有具体语言场景(如 iOS 开发 vs JVM 后端),可进一步展开!(本文知识主要是个人学习记录,文中有大量内容来自陈天老师的值放堆上还是栈上一章)