现代高级编程语言管理内存的方式分自动和手动两种。手动管理内存的典型代表是C和C++,编写代码过程中需要主动申请或者释放内存;而 PHP、Java 和 Go等语言使用自动的内存管理系统,由内存分配器和垃圾收集器来代为分配和回收内存,其中垃圾收集器就是我们常说的GC。本文中,笔者将从原理出发,介绍Java和Golang垃圾回收算法,并从原理上对他们做一个对比。
Java垃圾回收
垃圾回收区域及划分
在介绍Java垃圾回收之前,我们需要了解Java的垃圾主要存在于哪个区域。JVM内存运行时区域划分如下图所示:
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
- 程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,各条线程之间计数器互不影响,独立存储。
- 虚拟机栈:它描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame,是方法运行时的基础数据结构)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
- 本地方法栈:它与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
- Java 堆:它是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
- 方法区:它与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随着线程而生,随着线程而灭;栈中的栈帧随着方法的进入退出而进栈出栈,在类结构确定下来时就已知每个栈帧中的分配内存。而Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,而在java8中,方法区存放于元空间中,元空间与堆共享物理内存,因此,Java堆和方法区是垃圾收集器管理的主要区域。
从垃圾回收的角度,由于JVM垃圾收集器基本都采用分代垃圾收集理论,所以 Java 堆还可以细分为如下几个区域(以HotSpot虚拟机默认情况为例):
其中,Eden 区、From Survivor0(“From”) 区、To Survivor1(“To”) 区都属于新生代,Old Memory 区属于老年代。
大部分情况,对象都会首先在 Eden 区域分配;在一次新生代垃圾回收后,如果对象还存活,则会进入 To 区,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(超过了 survivor 区的一半时,取这个值和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值),就会晋升到老年代中。经过这次 GC 后,Eden 区和From区已经被清空。这个时候,From和To会交换他们的角色,保证名为To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程。在这个过程中,有可能当次Minor GC后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
- 部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 MajorGC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC):收集整个 Java 堆和方法区。
Java堆内存常见分配策略
- 对象优先在 eden 区分配。大部分对象朝生夕灭
- 大对象直接进入老年代。大对象就是需要大量连续内存空间的对象(比如:字符串、数组),容易导致内存还有不少空间就提前触发垃圾收集获取足够的连续空间来安置它们。为了避免为大对象分配内存时,由于分配担保机制带来的复制而降低效率,建议大对象直接进入空间较大的老年代。
- 长期存活的对象将进入老年代,动态对象年龄判定:在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(超过了 survivor 区的一半时,取这个值和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
- 空间分配担保。在发生Minor GC之前,虚拟机会先检查老年代最大可用连续内存空间是否大于新生代所有对象总空间。如果这个条件成立,那么Minor GC可以确保是安全的。 如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许【担保失败】
- 如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小
- 如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的
- 如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC
判断对象死亡
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。判断一个对象是否存活有引用计数、可达性分析这两种算法,两种算法各有优缺点。 Java和Go都使用可达性分析算法,一些动态脚本语言(如:ActionScript)一般使用引用计数算法。
引用计数法
引用计数法给每个对象的对象头添加一个引用计数器,每当其他地方引用一次该对象,计数器就加1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。
这个方法实现简单,效率高,但是主流的Java虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。即如下代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
目前Python语言使用的是引用计数法,它采用了“标记-清除”算法,解决容器对象可能产生的循环引用问题,关于详细原理可以参考Python垃圾回收机制详解
可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。算法优点是能准确标识所有的无用对象,包括相互循环引用的对象;缺点是算法的实现相比引用计数法复杂。比如如下图所示Root1和Root2都为“GC Roots” ,白色节点为应被垃圾回收的
关于Java查看可达性分析、内存泄露的工具,强烈推荐“Memory Analyzer Tool”,可以查看内存分布、对象间依赖、对象状态。
在Java中,可以作为 “GC Roots” 的对象有很多,比如:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的应用类型静态变量
- 在方法区中常量应用的对象,譬如字符串池中的引用
- 在本地方法栈中JNI引用的对象
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻异常对象(如NPE),还有系统类加载器。
- 所有被同步锁(synchronized)持有的对象
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
不可达的对象并非“非死不可”
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
判断一个运行时常量池中的常量是废弃常量
1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代 。
3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
假如在字符串常量池中存在字符串"abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量"abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc"就会被系统清理出常量池了。
如何判断一个方法区的类是无用的类
类需要同时满足下面 3 个条件才能算是 “无用的类”,虚拟机可以对无用类进行回收。
- 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
- 加载该类的ClassLoader 已经被回收。
- 该类对应的java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
当确定了哪些对象可以回收后,就要需要考虑如何对这些对象进行回收,目前垃圾回收算法主要有以下几种。
标记清除算法
该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
适用场合:存活对象较多的情况、适用于年老代(即旧生代)
缺点:
- 空间问题,易产生内存碎片,当为一个大对象分配空间时可能会提前触发垃圾回收(例如,对象的大小大于空闲表中的每一块儿大小但是小于其中两块儿的和)
- 效率问题,扫描了整个空间两次(第一次:标记存活对象;第二次:清除没有标记的对象)
标记复制算法
为了解决效率问题,出现了“标记-复制”收集算法。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。使用复制算法,回收过程中就不会出现内存碎片,也提高了内存分配和释放的效率
适用场合:存活对象较少的情况下比较高效、用于年轻代(即新生代)
缺点:需要一块儿空的内存空间,整理阶段,由于移动了可用对象,需要去更新引用。
标记整理算法
对于对象存活率较高的场景,复制算法要进行较多复制操作,使得效率会变低,这种场景更适合标记-整理算法,与标记-清理一样,标记整理算法先标记出对象的存活状态,但在清理时,是先把所有存活对象往一端移动,然后直接清掉边界以外的内存。
适用场合:对象存活率较高(即老年代)
缺点:整理阶段,由于移动了可用对象,需要去更新引用。
分代收集算法
当前Java虚拟机的垃圾收集采用分代收集算法,一般根据对象存活周期的不同将内存分为新生代和老年代。在新生代中,每次收集都会有大量对象死去,可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。 而老年代的对象存活几率是比较高,而且没有额外的空间对它进行分配担保,所以我们选择“标记-清除”或“标记-整理”算法进行垃圾收集。
垃圾收集器
图源:深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) —机械工业出版社
虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
Golang垃圾回收
从Go v1.12版本开始,Go使用了非分代的、并发的、基于三色标记清除的垃圾回收器。相关标记清除算法可以参考和C/C++一样,Go是一种静态类型的编译型语言。因此,Go不需要VM,Go应用程序二进制文件中嵌入了一个小型运行时(Go runtime),可以处理诸如垃圾收集(GC),调度和并发之类的语言功能。首先让我们看一下Go内部的内存管理是什么样子的。
Golang内存管理
这里先简单介绍一下 Golang 运行调度。在 Golang 里面有三个基本的概念:G, M, P。
- G: Goroutine 执行的上下文环境。
- M: 操作系统线程。
- P: Processer。进程调度的关键,调度器,也可以认为约等于 CPU。
一个 Goroutine 的运行需要 G + P + M 三部分结合起来。
图源:Golang---内存管理(内存分配)
TCMalloc
Go将内存划分和分组为页(Page),这和Java的内存结构完全不同,没有分代内存,这样的原因是Go的内存分配器采用了TCMalloc的设计思想:
Page
与TCMalloc中的Page相同,x64下1个Page的大小是8KB。上图的最下方,1个浅蓝色的长方形代表1个Page。
Span
与TCMalloc中的Span相同,Span是内存管理的基本单位,代码中为mspan,一组连续的Page组成1个Span,所以上图一组连续的浅蓝色长方形代表的是一组Page组成的1个Span,另外,1个淡紫色长方形为1个Span。
mcache
mcache是提供给P(逻辑处理器)的高速缓存,用于存储小对象(对象大小<= 32Kb)。尽管这类似于线程堆栈,但它是堆的一部分,用于动态数据。所有类大小的mcache包含scan和noscan类型mspan。Goroutine可以从mcache没有任何锁的情况下获取内存,因为一次P只能有一个锁G。因此,这更有效。mcache从mcentral需要时请求新的span。
mcentral
mcentral与TCMalloc中的CentralCache类似,是所有线程共享的缓存,需要加锁访问,它按Span class对Span分类,串联成链表,当mcache的某个级别Span的内存被分配光时,它会向mcentral申请1个当前级别的Span。每个mcentral包含两个mspanList:
- empty:双向span链表,包括没有空闲对象的span或缓存mcache中的span。当此处的span被释放时,它将被移至non-empty span链表。
- non-empty:有空闲对象的span双向链表。当从mcentral请求新的span,mcentral将从该链表中获取span并将其移入empty span链表。
mheap
mheap与TCMalloc中的PageHeap类似,它是堆内存的抽象,也是垃圾回收的重点区域,把从OS申请出的内存页组织成Span,并保存起来。当mcentral的Span不够用时会向mheap申请,mheap的Span不够用时会向OS申请,向OS的内存申请是按页来的,然后把申请来的内存页生成Span组织起来,同样也是需要加锁访问的。
栈
这是栈存储区,每个Goroutine(G)有一个栈。在这里存储了静态数据,包括函数栈帧,静态结构,原生类型值和指向动态结构的指针。这与分配给每个P的mcache不是一回事。
内存分配
Go中的内存分类并不像TCMalloc那样分成小、中、大对象,但是它的小对象里又细分了一个Tiny对象,Tiny对象指大小在1Byte到16Byte之间并且不包含指针的对象。小对象和大对象只用大小划定,无其他区分。
核心思想:把内存分为多级管理,降低锁的粒度(只是去mcentral和mheap会申请锁), 以及多种对象大小类型,减少分配产生的内存碎片。
- 微小对象(Tiny)(size <16B):使用mcache的微小分配器分配小于16个字节的对象,并且在单个16字节块上可完成多个微小分配。
- 小对象(尺寸16B〜32KB):大小在16个字节和32k字节之间的对象被分配在G运行所在的P的mcache的对应的mspan size class上。
- 大对象(大小> 32KB):大于32 KB的对象直接分配在mheap的相应大小类上(size class)。如果mheap为空或没有足够大的页面满足分配请求,则它将从操作系统中分配一组新的页(至少1MB)
- 如果对应的大小规格在 mcache 中没有可用的块,则向 mcentral 申请
- 如果 mcentral 中没有可用的块,则向 mheap 申请,并根据 BestFit 算法找到最合适的 mspan。如果申请到的 mspan 超出申请大小,将会根据需求进行切分,以返回用户所需的页数。剩余的页构成一个新的 mspan 放回 mheap 的空闲列表。
- 如果 mheap 中没有可用 span,则向操作系统申请一系列新的页(最小 1MB)。 Go 会在操作系统分配超大的页(称作 arena)。分配一大批页会减少和操作系统通信的成本。
内存回收
go内存会分成堆区(Heap)和栈区(Stack)两个部分,程序在运行期间可以主动从堆区申请内存空间,这些内存由内存分配器分配并由垃圾收集器负责回收。栈区的内存由编译器自动进行分配和释放,栈区中存储着函数的参数以及局部变量,它们会随着函数的创建而创建,函数的返回而销毁。如果只申请和分配内存,内存终将枯竭。Go使用垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行span的合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此,Go堆是Go垃圾收集器管理的主要区域。
标记清除算法
当成功区分出Go 垃圾收集器管理区域的存活对象和死亡对象后,Go垃圾收集器接下来的任务就是执行GC,释放无用对象占用的内存空间,以便有足够的可用内存空间为新对象分配内存。目前常见的垃圾回收算法在垃圾收集算法中已有介绍,而Go使用的是标记清除算法,这是一种非常基础和常见的垃圾收集算法,于1960年被J.McCarthy等人提出。
当堆空间被耗尽的时,就会STW(也被称为stop the world),其执行过程可以分成标记和清除两个阶段,具体可参照标记清除算法。Go垃圾收集器从根结点开始遍历,执行可达性分析算法,递归标记所有被引用的对象为存活状态;标记阶段结束后,垃圾收集器会依次遍历堆中的对象并清除其中的未被标记为存活的对象。
由于用户程序在垃圾收集的过程中也不能执行(STW)。在可达性分析算法中,Go的GC Roots一般为全局变量和G Stack中的引用指针,和整堆的对象相比只是极少数,因此它带来的停顿是非常短暂且相对固定的,不随堆容量增长。在从GC Roots往下遍历对象的过程,堆越大,存储对象越多,递归遍历越复杂,要标记更多对象而产生的停顿时间自然就更长。因此我们需要用到更复杂的机制来解决 STW 的问题。