Skip to content

Instantly share code, notes, and snippets.

@wangyingsm
Last active December 27, 2022 13:55
Show Gist options
  • Star 9 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save wangyingsm/0eebdfe41889efb12c346ae7852d8302 to your computer and use it in GitHub Desktop.
Save wangyingsm/0eebdfe41889efb12c346ae7852d8302 to your computer and use it in GitHub Desktop.
主流语言内存管理笔记

主流语言内存管理笔记

本笔记根据以下博客系列文章整理:

目录:

[TOC]

一、内存管理概述

内存在计算机程序中的主要作用包括:

  • 存放程序本身的二进制字节,这些二进制字节将被CPU读取用来运行
  • 存储程序运行过程需要的运行时Runtime和动态链接库Dynamic Library
  • 存取程序运行过程需要使用的静态分配或动态分配数据

1.1 栈

  • 栈内存访问速度一般都非常快,基于两点基本事实:1)栈内存不存在随机访问,并且一般都可以使用寄存器长期保留栈顶地址;2)栈内存大小有限,并且一段时间内访问的内容都紧靠在一起,非常方便CPU cache进行缓存,甚至L1都很少出现miss。
  • 这也说明栈分配内存必须是有限和静态的(编译期就能确定大小的分配)。
  • 每次函数调用时,栈上都会分配一个相应的空间,称为帧Frame。
  • 多线程中每个线程都有着各自独立的栈空间。
  • 栈内存的管理是非常简单和直接的,因此基本上都是直接交由操作系统管理。
  • 栈内存上存储的内容主要包括本地变量、堆内存指针和函数帧。
  • 栈内存空间有限,因此使用超出就会产生stack overflow。

1.2 堆

  • 很多情况下会比栈内存访问慢,基于两点:1)基本上都比栈内存多了一次间接引用;2)如果程序中使用的堆内存落在非连续空间,会造成cache miss。
  • 堆内存可以存储动态的数据(编译器无法确定大小的)。
  • 因为管理动态数据的复杂性,也是很多内存安全性问题的直接来源,因此这就是大部分语言自行管理内存的重点部分,也是这一系列博文的目的。
  • 堆内存基本上可以存储任何类型的内容,很多基于VM的语言更是将其作为主要内存存储空间。
  • 虽然堆内存很大(虚拟的),但是也是有限制的,当超出时发生out of memory。

1.3 不同的管理方式

  • 手动管理内存:需要程序员主动申请alloc和释放free内存,标志性语言包括C/C++。

  • 垃圾收集:由虚拟机或运行时代为管理内存,标志性语言包括JVM相关语言、JavaScript、C#、Golang、Python等。有两种主要的收集策略:

    • 标记和清除策略(Mark and sweep):垃圾收集包括两个阶段标记和清除。标志性语言包括JVM、JavaScript、C#和Golang。

    • 引用计数策略(Reference counting):运行时记录每个对象的引用计数,引用数到0时释放。标志性语言包括Python、PHP。

  • 资源获取即初始化策略(RAII):将对象的内存分配和释放绑定到其生命周期上,创建即分配,失效即释放。标志性语言包括C++、Rust、Ada。

  • 自动引用计数策略(Automatic reference counting):使用引用计数,但不是运行期执行,通过在编译器插入retain和release语句,在对象引用计数减为0时自动完成释放。标志性语言包括Objective-C、Swift。

  • 所有权策略(Ownership):将RAII与所有权模型结合在一起,每个对象有且仅有一个所有者Owner,当对象失去作用域时,由所有者负责释放内存。语言就是Rust。

    Ownership

二、JVM中的内存管理(Java、Kotlin、Scala、Groovy、Clojure)

2.1 JVM内存结构

下面是基于JDK11的JVM内存结构图:

JVM memory

2.1.1 堆

JVM的堆内存大小可以通过Xms(初始大小)和Xmx(最大大小)设置。整个堆内存空间进一步划分成两个区域,新生代Young generation和老年代Old generation(也称为Tenured space)。

  • 新生代:新创建的对象占用的内存空间,在这上面进行的GC称为Minor gc。进一步划分为两个区域:
    • 伊甸区:每次新创建对象都存储在这里。
    • 幸存区:伊甸区中在Minor gc中幸存的对象存放的地方。幸存区分为两半,S0和S1。
  • 老年代:当对象在超过了阈值次数的Minor gc之后仍然幸存,会被转移到这里,老年代需要通过Major gc来收集。

2.1.2 栈

可以通过Xss设置。

2.1.3 元空间

老版本的JVM中,这个空间被称为永久代空间。用来存储类定义,默认没有大小限制,但是如果出现内存不够扩展元空间的情况下,操作系统可能会使用辅助内存(如Linux中的Swap)来帮助存储对象,这会导致程序性能受到较大影响。可以通过XX:MetaspaceSize或XX:MaxMetaspaceSize对其进行限制,超出则会抛出out of memory。

2.1.4 代码缓存

这是JVM的JIT技术用来存储频繁访问的编译后代码的地方,从而提高程序运行效率。

2.1.5 共享库

这是JVM用来存储运行过程中需要使用的共享库的地方,一个JVM进程仅需要加载一次。

2.2 JVM内存使用过程

原博文中有一个实际例子,并有很棒的一个PPT互动可视化说明,强烈建议到原文查看。

  • 每个线程都有独立的栈,每次函数调用都有相应的栈帧Frame产生。
  • 所有的局部变量包括参数和返回值都会存储在栈帧中。
  • 所有基本类型如int都会存储在栈上。
  • 所有的复杂对象,如Integer、String和自定义的对象都会存储在堆上,程序通过栈上的引用(指针)来访问对象。对于类的静态字段也适用。
  • 当栈上的引用超出作用域时,其指向的堆上对象就变成了孤儿对象Orphan。
  • 除非明确适用Copy,所有嵌套对象都使用引用指向,对象中的对象只是一个引用。

2.3 JVM内存管理:垃圾收集

JVM中的GC负责的工作包括:

  • 向操作系统申请内存和释放内存。
  • 为其中的应用分配所需要的内存。
  • 找到应用已经分配的内存中,哪些还需要使用,哪些不再使用。
  • 回收那些应用已经不再使用的内存。

2.3.1 Mark and sweep垃圾收集

JVM使用一个独立的后台线程来进行垃圾回收。使用的是标记和清除策略:

  • 标记:从GC root开始遍历所有的对象,能够到达的对象会被标记为在用,这个阶段结束后未被标记的对象即表示可回收。
  • 清除:遍历应用的堆空间,将所有未标记的对象回收。
  • 整理:清除了所有未使用的空间后,所有在用的空间将被移动到一个连续空间,这能减少内存碎片和提高新分配内存的效率,但必然带来内存的复制和引用更新过程。

这种GC被称为世界停止Stop the world,因为过程中它会暂停应用的运行。

2.3.2 JDK 11中可用的收集器

  • 串行收集器,-XX:+UseSerialGC:使用单线程垃圾收集,适用于小量的应用数据和单CPU计算机的情况。
  • 并行收集器,-XX:+UseParallelGC:使用多个线程进行垃圾收集加快GC速度提升吞吐量,适用于大量的应用数据和多CPU计算机的情况。
  • G1收集器,-XX:+UseG1GC:默认值,根据需要使用并行机制,兼顾吞吐量和STW时间优化。
  • Z收集器,-XX:+UseZGC:实验性的收集器,并行化且不会暂停应用运行,因而没有STW,适用于低延迟大量数据的情况。

2.4 GC过程

2.4.1 Minor GC

只进行新生代垃圾收集。

  • 假设起始时,幸存区的S0和S1都是空的。
  • 当应用需要分配堆内存,而伊甸区剩余的空间不够时,发生Minor GC。
  • 在伊甸区进行标记Mark,分出在用和孤儿对象。
  • 随机选取S0或S1作为“To Space”(这里假定选择了S0),将伊甸区的所有在用对象转移到“To Space”中,更新栈指针的内容,清空整个伊甸区。因为转移到了一个新的空间,因此所有对象的内存占用都是紧凑的。
  • 假设过了一小段时间后,伊甸区空间再次不够,触发第二次Minor GC。
  • 在伊甸区和“To Space”(S0)区都进行Mark,分出在用和孤儿。
  • 将S1标记为“To Space”,S0标记为“From Space”。后续S0和S1在每次Minor GC时都会发生“From Space”和“To Space”的切换。
  • 将伊甸区和S0中所有在用对象转移到“To Space”(S1),更新栈指针内容,清空整个伊甸区和S0。因为转移到了一个新的空间,因此所有对象的内存占用都是紧凑的。
  • 幸存区中的对象会在每次Minor GC中进行S0与S1之间的转移,直至达到年龄最大值阈值Max-age threshold,默认为15。

Minor GC是一个STW过程,不过大多数情况下它都很快,一般可以忽略其影响。

2.4.2 Major GC

对老年代Tenured space进行垃圾收集。

触发条件:

  • 程序中主动调用System.gc()Runtime.getRuntime().gc()
  • JVM认为老年代空间不足以支持下一次Minor GC。
  • 在Minor GC期间,JVM发现无法在伊甸区和幸存区找到足够的空间分配内存。
  • 如果设置了MaxMetaspaceSize,JVM发现无法加载新的类定义。

Major GC的过程比Minor GC要简单:

  • 假设经过多次Minor GC之后,老年代已经没有足够的空间,触发了Major GC。
  • 标记在用和孤儿,如果发生在Minor GC期间,JVM会在新生代(伊甸区,幸存区)和老年代都进行标记,否则仅在老年代进行标记。
  • 清除所有标记为孤儿的对象。
  • 如果Major GC过程中堆区不再存在某个类的对象,JVM会同时回收这个类在Metaspace的内存分配,这也被称为Full GC。

三、V8中的内存管理(JavaScript、NodeJS、Deno、V8中的WebAssembly)

3.1 V8的内存结构

下图展示了V8的内存结构:

V8 memory

看起来很像上面的JVM结构,我们看看每个组成:

3.1.1 堆

V8的堆区包括下面各部分:

  • 新生代New space:新生代分为两个相同的Semi space,类似JVM的S0和S1。V8的GC策略有一个基本假设,那就是绝大多数的对象存活时间都很短,新生代就是些对象存放的地方。这里发生的GC叫做Scavenger(Minor GC)。新生代可以通过--min_semi_space_size来设置初始化大小,可以通过--max_semi_space_size来设置最大大小。
  • 老年代Old space:当对象在新生代中幸存超过两个GC周期之后,就会被转移到老年代。这里发生的是Major GC,使用Mark-sweep & mark-compact策略进行垃圾收集。老年代可以通过--min_old_space_size设置初始化大小,可以通过--max_old_space_size设置最大大小。老年代还会细分为两个区域:
    • 老年指针区Old pointer space:那些包含其他对象指针的对象存放在这里。
    • 老年数据区Old data space:那些不包含其他任何对象指针的对象存放在这里。注意string也是存在在这里,因为string是JavaScript的基本类型。
  • 大对象空间Large object space:这里存放的是那些超过大小限制值的对象,每个对象都会使用mmap系统调用获得自己的内存区域,这些对象永远不会被GC移动。
  • 代码区Code space:这是V8的JIT用来存放编译后的机器代码的地方。
  • Cell space, property cell space, map space:V8用来存储Cells、Property Cells和Maps的地方,这对应着就是V8中三种大小一致有相应内存要求的集合容器。

上面的这些空间都由内存页Page组成,页是一段内存连续空间的概念,由V8使用mmap向系统申请,每一页的大小都是1MB。

3.1.2 栈

可以通过--stack_size调整栈空间大小。

3.2 V8内存使用(堆vs栈)

原博中有一个交互可视化例子,强烈推荐。

  • JavaScript中的全局作用域Global scope放置在栈上的全局帧Global frame中。
  • 每次函数调用都会创建自己的栈帧。
  • 局部变量、参数和返回值存放在该函数的栈帧上。
  • 所有的基本类型,如int和string也保存在栈帧上。
  • 所有的复杂对象,如Function或自定义对象都存放在堆上。对于全局作用域来说也是一致的。
  • 当栈指针超出作用范围后,其指向的堆对象将变为孤儿。
  • 除非明确使用Copy,否则所有包含的对象都是引用。

对于V8来说,区别对待指针还是数据本身是非常重要的,因此使用了一种标签指针Tagged pointer,V8使用每个机器字最后的一个二进制位作为指针还是数据的标识位。

3.3 V8内存管理

3.3.1 Minor GC(Scavenger)

V8中所有新对象都创建在New space,所有的对象都是连续存放的,也就是内存布局是紧凑的。当新生代中的空闲指针移到了空间末尾时,就说明此时空间不足够进行新对象分配了,触发Minor GC,也叫作Scavenger,使用的是Cheney's算法回收内存。

  • 新生代分为两个相同的Semi space,将其中一个标记为“From space”,另一个标记为“To space”。
  • 所有的分配都发生在From space,假设某个时间From space中已经满了,无法分配新对象内存,触发了Minor GC(Scavenger)。
  • 递归搜索所有栈指针指向的From space对象,找到后就将其转移到To space中,并更新栈指针。当所有From space的对象都遍历过之后,所有在用的对象都已经被转移到To space,并且To space保持布局紧凑。
  • 此时From space可以被清空,因为在用的对象都已经移走了。
  • 将From space和To space标记交换,新的对象就能够从新的From space当前位置开始分配。而新的To space是空的。
  • 当一个对象在两次Minor GC之后存活下来,也就是该对象在两个Semi space中都存活了下来,它将被移到老年代Old space。

这是一个STW过程,不过它很快因此大部分情形中都可以忽略。因为这个过程中没有扫描老年代可能存在的指向新生代的引用指针,因此需要使用写屏障技术来记录这些可能的情况。

3.3.2 Major GC

当老年代可用空间不足时触发,可能是V8动态计算判断的结果,也可能是Minor GC中转移到老年代时发生的。它使用的是Mark-sweep-compact算法,在标记阶段使用的是三原色标记法。

  • 标记:在堆中使用深度优先搜索标记所有的对象,分出在用和孤儿。
  • 清除:删除释放那些孤儿对象。
  • 整理:清除完毕后,如果需要的话,会将对象重新复制到一起,以保持内存布局的紧凑。

这个过程是STW的。因此V8对其做了很多优化:

V8 optimized GC

  • 增量GC:将一次性的GC分散成多次来处理,小步快跑。
  • 并发标记:使用协助线程来进行标记,从而不用堵塞主线程(V8只有一个线程)。过程中主线程产生的新对象使用写屏障进行保护。
  • 并发清除和整理:使用多线程来清除和整理对象,加快GC速度,因为此时会发生栈指针的更新,因此主线程必须阻塞,必须STW。
  • 懒清除:在某些情况下,如果内存页暂时不需要使用,那么可以将清除过程延迟到需要的时候。

详细流程如下:

  • 经过多次Minor GC后,V8发现老年代已经快满了,因此触发一次Major GC。
  • 递归标记所有栈指针指向的堆上对象,因为使用了协助线程,此时主线程不受影响。
  • 使用多线程对孤儿对象进行清理,当标记完成需要启动清理时,主线程开始阻塞。
  • 同样使用多线程对内存进行整理,更新栈指针。

四、Golang中的内存管理

4.1 Go的并发模型

Go使用一种被称为“GMP”的模型来实现异步并发。此处的G代表Goroutine,P代表逻辑处理器,M代表机器(也就是实际上CPU的核),我们在本节中将使用GMP来简称它们。这也是通用MNP模型的一种变体。

GMP

希望了解更多GMP的细节,参考这篇文章

4.2 Go的内存结构

每个Go进程都会向操作系统申请一块虚拟内存专用,里面真实使用的部分被成为“Resident set”。整个内存结构如下图。

Go memory

虽然看起来上图是一块一块的,但是实际上Go将内存分成页Page进行管理。详细见这篇文章

这和JVM或V8比较有着很大的差异,底层原因就是Go使用TCMalloc(线程缓存内存分配Thread caching malloc)来分配内存的。

我们解释一下上图的各个部分:

4.2.1 页堆(Page heap,mheap)

整个Resident set被划分为内存页,每页8KB,由一个全局mheap对象进行管理。

超过32KB的大对象会直接在mheap中分配,不使用内存页,但是由于需要使用全局锁,因此同一时间只能有一个P能够申请大对象的内存。值得注意,高并发情况下申请超过32KB的大对象会有较大的性能惩罚。

其余的部分,mheap会分成下面的几种:

  • mspan是mheap中最基本的组成单元,它实际上一个由一系列内存页构成的双向链表。mspan的属性包括起始页地址、span类型和页数量。span类型是TCMalloc底层决定的,一共有67种,大小从8个Bytes到32KB。每种span还分为两类,一类的对象含有指针Scan,另一类对象不含有指针Noscan,提供GC判断是否需要继续遍历的依据。见下图:

mspan

  • mcentral是同样大小的mspan的组合,其中含有两个mspan的链表。
    • empty链表存放那些不存在空闲对象的mspan,当其中某个mspan被释放后,会移到nonempty链表中。
    • nonempty链表存放是空闲对象的mspan,当mcentral需要分配对象内存时,会根据大小在相应的nonempty链表中获得mspan,并将其移到empty链表中。 如果nonempty链表没有空闲的mspan,会向mheap请求一些新的内存页用来做mspan。
  • arena:mheap向虚拟内存申请空间时不会零散申请,会以一整块64MB的大小获取,叫做一个arena。
  • mcache是每个P独占的缓存空间,只能存放32KB或以下的对象,因此Goroutine在获取mcache内存的时候不需要加锁,原因是此时每个P上面只能运行一个G。虽然mcache很像栈,但是它实际上处于堆区,用来存储动态数据。每个mcache都含有所有67种不同大小的mspan,而且也都具有Scan和Noscan。

4.2.2 栈

对每个G来说,都具有自己的栈空间,用来存放栈帧、基本类型、静态数据和栈指针。这里要注意区分栈和mcache,mcache是分配给每个P的,栈是分配给每个G的。

4.3 Go的内存使用

原博中有一个很棒的交互可视化PPT,强烈建议去看。

Go与其他很多GC的语言有一个很大的差别,那就是很多的对象都会直接被分配到栈上。编译器使用了“逃逸分析”来判断对象的生命周期是否能够直接在栈上分配而不需要用到堆区。

  • 每个函数都有自己的栈帧。
  • 所有静态数据(编译期逃逸分析判断的结果)、参数、返回值都处于栈帧上。
  • 只要是静态数据,不管类型,都在栈上分配。
  • 动态数据会逃逸到堆上,栈上使用指针引用它,小于等于32KB的对象会分配到mcache上。
  • 一个结构体如果全部都是静态数据,将保持分配在栈上,当出现动态数据时,逃逸到堆上。
  • 栈指针作用范围结束后,其指向的堆对象变为孤儿。

4.4 Go的内存管理

4.4.1 内存分配

大部分GC语言的内存都使用年代来管理,并且在回收之后会整理内存使得布局紧凑。Go使用了完全不同的方法,它使用mcache提高小内存分配的效率,维护Scan和Noscan来提升回收的效率。分配内存的方式依据需要的大小不同分成三种:

  • 微分配(小于16B):使用mcache的微分配器进行分配,非常高效。
  • 小分配(16B-32KB):在相应大小的mspan上分配,仍然处于mcache内,因此每个P都能并发响应当前G的内存要求。
  • 大分配(大于32KB):直接在mheap上进行分配,需要加全局锁。

4.4.2 垃圾收集

Go 1.12后,已经不再对内存分代,使用并发的三原色Mark and sweep策略进行垃圾收集。主要分四步:

  • 标记初始化(Mark setup,STW):当GC开始时,需要短暂停止所有G,用来开启写屏障。
  • 标记(Mark,并发):使用25%的CPU资源开始标记对象,其他G正常运行,该25%的P全部用来做颜色标记。如果标记时间过长,GC还会征调活动的G帮助进行标记,称为协助标记Mark assist。
  • 标记终止(Mark termination,STW):标记结束后又需要一次短暂的停止所有的G,用来关闭写屏障。
  • 清理(Sweep,并发):执行清理,不需要暂停应用的G,清理和新的分配会同步进行。

详细例子说明:

  • 触发GC后,短暂STW,开启写屏障。
  • GC选择一个栈指针作为root,标记为黑色,深度遍历直到遇到没有指针的对象Noscan为止,途径每个对象标记成灰色。
  • 选择新的一个GC root,重复上述上色过程。直到所有GC root遍历完成。
  • 选择一个灰色对象,重复搜索过程,直到所有灰色对象遍历完成。
  • 如果过程中某个对象指针发生更改,写屏障会记录下来,并且将对象标记为灰色,因此GC会重新扫描这个对象。
  • 当不存在灰色对象后,标记结束。清理会在下一次内存分配的时候发生。 因为采用了TCMalloc的机制,所以不需要整理内存,这里节省了很多内存复制移动的开销。有两段很小的STW,不过大部分情况下都是感受不到的。

五、Rust的内存管理

我们来到了整个系列唯一不用GC的语言。本节仅做原博的阅读笔记,事实上,Rust有着完全区别于其他语言的内存管理方式,也以其陡峭的学习曲线著称,因此不要期望以本节内容完全掌握Rust内存管理的相关知识。强烈推荐学习Rust,自从1.0在2015年发布以来,这门语言就没有让出过stackoverflow的开发者最喜爱语言的榜首位置。学习Rust对你理解其他语言的内存及其他所有概念都是有帮助的。

5.1 Rust的内存结构

Rust目前还没有标准化它的内存模型。用下图可以简单表示:

Rust memory

对比前面的JVM、V8和Go,是不是特别简单?没有复杂的结构,没有GC,一切都像你在操作系统中运行了一个简单的进程一样。各个组成部分:

5.1.1 堆

  • Box就是常说的装箱,这是Rust中最基本的堆分配方式。通过Box::new可以将任何类型转为堆分配。

5.1.2 栈

没有任何的魔法,一切都由操作系统管理。除了调用惯例的区别外,与C语言没有任何差异。

5.2 Rust的内存使用(堆vs栈)

原博中有一个交互可视化的PPT,推荐阅读。

Rust的内存分配基本原则:

  • 所有的值默认都是栈分配,除非那些无法在编译期确定大小的值。
  • 你也可以主动要求堆分配,通过使用Box封装。

具体实现和Go很像(但细节上有很大差异):

  • 每个函数都有独立的栈帧。
  • 静态数据、参数、返回值存放在栈帧中。
  • 所有使用的到静态数据(编译期可知大小),无论是任何类型,都在栈分配。
  • 所有动态数据(编译期不可知大小)都使用智能指针Smart pointer在堆上分配。栈上存放该智能指针。
  • 没有GC,所以没有在用对象和孤儿对象的概念。

5.3 Rust内存管理:所有权

简要来说:

  • 任何在Rust中使用到的值都是一个绑定binding,将值绑定到其所有者Owner上。
  • 同一时间每个值有且只有一个所有者。
  • 当所有者绑定超出作用范围时,绑定的值会被释放。

上面的规则无论对堆还是对栈都是通用的规则,没有任何潜规则。

5.2.1 RAII

RAII很难从名称理解,资源获取即初始化。如果熟悉Python,你应该知道with...as...结构,其实Rust的RAII就是扩展后的这种结构。在Rust中任何资源都无需手动释放,这不仅包括内存,还包括文件描述符、网络套接字、同步锁等等。当这些对象的所有者离开其作用域时,这些资源都会在其Drop实现中释放。

5.2.2 借用和借用检查

对于每个用到的值都要获取所有权,无论是逻辑上还是性能上都是不可能的,因此Rust设计了借用,并且依赖编译器帮助程序员对借用的内存安全性进行检查,通常来说,在没有使用unsafe的情况下,通过rustc编译的程序都可以认为是内存安全的。

5.2.3 生命周期

Rust使用一种强类型方式定义生命周期,你也可以认为是Lifetime trait,也就是你定义的类型,无论是枚举、结构体、实现impl还是函数都必须符合生命周期的类型定义,就像你在Rust中实现一个Trait bound一样。

5.2.4 智能指针

Rust中存在很多种智能指针,以满足不同场景的需要,前面说的Box就是堆分配的智能指针,还有Cell、RefCell、Rc、Arc、Pin等,这些都为管理内存提供了相应的功能。实际上原博并没有提到,这里鉴于篇幅,也不赘述,有兴趣的还是一句话,学Rust吧。

六、在NodeJS中防止内存泄露:性能上的最佳实践

本篇干货不多,可以了解,此处不作概括。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment