文章内容更新请以 WGrape GitHub博客 : 谈垃圾回收机制 为准
前言
本文原创,著作权归WGrape所有,未经授权,严禁转载
一、背景
1. 程序载入
程序被载入内存时,OS会为其生成一个描述符task_struct,属性mm_struct指向所对应的内存区域 (包括代码段即指令区、堆栈段即数据区 )。当时间片分配给此程序时,CPU便会读取其内存区域中的指令并执行,这时程序不再是一个静态的数据结构,而是一个运行的实体,即进程( 进行的程序 )。
2. 程序执行
指令是汇编代码,汇编代码可以直接操作内存。所以在CPU读取程序指令执行的过程中,涉及内存操作的指令会使用掉部分的堆栈空间
3. 堆栈增长
随着程序的执行,使用的堆栈空间会朝着相反的方向不断增长
生长方向相反可以最大化的利用内存
4. 内存记忆
1) 物理层
内存存储器主要由锁存器 ( Latch ) 组成,锁存器内部是晶体管和逻辑门电路所制,高低电平信号在其电路上传输时会被锁住,通过这种方式实现信号存储,得名为锁存器
当很多种这样的锁存器组成时,就组成了一个内存存储器
因此,对于内存而言,其内部的锁存器存储的高低电平信息即是用户的数据。一旦锁存器存储的电平改变,上次存储的信息就会消失,这种消失从用户角度来看就是数据被删除。从计算机角度看,只是锁存器内部不再存有信号,或者虽然存储信号,但是其所存储的数据已被OS标识为空闲。
理论上,所有存储的数据,无论其抽象层如何标识数据的存在与否,只要在物理层还存在,数据就可以被恢复
2) OS层
OS会维护空闲的内存链表,这些都是可以作为再分配的内存资源。释放堆内存,本质是OS通过将其连接在空闲内存链表而实现。所以回收的堆内存中的保留的数据可能并未被删除。
3) 编程语言层
一般编程语言对堆内存的管理,是通过OS提供的接口实现
5. 栈的管理
计算机科学《函数调用约定》中规定了不同的CPU架构,在子程序调用后返回到调用方程序时,应该如何需要清理掉栈上的空间。所以无论C、JAVA、Go等任何语言,只是其在汇编层额外多了些栈操作,才得以实现栈空间在调用后的自动释放。
6. 堆的管理
1) 分配
创建新对象时,会在堆空间分配内存后,返回这段内存空间的地址,即指针 ( 参照malloc原理 ) 。
2) 释放
堆空间的生命周期会持续到手动释放后才结束。在C语言中,会提供free释放堆内存的函数,其本质也是通过空闲内存链表实现。
3) 问题
因为内存的申请和释放完全由开发者管理,经常出现未手动释放堆空间而造成内存泄露,为了解决人为管理内存的不便,便开始研究新机制:垃圾回收 / Garbage Collection
二、认识垃圾回收
1. 基本原理
通过回收算法识别出其中不会再被引用的对象,从而进行系统级别的对象自动释放。
2. 回收算法
根据回收时机 ( 回收周期 ) 的不同,可分为即刻回收和延迟回收两种
1) 即刻回收
① 引用计数法
在运行时,记录每个对象被引用的次数,每次创建新对象、赋值引用、删除引用的同时,GC会立即更新计数器,如果计数器为0则立即进行内存回收。
2) 延迟回收
由于延迟回收策略中的回收周期时间不定,无法跟踪堆对象。此时GC会面临 when 和 how 问题 。how 是指如何找到目标堆对象 ,when 是指什么时候去找。
-
解决 How :【根搜索法】在每一次创建对象的时候,都会根据引用及其指向对象的信息,生成对应的 GC Root 结构体,并存储在一个类似映射表的数据结构中 ( 如 OopMap ),通过遍历 GC Roots 解决 find 问题
-
解决 When :一般可以把 GC Roots 大小、堆空间的使用大小,定期等作为是否触发GC的因素
① 标记清除法 / Mark Sweep
第一步 :遍历所有 Roots ,并标记所有可达的 Object 第二步 :遍历堆 ,清除所有未被标记的 Object
清理后的内存使用情况如下图所示
② 标记压缩法 / Mark Compact
第一步 :遍历所有 Roots ,并标记所有可达的 Object 第二步 :遍历堆 ,清除所有未被标记的 Object 第三步 :重新排列所有未被清除的Object
压缩法相比清除法,多了一步整理移动堆空间的操作,减少了内存碎片
清理后的内存使用情况如下图所示
③ 内存复制 / Copying
前提条件 :需要把内存等分为 空闲区和使用区 两个区
第一步 :当使用区用完后,标记所有可达对象,并删除不可达对象 第二步 :GC将其中还存活的对象复制到空闲区中 第三步 :清除使用区内的所有对象 第四步 :把两个区块的身份对调 ( 即空闲区变成使用区,使用区变成空闲区 )
清理后的内存使用情况如下图所示
相比压缩算法,复制算法解决了标记压缩算法中 内存的移动整理 **较复杂且效率低 **的问题。
3.回收优化
在基本的回收算法中,还存在一些可以优化的地方。
1) 内存分代
在遍历GC Roots时,由于有些对象的生命周期较长,就会造成无用遍历的情况,即某个对象被遍历了多次,但是一直是alive状态导致其引用的对象不能被回收。既不能强制回收,又需要减少遍历的代价提高效率,便提出了内存分代的概念。
一般可分为年轻代和老年代,年轻代存储存活周期较短的对象,老年代存储存活周期较长的对象。年轻代意味着更容易被释放,老年代意味着需要很久才会被释放。所以通过这种方式明显提高遍历效率 :
第一步 :创建的新生对象进入Young区 第二步 :在GC遍历Map时,会把所有可达对象的“年龄”+1 第三步 :当对象的年龄超过限制后,对象及其GC Root会被移入老年区
2) 解决引用计数中的循环引用问题
传统的引用计数中存在缺陷,当对象循环引用时会存在无法清理对象的情况。即本质上对象已经是可以回收的了,但是因为引用计数非0,导致无法释放内存。
为解决问题,可以通过算法识别出循环引用的对象,然后加到缓冲中,当缓冲容量超过限制后便进行回收。
三、相关问题解释
1. 垃圾的定义
当对象不再被任何变量引用时,这个对象就是GC应该回收的垃圾
2. 垃圾的生命周期
原则上GC每回收一次垃圾都会降低效率,所以对象被标识为垃圾后,不同的GC机制都会有对应的优化策略,比如当存储垃圾对象的某个缓冲区满之后才会回收。
3. 理解 GC Root
GC Root 概念比较难理解,不过其本质是描述GC把一切堆对象视为回收目标,各个堆对象之间的引用关系可能错综复杂,所以需要有一个类似起点的节点对象,这样GC每次只需要从起点出发,就能遍历到所有与之相关的所有堆对象
4. 引用计数的循环引用
① 定义对象a、b
② 对象a、b循环引用
对象a、b的引用次数都+1
a.ref = b;
b.ref = a;
③ 删除对象a、b
对象a、b的引用次数都-1
unset(a);
unset(b);
④ 无法清理
因为对象a、b的引用计数都不为0,导致堆对象无法被回收
四、不同语言的垃圾回收
1. JAVA
1) 回收机制 - 根搜索法 + 内存分代
① Java把内存分为新生代、老年代 ( 新老内存大小比例 1:2 ) 和 永久代,其中新生代分为Eden、From、To三个区域 ( 内存大小比例默认8:1:1 )
- Eden (伊甸园,象征起源) :所有新创建的对象会在分配在此区域中
- From、To区域就是对内存复制方式的实现
- 永久代用于存储如静态类的对象,一般不会进行垃圾回收
② Java早期版本如果对象不被引用会被GC直接回收,后续需要只能由JVM重新创建,降低了JVM性能。因此后续版本Java把引用分为强引用、软引用、弱引用、虚引用,一般很少用后两种。
- 强引用 :宁愿抛出OOM错误,GC也不会回收强引用的对象
// 强引用 Object strongReference = new Object();
- 软引用 :如果内存空间不足时,会回收这些软引用的对象。适合缓存场景
// 软引用: Java中用java.lang.ref.SoftReference类来表示 String str = new String("abc"); SoftReference<String> softReference = new SoftReference<String>(str);
区分不同引用类型的本质 :优化GC回收策略,解决对象无引用时会被GC立即回收 而引起的 JVM 性能下降问题
2. PHP
1) 变量容器 ZVal
ZVal 全称 Zend Value,是Zend引擎中描述PHP变量的容器 (结构体) 。
typedef struct _zval_struct {
zvalue_value value; // 变量的值
zend_uint refcount__gc; // 引用计数器, 记录引用次数
zend_uchar type; // 变量的类型
zend_uchar is_ref__gc; // 是否是引用变量, 1是, 0不是
}zval;
typedef union _zvalue_value {
long lval;
double dval;
struct { // 如字符串变量
char *val;
int len;
} str;
HashTable *ht; // 如数组变量
zend_object_value obj; // 如对象变量
} zvalue_value;
// zend_uchar 定义了8种变量类型
#define IS_NULL 0
#define IS_LONG 1
#define IS_DOUBLE 2
#define IS_BOOL 3
#define IS_ARRAY 4
#define IS_OBJECT 5
#define IS_STRING 6
#define IS_RESOURCE 7
#define IS_CONSTANT 8
#define IS_CONSTANT_ARRAY 9
#define IS_CALLABLE 10
2) 回收机制 - 引用计数
在PHP5.3以前,使用的是最简单引用计数方式,不过从5.3开始,使用了改进版的引用计数算法,来解决内存泄露问题。 当对象的refcount减一操作导致结果为0时,表示其引用已全部不存在可以清理。反之如果减一操作执行后其结果仍然大于0,表示还存在其他引用,这个对象可能是垃圾。这时垃圾回收器会把这些垃圾收集起来,存在缓冲区中,当缓冲区满时,识别出其中的循环引用的对象和无引用对象并进行清理。
3) 相关知识点
1. unset函数
unset只是断开一个变量到其引用的对象之间的连接,同时将该对象的引用计数-1,对象是否回收主要还是根据refcount是否为0,以及GC策略。
2. = null 操作
a=null是直接将a指向的数据结构置空,同时将其引用计数归0。
3. 脚本执行结束
脚本执行结束,该脚本中使用的所有堆内存都会被释放,不论是否有引用。
4. xdebug调试
通过Xdebug查看变量容器,观察refcount的引用变化 ## 3. JavaScript ### 1) 回收机制 - 根搜索法 + 内存分代 最初级的JavaScript使用引用计数法,但是循环引用问题无法解决。大概从2012年起JavaScript GC开始使用根搜索法,并且将内存分为年轻代和老年代 ### 2) 相关知识点 #### 1. JS堆内存分析 需要分析内存的时候,可以使用Chrome工具调试
2. 闭包和GC
只要是处于被引用状态的堆内存数据,都不会被回收掉。闭包机制中,在函数结束时,由于其内部变量仍然被引用,所以即使是局部变量,但是其仍然可用。这也是闭包为什么会造成内存泄露的原因。
// https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Closures
function makeFunc() {
var name = "Mozilla"; // 变量name
function displayName() {
alert(name);
}
return displayName;
}
var myFunc = makeFunc();
myFunc();
五、总结
垃圾回收本质最重要的还是回收算法和回收时机这两方法,对于回收算法而言,目前常用算法基本都大同小异,或是上述算法中的变种。对于回收时机来说,也是基本都采用缓冲区容量限制等作为是否触发的标准。