Skip to content
🔗 内容纲要:

清晰易懂的现代编程语言内存管理

目录

在这个由多个部分组成的系列文章中,我的目标是要讲明白内存管理背后的概念,并深入观察一些现代编程语言的内存管理方式。我希望该系列文章能带你了解这些编程语言内存管理方面是如何运作的。学习内存管理也将会有助于我们写出性能更好的代码,因为我们写代码的方式也会影响到内存管理,不管编程语言有没有提供自动内存管理技术(automatic memory management technique)。

内存管理是控制和协调应用程序访问计算机内存的方式的过程。它在软件工程领域是一个很重要的话题,且是一个困扰了很多人的话题,对很多人来说,它就是一个黑盒(black box)。

内存管理是什么

当一个软件运行在一台计算机的目标操作系统上时,它需要访问计算机的内存 RAM (Random-access memory) 来:

  • 加载自己的需要被执行的字节码(bytecode)。
  • 存储被执行程序使用的数据值(data values)和数据结构(data structure)。
  • 加载程序执行时所必需的任何运行时系统(run-time systems)。

当一个软件程序使用内存时,用到了两个内存区域,除了用于加载字节码的空间之外,是栈(Stack)和(Heap)堆内存。

栈 Stack

栈 stack 用于 静态内存分配(static memory allocation),其名字也表示了它是一个后进先出模型 LIFO(last in first out) (可以把它想象为一叠盒子)。

  • 由于栈的特性,从栈中存储和获取数据的过程是非常快的,无需查找操作。你只需要从栈的最顶层存储和获取数据就好了。
  • 但是这也意味着存储在栈上的任何数据必须是 有限和静态的(finite and static) (数据的大小在编译时(compile-time)确定)。
  • 栈是函数的执行数据被保存的地方,保存的形式为栈帧(stack frames)(栈帧是函数的实际执行区域)。每一帧是栈中的一块空间,函数所需数据被存储于此。比如,每次一个函数声明一个新的变量,这个变量就被 push 到栈的最顶层。然后每次一个函数退出,栈顶就会被清理,这样所有的被该函数 push 到栈顶的变量都会被清除掉。由于栈数据存储的静态特性,以上行为会在编译时确定。
  • 在多线程应用程序(Multi-threaded applications)中,每个线程拥有一个栈(stack per thread)。
  • 栈的内存管理既简单又直接(simple and straightforward),由操作系统来完成。
  • 存储在栈上的典型数据包括局部变量(local variables) (值类型或原始类型,原始常量),指针(pointers)和函数帧(function frames)。
  • 这里是你会遇到栈溢出错误(stack overflow errors)的地方,因为相较于堆来说,栈的大小是受到限制的。
  • 大部分编程语言对能存储在栈上的值的大小都有所限制(limit on the size)。

7KpvEn1.gif

上图为 JavaScript 中栈的使用情况,对象被存储在堆中,需要的时候被引用。视频演示:

堆 Heap

堆用于动态内存分配(dynamic memory allocation),与栈不同,程序需要用指针(pointers)在堆中查找数据 (可以把它想象为一个大的多级图书馆)。

  • 堆比栈更慢(slower),堆涉及到更多的查找数据的过程,但是它可以存储更多的数据。
  • 这意味着堆可以存储动态大小(dynamic size)的数据。
  • 对于整个应用程序的所有线程,堆是共享的(shared)。
  • 由于堆的动态特性,使其更难于管理,堆也是大部分内存管理问题出现的地方,同时也是编程语言自动内存管理解决方案出现的原因。
  • 存储在堆中的典型数据包括全局变量(global variables)、引用类型(reference types)比如对象、字符串、字典,其它复杂的数据结构。
  • 如果你的应用程序尝试使用更多超出堆现有可用的空间时,会遇到内存不足错误(out of memory errors)。(尽管也会有像垃圾收集(GC)、紧缩处理(compacting)这样许多其它的因素的影响)。
  • 通常,存储在堆上的值的大小是没有限制的。当然,分配给应用程序的内存是有上限的。

为什么内存管理很重要

不像硬盘存储,RAM 容量是有限的。如果一个程序一直在消耗内存而不去释放,最终将导致内存不足,程序崩溃,甚至更糟糕的是,操作系统也会跟着崩溃。因此软件程序不能一直随心所欲地占用内存,因为这将会导致其它程序和进程内存不足。因此为了不让软件开发人员自己来管理内存,大部分编程语言提供了自动管理内存的方式。而且当我们谈到内存管理时,我们主要讨论的是堆内存的管理。

不同的实现方式?

因为现代编程语言不想给终端的开发人员增加增加管理应用程序内存的负担 (更多情况下是不信任他们👅),大部分的语言设计出了一种自动管理内存的方式。一些较老的语言仍然需要手动管理内存,但其中一些语言也提供了简洁的处理方式。一些语言使用了多种内存管理的方式,一些语言甚至让开发人员去选择最适合他的一种 (C++ 就是一个好例子)。这些内存管理方式分为如下几类:

手动内存管理

编程语言默认不会为你管理内存,需要你自己为创建的对象分配和释放内存。比如,CC++ ,它们提供了 mallocrealloccallocfree 方法去管理内存,开发人员在程序中使用这些方法分配和释放堆内存,利用指针有效地管理内存。但我们说这并不适合所有人😉。

垃圾收集 (GC)

垃圾收集(garbage collection)是指,通过释放不再使用的已分配内存的堆内存自动管理方式。在现代编程语言中,GC 是最常见的内存管理方式之一,垃圾收集过程经常运行在特定的时间间隔(intervals)内,因此可能会产生轻微的开销,被称为 “暂停时间”(pause times)。

JVM(Java/Scala/Groovy/Kotlin) , JavaScript , C# , Golang , OCaml , 和 Ruby 这些编程语言默认使用垃圾收集的内存管理方式。

AZaR0LP.gif

  • Mark & Sweep GC(标记清除 GC):也被称为 Tracing GC 。它通常是一个两步算法,第一步标记那些仍然被引用为 “alive” 状态的对象,下一步释放那些非 “alive” 状态的对象的内存。JVM , C# , Ruby , JavaScript , 和 Golang 正是使用了这种方式。JVM 中有着不同的 GC 算法可以选择,而像 V8 这样的 JavaScript 引擎使用了 Mark & Sweep GC,同时引用计数 GC 用于补充。这种 GC 也可用于 C 和 C++,不过是以一个外部依赖库的方式提供的。
  • Reference counting GC(引用计数 GC):这种收集方式中,每个对象都会获得一个引用计数(reference count),随着它被引用次数的变化而增加或减少,当引用计数变为 0 时,垃圾收集过程也就完成了。因为这种方式不能处理循环引用(cyclic references)的问题,所以并不是非常推荐。 PHP , Perl , 和 Python 就使用了这种类型的 GC 方式而且提供了解决循环引用的方法(workarounds)。这种类型的 GC 也可以用在 C++ 上。

资源获取即初始化 (RAII)

RAII(Resource Acquisition is Initialization)。这种类型的内存管理中,一个对象的内存分配被绑定到了它的生命周期上,对象创建就分配,销毁就释放。它被引入到了 C++ 中,也被 Ada Rust 使用着。

资源获取即初始化

RAII,全称资源获取即初始化(英语:Resource Acquisition Is Initialization),它是在一些面向对象语言中的一种惯用法(英语:Programming idiom)。RAII 源于 C++,在 Java,C#,D,Ada,Vala 和 Rust 中也有应用。

RAII 要求,资源的有效期与持有资源的对象的生命期(英语:Object lifetime)严格绑定,即由对象的构造函数完成资源的分配(英语:Resource allocation (computer))(获取),同时由析构函数完成资源的释放。在这种要求下,只要对象能正确地析构,就不会出现资源泄露(英语:Resource leak)问题。

参考:

自动引用计数 (ARC)

ARC(Automatic Reference Counting)与引用计数 GC 类似,但不是用一个运行时进程(runtime process)运行在特定的时间间隔,而是将 retain 和 release 指令在编译期插入到编译好的代码中,当一个对象的引用变为 0 时,内存会被自动清理,这个过程会被作为程序执行的一部分,无需暂停任何程序。这种方式也不能处理循环引用问题(cyclic references),需要依赖于开发人员使用特定的关键字去处理。这是一种 Clang 编译器的特性,Clang 为 Objective C & Swift 提供了 ARC 支持。

所有权(Ownership)

Ownership 用所有权模型(ownership model)结合了 RAII,任何值必须有一个变量作为它的所有者(owner)(一个时间点仅有一个所有者)。当所有者超出了作用域(scope),值就将被丢弃,释放掉内存,不管是在栈还是堆内存中。它有点像编译时引用计数(Compile-time reference counting),正在被 Rust 使用。在我的研究中,我还没有发现任何其他正在这种这种机制的编程语言。

我们刚刚对内存管理有了粗浅认识。每种编程语言在使用它自己版本的内存管理方式,并利用不同的调教好的算法达成不同的目标。在该系列文章接下来的部分里,我们将深入了解一些流行编程语言的具体的内存管理实现方案。

image

参考

版权声明

本文翻译自🚀 Demystifying memory management in modern programming languages,版权归原作者所有。

Released under the MIT License.