GC垃圾回收机制: 浅析与理解
对垃圾回收进行分析前,我们先来了解一些基本概念
基本概念
内存管理:内存管理对于编程语言至关重要。汇编允许你操作所有东西,或者说要求你必须全权处理所有细节更合适。C 语言中虽然标准库函数提供一些内存管理支持,但是对于之前调用 malloc 申请的内存,还是依赖于你亲自 free 掉。从C++、Python、Swift 和 Java 开始,才在不同程度上支持内存管理。
内存压缩:对内存碎片进行压缩。(和win10的那个“内存压缩”不太一样啦)
win10内存压缩:物理内存已经见底,将一部分不常使用的内存数据打包压缩起来,等到有程序需要访问那些数据的时候,再解压缩出来。
引用与指针:
引用被创建的同时必须被初始化(指针则可以在任何时候被初始化)。
不能有NULL 引用,引用必须与合法的存储单元关联(指针则可以是NULL)。
一旦引用被初始化,就不能改变引用的关系(指针则可以随时改变所指的对象)。
引用只是某块内存的别名。
实际上“引用”可以做的任何事情“指针”也都能够做,为什么还要“引用” 这东西?
答案是“用适当的工具做恰如其分的工作”。比如说,某人需要一份证明,本来在文件上盖 上公章的印子就行了,如果把取公章的钥匙交给他,那么他就获得了不该有的权利。(什么情况下,就用什么对策)为什么还要说“只有指针,没有引用是一个重要改变?”?
答案是虽然引用在某些情况下好用,但他也会导致致命错误。如下:12char *pc = 0; // 设置指针为空值char& rc = *pc; // 让引用指向空值
这是非常有害的,毫无疑问。结果将是不确定的(编译器能产生一些输出,导致任何事情都有可能发生),应该躲开写出这样代码的人除非他们同意改正错误。如果你担心这样的代码会出现在你的软件里,那么你最好完全避免使用引用,要不然就去让更优秀的程序员去做。
- 最后上附图,帮助理解
堆(heap)和栈(stack)
平常说的“堆栈”其实是栈。
栈,就是那些由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等。
堆,就是那些由new分配的内存块,他们的释放编译器不去管,由我们的应用程序去控 制,一般一个new就要对应一个delete。如果程序员没有释放掉,那么在程序结束后,操作系统会自动回收。
程序的栈结构
程序的地址空间布局: 程序运行靠四个东西:代码、栈、堆、数据段。代码段主要存放的就是可执行文件(通常可执行文件内,含有以二进制编码的微处理器指令,也因此可执行文件有时称为二进制文件)中的代码;数据段存放的就是程序中全局变量和静态变量;堆中是程序的动态内存区域,当程序使用malloc或new得到的内存是来自堆的;栈中维护的是函数调用的上下文,离开了栈就不可能实现函数的调用。
栈帧: 也叫活动记录,保存的是一个函数调用所需要维护的所有信息。如下:
1.函数的返回地址和参数
2.临时变量:包括函数的非静态局部变量以及编译器自动生成的其它临时变量
3.保存的上下文:包括在函数调用前后需要保存不变的寄存器值- 就是它,先上图
]
1.返回地址:一个main函数中断执行的执行点.
2.ebp:指向函数活动记录的一个固定位置,ebp又被称为帧指针.固定位置是,这样在函数返回的时候,ebp就可以通过这个恢复到调用前的值。
3.esp始终指向栈顶,因此随着函数的执行,它总是变化的。
4.入栈顺序:先压此次调用函数参数入栈,接着是main函数返回地址,然后是ebp等寄存器。- Link:C程序的函数栈作用机理(这个讲得好,有实例,所以不再熬述)
这里我们对比了解不同的 “找到需要标记的对象”的方法
可回收对象的判定
- 引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时, 计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。如下图所示:
|
|
- 可达性分析算法(根搜索算法)
从GC Roots(每种具体实现对GC Roots有不同的定义)作为起点,向下搜索它们引用的对象,可以生成一棵引用树,树的节点视为可达对象,反之视为不可达。如下图所示:
- 接下来补充几个概念帮助理解(以java为例):
GC Roots对象:
12345678虚拟机栈(帧栈中的本地变量表)中引用的对象。方法区中静态属性引用的对象。方法区中常量引用的对象。本地方法栈中JNI引用的对象。本地方法栈则为虚拟机所使用的Native方法服务。Native方法是指本地方法,当在方法中调用一些不是由java语言写的代码或者在方法中用java语言直接操纵计算机硬件。JNI:Java Native Interface缩写,允许Java代码和其他语言写的代码进行交互。上述如图,关于root区域的详细解释参考这里
这里我们介绍几种不同的 “标记对象”的方法
可回收对象的标记
- 保守法
将所有堆上对齐的字都认为是指针,那么有些数据就会被误认为是指针。于是某些实际是数字的假指针,会背误认为指向活跃对象,导致内存泄露(假指针指向的对象可能是死对象,但依旧有指针指向——这个假指针指向它)同时我们不能移动任何内存区域。
- 编译器提示法
如果是静态语言,编译器能够告诉我们每个类当中指针的具体位置,而一旦我们知道对象时哪个类实例化得到的,就能知道对象中所有指针。这是JVM实现垃圾回收的方式,但这种方式并不适合JS这样的动态语言
标记指针法
标记指针法:这种方法需要在每个字末位预留一位来标记这个字段是指针还是数据。这种方法需要编译器支持,但实现简单,而且性能不错。V8采用的是这种方式。位图标记(Go语言为例)
非侵入式标记位定义
既然垃圾回收算法要求给对象加上垃圾回收的标记,显然是需要有标记位的。一般的做法
会将对象结构体中加上一个标记域,一些优化的做法会利用对象指针的低位进行标记,这
都只是些奇技淫巧罢了。Go没有这么做,它的对象和C的结构体对象完全一致,使用的是
非侵入式的标记位。具体实现
堆区域对应了一个标记位图区域,堆中每个字(不是byte,而是word)都会在标记位区域
中有对应的标记位。每个机器字(32位或64位)会对应4位的标记位。因此,64位系统中
相当于每个标记位图的字节对应16个堆中的字节。虽然是一个堆字节对应4位标记位,但标记位图区域的内存布局并不是按4位一组,而是
16个堆字节为一组,将它们的标记位信息打包存储的。每组64位的标记位图从上到下依
次包括:123416位的 特殊位 标记位16位的 垃圾回收 标记位16位的 无指针/块边界 的标记位16位的 已分配 标记位这样设计使得对一个类型的相应的位进行遍历很容易。
前面提到堆区域和堆地址的标记位图区域是分开存储的,其实它们是以
mheap.arena_start地址为边界,向上是实际使用的堆地址空间,向下则是标记位图区
域。以64位系统为例,计算堆中某个地址的标记位的公式如下:1234偏移 = 地址 - mheap.arena_start标记位地址 = mheap.arena_start - 偏移/16 - 1移位 = 偏移 % 16标记位 = *标记位地址 >> 移位然后就可以通过 (标记位 & 垃圾回收标记位),(标记位 & 分配位),等来测试相应的位。
(也就是说,本来64位是一个字,需要4位标记位。但是,为了与字长相对,16个标记位
放一起(刚好一个字长)一起表示16个字。并且每类标记位都放在一起
AA..AABB…BB)
- 接下来补充几个概念帮助理解:
为什么要判断哪些是数据,哪些是指针?
假设堆中有一个long的变量,它的值是8860225560。但是我们不知道它的类型是
long,所以在进行垃圾回收时会把个当作指针处理,这个指针引用到了0x2101c5018位
置。假设0x2101c5018碰巧有某个对象,那么这个对象就无法被释放了,即使实际上已
经没任何地方使用它。由于没有类型信息,我们并不知道这个结构体成员不包含指针,因此我们只能对结构体
的每个字节递归地标记下去,这显然会浪费很多时间。
(能不能清除 变成了概率事件)。垃圾收集器(CMS收集器为例)
几个阶段:初始标记
并发标记
最终标记
筛选回收初始标记仅仅是标记一下GC Roots能直接关联到的对象,速度很快,并发标记就是进行
GC Roots Trancing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继
续运行而导致标记产生变动那一部分对象的标记记录,这个阶段的停顿时间比初始标记稍
长一些,但远比并发标记时间短。stop the world
因为垃圾回收的时候,需要整个的引用状态保持不变,否则判定是垃圾,等我稍后回
收的时候它又被引用了,这就全乱套了。所以,GC的时候,其他所有的程序执行处于暂停
状态,卡住了。
这个概念提前引入,在这里进行对比,效果会更好些。与标记阶段对比,stop the world发生在回收阶段。
这里我们介绍几种不同的垃圾回收算法
垃圾回收算法
- 标记清除算法 (Mark-Sweep)
标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。
优点是简单,容易实现。
缺点是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。(因为没有对不同生命周期的对象采用不同算法,所以碎片多,内存容易满,gc频率高,耗时,看了后面的方法就明白了)
- 分代回收算法
根据对象存活的生命周期将内存划分为若干个不同的区域。不同区域采用不同算法(复制算法,标记整理算法),这就是分代回收算法。
一般情况下将堆区划分为老年代(Old Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。
1.新生代回收
新生代使用Scavenge算法进行回收。在Scavenge算法的实现中,主要采用了Cheney算法。
|
|
新生代的空间划分比例为什么是比例为8:1:1(不是按照上面算法中说的1:1)?
|
|
Eden空间和两块Survivor空间的工作流程是怎样的?
具体的执行过程是怎样的?
|
|
最终当scanPtr和allocationPtr重合,说明复制结束。
注意:如果指向老生代我们就不必考虑它了。(通过写屏障)
对象何时晋升?
|
|
2.老生代回收
Mark-Sweep(标记清除)
|
|
Mark-Compact(标记整理)
|
|
- 补充概念方便理解
1.触发GC(何时发生垃圾回收?)
|
|
2.写屏障(一个老年代的对象需要引用年轻代的对象,该怎么办?)
|
|
3.深度、广度优先搜索(为什么新生代用广度搜索,老生代用深度搜索)
|
|